commit 93f0689c1eaa210158b45e32d6453a7c95942b19 Author: MeeJay Date: Fri Aug 14 10:48:57 2026 +0200 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) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..59a2818 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# Docker build-context exclusions. +# Both images build from the repository root (docker-compose.yml: `context: .`), +# so without this file the daemon would receive .git, node_modules and the +# Python venv on every build — hundreds of MB over the wire, and +# `COPY apps/web/ ./` in web.Dockerfile would overwrite the node_modules that +# `npm ci` just installed inside the image. + +# VCS +.git +.gitignore +.gitattributes + +# Python +**/__pycache__ +**/*.py[cod] +**/.pytest_cache +**/.mypy_cache +**/.ruff_cache +**/*.egg-info +apps/api/.venv +apps/api/venv + +# Node +apps/web/node_modules +apps/web/dist +apps/web/.vite + +# Secrets & local data (never bake them into an image) +.env +.env.local +*.local.env +*.sqlite3 +pgdata +uploads + +# Documentation (not needed at runtime) +docs +README.md +DESIGN.md +CONVENTIONS.md + +# IDE / OS +.idea +.vscode +*.swp +Thumbs.db +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5e24c70 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# LifeTrack — copy to .env next to docker-compose.yml, then edit. +# cp .env.example .env +# .env is git-ignored (C7): it is the only place secrets live. + +# --- PostgreSQL --------------------------------------------------------- +POSTGRES_USER=lifetrack +# Ends up inside LIFETRACK_DATABASE_URL: keep it URL-safe (letters/digits/-/_), +# a '@', ':' or '/' would break the connection string. +# openssl rand -hex 16 +POSTGRES_PASSWORD=change-me +POSTGRES_DB=lifetrack + +# --- API (every name is LIFETRACK_ + a field of app/core/config.py) ------ +# MUST be overridden: it signs the JWTs. openssl rand -hex 32 +LIFETRACK_JWT_SECRET=generate-a-long-random-string +# Default timezone of the daily aggregations (?tz= overrides it per request). +LIFETRACK_TIMEZONE=Europe/Paris +# Max size of an uploaded import file, in bytes (20 MiB). Keep it <= the +# client_max_body_size of docker/nginx.conf (25m). +# LIFETRACK_MAX_UPLOAD_BYTES=20971520 +# Dev only (Vite dev server without the nginx proxy). JSON array, not a bare +# list: pydantic-settings parses complex fields as JSON. +# LIFETRACK_CORS_ORIGINS=["http://localhost:5173"] +# Dev/tests only: comma-separated whitelist of backend modules (empty = all). +# Read straight from the environment by app/core/module_loader.py. +# LIFETRACK_MODULES=auth,imports,health + +# --- Published ports ---------------------------------------------------- +# The SPA (nginx). This is the URL you open in a browser. +WEB_PORT=80 +# The API, exposed for /api/docs and the device ingestion endpoints. +API_PORT=8000 + +# --- Build --------------------------------------------------------------- +# Empty by default. On a host with less than ~2 GB of RAM, cap the V8 heap so +# `vite build` cannot be OOM-killed: +# NODE_OPTIONS=--max-old-space-size=1024 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..afc9d5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ + +# Node +node_modules/ +dist/ +.vite/ + +# Env & secrets +.env +.env.local +*.local.env + +# Data +*.sqlite3 +pgdata/ +uploads/ + +# IDE / OS +.idea/ +.vscode/ +*.swp +Thumbs.db +.DS_Store diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100644 index 0000000..a035438 --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,133 @@ +# 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//` 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). + +### 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`.) + diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..b8ab6b4 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,59 @@ +# LifeTrack — Synthèse du design (v1) + +> Tracker de vie auto-hébergé : sport/calories/poids, arrêt de la cigarette (vape), et finances +> personnelles — interface web riche en graphiques, déployée via Docker Compose. + +## Documents de référence + +| Document | Contenu | +|---|---| +| [docs/design/architecture.md](docs/design/architecture.md) | Architecture technique complète : monorepo, FastAPI, React, auto-découverte des modules, framework de connecteurs, Docker. **§10 = CONVENTIONS (extrait dans [CONVENTIONS.md](CONVENTIONS.md))** | +| [docs/design/datamodel-health-vape.md](docs/design/datamodel-health-vape.md) | Tables + formules santé/nutrition/vape : BMR/TDEE, tendance EMA, projections, coût/ml, économies | +| [docs/design/datamodel-finance.md](docs/design/datamodel-finance.md) | Tables + logique finances : pipeline d'import, dédup, moteur de règles, virements, récurrents, budgets | +| [docs/design/ux-pages.md](docs/design/ux-pages.md) | Spec UX française page par page : ~30 graphiques ECharts, KPI, modales, palette validée daltonisme | +| [docs/research/health-connect.md](docs/research/health-connect.md) | Comment sortir les données de Health Connect (bridge webhook, Health Sync CSV, app compagnon v2) | +| [docs/research/nutrition-sources.md](docs/research/nutrition-sources.md) | Foodvisor (export RGPD), Open Food Facts, CIQUAL, importeurs CSV nutrition | +| [docs/research/finance-sources.md](docs/research/finance-sources.md) | Formats CSV réels de 10 banques FR + PayPal, OFX, Enable Banking (v2) | + +## Stack (décidée) + +- **Backend** : Python 3.12 (Docker) · FastAPI · SQLAlchemy 2.0 typé · PostgreSQL 16 · PyJWT + argon2 +- **Frontend** : React 18 · TypeScript strict · Vite · TailwindCSS (thème sombre) · Apache ECharts · TanStack Query +- **Déploiement** : Docker Compose (postgres + api + web/nginx) ; dev = uvicorn --reload + vite +- **Langues** : code en anglais, UI et messages d'erreur en français (fr-FR : `1 234,56 €`, `dd/MM/yyyy`) + +## Décisions structurantes + +1. **Modules auto-découverts** des deux côtés (pkgutil côté API, `import.meta.glob` côté web) : + ajouter un module = créer un dossier, personne ne modifie `main.py`/`router.tsx` → chantiers + parallèles sans conflit, extensibilité maximale (« l'appli peut aller dans plein de sens »). +2. **Framework de connecteurs** à deux voies : importeurs de **fichiers** (`BaseImporter`, + `POST /api/imports`, suivi `ImportRun`, dédup idempotente) et handlers d'**ingestion JSON** + (`BaseIngestHandler`, `POST /api/ingest/{domain}`, clés d'appareil à scopes `ingest:`). +3. **Dédup normalisée** : `(user_id, source, external_id)` + `(user_id, content_hash)` pour les + événements ; `(user_id, day, source)` + upsert pour les agrégats journaliers → ré-imports sûrs. +4. **Health Connect = push, jamais pull** (données on-device, pas d'API cloud ; Google Fit API + morte). v1 : endpoint d'ingestion + app bridge `health-connect-webhook` + import CSV Health + Sync ; v2 : app compagnon Android (Kotlin, 3-5 j) ; v2/v3 : tapis via Web Bluetooth FTMS. +5. **Nutrition v1** : saisie rapide + recherche Open Food Facts (proxy backend + cache) + table + CIQUAL + import CSV Foodvisor (export RGPD) ; connecteur Foodvisor natif impossible (pas d'API). +6. **Finances v1 = fichiers** : mapper CSV générique + presets par banque (10 banques FR), + OFX, PayPal ; aperçu avant import + rollback par lot. **v2 = Enable Banking** (PSD2 gratuit + pour ses propres comptes — GoCardless/Nordigen ferme, ne pas construire dessus). +7. **Vape** : modèle de coût DIY historisé (base, boosters, arômes, résistances) → coût/ml, + coût/jour avec amortissement résistance, économies cumulées vs baseline cigarettes gelée. +8. **Temps** : stockage UTC `timestamptz`, jours locaux `Date` en Europe/Paris (`?tz=` sur les + agrégations). **Unités canoniques** : kg, cm/m, ml, kcal, secondes, centimes d'euro. +9. **v1 sans Alembic** : `metadata.create_all` au démarrage (migrations ajoutées quand le schéma + bougera). Tests pytest sur SQLite → types portables (voir CONVENTIONS C8). +10. **Graphiques** : palette 8 couleurs validée daltonisme sur fond sombre, jamais de double axe Y, + sémantique directionnelle (déficit/économies = vert), réponses stats déjà au format ECharts. + +## Périmètre v1 (ce dépôt) / v2 / v3 + +- **v1** : tout le web + API ci-dessus, imports fichiers, ingestion JSON Health Connect (bridge), + dashboards complets, wizard premier démarrage. +- **v2** : app compagnon Android Health Connect, connecteur Enable Banking, calculateur DIY avancé, + Alembic, notifications. +- **v3** : enregistrement tapis Web Bluetooth FTMS, analyse photo repas (si API Foodvisor s'ouvre), + multi-utilisateurs complet, thème clair. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc82d73 --- /dev/null +++ b/README.md @@ -0,0 +1,565 @@ +# LifeTrack + +> Tracker de vie **auto-hébergé** : poids et calories, sevrage tabagique (vape) et finances +> personnelles — le tout dans une interface web sombre et riche en graphiques. + +Vos données restent chez vous, dans votre PostgreSQL. Aucun compte tiers, aucune télémétrie, +aucune ressource chargée depuis Internet au moment de l'affichage. + +Une seule dépendance réseau existe côté serveur, et elle est facultative : la recherche +d'aliments interroge **Open Food Facts** (base publique, sans clé d'API) et met chaque +produit consulté en cache local. Tout le reste fonctionne hors ligne. + +--- + +## Sommaire + +- [À quoi sert LifeTrack](#à-quoi-sert-lifetrack) +- [Fonctionnalités par module](#fonctionnalités-par-module) +- [Captures d'écran](#captures-décran) +- [Démarrage rapide (Docker Compose)](#démarrage-rapide-docker-compose) +- [Premier démarrage](#premier-démarrage) +- [Mode développement](#mode-développement) +- [Architecture](#architecture) +- [Stack technique](#stack-technique) +- [Feuille de route et écarts connus](#feuille-de-route-et-écarts-connus) +- [Documentation](#documentation) +- [Licence](#licence) + +--- + +## À quoi sert LifeTrack + +Trois besoins réunis dans une seule application, parce qu'ils s'alimentent mutuellement : + +1. **Perdre du poids sans se tromper de chiffres.** LifeTrack calcule votre métabolisme de + base, votre dépense réelle et votre budget calorique quotidien, lisse vos pesées en une + tendance, et projette la date d'atteinte de votre objectif. +2. **Tenir l'arrêt de la cigarette.** Le module vape chiffre au centime près ce que vous + dépensez en e-liquide et en résistances, et le compare à ce que le tabac vous coûterait + aujourd'hui. +3. **Savoir où part l'argent.** Les relevés bancaires s'importent en quelques clics, + se catégorisent tout seuls et alimentent budgets, récurrents et graphiques. + +L'application est **mono-utilisateur en pratique** : le premier compte créé est le vôtre, +il n'y a pas d'inscription publique. + +--- + +## Fonctionnalités par module + +L'interface est découpée en modules auto-découverts. Voici ce qui existe réellement +aujourd'hui. + +### Tableau de bord (`/`) + +- Carte « Aujourd'hui » : ce qui est planifié le jour même (pesée, séance, journal + alimentaire), fait ou non, avec les séries en cours. +- Ajout rapide d'une pesée sans quitter la page. +- Widgets KPI et graphiques de synthèse tirés des modules installés. + +### Santé (`/sante/...`) + +| Page | Contenu | +|---|---| +| **Poids & Objectif** (`/sante/poids`) | Historique des pesées, tendance lissée (EMA), projection de la date d'atteinte, mensurations, calendrier d'assiduité | +| **Nutrition** (`/sante/nutrition`) | Journal alimentaire par repas, recherche d'aliments, favoris, aliments récents, suivi de l'hydratation | +| **Activité & Sport** (`/sante/activite`) | Activité quotidienne (pas, distance, calories, minutes actives, étages), séances de sport | +| **Balance énergétique** (`/sante/balance`) | Calories ingérées contre calories dépensées, déficit cumulé, calibration adaptative du TDEE | + +Détails utiles : + +- **Profil corporel** (taille, sexe, date de naissance, niveau d'activité) → BMR par la + formule **Mifflin-St Jeor**, puis TDEE. +- **Objectif de poids** en trois modes : rythme hebdomadaire, date cible, ou maintien. + Budget calorique = TDEE lissé sur 7 jours − (rythme × 7 700 kcal ⁄ 7), avec un plancher + de sécurité. +- **Planning de suivi** : vous choisissez les jours de la semaine où vous prévoyez de vous + peser (`weigh_in`), de faire une séance (`workout`) et de tenir votre journal alimentaire + (`food_log`). LifeTrack en déduit assiduité, jours manqués et séries — sans case à cocher : + l'habitude est considérée faite dès qu'une donnée existe pour ce jour. +- **Recherche d'aliments** : cache local d'abord, puis **Open Food Facts** (recherche + plein texte et code-barres) via un proxy côté serveur. Chaque produit consulté est mis en + cache et n'est plus jamais redemandé. +- **Composition corporelle** : IMC, et estimation de masse grasse par la méthode US Navy + quand les mensurations nécessaires sont saisies. + +### Vape / sevrage (`/vape`) + +| Onglet | Contenu | +|---|---| +| **Consommation** | Recharges quotidiennes en ml, nicotine consommée, tendance, équivalent cigarettes | +| **Coûts & modèle** | Catalogue de produits (base, boosters, arômes, résistances), recettes DIY, coût de revient au ml, achats réels | +| **Résistances** | Changement de résistance en un clic, durée de vie moyenne, volume passé par résistance | +| **Économies** | Économies cumulées face à la référence tabac, cigarettes évitées, temps de vie récupéré, jalons santé | + +- **Référence tabac gelée** : date d'arrêt, cigarettes par jour avant l'arrêt, cigarettes par + paquet, prix du paquet. C'est l'ancre de tout le calcul d'économies. +- **Modèle de coût DIY** : chaque recette additionne `quantité × (prix du flacon ⁄ contenance)` + pour donner un coût au ml, auquel s'ajoute l'amortissement de la résistance. +- **Assistant de recette** : à partir d'un volume total, d'un taux de nicotine visé et d'un + booster, LifeTrack calcule les millilitres de booster, d'arôme et de base. +- **Jalons santé** : 12 étapes chronométrées depuis la date d'arrêt (20 minutes, 8 heures, + 24 heures, 48 heures, 72 heures, 2 semaines, 3 mois, 9 mois, 1 an, 5 ans, 10 ans, 15 ans). + +### Finances (`/finances`) + +| Page | Contenu | +|---|---| +| **Aperçu** (`/finances`) | Flux de trésorerie, dépenses par catégorie, diagramme Sankey, principaux marchands | +| **Transactions** (`/finances/transactions`) | Liste filtrable, catégorisation unitaire ou en masse, saisie manuelle | +| **Budgets** (`/finances/budgets`) | Budget mensuel par catégorie, avancement, projection de fin de mois | +| **Récurrents** (`/finances/recurrents`) | Abonnements et prélèvements détectés automatiquement, prochaine échéance estimée | +| **Comptes** (`/finances/comptes`) | Comptes courants, épargne, PayPal, espèces | +| **Catégories** (`/finances/categories`) | Arborescence de catégories (un jeu français complet est créé automatiquement) | + +- **Import de relevés** avec choix du compte, choix du profil bancaire, **aperçu avant + écriture** et **annulation d'un lot** en un clic. +- **Presets de banques intégrés** : CSV générique, BoursoBank / Boursorama, Crédit Agricole, + BNP Paribas, Société Générale, La Banque Postale, Caisse d'Épargne, Fortuneo, Revolut, N26, + OFX (toutes banques) et PayPal. +- **Déduplication** systématique : ré-importer le même fichier n'ajoute rien deux fois. +- **Moteur de règles** de catégorisation (libellé contenant, expression régulière, sens, + fourchette de montant, compte), avec priorité, aperçu et application en masse. Une + catégorisation manuelle n'est jamais écrasée. +- **Virements internes** : détection automatique des paires de transactions qui s'annulent + entre deux de vos comptes, pour ne pas les compter comme dépense. + +### Imports et connecteurs (`/imports`) + +- Assistant en trois étapes : **Fichier → Vérification → Import**, avec détection + automatique du format à partir de l'en-tête du fichier. +- Éditeur de mappage de colonnes (séparateur, encodage, format de date, colonnes) enregistré + dans un profil personnel. +- **Formats reconnus** : relevé bancaire CSV, relevé bancaire OFX, PayPal CSV, + Foodvisor CSV, Health Sync / Health Connect CSV, historique de poids CSV (date + poids). +- **Historique des imports** avec statistiques par lot et **annulation (rollback)** : + supprimer un lot supprime les lignes qu'il avait créées. +- Taille maximale d'un fichier : **20 Mio**. +- Carte « Connecteurs & API » : URL d'ingestion à coller dans une passerelle Android, lien + vers les clés d'appareil, et procédure d'export Foodvisor. + +### Réglages (`/reglages`) + +Cinq onglets : **Profil**, **Objectif**, **Vape**, **Appareils & API** (création et +révocation de clés d'API par appareil), **Application** (préférences locales au navigateur). + +--- + +## Captures d'écran + +**Il n'y en a pas, et ce n'est pas un oubli.** + +L'application a été développée et testée intégralement hors ligne : la suite de tests +backend et la compilation du frontend passent, mais **la pile Docker Compose n'a jamais été +démarrée** (le démon Docker était indisponible pendant tout le développement). Aucune +capture n'a donc pu être prise sur une instance réelle. + +Plutôt que de publier des images de synthèse trompeuses, cette section restera vide jusqu'à +votre premier démarrage. Une fois l'application lancée, les pages à capturer en priorité +sont : le tableau de bord, `/sante/poids`, `/vape/economies` et `/finances`. + +--- + +## Démarrage rapide (Docker Compose) + +### Prérequis + +- **Docker Desktop installé ET démarré.** Vérifiez que la baleine est bien active dans la + barre des tâches ; `docker compose version` doit répondre sans erreur. +- Environ 2 Go d'espace disque pour les images et la base. +- Aucun autre service n'écoute sur le port choisi (80 par défaut). + +> ⚠️ **À lire avant le premier lancement.** Le démon Docker était **hors service pendant +> toute la phase de développement** : la pile Compose n'a donc jamais été construite ni +> démarrée. Le code applicatif, lui, est validé (292 tests backend, compilation frontend). +> Attendez-vous éventuellement à un petit ajustement lors du tout premier `up` — le plus +> probable étant un temps de build long, ou une image de base à retélécharger. Si un +> conteneur refuse de démarrer, consultez ses journaux avec +> `docker compose logs api` avant toute autre chose. + +### 1. Créer le fichier `.env` + +Depuis la racine du dépôt : + +```bash +cp .env.example .env +``` + +Sous Windows (PowerShell) : + +```powershell +Copy-Item .env.example .env +``` + +### 2. Modifier les deux variables obligatoires + +Ouvrez `.env` et changez **impérativement** ces deux valeurs : + +| Variable | Valeur d'exemple à remplacer | Pourquoi | +|---|---|---| +| `LIFETRACK_JWT_SECRET` | `generate-a-long-random-string` | Signe vos jetons de session. Laissée telle quelle, n'importe qui pourrait forger une session valide. | +| `POSTGRES_PASSWORD` | `change-me` | Mot de passe de la base PostgreSQL. | + +Pour générer un secret solide : + +```bash +openssl rand -hex 32 +``` + +Sans `openssl` (PowerShell) : + +```powershell +-join ((1..64) | ForEach-Object { '0123456789abcdef'[(Get-Random -Maximum 16)] }) +``` + +Les autres variables ont des valeurs par défaut utilisables telles quelles : + +| Variable | Défaut | Rôle | +|---|---|---| +| `POSTGRES_USER` | `lifetrack` | Utilisateur PostgreSQL | +| `POSTGRES_DB` | `lifetrack` | Nom de la base | +| `LIFETRACK_TIMEZONE` | `Europe/Paris` | Fuseau des agrégations journalières | +| `WEB_PORT` | `80` | Port d'écoute de l'interface web | + +Deux variables restent commentées et ne servent qu'au développement : +`LIFETRACK_CORS_ORIGINS` (origines autorisées quand Vite tourne sans proxy) et +`LIFETRACK_MODULES` (liste blanche de modules backend à charger ; vide = tous). + +> `LIFETRACK_DATABASE_URL` n'est **pas** à renseigner dans `.env` : `docker-compose.yml` la +> compose automatiquement à partir de `POSTGRES_USER`, `POSTGRES_PASSWORD` et `POSTGRES_DB`. + +### 3. Lancer la pile + +```bash +docker compose up -d --build +``` + +Trois conteneurs démarrent : `postgres` (base de données), `api` (FastAPI) et `web` +(nginx servant le frontend compilé et relayant `/api/` vers l'API). Le premier build prend +plusieurs minutes. + +Suivre le démarrage : + +```bash +docker compose logs -f +``` + +### 4. Ouvrir l'application + + + +Si vous avez changé `WEB_PORT`, adaptez l'adresse (par exemple `http://localhost:8080`). +Depuis un autre appareil du réseau local, utilisez l'adresse IP du serveur. + +Deux adresses utiles : + +- — doit répondre `{"status":"ok"}` +- — documentation interactive de l'API + +### Arrêter, mettre à jour, repartir de zéro + +```bash +docker compose down # arrêt, les données sont conservées +docker compose up -d --build # reconstruction après mise à jour du code +docker compose down -v # ⚠️ supprime AUSSI le volume : toutes vos données +``` + +--- + +## Premier démarrage + +Au premier accès, LifeTrack détecte qu'aucun compte n'existe et vous redirige vers l'écran +de création. + +**Étape 1 — Compte administrateur.** Adresse e-mail, prénom ou pseudonyme (facultatif) et +mot de passe de **8 caractères minimum**. C'est le seul compte de l'instance : il n'y a pas +de page d'inscription publique. Vous êtes connecté immédiatement après validation et arrivez +sur le tableau de bord. + +**Étape 2 — Profil corporel.** Rendez-vous dans **Réglages → Profil** et renseignez taille, +sexe, date de naissance et niveau d'activité. Sans ces informations, LifeTrack ne peut pas +calculer votre métabolisme de base, donc ni votre dépense estimée ni votre budget calorique. + +**Étape 3 — Objectif.** Dans **Réglages → Objectif**, indiquez poids de départ, poids cible +et mode de calcul (rythme hebdomadaire, date cible ou maintien). Le budget calorique +quotidien et la date d'atteinte estimée s'affichent aussitôt. C'est aussi ici que vous +accédez à votre **planning de pesées et de séances**. + +**Étape 4 (facultative) — Vape.** Dans **Réglages → Vape**, saisissez votre date d'arrêt du +tabac et votre consommation d'avant : sans cette référence, le module vape reste en mode +« non configuré » et n'affiche aucune économie. + +Ensuite seulement : importez vos premières données depuis **Imports**, ou saisissez-les à la +main. Le [guide d'utilisation](docs/GUIDE.md) détaille chaque parcours. + +--- + +## Mode développement + +Commandes réellement exécutées et validées sur ce dépôt (Windows, PowerShell ou Git Bash). + +### Base de données seule + +Le démon Docker doit tourner. Depuis la racine : + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml up postgres +``` + +L'overlay de développement publie le port `5432` sur l'hôte, ce qui permet de faire tourner +l'API directement sur la machine. + +### Backend (FastAPI) + +L'environnement virtuel Python est déjà présent dans `apps/api/.venv`. + +```bash +cd apps/api + +# Suite de tests — 292 tests, exécutés sur SQLite en mémoire +.venv/Scripts/python.exe -m pytest + +# Lint et formatage +.venv/Scripts/ruff.exe check . +.venv/Scripts/ruff.exe format . + +# Serveur de développement avec rechargement à chaud +.venv/Scripts/python.exe -m uvicorn app.main:app --reload --port 8000 +``` + +L'API écoute alors sur , documentation sur +. + +Si l'environnement virtuel doit être recréé : + +```bash +cd apps/api +python -m venv .venv +.venv/Scripts/python.exe -m pip install -r requirements.txt -r requirements-dev.txt +``` + +> Les tests tournent sur **SQLite en mémoire** alors que la production utilise +> **PostgreSQL 16** : tous les types de colonnes doivent rester portables (voir +> [CONVENTIONS.md](CONVENTIONS.md) §C8). + +### Frontend (React + Vite) + +```bash +cd apps/web + +npm install # une seule fois +npm run dev # serveur de développement sur http://localhost:5173 +npm run build # tsc --noEmit puis build de production +npm run preview # prévisualiser le build +``` + +**Proxy Vite** : `vite.config.ts` redirige tout `/api` vers `http://localhost:8000`. Vous +n'avez donc rien à configurer côté CORS tant que l'API tourne sur le port 8000 — le frontend +appelle simplement `/api/...` en chemin relatif, exactement comme en production derrière +nginx. + +Si vous préférez ne pas utiliser le proxy, décommentez `LIFETRACK_CORS_ORIGINS` dans `.env` : + +``` +LIFETRACK_CORS_ORIGINS=["http://localhost:5173"] +``` + +### Tout en conteneurs, avec rechargement à chaud + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml up +``` + +L'API tourne alors en `--reload` sur le port 8000 et Vite sur le port 5173, les sources +étant montées depuis l'hôte. + +--- + +## Architecture + +### Organisation du dépôt + +``` +LifeTrack/ +├─ apps/ +│ ├─ api/ # Backend FastAPI +│ │ ├─ app/ +│ │ │ ├─ core/ # config, base, sécurité, pagination, erreurs, +│ │ │ │ # framework d'import et d'ingestion, chargeur de modules +│ │ │ ├─ modules/ # auth, health, vape, finance, imports +│ │ │ ├─ tests/ # 292 tests pytest +│ │ │ └─ main.py # création de l'app — ne jamais modifier pour un module +│ │ ├─ requirements.txt +│ │ └─ openapi.json # schéma OpenAPI exporté +│ └─ web/ # Frontend React + TypeScript +│ └─ src/ +│ ├─ app/ # routeur, layout, authentification — ne pas modifier +│ ├─ components/ # composants partagés (ui/, charts/) +│ ├─ lib/ # wrapper API, formatage fr-FR +│ └─ modules/ # home, health, vape, finance, imports, settings +├─ docker/ # Dockerfiles et configuration nginx +├─ docs/ # documents de conception et de recherche +├─ docker-compose.yml +├─ docker-compose.dev.yml +├─ .env.example +├─ CONVENTIONS.md # règles obligatoires pour toute contribution +└─ DESIGN.md # synthèse des décisions de conception +``` + +### Modules auto-découverts + +C'est la décision structurante du projet : **ajouter un module ne demande de modifier aucun +fichier existant.** + +- **Côté backend**, `app/core/module_loader.py` parcourt `app/modules/` avec `pkgutil`. Tout + dossier qui expose un `router.py` contenant `router = APIRouter(prefix="/")` est monté + automatiquement sous `/api`. Les fichiers `models.py`, `importers.py` et `ingest.py` sont + importés au démarrage pour peupler les métadonnées SQLAlchemy et les registres de + connecteurs. +- **Côté frontend**, `src/app/modules.ts` utilise `import.meta.glob` sur + `src/modules/*/index.ts`. Chaque module exporte par défaut un `ModuleManifest` déclarant son + identifiant, son titre français, son ordre, ses routes et ses entrées de navigation. + +Pour créer un module, suivez [CONVENTIONS.md](CONVENTIONS.md) §C2 (backend) et §C5 +(frontend). Les règles y sont normatives : nommage, unités canoniques, pagination, gestion +des erreurs, portabilité SQLite. + +### Où vivent les données + +- **PostgreSQL 16**, dans le volume Docker nommé `pgdata`. Il survit à `docker compose down` + mais **pas** à `docker compose down -v`. +- Le schéma est créé au démarrage par `Base.metadata.create_all` — il n'y a **pas encore de + migrations Alembic** en v1. +- Conventions de stockage : horodatages en **UTC**, jours locaux en `Date`, poids en **kg**, + distances en **m**, volumes en **ml**, énergie en **kcal**, durées en **secondes**, argent + en **centimes d'euro**. L'affichage en `fr-FR` (virgule décimale, `€` après le montant) est + fait côté interface. +- Sauvegarde recommandée : + ```bash + docker compose exec postgres pg_dump -U lifetrack lifetrack > sauvegarde.sql + ``` + +### API + +- Toutes les routes sont préfixées par **`/api`**, suivi du nom du module : + `/api/auth`, `/api/health`, `/api/vape`, `/api/finance`, `/api/imports`, `/api/ingest`. +- En production, nginx relaie `/api/` vers le conteneur `api` sur le port 8000 ; tout le + reste retombe sur `index.html` (application monopage). +- Format d'erreur unique : `{"error": {"code", "message", "details"}}`, `code` stable en + snake_case, `message` en français. +- Les dates-heures sont en UTC ISO 8601, les jours locaux en `YYYY-MM-DD`, et les agrégations + journalières acceptent un paramètre `?tz=` (défaut `Europe/Paris`). + +### Authentification + +Deux mécanismes, pour deux usages : + +1. **JWT** — pour vous, dans le navigateur. Obtenu via `POST /api/auth/login`, envoyé dans + l'en-tête `Authorization: Bearer `. Mots de passe hachés en argon2. Durée de + validité : **7 jours** (choix assumé pour un déploiement domestique). +2. **Clés d'appareil** — pour vos machines. Créées dans **Réglages → Appareils & API**, + préfixées `ltk_`, envoyées dans l'en-tête **`X-API-Key`**, et portant des **portées** + explicites (`ingest:health`, `ingest:*`, …). La clé en clair n'est affichée **qu'une seule + fois** à la création ; seul son empreinte est stockée. Chaque clé est révocable + individuellement. + +Tout endpoint hors `POST /api/auth/login`, `POST /api/auth/setup`, `GET /api/auth/status` et +`/api/healthz` exige une authentification et filtre systématiquement les données par +utilisateur. + +--- + +## Stack technique + +| Couche | Technologies | +|---|---| +| Backend | Python 3.12 · FastAPI · SQLAlchemy 2.0 typé · Pydantic v2 · PyJWT · argon2 (pwdlib) | +| Base de données | PostgreSQL 16 (pilote psycopg 3) — SQLite en mémoire pour les tests | +| Frontend | React 18 · TypeScript strict · Vite 6 · TailwindCSS · Apache ECharts · TanStack Query · React Router | +| Déploiement | Docker Compose : `postgres` + `api` + `web` (nginx 1.27) | +| Qualité | pytest (292 tests) · ruff · `tsc --noEmit` | + +Aucune ressource externe n'est chargée à l'exécution : polices, icônes et bibliothèques sont +embarquées dans le build. + +--- + +## Feuille de route et écarts connus + +Ce qui suit est **décrit dans les documents de conception mais absent du code +d'aujourd'hui**. Rien de tout cela n'est nécessaire pour utiliser LifeTrack, mais autant le +savoir avant de le chercher dans l'interface. + +### Écarts entre la conception et le code actuel + +- **Table CIQUAL absente.** La recherche d'aliments prévoyait une base d'aliments génériques + français (ANSES). Le code ne contient ni script d'import ni données : seule la recherche + Open Food Facts fonctionne. Les plats maison se saisissent donc à la main. +- **Adaptateur webhook sans en-tête d'authentification non implémenté.** La recherche + prévoyait une route acceptant un jeton dans l'URL, pour les passerelles Android incapables + de poser un en-tête HTTP. Elle n'existe pas : `POST /api/ingest/{domain}` exige + `X-API-Key` ou un jeton JWT. Conséquence pratique détaillée dans le + [guide](docs/GUIDE.md#connecter-health-connect). +- **Ingestion limitée au domaine `health`.** L'écran de création de clé propose les portées + `ingest:vape` et `ingest:finance`, mais aucun gestionnaire ne les traite : + `POST /api/ingest/vape` et `POST /api/ingest/finance` répondent « Domaine d'ingestion + inconnu ». Ces portées sont réservées pour plus tard. +- **Pas de table de charges utiles brutes.** Chaque ligne conserve sa charge utile d'origine + dans une colonne `raw`, mais il n'existe pas de table dédiée permettant de rejouer une + normalisation après coup. +- **Importeurs de séances absents** : pas de lecteur TCX, GPX ou FIT. Les séances de sport se + saisissent à la main ou arrivent par l'ingestion JSON. +- **Importeurs MyFitnessPal et Cronometer absents** : seul Foodvisor dispose d'un profil + nutrition dédié. +- **Pas de migrations Alembic** : le schéma est créé au démarrage. Une modification de schéma + sur une base existante devra être gérée manuellement. + +### v2 — envisagé + +- Application compagnon Android maison (Kotlin, SDK Health Connect), avec vraie + authentification par jeton et rattrapage d'historique au-delà de 48 heures. +- Connecteur bancaire **Enable Banking** (PSD2, gratuit pour ses propres comptes) en + remplacement des imports de fichiers. +- Calculateur DIY avancé, migrations Alembic, notifications. + +### v3 — exploratoire + +- Enregistrement direct des séances de tapis de course via **Web Bluetooth + FTMS** + (Chrome/Edge uniquement, HTTPS requis). +- Analyse photo des repas, multi-utilisateurs complet, thème clair. + +--- + +## Documentation + +| Document | Contenu | +|---|---| +| [docs/GUIDE.md](docs/GUIDE.md) | **Guide d'utilisation au quotidien** : connecter Health Connect, suivre poids et calories, nutrition, vape, finances, dépannage | +| [CONVENTIONS.md](CONVENTIONS.md) | Règles obligatoires pour contribuer au code | +| [DESIGN.md](DESIGN.md) | Synthèse des décisions de conception | +| [docs/design/architecture.md](docs/design/architecture.md) | Architecture technique complète | +| [docs/design/datamodel-health-vape.md](docs/design/datamodel-health-vape.md) | Modèle de données et formules santé, nutrition, vape | +| [docs/design/datamodel-finance.md](docs/design/datamodel-finance.md) | Modèle de données et logique finances | +| [docs/design/ux-pages.md](docs/design/ux-pages.md) | Spécification UX page par page | +| [docs/design/addendum-planning.md](docs/design/addendum-planning.md) | Planning d'habitudes, assiduité, séries | +| [docs/research/health-connect.md](docs/research/health-connect.md) | Comment sortir les données de Health Connect | +| [docs/research/nutrition-sources.md](docs/research/nutrition-sources.md) | Foodvisor, Open Food Facts, CIQUAL | +| [docs/research/finance-sources.md](docs/research/finance-sources.md) | Formats d'export réels des banques françaises | + +--- + +## Licence + +**Aucune licence n'est déclarée** : ce dépôt est un projet personnel auto-hébergé, il n'est +pas publié sous licence libre. En l'absence de fichier `LICENSE`, tous droits sont réservés +par défaut. + +Notez les obligations attachées aux données tierces que vous consommerez : + +- **Open Food Facts** — base sous **ODbL**, contenus sous DbCL. Un usage personnel ne pose + aucune difficulté ; une attribution « Données produits : Open Food Facts (ODbL) » reste la + bonne pratique. +- Les applications passerelles Android citées dans la documentation ont leurs propres + licences (AGPL-3.0 pour `health-connect-webhook`, GPL-3.0 pour HCGateway) — elles ne sont + pas distribuées avec LifeTrack. diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/core/__init__.py b/apps/api/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py new file mode 100644 index 0000000..ca7a293 --- /dev/null +++ b/apps/api/app/core/config.py @@ -0,0 +1,33 @@ +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() diff --git a/apps/api/app/core/database.py b/apps/api/app/core/database.py new file mode 100644 index 0000000..9ce45dd --- /dev/null +++ b/apps/api/app/core/database.py @@ -0,0 +1,45 @@ +from collections.abc import Iterator +from typing import Any + +from sqlalchemy import Engine, create_engine, event +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.config import get_settings + + +class Base(DeclarativeBase): + """Single declarative base for the whole application.""" + + +def _build_engine(url: str) -> Engine: + if url.startswith("sqlite"): + # Test configuration (pytest runs on SQLite in-memory): a StaticPool + # shares the single in-memory database across sessions/threads. + return create_engine( + url, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + return create_engine(url, pool_pre_ping=True) + + +engine = _build_engine(get_settings().database_url) + +if engine.dialect.name == "sqlite": + # SQLite does not enforce foreign keys (and thus ON DELETE CASCADE, used by + # the import rollback) unless the pragma is enabled per connection. + @event.listens_for(engine, "connect") + def _enable_sqlite_fks(dbapi_connection: Any, _record: Any) -> None: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +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 diff --git a/apps/api/app/core/dependencies.py b/apps/api/app/core/dependencies.py new file mode 100644 index 0000000..16f5f14 --- /dev/null +++ b/apps/api/app/core/dependencies.py @@ -0,0 +1,81 @@ +from collections.abc import Callable +from typing import TYPE_CHECKING, Annotated + +from fastapi import Depends, Header +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.errors import ForbiddenError, UnauthorizedError +from app.core.security import decode_access_token, verify_device_key + +if TYPE_CHECKING: + from app.modules.auth.models import User + +bearer = HTTPBearer(auto_error=False) + +INGEST_WILDCARD_SCOPE = "ingest:*" + + +def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], + db: Annotated[Session, Depends(get_db)], +) -> "User": + """JWT Bearer -> User. Raises UnauthorizedError (401) otherwise.""" + from app.modules.auth.models import User + + if credentials is None: + raise UnauthorizedError("Authentification requise.") + user_id = decode_access_token(credentials.credentials) + user = db.get(User, user_id) + if user is None or not user.is_active: + raise UnauthorizedError("Session invalide ou expirée.") + return user + + +def authenticate_ingest( + db: Session, + credentials: HTTPAuthorizationCredentials | None, + api_key: str | None, + required_scope: str, +) -> "User": + """Shared ingest authentication: JWT Bearer OR X-API-Key device key. + + A device key must hold `required_scope` (ex: "ingest:health") or the + wildcard "ingest:*". Raises 401/403 accordingly. + """ + from app.modules.auth.models import User + + if api_key: + key = verify_device_key(db, api_key) + scopes = key.scopes or [] + if required_scope not in scopes and INGEST_WILDCARD_SCOPE not in scopes: + raise ForbiddenError( + "Cette clé d'appareil ne dispose pas du droit requis.", + details={"required_scope": required_scope}, + ) + user = db.get(User, key.user_id) + if user is None or not user.is_active: + raise UnauthorizedError("Clé d'appareil invalide.") + return user + if credentials is not None: + return get_current_user(credentials, db) + raise UnauthorizedError("Authentification requise.") + + +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) + """ + + def dependency( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], + db: Annotated[Session, Depends(get_db)], + x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, + ) -> "User": + return authenticate_ingest(db, credentials, x_api_key, required_scope) + + return dependency diff --git a/apps/api/app/core/errors.py b/apps/api/app/core/errors.py new file mode 100644 index 0000000..6b819cb --- /dev/null +++ b/apps/api/app/core/errors.py @@ -0,0 +1,94 @@ +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + + +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" + + +class PayloadTooLargeError(AppError): + status_code, code = 413, "payload_too_large" + + +_HTTP_CODES = { + 400: "bad_request", + 401: "unauthorized", + 403: "forbidden", + 404: "not_found", + 405: "method_not_allowed", + 409: "conflict", + 413: "payload_too_large", + 422: "validation_error", + 500: "internal_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": jsonable_encoder(exc.errors())}, + ), + ) + + @app.exception_handler(StarletteHTTPException) + async def http_exception_handler( + _: Request, exc: StarletteHTTPException + ) -> JSONResponse: + # Normalize framework-raised HTTP errors (404 route, 405…) to the + # single error shape of §8.3. + code = _HTTP_CODES.get(exc.status_code, "error") + message = ( + exc.detail if isinstance(exc.detail, str) else "Une erreur est survenue." + ) + return JSONResponse( + status_code=exc.status_code, content=_payload(code, message) + ) diff --git a/apps/api/app/core/importing/__init__.py b/apps/api/app/core/importing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/core/importing/base.py b/apps/api/app/core/importing/base.py new file mode 100644 index 0000000..160cc8f --- /dev/null +++ b/apps/api/app/core/importing/base.py @@ -0,0 +1,63 @@ +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 ImporterParseError(Exception): + """Fatal error: the whole file is unreadable/malformed for this importer.""" + + +class RowError(Exception): + """Non-fatal error on a single row; the run keeps going.""" + + +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",)… + + # Set by the imports service (run_import) before the upsert loop, so that + # rows written by `upsert` can reference the ImportRun (FK ondelete CASCADE + # -> central rollback). + import_run_id: int | None = None + + @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.""" diff --git a/apps/api/app/core/importing/hashing.py b/apps/api/app/core/importing/hashing.py new file mode 100644 index 0000000..1327750 --- /dev/null +++ b/apps/api/app/core/importing/hashing.py @@ -0,0 +1,10 @@ +import hashlib +import json + +from app.core.importing.base import NormalizedRecord + + +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() diff --git a/apps/api/app/core/importing/registry.py b/apps/api/app/core/importing/registry.py new file mode 100644 index 0000000..33e9549 --- /dev/null +++ b/apps/api/app/core/importing/registry.py @@ -0,0 +1,17 @@ +from app.core.importing.base import BaseImporter + +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 diff --git a/apps/api/app/core/ingest/__init__.py b/apps/api/app/core/ingest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/core/ingest/base.py b/apps/api/app/core/ingest/base.py new file mode 100644 index 0000000..6112254 --- /dev/null +++ b/apps/api/app/core/ingest/base.py @@ -0,0 +1,25 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, ClassVar + +from sqlalchemy.orm import Session + +from app.core.importing.base import UpsertOutcome + + +@dataclass +class IngestRecord: + type: str # ex: "steps", "weight", "workout" + data: dict[str, Any] + external_id: str | None = None + source: str = "ingest" # sender-declared source (ex: "android_bridge") + + +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: ... diff --git a/apps/api/app/core/ingest/registry.py b/apps/api/app/core/ingest/registry.py new file mode 100644 index 0000000..03a9e5a --- /dev/null +++ b/apps/api/app/core/ingest/registry.py @@ -0,0 +1,11 @@ +from app.core.ingest.base import BaseIngestHandler + +INGEST_REGISTRY: dict[str, BaseIngestHandler] = {} + + +def register_ingest_handler(cls: type[BaseIngestHandler]) -> type[BaseIngestHandler]: + handler = cls() + if handler.domain in INGEST_REGISTRY: + raise RuntimeError(f"Duplicate ingest handler for domain: {handler.domain}") + INGEST_REGISTRY[handler.domain] = handler + return cls diff --git a/apps/api/app/core/mixins.py b/apps/api/app/core/mixins.py new file mode 100644 index 0000000..8822a13 --- /dev/null +++ b/apps/api/app/core/mixins.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from sqlalchemy import JSON, DateTime, String, func +from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import Mapped, mapped_column + +# Portable JSON type (see CONVENTIONS C8): plain JSON on SQLite (tests), +# JSONB on PostgreSQL (production). Use this alias for every JSON column. +JSONB_V = JSON().with_variant(postgresql.JSONB(), "postgresql") + + +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) diff --git a/apps/api/app/core/module_loader.py b/apps/api/app/core/module_loader.py new file mode 100644 index 0000000..26a831c --- /dev/null +++ b/apps/api/app/core/module_loader.py @@ -0,0 +1,103 @@ +import importlib +import os +import pkgutil +from collections.abc import Iterable, Iterator + +from fastapi import APIRouter, FastAPI +from starlette.routing import BaseRoute + +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") + +# Optional comma-separated whitelist of module folder names; unset/empty means +# "discover everything". Used by parallel test runs to isolate modules. +_MODULES_ENV_VAR = "LIFETRACK_MODULES" + + +def _whitelist() -> set[str] | None: + raw = os.environ.get(_MODULES_ENV_VAR, "").strip() + if not raw: + return None + return {part.strip() for part in raw.split(",") if part.strip()} + + +def iter_module_names() -> list[str]: + allowed = _whitelist() + names = sorted( + info.name + for info in pkgutil.iter_modules(modules_pkg.__path__) + if not info.name.startswith("_") + ) + if allowed is not None: + names = [name for name in names if name in allowed] + return names + + +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): + # RuntimeError (not TypeError): broken module contract, per §3.4. + raise RuntimeError( # noqa: TRY004 + f"Module '{name}' must expose an APIRouter named 'router' in router.py" + ) + app.include_router(router, prefix="/api") + # Optional extra routers mounted under a different prefix (ex: the + # imports module also serves /api/ingest/{domain}, see §5.5). + extra_routers = getattr(mod, "extra_routers", None) or [] + for extra in extra_routers: + if not isinstance(extra, APIRouter): + # RuntimeError (not TypeError): broken module contract, per §3.4. + raise RuntimeError( # noqa: TRY004 + f"Module '{name}': every item of 'extra_routers' must be an APIRouter" + ) + app.include_router(extra, prefix="/api") + + +def iter_mounted_routes(app: FastAPI) -> Iterator[tuple[str, frozenset[str]]]: + """Yield `(full_path, http_methods)` for every route mounted on `app`. + + `app.routes` cannot be read directly: since FastAPI 0.140 `include_router()` + appends an opaque `_IncludedRouter` wrapper instead of copying the + sub-router's routes into the parent, so module endpoints never appear as + top-level `APIRoute` objects. This walks the wrappers (recursively, since a + module may itself include sub-routers — `imports` does) and rebuilds the + effective paths. Falls back to plain iteration on older FastAPI versions. + """ + yield from _walk_routes(app.routes, "") + + +def _walk_routes( + routes: Iterable[BaseRoute], prefix: str +) -> Iterator[tuple[str, frozenset[str]]]: + for route in routes: + included = getattr(route, "original_router", None) + if included is not None: + # `include_context.prefix` already carries the parent router's own + # prefix, so it is not accumulated twice. + context = getattr(route, "include_context", None) + yield from _walk_routes( + included.routes, prefix + getattr(context, "prefix", "") + ) + continue + path = getattr(route, "path", None) + if path is None: + continue + yield prefix + path, frozenset(getattr(route, "methods", None) or ()) diff --git a/apps/api/app/core/pagination.py b/apps/api/app/core/pagination.py new file mode 100644 index 0000000..e1514a3 --- /dev/null +++ b/apps/api/app/core/pagination.py @@ -0,0 +1,35 @@ +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 diff --git a/apps/api/app/core/security.py b/apps/api/app/core/security.py new file mode 100644 index 0000000..08ca130 --- /dev/null +++ b/apps/api/app/core/security.py @@ -0,0 +1,90 @@ +import hashlib +import hmac +import secrets +from datetime import timedelta +from typing import TYPE_CHECKING + +import jwt +from pwdlib import PasswordHash +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.errors import UnauthorizedError +from app.core.timeutils import utcnow + +if TYPE_CHECKING: + from app.modules.auth.models import DeviceApiKey + +password_hasher = PasswordHash.recommended() + +DEVICE_KEY_PREFIX = "ltk" + + +def hash_password(password: str) -> str: + return password_hasher.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + return password_hasher.verify(password, password_hash) + + +def create_access_token(user_id: int) -> str: + settings = get_settings() + now = utcnow() + payload = { + "sub": str(user_id), + "iat": now, + "exp": now + timedelta(minutes=settings.access_token_expire_minutes), + } + return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> int: + settings = get_settings() + try: + payload = jwt.decode( + token, settings.jwt_secret, algorithms=[settings.jwt_algorithm] + ) + return int(payload["sub"]) + except (jwt.PyJWTError, KeyError, ValueError) as exc: + raise UnauthorizedError("Session invalide ou expirée.") from exc + + +def generate_device_key() -> tuple[str, str, str]: + """Return (full_key, prefix, key_hash) for a new device API key. + + Format: ltk__. Only the sha256 of the full key is stored; + the plaintext key is shown to the user exactly once. + """ + prefix = secrets.token_hex(4) # 8 hex chars + secret = secrets.token_urlsafe(32) + full_key = f"{DEVICE_KEY_PREFIX}_{prefix}_{secret}" + key_hash = hashlib.sha256(full_key.encode("utf-8")).hexdigest() + return full_key, prefix, key_hash + + +def verify_device_key(db: Session, full_key: str) -> "DeviceApiKey": + """Validate an X-API-Key value and return its DeviceApiKey row. + + Lookup by prefix, constant-time hash comparison, rejects revoked keys, + updates last_used_at. Raises UnauthorizedError on any failure. + """ + from app.modules.auth.models import DeviceApiKey + + invalid = UnauthorizedError("Clé d'appareil invalide.") + parts = full_key.split("_", 2) + if len(parts) != 3 or parts[0] != DEVICE_KEY_PREFIX: + raise invalid + prefix = parts[1] + key = db.scalar(select(DeviceApiKey).where(DeviceApiKey.key_prefix == prefix)) + if key is None: + raise invalid + candidate_hash = hashlib.sha256(full_key.encode("utf-8")).hexdigest() + if not hmac.compare_digest(candidate_hash, key.key_hash): + raise invalid + if key.revoked_at is not None: + raise UnauthorizedError("Clé d'appareil révoquée.") + key.last_used_at = utcnow() + db.commit() + return key diff --git a/apps/api/app/core/timeutils.py b/apps/api/app/core/timeutils.py new file mode 100644 index 0000000..c587265 --- /dev/null +++ b/apps/api/app/core/timeutils.py @@ -0,0 +1,57 @@ +from datetime import UTC, date, datetime +from typing import Annotated +from zoneinfo import ZoneInfo + +from pydantic import AfterValidator + +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() + + +def as_utc(value: datetime) -> datetime: + """Normalise a *stored* instant to UTC-aware. + + Every instant is persisted in UTC (CONVENTIONS C2.3), but the driver decides + what comes back: PostgreSQL `TIMESTAMPTZ` yields an aware datetime while + SQLite yields a naive one. Stamping UTC on naive values keeps responses + compliant with the `Z` suffix required by architecture §8.4 on both engines. + """ + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +def require_utc(value: datetime) -> datetime: + """Validate an *inbound* instant: naive is refused, aware is converted to UTC. + + Architecture §8.4: "entrée avec offset acceptée et convertie en UTC ; entrée + naïve refusée (422)". Silently reading a naive datetime as UTC would record a + local wall-clock time off by the client's offset. + """ + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError( + "Horodatage sans fuseau horaire : précisez le décalage " + "(par exemple « 2026-08-13T06:31:00Z »)." + ) + return value.astimezone(UTC) + + +# Use in request schemas (XxxCreate/XxxUpdate) for any client-supplied instant. +UtcDatetime = Annotated[datetime, AfterValidator(require_utc)] + +# Use in response schemas (XxxRead) for any instant read back from the database. +StoredUtcDatetime = Annotated[datetime, AfterValidator(as_utc)] diff --git a/apps/api/app/main.py b/apps/api/app/main.py new file mode 100644 index 0000000..69d1ee2 --- /dev/null +++ b/apps/api/app/main.py @@ -0,0 +1,63 @@ +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +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 architecture §3.4 + + @app.get("/api/healthz", include_in_schema=False) + def healthz() -> dict[str, str]: + return {"status": "ok"} + + # Bare aliases (no /api prefix) for container/orchestrator probes. + @app.get("/healthz", include_in_schema=False) + def healthz_alias() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/openapi.json", include_in_schema=False) + def openapi_alias() -> JSONResponse: + schema: dict[str, Any] = app.openapi() + return JSONResponse(schema) + + return app + + +app = create_app() diff --git a/apps/api/app/modules/__init__.py b/apps/api/app/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/modules/auth/__init__.py b/apps/api/app/modules/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/modules/auth/models.py b/apps/api/app/modules/auth/models.py new file mode 100644 index 0000000..698709a --- /dev/null +++ b/apps/api/app/modules/auth/models.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.core.mixins import JSONB_V, TimestampMixin + + +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( + JSONB_V, 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)) diff --git a/apps/api/app/modules/auth/router.py b/apps/api/app/modules/auth/router.py new file mode 100644 index 0000000..5236fdc --- /dev/null +++ b/apps/api/app/modules/auth/router.py @@ -0,0 +1,80 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.dependencies import get_current_user +from app.core.security import create_access_token +from app.modules.auth import service +from app.modules.auth.models import User +from app.modules.auth.schemas import ( + AuthStatus, + DeviceKeyCreate, + DeviceKeyCreated, + DeviceKeyRead, + LoginRequest, + SetupRequest, + TokenResponse, + UserRead, + UserUpdate, +) + +router = APIRouter(prefix="/auth", tags=["auth"]) + +DbDep = Annotated[Session, Depends(get_db)] +UserDep = Annotated[User, Depends(get_current_user)] + + +def _token_response(user: User) -> TokenResponse: + return TokenResponse( + access_token=create_access_token(user.id), + user=UserRead.model_validate(user), + ) + + +@router.get("/status", response_model=AuthStatus) +def auth_status(db: DbDep) -> AuthStatus: + required = service.setup_required(db) + return AuthStatus(setup_required=required, needs_setup=required) + + +@router.post("/setup", response_model=TokenResponse, status_code=201) +def setup(payload: SetupRequest, db: DbDep) -> TokenResponse: + user = service.create_first_user(db, payload) + return _token_response(user) + + +@router.post("/login", response_model=TokenResponse) +def login(payload: LoginRequest, db: DbDep) -> TokenResponse: + user = service.authenticate(db, payload.email, payload.password) + return _token_response(user) + + +@router.get("/me", response_model=UserRead) +def me(user: UserDep) -> User: + return user + + +@router.patch("/me", response_model=UserRead) +def update_me(payload: UserUpdate, db: DbDep, user: UserDep) -> User: + return service.update_me(db, user, payload) + + +@router.get("/device-keys", response_model=list[DeviceKeyRead]) +def list_device_keys(db: DbDep, user: UserDep) -> list[DeviceKeyRead]: + return service.list_device_keys(db, user.id) + + +@router.post("/device-keys", response_model=DeviceKeyCreated, status_code=201) +def create_device_key( + payload: DeviceKeyCreate, db: DbDep, user: UserDep +) -> DeviceKeyCreated: + key, plaintext = service.create_device_key(db, user.id, payload) + read = DeviceKeyRead.model_validate(key) + return DeviceKeyCreated(**read.model_dump(), key=plaintext) + + +@router.delete("/device-keys/{key_id}", status_code=204) +def revoke_device_key(key_id: int, db: DbDep, user: UserDep) -> None: + service.revoke_device_key(db, user.id, key_id) diff --git a/apps/api/app/modules/auth/schemas.py b/apps/api/app/modules/auth/schemas.py new file mode 100644 index 0000000..0a1231e --- /dev/null +++ b/apps/api/app/modules/auth/schemas.py @@ -0,0 +1,85 @@ +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.core.timeutils import StoredUtcDatetime + + +class AuthStatus(BaseModel): + # `setup_required` is the documented flag (§4.4); `needs_setup` is kept as + # a synonym for tooling compatibility. + setup_required: bool + needs_setup: bool + + +class SetupRequest(BaseModel): + email: str = Field( + min_length=3, max_length=255, pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$" + ) + password: str = Field(min_length=8, max_length=128) + display_name: str = Field(min_length=1, max_length=100) + + +class LoginRequest(BaseModel): + email: str + password: str + + +class UserRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + email: str + display_name: str + created_at: StoredUtcDatetime + + +class UserUpdate(BaseModel): + email: str | None = Field( + default=None, + min_length=3, + max_length=255, + pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$", + ) + display_name: str | None = Field(default=None, min_length=1, max_length=100) + password: str | None = Field(default=None, min_length=8, max_length=128) + current_password: str | None = None + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserRead + + +class DeviceKeyCreate(BaseModel): + name: str = Field(min_length=1, max_length=100) + scopes: list[str] = Field(min_length=1) + + @field_validator("scopes") + @classmethod + def check_scopes(cls, scopes: list[str]) -> list[str]: + for scope in scopes: + if scope != "ingest:*" and not ( + scope.startswith("ingest:") and len(scope) > len("ingest:") + ): + raise ValueError( + "Les droits doivent être de la forme « ingest: » ou « ingest:* »." + ) + return scopes + + +class DeviceKeyRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + key_prefix: str + scopes: list[str] + last_used_at: StoredUtcDatetime | None + created_at: StoredUtcDatetime + revoked_at: StoredUtcDatetime | None + + +class DeviceKeyCreated(DeviceKeyRead): + """Returned once at creation time: the only moment the plaintext key exists.""" + + key: str diff --git a/apps/api/app/modules/auth/service.py b/apps/api/app/modules/auth/service.py new file mode 100644 index 0000000..0b8e750 --- /dev/null +++ b/apps/api/app/modules/auth/service.py @@ -0,0 +1,109 @@ +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.errors import ( + ConflictError, + DomainValidationError, + ForbiddenError, + NotFoundError, + UnauthorizedError, +) +from app.core.security import ( + generate_device_key, + hash_password, + verify_password, +) +from app.core.timeutils import utcnow +from app.modules.auth.models import DeviceApiKey, User +from app.modules.auth.schemas import DeviceKeyCreate, SetupRequest, UserUpdate + + +def setup_required(db: Session) -> bool: + return (db.scalar(select(func.count()).select_from(User)) or 0) == 0 + + +def create_first_user(db: Session, data: SetupRequest) -> User: + if not setup_required(db): + raise ForbiddenError( + "Un utilisateur existe déjà : l'assistant de configuration est désactivé." + ) + user = User( + email=data.email.strip().lower(), + password_hash=hash_password(data.password), + display_name=data.display_name.strip(), + ) + db.add(user) + db.commit() + return user + + +def authenticate(db: Session, email: str, password: str) -> User: + user = db.scalar(select(User).where(User.email == email.strip().lower())) + if ( + user is None + or not user.is_active + or not verify_password(password, user.password_hash) + ): + raise UnauthorizedError("Adresse e-mail ou mot de passe incorrect.") + return user + + +def update_me(db: Session, user: User, data: UserUpdate) -> User: + if data.password is not None or ( + data.email is not None and data.email.strip().lower() != user.email + ): + if not data.current_password: + raise DomainValidationError( + "Le mot de passe actuel est requis pour cette modification." + ) + if not verify_password(data.current_password, user.password_hash): + raise UnauthorizedError("Mot de passe actuel incorrect.") + if data.email is not None: + email = data.email.strip().lower() + if email != user.email: + existing = db.scalar(select(User).where(User.email == email)) + if existing is not None: + raise ConflictError("Cette adresse e-mail est déjà utilisée.") + user.email = email + if data.display_name is not None: + user.display_name = data.display_name.strip() + if data.password is not None: + user.password_hash = hash_password(data.password) + db.commit() + return user + + +def list_device_keys(db: Session, user_id: int) -> list[DeviceApiKey]: + stmt = ( + select(DeviceApiKey) + .where(DeviceApiKey.user_id == user_id) + .order_by(DeviceApiKey.created_at.desc(), DeviceApiKey.id.desc()) + ) + return list(db.scalars(stmt).all()) + + +def create_device_key( + db: Session, user_id: int, data: DeviceKeyCreate +) -> tuple[DeviceApiKey, str]: + """Create a device key; returns (row, plaintext_key). The plaintext key is + returned exactly once and never stored.""" + full_key, prefix, key_hash = generate_device_key() + key = DeviceApiKey( + user_id=user_id, + name=data.name.strip(), + key_prefix=prefix, + key_hash=key_hash, + scopes=list(data.scopes), + ) + db.add(key) + db.commit() + return key, full_key + + +def revoke_device_key(db: Session, user_id: int, key_id: int) -> None: + key = db.get(DeviceApiKey, key_id) + if key is None or key.user_id != user_id: + raise NotFoundError("Clé d'appareil introuvable.") + if key.revoked_at is None: + key.revoked_at = utcnow() + db.commit() diff --git a/apps/api/app/modules/finance/__init__.py b/apps/api/app/modules/finance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/modules/finance/categorize.py b/apps/api/app/modules/finance/categorize.py new file mode 100644 index 0000000..37f875e --- /dev/null +++ b/apps/api/app/modules/finance/categorize.py @@ -0,0 +1,577 @@ +"""Rules engine, internal-transfer detection and recurring-series detection. + +Reference: datamodel-finance.md §5 (rules), §6 (transfers), §7 (recurring). +Nothing here ever overwrites a manual categorisation (`category_source = +'user'`) unless the caller explicitly forces it (§10.6). +""" + +import re +import uuid +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from datetime import date, timedelta +from decimal import Decimal +from itertools import pairwise +from statistics import median +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.errors import DomainValidationError, NotFoundError +from app.core.timeutils import utcnow +from app.modules.finance.enums import CategoryKind, CategorySource +from app.modules.finance.models import FinCategory, FinRule, FinTransaction +from app.modules.finance.normalize import merchant_key, normalize_label_for_hash + +TRANSFER_MAX_DAYS = 3 +TRANSFER_LABEL_RE = re.compile(r"\bVIR(EMENT)?\b|\bVIRT\b|TRANSFERT|PAYPAL") + +RULE_SCOPES = ("uncategorized", "all_non_manual", "all") + +# (name, min_interval_days, max_interval_days, nominal_interval_days) +PERIODICITIES: tuple[tuple[str, int, int, int], ...] = ( + ("weekly", 6, 8, 7), + ("monthly", 25, 35, 30), + ("quarterly", 85, 97, 91), + ("yearly", 350, 380, 365), +) +# Monthly normalisation factor used by `monthly_total_estimate` (§9.8). +MONTHLY_FACTOR: dict[str, Decimal] = { + "weekly": Decimal("4.33"), + "monthly": Decimal(1), + "quarterly": Decimal(1) / Decimal(3), + "yearly": Decimal(1) / Decimal(12), +} +MIN_OCCURRENCES = 3 +REGULARITY_RATIO = 0.8 + + +# --------------------------------------------------------------------------- +# Rules engine (§5) +# --------------------------------------------------------------------------- + + +def compile_matchers(matchers: dict[str, Any]) -> re.Pattern[str] | None: + """Compile `label_regex` once; raises DomainValidationError if invalid.""" + pattern = matchers.get("label_regex") + if not pattern: + return None + try: + return re.compile(pattern, re.IGNORECASE) + except re.error as exc: + raise DomainValidationError(f"Expression régulière invalide : {exc}") from exc + + +def validate_matchers(matchers: dict[str, Any]) -> None: + if not isinstance(matchers, dict): + raise DomainValidationError("Conditions de règle invalides.") + direction = matchers.get("direction") + if direction is not None and direction not in ("debit", "credit", "any"): + raise DomainValidationError( + "Sens invalide : utilisez « debit », « credit » ou « any »." + ) + conditions = ( + "label_contains", + "label_regex", + "amount_min", + "amount_max", + "account_id", + ) + has_condition = any( + matchers.get(key) not in (None, [], "") for key in conditions + ) or direction in ("debit", "credit") + if not has_condition: + raise DomainValidationError("La règle doit comporter au moins une condition.") + compile_matchers(matchers) + + +def validate_actions(actions: dict[str, Any]) -> None: + if not isinstance(actions, dict) or not any( + actions.get(key) not in (None, "", False) + for key in ( + "set_category_id", + "set_label_clean", + "set_counterparty", + "mark_transfer", + ) + ): + raise DomainValidationError("La règle doit comporter au moins une action.") + + +@dataclass +class CompiledRule: + rule: FinRule + regex: re.Pattern[str] | None + contains: tuple[str, ...] + + @property + def matchers(self) -> dict[str, Any]: + return self.rule.matchers or {} + + @property + def actions(self) -> dict[str, Any]: + return self.rule.actions or {} + + +def compile_rules(rules: list[FinRule]) -> list[CompiledRule]: + compiled: list[CompiledRule] = [] + for rule in rules: + matchers = rule.matchers or {} + try: + regex = compile_matchers(matchers) + except DomainValidationError: + regex = None # stored rule broke: ignore its regex, keep the rest + contains = tuple( + normalize_label_for_hash(str(needle)) + for needle in (matchers.get("label_contains") or []) + if str(needle).strip() + ) + compiled.append(CompiledRule(rule=rule, regex=regex, contains=contains)) + return compiled + + +def load_enabled_rules(db: Session, user_id: int) -> list[CompiledRule]: + stmt = ( + select(FinRule) + .where(FinRule.user_id == user_id, FinRule.enabled.is_(True)) + .order_by(FinRule.priority.asc(), FinRule.created_at.asc(), FinRule.id.asc()) + ) + return compile_rules(list(db.scalars(stmt).all())) + + +def rule_matches(compiled: CompiledRule, tx: FinTransaction) -> bool: + m = compiled.matchers + account_id = m.get("account_id") + if account_id and str(tx.account_id) != str(account_id): + return False + direction = m.get("direction") + if direction == "debit" and tx.amount >= 0: + return False + if direction == "credit" and tx.amount <= 0: + return False + if m.get("amount_min") is not None and tx.amount < Decimal(str(m["amount_min"])): + return False + if m.get("amount_max") is not None and tx.amount > Decimal(str(m["amount_max"])): + return False + if compiled.contains: + hay = ( + normalize_label_for_hash(tx.label_raw) + + " | " + + normalize_label_for_hash(tx.label_clean or "") + ) + if not any(needle in hay for needle in compiled.contains): + return False + return compiled.regex is None or bool( + compiled.regex.search(tx.label_raw or "") + or compiled.regex.search(tx.label_clean or "") + ) + + +def apply_rules_to_tx( + tx: FinTransaction, + rules: list[CompiledRule], + transfer_category_id: uuid.UUID | None, + *, + force: bool = False, +) -> list[uuid.UUID]: + """Mutate `tx` in place; returns the ids of the rules that fired (§5.3).""" + fired: list[uuid.UUID] = [] + for compiled in rules: + if not rule_matches(compiled, tx): + continue + actions = compiled.actions + protected = tx.category_source == CategorySource.USER and not force + if actions.get("set_category_id") and not protected: + tx.category_id = uuid.UUID(str(actions["set_category_id"])) + tx.category_source = CategorySource.RULE + tx.applied_rule_id = compiled.rule.id + if actions.get("set_label_clean"): + tx.label_clean = str(actions["set_label_clean"]) + if actions.get("set_counterparty"): + tx.counterparty = str(actions["set_counterparty"])[:150] + if actions.get("mark_transfer") and not protected and transfer_category_id: + tx.category_id = transfer_category_id + tx.category_source = CategorySource.RULE + tx.applied_rule_id = compiled.rule.id + fired.append(compiled.rule.id) + compiled.rule.hit_count += 1 + compiled.rule.last_applied_at = utcnow() + if compiled.rule.stop: + break + return fired + + +def get_transfer_category_id(db: Session, user_id: int) -> uuid.UUID | None: + stmt = select(FinCategory.id).where( + FinCategory.user_id == user_id, + FinCategory.kind == CategoryKind.TRANSFER, + ) + return db.scalars(stmt.limit(1)).first() + + +@dataclass +class RuleApplyResult: + scanned: int = 0 + matched: int = 0 + updated: int = 0 + dry_run: bool = False + by_rule: list[dict[str, Any]] = field(default_factory=list) + + +def apply_rules( + db: Session, + user_id: int, + *, + scope: str = "uncategorized", + date_from: date | None = None, + date_to: date | None = None, + account_id: uuid.UUID | None = None, + rule_id: uuid.UUID | None = None, + dry_run: bool = False, + force: bool = False, +) -> RuleApplyResult: + """Re-run the rules over stored transactions (§5.4).""" + if scope not in RULE_SCOPES: + raise DomainValidationError( + "Portée inconnue : utilisez « uncategorized », « all_non_manual » " + "ou « all »." + ) + if scope == "all" and not force: + raise DomainValidationError( + "La portée « all » écrase les catégories manuelles : " + "confirmez avec force=true." + ) + + rules = load_enabled_rules(db, user_id) + if rule_id is not None: + rules = [c for c in rules if c.rule.id == rule_id] + if not rules: + raise NotFoundError("Règle introuvable.") + + stmt = select(FinTransaction).where(FinTransaction.user_id == user_id) + if scope == "uncategorized": + stmt = stmt.where(FinTransaction.category_id.is_(None)) + elif scope == "all_non_manual": + stmt = stmt.where( + (FinTransaction.category_source.is_(None)) + | (FinTransaction.category_source != CategorySource.USER) + ) + if date_from is not None: + stmt = stmt.where(FinTransaction.booked_date >= date_from) + if date_to is not None: + stmt = stmt.where(FinTransaction.booked_date <= date_to) + if account_id is not None: + stmt = stmt.where(FinTransaction.account_id == account_id) + stmt = stmt.order_by(FinTransaction.booked_date.asc(), FinTransaction.id.asc()) + + transfer_cat = get_transfer_category_id(db, user_id) + result = RuleApplyResult(dry_run=dry_run) + per_rule: Counter[uuid.UUID] = Counter() + + for tx in db.scalars(stmt.execution_options(yield_per=1000)): + result.scanned += 1 + before = (tx.category_id, tx.category_source, tx.label_clean, tx.counterparty) + fired = apply_rules_to_tx(tx, rules, transfer_cat, force=force) + if fired: + result.matched += 1 + for fired_id in fired: + per_rule[fired_id] += 1 + after = (tx.category_id, tx.category_source, tx.label_clean, tx.counterparty) + if before != after: + result.updated += 1 + + result.by_rule = [ + { + "rule_id": compiled.rule.id, + "name": compiled.rule.name, + "matched": per_rule.get(compiled.rule.id, 0), + } + for compiled in rules + if per_rule.get(compiled.rule.id, 0) + ] + if dry_run: + db.rollback() + else: + db.commit() + return result + + +def preview_rule( + db: Session, + user_id: int, + matchers: dict[str, Any], + limit: int = 50, +) -> tuple[list[FinTransaction], int]: + """Transactions that an unsaved rule would match (§9.4 `/rules/preview`).""" + validate_matchers(matchers) + probe = FinRule( + id=uuid.uuid4(), + user_id=user_id, + name="preview", + matchers=matchers, + actions={}, + ) + compiled = compile_rules([probe])[0] + stmt = ( + select(FinTransaction) + .where(FinTransaction.user_id == user_id) + .order_by(FinTransaction.booked_date.desc(), FinTransaction.id.desc()) + ) + matches: list[FinTransaction] = [] + total = 0 + for tx in db.scalars(stmt.execution_options(yield_per=1000)): + if rule_matches(compiled, tx): + total += 1 + if len(matches) < limit: + matches.append(tx) + return matches, total + + +# --------------------------------------------------------------------------- +# Internal transfers (§6) +# --------------------------------------------------------------------------- + + +def detect_transfers( + db: Session, + user_id: int, + date_min: date | None = None, + date_max: date | None = None, +) -> int: + """Pair opposite transactions across accounts; returns the pairs created.""" + stmt = select(FinTransaction).where( + FinTransaction.user_id == user_id, + FinTransaction.transfer_group_id.is_(None), + ) + if date_min is not None: + stmt = stmt.where(FinTransaction.booked_date >= date_min) + if date_max is not None: + stmt = stmt.where(FinTransaction.booked_date <= date_max) + txs = list(db.scalars(stmt).all()) + + debits = [t for t in txs if t.amount < 0] + credits_by_amount: dict[Decimal, list[FinTransaction]] = defaultdict(list) + for t in txs: + if t.amount > 0: + credits_by_amount[t.amount].append(t) + + pairs: list[tuple[int, FinTransaction, FinTransaction]] = [] + for debit in debits: + for credit in credits_by_amount.get(-debit.amount, []): + if credit.account_id == debit.account_id: + continue + if credit.currency != debit.currency: + continue + delta = abs((credit.booked_date - debit.booked_date).days) + if delta > TRANSFER_MAX_DAYS: + continue + score = (TRANSFER_MAX_DAYS - delta) * 10 + joined = normalize_label_for_hash(f"{debit.label_raw} {credit.label_raw}") + if TRANSFER_LABEL_RE.search(joined): + score += 5 + pairs.append((score, debit, credit)) + + pairs.sort(key=lambda p: (-p[0], p[1].booked_date, str(p[1].id))) + transfer_cat = get_transfer_category_id(db, user_id) + used: set[uuid.UUID] = set() + created = 0 + for _score, debit, credit in pairs: + if debit.id in used or credit.id in used: + continue + group_id = uuid.uuid4() + for leg in (debit, credit): + leg.transfer_group_id = group_id + if leg.category_source != CategorySource.USER: + leg.category_id = transfer_cat + leg.category_source = CategorySource.RULE + used |= {debit.id, credit.id} + created += 1 + return created + + +def link_transfer( + db: Session, user_id: int, id_a: uuid.UUID, id_b: uuid.UUID +) -> uuid.UUID: + """Manual pairing (§6): opposite amounts, different accounts.""" + if id_a == id_b: + raise DomainValidationError("Sélectionnez deux transactions différentes.") + legs = list( + db.scalars( + select(FinTransaction).where( + FinTransaction.user_id == user_id, + FinTransaction.id.in_([id_a, id_b]), + ) + ).all() + ) + if len(legs) != 2: + raise NotFoundError("Transaction introuvable.") + first, second = legs + if first.amount + second.amount != 0: + raise DomainValidationError( + "Les montants doivent être strictement opposés pour former " + "un virement interne." + ) + if first.account_id == second.account_id: + raise DomainValidationError( + "Les deux jambes d'un virement doivent appartenir à des comptes différents." + ) + if first.currency != second.currency: + raise DomainValidationError("Les deux jambes doivent être dans la même devise.") + group_id = uuid.uuid4() + transfer_cat = get_transfer_category_id(db, user_id) + for leg in legs: + leg.transfer_group_id = group_id + if leg.category_source != CategorySource.USER: + leg.category_id = transfer_cat + leg.category_source = CategorySource.RULE + db.commit() + return group_id + + +def unlink_transfer(db: Session, user_id: int, group_id: uuid.UUID) -> None: + legs = list( + db.scalars( + select(FinTransaction).where( + FinTransaction.user_id == user_id, + FinTransaction.transfer_group_id == group_id, + ) + ).all() + ) + if not legs: + raise NotFoundError("Virement introuvable.") + transfer_cat = get_transfer_category_id(db, user_id) + for leg in legs: + leg.transfer_group_id = None + if ( + leg.category_id == transfer_cat + and leg.category_source == CategorySource.RULE + ): + leg.category_id = None + leg.category_source = None + db.commit() + + +# --------------------------------------------------------------------------- +# Recurring series (§7) +# --------------------------------------------------------------------------- + + +@dataclass +class RecurringSeries: + merchant_key: str + label_display: str + category_id: uuid.UUID | None + category_name: str | None + periodicity: str + occurrences: int + average_amount: Decimal + expected_amount: Decimal + last_date: date + next_date_predicted: date + is_active: bool + + +def _most_common(values: list[str]) -> str: + counter = Counter(v for v in values if v) + return counter.most_common(1)[0][0] if counter else "" + + +def detect_recurring( + db: Session, + user_id: int, + *, + direction: str = "debit", + lookback_months: int = 18, + include_inactive: bool = False, + today: date | None = None, +) -> list[RecurringSeries]: + """On-the-fly recurring detection (§7.2) — no dedicated table in v1.""" + today = today or utcnow().date() + since = today - timedelta(days=int(lookback_months * 30.44)) + stmt = select(FinTransaction).where( + FinTransaction.user_id == user_id, + FinTransaction.booked_date >= since, + FinTransaction.transfer_group_id.is_(None), + ) + stmt = stmt.where( + FinTransaction.amount < 0 if direction == "debit" else FinTransaction.amount > 0 + ) + txs = list(db.scalars(stmt).all()) + if not txs: + return [] + + category_names = dict( + db.execute( + select(FinCategory.id, FinCategory.name).where( + FinCategory.user_id == user_id + ) + ).all() + ) + transfer_cat = get_transfer_category_id(db, user_id) + + groups: dict[str, list[FinTransaction]] = defaultdict(list) + for tx in txs: + if transfer_cat is not None and tx.category_id == transfer_cat: + continue + groups[merchant_key(tx.label_raw, tx.counterparty)].append(tx) + + series: list[RecurringSeries] = [] + for key, items in groups.items(): + if not key or len(items) < MIN_OCCURRENCES: + continue + dates = sorted({t.booked_date for t in items}) + if len(dates) < MIN_OCCURRENCES: + continue + intervals = [(b - a).days for a, b in pairwise(dates)] + med_int = median(intervals) + period = next( + (p for p in PERIODICITIES if p[1] <= med_int <= p[2]), + None, + ) + if period is None: + continue + regular = sum(1 for i in intervals if period[1] <= i <= period[2]) + if regular / len(intervals) < REGULARITY_RATIO: + continue + amounts = [abs(t.amount) for t in items] + med_amt = Decimal(str(median(amounts))) + mad = Decimal(str(median([abs(a - med_amt) for a in amounts]))) + if mad > max(Decimal("1.00"), med_amt * Decimal("0.10")): + continue + cat_counter = Counter(t.category_id for t in items if t.category_id) + category_id = cat_counter.most_common(1)[0][0] if cat_counter else None + average = (sum(amounts) / Decimal(len(amounts))).quantize(Decimal("0.01")) + is_active = (today - dates[-1]).days <= period[2] * 2 + if not is_active and not include_inactive: + continue + series.append( + RecurringSeries( + merchant_key=key, + label_display=_most_common([t.label_clean for t in items]) or key, + category_id=category_id, + category_name=category_names.get(category_id), + periodicity=period[0], + occurrences=len(dates), + average_amount=average, + expected_amount=med_amt.quantize(Decimal("0.01")), + last_date=dates[-1], + next_date_predicted=dates[-1] + + timedelta(days=round(median(intervals))), + is_active=is_active, + ) + ) + series.sort(key=lambda s: (not s.is_active, s.next_date_predicted)) + return series + + +def monthly_total_estimate(series: list[RecurringSeries]) -> Decimal: + total = sum( + ( + s.expected_amount * MONTHLY_FACTOR.get(s.periodicity, Decimal(1)) + for s in series + if s.is_active + ), + Decimal(0), + ) + return Decimal(total).quantize(Decimal("0.01")) diff --git a/apps/api/app/modules/finance/enums.py b/apps/api/app/modules/finance/enums.py new file mode 100644 index 0000000..debc9fb --- /dev/null +++ b/apps/api/app/modules/finance/enums.py @@ -0,0 +1,33 @@ +from enum import StrEnum + + +class AccountKind(StrEnum): + CHECKING = "checking" + SAVINGS = "savings" + PAYPAL = "paypal" + CASH = "cash" + OTHER = "other" + + +class CategoryKind(StrEnum): + INCOME = "income" + EXPENSE = "expense" + TRANSFER = "transfer" + + +class SourceKind(StrEnum): + CSV = "csv" + OFX = "ofx" + PAYPAL_CSV = "paypal_csv" + + +class ImportStatus(StrEnum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +class CategorySource(StrEnum): + RULE = "rule" + USER = "user" diff --git a/apps/api/app/modules/finance/importers.py b/apps/api/app/modules/finance/importers.py new file mode 100644 index 0000000..c90ae2a --- /dev/null +++ b/apps/api/app/modules/finance/importers.py @@ -0,0 +1,191 @@ +"""File importers registered in the core registry (CONVENTIONS C3). + +They make the finance formats available through the generic +`POST /api/imports` endpoint (source = importer id). The richer, account-aware +flow (profile choice, preview, per-run stats) lives in `POST /api/finance/imports` +and shares the same parsers/dedup helpers — see `pipeline.py`. + +Because the generic endpoint carries no account, rows land in a per-format +default account created on first use; the user can rename it afterwards. +""" + +from collections.abc import Iterator +from typing import Any, ClassVar + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.importing.base import ( + BaseImporter, + ImporterParseError, + NormalizedRecord, + UpsertOutcome, +) +from app.core.importing.registry import register_importer +from app.modules.finance import service +from app.modules.finance.categorize import ( + apply_rules_to_tx, + get_transfer_category_id, + load_enabled_rules, +) +from app.modules.finance.enums import SourceKind +from app.modules.finance.models import FinTransaction +from app.modules.finance.normalize import compute_dedup_hash, occurrence_key +from app.modules.finance.parsers import NormalizedRow +from app.modules.finance.pipeline import build_transaction, parse_file +from app.modules.finance.presets import BUILTIN_PROFILES + +# Header signatures used by `sniff()` (lowercased, accent-insensitive enough). +_BANK_HEADER_HINTS = ( + "dateop;", + "date;date valeur", + "date;libell", + "date de comptabilisation;", + "date op", + "date_comptabilisation;", + "booking date", + "completed date", + "montant", + "d\xe9bit", + "debit;", +) +_PAYPAL_HINTS = ("transaction id", "num\xe9ro de transaction", "numero de transaction") + + +def _preset_config(slug: str) -> dict[str, Any]: + for preset in BUILTIN_PROFILES: + if preset["slug"] == slug: + return preset["config"] + return {} + + +class FinanceImporter(BaseImporter): + """Shared upsert logic: dedup by (account, hash) then (account, external_id).""" + + source_kind: ClassVar[str] + profile_slug: ClassVar[str] + default_account_name: ClassVar[str] + domain: ClassVar[str] = "finance" + + def __init__(self) -> None: + self._occurrences: dict[str, int] = {} + + def config(self) -> dict[str, Any]: + return _preset_config(self.profile_slug) + + def parse(self, data: bytes, filename: str) -> Iterator[NormalizedRecord]: + parsed = parse_file(self.source_kind, self.config(), data) + # Only a file where every data row errored is unusable; rows filtered + # on purpose (§3.3) are a legitimate empty import. + if parsed.rows_total and parsed.rows_error == parsed.rows_total: + raise ImporterParseError( + "Aucune ligne exploitable — vérifiez le profil de source." + ) + for row in parsed.rows: + yield NormalizedRecord( + kind="transaction", + data={"row": row}, + external_id=row.external_id, + dedupe_fields=("booked_date", "amount", "label_raw"), + ) + + def upsert( + self, db: Session, user_id: int, record: NormalizedRecord + ) -> UpsertOutcome: + row: NormalizedRow = record.data["row"] + service.ensure_seed(db, user_id) + account = service.account_for_source_kind( + db, user_id, self.source_kind, self.default_account_name + ) + # Occurrence numbering of §4.3, kept across the whole file by the + # importer instance (one instance per run). + key = occurrence_key(row.booked_date, row.amount, row.label_raw) + occurrence = self._occurrences.get(key, 0) + self._occurrences[key] = occurrence + 1 + dedup_hash = compute_dedup_hash( + account.id, row.booked_date, row.amount, row.label_raw, occurrence + ) + + if row.external_id: + exists = db.scalars( + select(FinTransaction.id).where( + FinTransaction.account_id == account.id, + FinTransaction.external_id == row.external_id, + ) + ).first() + if exists is not None: + return UpsertOutcome.DUPLICATE + exists = db.scalars( + select(FinTransaction.id).where( + FinTransaction.account_id == account.id, + FinTransaction.dedup_hash == dedup_hash, + ) + ).first() + if exists is not None: + return UpsertOutcome.DUPLICATE + + tx = build_transaction(user_id, account, row, dedup_hash, self.import_run_id) + apply_rules_to_tx( + tx, load_enabled_rules(db, user_id), get_transfer_category_id(db, user_id) + ) + db.add(tx) + db.flush() + return UpsertOutcome.INSERTED + + +@register_importer +class BankGenericCsvImporter(FinanceImporter): + id = "bank_generic_csv" + label = "Relevé bancaire (CSV générique)" + accepted_extensions = (".csv", ".txt") + source_kind = SourceKind.CSV + profile_slug = "generic" + default_account_name = "Compte importé (CSV)" + + @classmethod + def sniff(cls, filename: str, head: bytes) -> bool: + if not filename.lower().endswith(cls.accepted_extensions): + return False + try: + text = head.decode("utf-8", errors="replace").lower() + except Exception: # noqa: BLE001 — sniff must never raise + return False + first = text.splitlines()[0] if text.splitlines() else "" + if any(hint in first for hint in _PAYPAL_HINTS): + return False + return any(hint in first for hint in _BANK_HEADER_HINTS) + + +@register_importer +class BankOfxImporter(FinanceImporter): + id = "bank_ofx" + label = "Relevé bancaire (OFX)" + accepted_extensions = (".ofx", ".qfx") + source_kind = SourceKind.OFX + profile_slug = "ofx" + default_account_name = "Compte importé (OFX)" + + @classmethod + def sniff(cls, filename: str, head: bytes) -> bool: + if not filename.lower().endswith(cls.accepted_extensions): + return False + text = head.decode("latin-1", errors="replace").upper() + return "OFXHEADER" in text or "" in text + + +@register_importer +class PaypalCsvImporter(FinanceImporter): + id = "paypal_csv" + label = "PayPal — rapport d'activité (CSV)" + accepted_extensions = (".csv",) + source_kind = SourceKind.PAYPAL_CSV + profile_slug = "paypal" + default_account_name = "PayPal" + + @classmethod + def sniff(cls, filename: str, head: bytes) -> bool: + if not filename.lower().endswith(cls.accepted_extensions): + return False + text = head.decode("utf-8", errors="replace").lower() + first = text.splitlines()[0] if text.splitlines() else "" + return any(hint in first for hint in _PAYPAL_HINTS) diff --git a/apps/api/app/modules/finance/models.py b/apps/api/app/modules/finance/models.py new file mode 100644 index 0000000..a880cb7 --- /dev/null +++ b/apps/api/app/modules/finance/models.py @@ -0,0 +1,319 @@ +"""Finance module tables (datamodel-finance.md §2), portable SQLite/PostgreSQL. + +Deviations from the normative DDL, imposed by the finished backend core +("real code wins"): `users.id` and the central `import_runs.id` are integers, +so every FK to those tables is an int. Import rollback is centralised: any +column referencing `import_runs.id` declares ondelete="CASCADE" (CONVENTIONS +C8.5). The pg_trgm GIN index of §2.5 is created on PostgreSQL only, best +effort — see `_TRGM_DDL` at the bottom of this module. + +Deduplication follows §4.3 of the datamodel (which supersedes the generic +CONVENTIONS C2.4 pair for this module): UNIQUE(account_id, dedup_hash) plus a +partial UNIQUE(account_id, external_id) — the finance scope is the account, not +the user, and the hash embeds an occurrence counter so that two identical +purchases of the same day both survive. +""" + +import uuid +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import ( + DDL, + CheckConstraint, + Date, + DateTime, + Enum, + ForeignKey, + Index, + Numeric, + String, + Text, + UniqueConstraint, + Uuid, + event, + text, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.core.mixins import JSONB_V, TimestampMixin +from app.modules.finance.enums import ( + AccountKind, + CategoryKind, + CategorySource, + ImportStatus, + SourceKind, +) + + +def _enum(enum_cls: type, name: str) -> Enum: + """Portable enum column: VARCHAR + CHECK, never a native PG ENUM (C8.1). + + `create_constraint=True` is explicit: SQLAlchemy defaults it to False, which + would degrade the column to a bare VARCHAR with no integrity check at all on + PostgreSQL as well as on SQLite. + """ + return Enum( + enum_cls, + name=name, + native_enum=False, + create_constraint=True, + length=20, + values_callable=lambda e: [m.value for m in e], + ) + + +class FinAccount(TimestampMixin, Base): + __tablename__ = "fin_accounts" + __table_args__ = ( + UniqueConstraint("user_id", "name", name="uq_fin_accounts_user_name"), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + name: Mapped[str] = mapped_column(String(100)) + kind: Mapped[AccountKind] = mapped_column( + _enum(AccountKind, "fin_account_kind"), default=AccountKind.CHECKING + ) + currency: Mapped[str] = mapped_column(String(3), default="EUR") + institution: Mapped[str | None] = mapped_column(String(100), default=None) + # Never the full IBAN: "FR76 **** **** **** 1234" at most. + iban_masked: Mapped[str | None] = mapped_column(String(34), default=None) + initial_balance: Mapped[Decimal] = mapped_column( + Numeric(12, 2), default=Decimal("0.00") + ) + is_archived: Mapped[bool] = mapped_column(default=False) + + +class FinCategory(TimestampMixin, Base): + __tablename__ = "fin_categories" + __table_args__ = ( + UniqueConstraint( + "user_id", "parent_id", "name", name="uq_fin_categories_sibling" + ), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + parent_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("fin_categories.id", ondelete="CASCADE"), index=True, default=None + ) + name: Mapped[str] = mapped_column(String(80)) + icon: Mapped[str | None] = mapped_column(String(50), default=None) + color: Mapped[str | None] = mapped_column(String(7), default=None) + kind: Mapped[CategoryKind] = mapped_column( + _enum(CategoryKind, "fin_category_kind"), default=CategoryKind.EXPENSE + ) + is_system: Mapped[bool] = mapped_column(default=False) + sort_order: Mapped[int] = mapped_column(default=0) + + +class FinSourceProfile(TimestampMixin, Base): + __tablename__ = "fin_source_profiles" + __table_args__ = ( + CheckConstraint( + "(is_builtin = true AND user_id IS NULL)" + " OR (is_builtin = false AND user_id IS NOT NULL)", + name="ck_fin_source_profiles_builtin", + ), + Index( + "uq_fin_source_profiles_builtin_name", + "name", + unique=True, + sqlite_where=text("user_id IS NULL"), + postgresql_where=text("user_id IS NULL"), + ), + Index( + "uq_fin_source_profiles_user_name", + "user_id", + "name", + unique=True, + sqlite_where=text("user_id IS NOT NULL"), + postgresql_where=text("user_id IS NOT NULL"), + ), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + # NULL = built-in preset, visible by every user, read-only through the API. + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), default=None + ) + name: Mapped[str] = mapped_column(String(100)) + kind: Mapped[SourceKind] = mapped_column(_enum(SourceKind, "fin_source_kind")) + config: Mapped[dict[str, Any]] = mapped_column(JSONB_V, default=dict) + is_builtin: Mapped[bool] = mapped_column(default=False) + + +class FinImportRun(TimestampMixin, Base): + """Finance-side record of an import run. + + One-to-one with the central `import_runs` row (which owns the rollback + cascade); carries the finance-specific metadata and rich stats of §2.4. + """ + + __tablename__ = "fin_import_runs" + __table_args__ = ( + Index("ix_fin_import_runs_user_started", "user_id", "started_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + import_run_id: Mapped[int] = mapped_column( + ForeignKey("import_runs.id", ondelete="CASCADE"), unique=True, index=True + ) + source_profile_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("fin_source_profiles.id", ondelete="SET NULL"), default=None + ) + account_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("fin_accounts.id", ondelete="CASCADE") + ) + filename: Mapped[str] = mapped_column(String(255)) + file_sha256: Mapped[str] = mapped_column(String(64)) + status: Mapped[ImportStatus] = mapped_column( + _enum(ImportStatus, "fin_import_status"), default=ImportStatus.PENDING + ) + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), default=None + ) + stats: Mapped[dict[str, Any]] = mapped_column(JSONB_V, default=dict) + error_message: Mapped[str | None] = mapped_column(Text, default=None) + + +class FinTransaction(TimestampMixin, Base): + __tablename__ = "fin_transactions" + __table_args__ = ( + UniqueConstraint("account_id", "dedup_hash", name="uq_fin_transactions_dedup"), + Index( + "uq_fin_transactions_external", + "account_id", + "external_id", + unique=True, + sqlite_where=text("external_id IS NOT NULL"), + postgresql_where=text("external_id IS NOT NULL"), + ), + Index("ix_fin_transactions_user_date", "user_id", "booked_date"), + Index("ix_fin_transactions_account_date", "account_id", "booked_date"), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + account_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("fin_accounts.id", ondelete="CASCADE") + ) + booked_date: Mapped[date] = mapped_column(Date) # civil date, never tz-shifted + value_date: Mapped[date | None] = mapped_column(Date, default=None) + amount: Mapped[Decimal] = mapped_column(Numeric(12, 2)) # signed: debit < 0 + currency: Mapped[str] = mapped_column(String(3), default="EUR") + label_raw: Mapped[str] = mapped_column(Text) + label_clean: Mapped[str] = mapped_column(Text) + counterparty: Mapped[str | None] = mapped_column(String(150), default=None) + category_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("fin_categories.id", ondelete="SET NULL"), index=True, default=None + ) + category_source: Mapped[CategorySource | None] = mapped_column( + _enum(CategorySource, "fin_category_source"), default=None + ) + # Logical FK to fin_rules.id (no constraint: rules are freely deletable). + applied_rule_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, default=None) + notes: Mapped[str | None] = mapped_column(Text, default=None) + # Central import run (int id); NULL = manual entry. CASCADE = C8.5 rollback. + import_run_id: Mapped[int | None] = mapped_column( + ForeignKey("import_runs.id", ondelete="CASCADE"), index=True, default=None + ) + external_id: Mapped[str | None] = mapped_column(String(255), default=None) + dedup_hash: Mapped[str] = mapped_column(String(64)) + transfer_group_id: Mapped[uuid.UUID | None] = mapped_column( + Uuid, index=True, default=None + ) + + +class FinRule(TimestampMixin, Base): + __tablename__ = "fin_rules" + __table_args__ = ( + Index("ix_fin_rules_user_priority", "user_id", "priority", "created_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + name: Mapped[str] = mapped_column(String(100)) + priority: Mapped[int] = mapped_column(default=100) # ascending = first + enabled: Mapped[bool] = mapped_column(default=True) + stop: Mapped[bool] = mapped_column(default=True) + matchers: Mapped[dict[str, Any]] = mapped_column(JSONB_V) + actions: Mapped[dict[str, Any]] = mapped_column(JSONB_V) + hit_count: Mapped[int] = mapped_column(default=0) + last_applied_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), default=None + ) + + +class FinBudget(TimestampMixin, Base): + __tablename__ = "fin_budgets" + __table_args__ = ( + CheckConstraint("monthly_amount > 0", name="ck_fin_budgets_positive"), + Index("ix_fin_budgets_user_cat", "user_id", "category_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + category_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("fin_categories.id", ondelete="CASCADE") + ) + monthly_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + start_month: Mapped[date] = mapped_column(Date) # always the 1st of the month + end_month: Mapped[date | None] = mapped_column(Date, default=None) + + +# -------------------------------------------------------------------------- +# pg_trgm — trigram GIN index of datamodel-finance §2.5 (PostgreSQL only). +# +# `GET /api/finance/transactions?q=` searches with ILIKE '%…%' on label_clean; +# without a trigram index PostgreSQL can only answer with a sequential scan. +# The index cannot be declared as a plain `Index(...)`: `gin_trgm_ops` does not +# exist on SQLite (the test engine) and does not exist on a PostgreSQL where the +# extension was never installed. +# +# So it is emitted as an `after_create` DDL hook restricted to the PostgreSQL +# dialect, written so that it can never break `create_all` (§9.4 has no +# migration step in v1, the API creates its own schema at startup): +# - `CREATE EXTENSION` is attempted in a nested block: pg_trgm is a *trusted* +# extension since PostgreSQL 13, so the database owner (the compose +# POSTGRES_USER) can install it; if the deployment forbids it the exception +# is swallowed instead of aborting the whole transaction; +# - the index itself is only created when the extension really is present. +# `docker/postgres-init/10-extensions.sql` installs it at initdb time as well, +# for deployments where the API user is not the database owner. +# -------------------------------------------------------------------------- + +_TRGM_DDL = DDL( + """ +DO $$ +BEGIN + BEGIN + CREATE EXTENSION IF NOT EXISTS pg_trgm; + EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'pg_trgm indisponible : recherche de libelles non indexee'; + END; + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm') THEN + CREATE INDEX IF NOT EXISTS ix_fin_transactions_label_trgm + ON fin_transactions USING gin (label_clean gin_trgm_ops); + END IF; +END +$$; +""" +) + +event.listen( + FinTransaction.__table__, + "after_create", + _TRGM_DDL.execute_if(dialect="postgresql"), +) diff --git a/apps/api/app/modules/finance/normalize.py b/apps/api/app/modules/finance/normalize.py new file mode 100644 index 0000000..b4cef26 --- /dev/null +++ b/apps/api/app/modules/finance/normalize.py @@ -0,0 +1,139 @@ +"""Label/amount normalisation and dedup hashing (datamodel-finance.md §4.3, §7.1).""" + +import hashlib +import re +import unicodedata +from datetime import date, timedelta +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +from uuid import UUID + +TWO_PLACES = Decimal("0.01") + +# Purely technical bank prefixes stripped from label_clean (§4.3). +_TECH_PREFIX_RE = re.compile( + r"^(CARTE \d{2}/\d{2}(/\d{2,4})? |PAIEMENT (PSC |CB )?\d{4} |PRLV SEPA " + r"|VIR SEPA |VIR INST |ACHAT CB )", + re.IGNORECASE, +) + +# Spaces found in French bank exports: NBSP, narrow NBSP, thin space. +_SPACES = "   " + + +def normalize_label_for_hash(label: str) -> str: + """STABLE and CONSERVATIVE normalisation used by the dedup hash. + + Uppercase + accents stripped + whitespace collapsed, nothing else — any + more aggressive cleaning would merge distinct transactions. + """ + s = unicodedata.normalize("NFKD", label) + s = "".join(c for c in s if not unicodedata.combining(c)) + s = s.upper() + return re.sub(r"\s+", " ", s).strip() + + +def light_clean(label_raw: str) -> str: + """Initial label_clean: trim, collapse whitespace, drop technical prefixes.""" + s = re.sub(r"\s+", " ", label_raw).strip() + s = _TECH_PREFIX_RE.sub("", s).strip() + return s or label_raw.strip() + + +def compute_dedup_hash( + account_id: UUID, + booked_date: date, + amount: Decimal, + label_raw: str, + occurrence: int, +) -> str: + canonical = "|".join( + [ + str(account_id), + booked_date.isoformat(), + f"{amount:.2f}", # sign included + normalize_label_for_hash(label_raw), + str(occurrence), # rank among identical rows of the SAME file + ] + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def occurrence_key(booked_date: date, amount: Decimal, label_raw: str) -> tuple: + return (booked_date, f"{amount:.2f}", normalize_label_for_hash(label_raw)) + + +def quantize2(value: Decimal) -> Decimal: + return value.quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + + +def parse_amount(raw: str, decimal_separator: str = ",") -> Decimal: + """Parse a French/English bank amount string into a signed Decimal. + + Covers: "-6,4", "+3500,00", "-123 456,78", "1.234,56", "12,50 €", + "−12,50" (U+2212), "226.68". + """ + s = raw.strip() + if not s: + raise ValueError("montant vide") + s = s.replace("−", "-") # unicode minus + for sp in _SPACES: + s = s.replace(sp, "") + s = s.replace(" ", "") + s = re.sub(r"(?i)(€|EUR)", "", s).strip() + if decimal_separator == ",": + # Remove dot/space thousands separators, then turn the comma into a dot. + if "," in s: + s = s.replace(".", "").replace(",", ".") + # else: already dot-decimal (BoursoBank mixes both in one file) + else: + s = s.replace(",", "") + try: + return Decimal(s) + except InvalidOperation as exc: + raise ValueError(f"montant illisible : {raw!r}") from exc + + +_MERCHANT_NOISE_WORDS = re.compile( + r"\b(CB|CARTE|PRLV|SEPA|VIR|ECH|PAIEMENT|ACHAT|WEB|FACT(URE)?)\b" +) + + +def merchant_key(label_raw: str, counterparty: str | None = None) -> str: + """Aggressive grouping key for recurring/merchant detection (§7.1).""" + if counterparty: + return normalize_label_for_hash(counterparty)[:60] + s = normalize_label_for_hash(label_raw) + s = re.sub(r"\b\d{2}/\d{2}(/\d{2,4})?\b", "", s) # dates inside the label + s = re.sub(r"\b\d{4,}\b", "", s) # card/reference numbers + s = _MERCHANT_NOISE_WORDS.sub("", s) + s = re.sub(r"[^A-Z0-9 ]", " ", s) + s = re.sub(r"\s+", " ", s).strip() + return s[:60] + + +def month_str(d: date) -> str: + return f"{d.year:04d}-{d.month:02d}" + + +def month_first_day(year: int, month: int) -> date: + return date(year, month, 1) + + +def add_months(d: date, months: int) -> date: + """First day of the month `months` after (or before) d's month.""" + total = d.year * 12 + (d.month - 1) + months + return date(total // 12, total % 12 + 1, 1) + + +def month_last_day(d: date) -> date: + return add_months(d, 1) - timedelta(days=1) + + +def parse_month(value: str) -> date: + m = re.fullmatch(r"(\d{4})-(\d{2})", value.strip()) + if not m: + raise ValueError(f"mois invalide : {value!r}") + year, month = int(m.group(1)), int(m.group(2)) + if not 1 <= month <= 12: + raise ValueError(f"mois invalide : {value!r}") + return date(year, month, 1) diff --git a/apps/api/app/modules/finance/parsers.py b/apps/api/app/modules/finance/parsers.py new file mode 100644 index 0000000..58e118b --- /dev/null +++ b/apps/api/app/modules/finance/parsers.py @@ -0,0 +1,642 @@ +"""File parsers for the finance import pipeline (datamodel-finance.md §3-4). + +Three parsers (CSV generic driven by a source-profile config, OFX, PayPal CSV) +all produce the same `ParseResult` of `NormalizedRow`s. Row-level problems are +collected (never fatal); a structurally unreadable file raises +`ImporterParseError` with a French message. +""" + +import csv +import io +import re +import unicodedata +from dataclasses import dataclass, field +from datetime import date, datetime +from decimal import Decimal + +from app.core.importing.base import ImporterParseError +from app.modules.finance.normalize import parse_amount, quantize2 + +MAX_ERRORS = 50 + +_GENERIC_DATE_FORMATS = ( + "%d/%m/%Y", + "%Y-%m-%d", + "%d.%m.%Y", + "%Y-%m-%d %H:%M:%S", + "%d/%m/%y", +) + + +@dataclass +class NormalizedRow: + booked_date: date + value_date: date | None + amount: Decimal # signed, quantized to 2 decimals + currency: str # ISO 4217 upper + label_raw: str # trimmed, newlines collapsed to spaces + counterparty: str | None = None # PayPal / "counterparty" column only + external_id: str | None = None + source_row_index: int = 0 # 1-based row index for error messages + + +@dataclass +class ParseResult: + rows: list[NormalizedRow] = field(default_factory=list) + errors: list[dict] = field(default_factory=list) # [{"row": int, "message": str}] + rows_filtered: int = 0 # rows intentionally skipped by the parser + # Every data row seen, blank lines and footers excluded. §2.4 requires + # rows_total == imported + skipped_duplicate + skipped_filtered + error, so + # the intentionally filtered rows are part of it. + rows_total: int = 0 + errors_truncated: bool = False + + @property + def rows_error(self) -> int: + return self.rows_total - self.rows_filtered - len(self.rows) + + def add_error(self, row_index: int, message: str) -> None: + if len(self.errors) < MAX_ERRORS: + self.errors.append({"row": row_index, "message": message}) + else: + self.errors_truncated = True + + +# --------------------------------------------------------------------------- +# Decoding +# --------------------------------------------------------------------------- + + +def decode_bytes(raw: bytes, encoding_cfg: str = "auto") -> str: + """§4.1 + research §7.3: BOM first, then explicit encoding, then auto + (utf-8 strict -> charset-normalizer -> cp1252, which never fails).""" + if raw.startswith(b"\xef\xbb\xbf"): + return raw.decode("utf-8-sig") + if encoding_cfg and encoding_cfg != "auto": + return raw.decode(encoding_cfg, errors="replace") + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + pass + try: + from charset_normalizer import from_bytes + + best = from_bytes(raw).best() + if best is not None and best.encoding not in ("utf_8", "utf-8"): + return str(best) + except Exception: # noqa: BLE001, S110 — auto mode must never fail + pass + return raw.decode("cp1252") + + +def _norm_header(name: str) -> str: + """Case/accent-insensitive header comparison key.""" + s = unicodedata.normalize("NFKD", name) + s = "".join(c for c in s if not unicodedata.combining(c)) + return re.sub(r"\s+", " ", s).strip().strip('"').casefold() + + +def _clean_cell(value: str) -> str: + return re.sub(r"\s+", " ", value).strip() + + +# --------------------------------------------------------------------------- +# Generic CSV parser (kind = 'csv') +# --------------------------------------------------------------------------- + + +def _sniff_delimiter(sample: str) -> str: + try: + return csv.Sniffer().sniff(sample, delimiters=";,\t|").delimiter + except csv.Error: + first = sample.splitlines()[0] if sample.splitlines() else "" + counts = {d: first.count(d) for d in (";", ",", "\t", "|")} + best = max(counts, key=lambda d: counts[d]) + return best if counts[best] > 0 else ";" + + +def _resolve_column(spec: object, header_map: dict[str, int] | None) -> int | None: + """Column spec: header name (str), 0-based index (int) or list of aliases.""" + if spec is None or spec == "": + return None + if isinstance(spec, bool): + return None + if isinstance(spec, int): + return spec + if isinstance(spec, str): + if header_map is None: + return None + return header_map.get(_norm_header(spec)) + if isinstance(spec, (list, tuple)): + for alias in spec: + idx = _resolve_column(alias, header_map) + if idx is not None: + return idx + return None + + +def _parse_date(value: str, config: dict) -> date: + v = value.strip().strip('"') + formats: list[str] = [] + if config.get("date_format"): + formats.append(config["date_format"]) + formats.extend(config.get("date_formats") or []) + formats.extend(f for f in _GENERIC_DATE_FORMATS if f not in formats) + for fmt in formats: + try: + # Civil date from a bank statement: never timezone-converted (§1). + return datetime.strptime(v, fmt).date() # noqa: DTZ007 + except ValueError: + continue + raise ValueError(f"Date invalide : '{value.strip()}'") + + +def _cell(row: list[str], idx: int | None) -> str: + if idx is None or idx >= len(row): + return "" + return row[idx] + + +def parse_csv(config: dict, text: str, default_currency: str = "EUR") -> ParseResult: + result = ParseResult() + lines = text.splitlines(keepends=True) + + # Preamble handling: fixed number of rows and/or "skip until header" marker. + start = int(config.get("skip_rows_top") or 0) + marker = config.get("skip_until_header_startswith") + if marker: + want = _norm_header(marker) + found = None + for i, line in enumerate(lines): + if _norm_header(line)[: len(want)] == want: + found = i + break + if found is None: + raise ImporterParseError( + "Ligne d'en-tête introuvable — vérifiez le profil de source." + ) + start = found + if start >= len(lines) and lines: + raise ImporterParseError("Le fichier ne contient aucune ligne de données.") + body = "".join(lines[start:]) + + delimiter = config.get("delimiter") or "auto" + if delimiter == "auto": + delimiter = _sniff_delimiter("".join(lines[start : start + 6])) + quote_char = config.get("quote_char") or '"' + + reader = csv.reader(io.StringIO(body), delimiter=delimiter, quotechar=quote_char) + try: + raw_rows = list(reader) + except csv.Error as exc: + raise ImporterParseError( + "Fichier CSV illisible — vérifiez le séparateur et les guillemets." + ) from exc + + skip_bottom = int(config.get("skip_rows_bottom") or 0) + if skip_bottom: + raw_rows = raw_rows[:-skip_bottom] if skip_bottom < len(raw_rows) else [] + + has_header = config.get("has_header", True) + header_map: dict[str, int] | None = None + data_start = 0 + if has_header: + if not raw_rows: + raise ImporterParseError("Le fichier est vide.") + header_map = { + _norm_header(cell): i for i, cell in enumerate(raw_rows[0]) if cell.strip() + } + data_start = 1 + + columns = config.get("columns") or {} + col_booked = _resolve_column(columns.get("booked_date"), header_map) + col_value = _resolve_column(columns.get("value_date"), header_map) + col_label = _resolve_column(columns.get("label"), header_map) + col_amount = _resolve_column(columns.get("amount"), header_map) + col_debit = _resolve_column(columns.get("debit"), header_map) + col_credit = _resolve_column(columns.get("credit"), header_map) + col_currency = _resolve_column(columns.get("currency"), header_map) + col_external = _resolve_column(columns.get("external_id"), header_map) + col_counterparty = _resolve_column(columns.get("counterparty"), header_map) + col_fee = _resolve_column(columns.get("fee"), header_map) + + amount_mode = config.get("amount_mode", "signed") + if amount_mode == "auto": + amount_mode = "signed" if col_amount is not None else "split" + + if col_booked is None or col_label is None: + raise ImporterParseError( + "Colonnes obligatoires introuvables (date, libellé) — " + "vérifiez le profil de source." + ) + if amount_mode == "signed" and col_amount is None: + raise ImporterParseError( + "Colonne de montant introuvable — vérifiez le profil de source." + ) + if amount_mode == "split" and col_debit is None and col_credit is None: + raise ImporterParseError( + "Colonnes Débit/Crédit introuvables — vérifiez le profil de source." + ) + + decimal_sep = config.get("decimal_separator", ",") + invert = bool(config.get("invert_sign", False)) + label_join = config.get("label_join") or [] + join_idx = [ + idx + for idx in (_resolve_column(c, header_map) for c in label_join) + if idx is not None + ] + + filters = config.get("skip_row_if") or [] + filter_specs = [] + for f in filters: + fidx = _resolve_column(f.get("column"), header_map) + if fidx is not None: + filter_specs.append((fidx, f)) + + stop_on_non_date = bool(config.get("stop_on_non_date_row", False)) + + for offset, row in enumerate(raw_rows[data_start:]): + row_index = start + data_start + offset + 1 # 1-based, file-relative + if not any(cell.strip() for cell in row): + continue + + booked_raw = _cell(row, col_booked) + if stop_on_non_date and booked_raw.strip(): + try: + _parse_date(booked_raw, config) + except ValueError: + break # footer reached (Crédit Agricole) + + result.rows_total += 1 + skip = False + for fidx, f in filter_specs: + cell_val = _clean_cell(_cell(row, fidx)).casefold() + if "equals" in f and cell_val == str(f["equals"]).casefold(): + skip = True + if "not_equals" in f and cell_val != str(f["not_equals"]).casefold(): + skip = True + if skip: + result.rows_filtered += 1 + continue + + try: + booked = _parse_date(booked_raw, config) + value_raw = _cell(row, col_value).strip() + value_d = _parse_date(value_raw, config) if value_raw else None + + if amount_mode == "signed": + amount = parse_amount(_cell(row, col_amount), decimal_sep) + else: + debit_raw = _cell(row, col_debit).strip() + credit_raw = _cell(row, col_credit).strip() + if bool(debit_raw) == bool(credit_raw): + raise ValueError( + "exactement une des colonnes Débit/Crédit doit être remplie" + ) + if credit_raw: + amount = abs(parse_amount(credit_raw, decimal_sep)) + else: + amount = -abs(parse_amount(debit_raw, decimal_sep)) + if col_fee is not None: + fee_raw = _cell(row, col_fee).strip() + if fee_raw: + amount -= parse_amount(fee_raw, decimal_sep) + if invert: + amount = -amount + + label = _clean_cell(_cell(row, col_label)) + for jidx in join_idx: + extra = _clean_cell(_cell(row, jidx)) + if extra: + label = f"{label} — {extra}" if label else extra + if not label: + raise ValueError("libellé vide") + + currency = _clean_cell(_cell(row, col_currency)).upper() or ( + default_currency + ) + external = _clean_cell(_cell(row, col_external)) or None + counterparty = _clean_cell(_cell(row, col_counterparty)) or None + + result.rows.append( + NormalizedRow( + booked_date=booked, + value_date=value_d, + amount=quantize2(amount), + currency=currency[:3], + label_raw=label, + counterparty=counterparty, + external_id=external, + source_row_index=row_index, + ) + ) + except ValueError as exc: + result.add_error(row_index, str(exc).capitalize()) + return result + + +# --------------------------------------------------------------------------- +# OFX parser (kind = 'ofx') +# --------------------------------------------------------------------------- + +_OFX_FIELD_RES = { + name: re.compile(rf"<{name}>([^<\r\n]*)", re.IGNORECASE) + for name in ("DTPOSTED", "TRNAMT", "FITID", "NAME", "MEMO") +} +_OFX_CURDEF_RE = re.compile(r"([^<\r\n]*)", re.IGNORECASE) +_OFX_ACCTID_RE = re.compile(r"([^<\r\n]*)", re.IGNORECASE) + + +def _ofx_decode(raw: bytes, fallback_encoding: str) -> str: + head = raw[:512].decode("latin-1", errors="replace") + m = re.search(r"CHARSET:\s*([A-Za-z0-9-]+)", head) + if m: + charset = m.group(1).strip().lower() + if charset in ("1252", "cp1252", "windows-1252"): + return raw.decode("cp1252", errors="replace") + if charset not in ("none",): + try: + return raw.decode(charset, errors="replace") + except LookupError: + pass + m = re.search(r'encoding="([^"]+)"', head, re.IGNORECASE) + if m: + try: + return raw.decode(m.group(1), errors="replace") + except LookupError: + pass + if re.search(r"ENCODING:\s*UTF-8", head, re.IGNORECASE): + return raw.decode("utf-8", errors="replace") + return raw.decode(fallback_encoding or "cp1252", errors="replace") + + +def _ofx_date(value: str) -> date: + digits = re.sub(r"[^0-9]", "", value)[:8] + if len(digits) < 8: + raise ValueError(f"Date invalide : '{value.strip()}'") + # DTPOSTED: keep the 8 leading digits, no timezone conversion (§3.2). + return datetime.strptime(digits, "%Y%m%d").date() # noqa: DTZ007 + + +def _ofx_label(name: str, memo: str) -> str: + name, memo = _clean_cell(name), _clean_cell(memo) + if name and memo: + return f"{name} — {memo}" + return name or memo + + +def _parse_ofx_with_ofxtools( + raw: bytes, config: dict, default_currency: str +) -> ParseResult | None: + try: + from ofxtools.Parser import OFXTree + + tree = OFXTree() + tree.parse(io.BytesIO(raw)) + ofx = tree.convert() + statements = list(getattr(ofx, "statements", []) or []) + if not statements: + return None + except Exception: # noqa: BLE001 — malformed French SGML: use the regex fallback + return None + + wanted = config.get("account_match") + stmt = statements[0] + if wanted: + for st in statements: + acctid = getattr(getattr(st, "account", None), "acctid", None) + if acctid and str(acctid).strip() == str(wanted).strip(): + stmt = st + break + + result = ParseResult() + currency = (getattr(stmt, "curdef", None) or default_currency or "EUR").upper() + transactions = list(getattr(stmt, "transactions", None) or []) + for i, trn in enumerate(transactions, start=1): + result.rows_total += 1 + try: + dtposted = getattr(trn, "dtposted", None) + if dtposted is None: + raise ValueError("Date absente (DTPOSTED)") + booked = dtposted.date() if hasattr(dtposted, "date") else dtposted + amount = getattr(trn, "trnamt", None) + if amount is None: + raise ValueError("Montant absent (TRNAMT)") + label = _ofx_label( + str(getattr(trn, "name", "") or ""), str(getattr(trn, "memo", "") or "") + ) + if not label: + raise ValueError("Libellé vide") + fitid = str(getattr(trn, "fitid", "") or "").strip() or None + result.rows.append( + NormalizedRow( + booked_date=booked, + value_date=None, + amount=quantize2(Decimal(str(amount))), + currency=currency, + label_raw=label, + external_id=fitid, + source_row_index=i, + ) + ) + except ValueError as exc: + result.add_error(i, str(exc)) + return result + + +def _parse_ofx_with_regex( + text: str, config: dict, default_currency: str +) -> ParseResult: + result = ParseResult() + m = _OFX_CURDEF_RE.search(text) + currency = (m.group(1).strip() if m else default_currency or "EUR").upper() + + wanted = config.get("account_match") + scope = text + if wanted: + # Keep the statement block whose ACCTID matches, when identifiable. + parts = re.split(r"(?i)(?=|)", text) + for part in parts: + am = _OFX_ACCTID_RE.search(part) + if am and am.group(1).strip() == str(wanted).strip(): + scope = part + break + + blocks = re.split(r"(?i)", scope)[1:] + if not blocks: + raise ImporterParseError("Aucune transaction OFX trouvée dans le fichier.") + for i, block in enumerate(blocks, start=1): + block = re.split(r"(?i)", block)[0] + result.rows_total += 1 + fields = {} + for name, rx in _OFX_FIELD_RES.items(): + fm = rx.search(block) + fields[name] = fm.group(1).strip() if fm else "" + try: + if not fields["DTPOSTED"]: + raise ValueError("Date absente (DTPOSTED)") + booked = _ofx_date(fields["DTPOSTED"]) + if not fields["TRNAMT"]: + raise ValueError("Montant absent (TRNAMT)") + amount = parse_amount(fields["TRNAMT"], decimal_separator=".") + label = _ofx_label(fields["NAME"], fields["MEMO"]) + if not label: + raise ValueError("Libellé vide") + result.rows.append( + NormalizedRow( + booked_date=booked, + value_date=None, + amount=quantize2(amount), + currency=currency, + label_raw=label, + external_id=fields["FITID"] or None, + source_row_index=i, + ) + ) + except ValueError as exc: + result.add_error(i, str(exc)) + return result + + +def parse_ofx(config: dict, raw: bytes, default_currency: str = "EUR") -> ParseResult: + result = _parse_ofx_with_ofxtools(raw, config, default_currency) + if result is None: + text = _ofx_decode(raw, config.get("fallback_encoding", "cp1252")) + result = _parse_ofx_with_regex(text, config, default_currency) + _dedupe_fitids(result) + return result + + +def _dedupe_fitids(result: ParseResult) -> None: + """LCL-style FITID collisions inside one file: suffix an occurrence counter + so the partial unique index (account_id, external_id) stays truthful.""" + seen: dict[str, int] = {} + for row in result.rows: + if not row.external_id: + continue + n = seen.get(row.external_id, 0) + seen[row.external_id] = n + 1 + if n: + row.external_id = f"{row.external_id}#{n}" + + +# --------------------------------------------------------------------------- +# PayPal activity CSV parser (kind = 'paypal_csv') +# --------------------------------------------------------------------------- + +_PAYPAL_ALIASES = { + "date": ("date",), + "name": ("nom", "name"), + "type": ("type",), + "status": ("etat", "status", "statut", "state"), + "currency": ("devise", "currency"), + "gross": ("brut", "gross"), + "fee": ("frais", "fee"), + "net": ("net",), + "amount": ("montant", "amount"), + "transaction_id": ("numero de transaction", "transaction id"), + "item_title": ("titre de l'objet", "item title", "objet", "subject"), + "balance_impact": ("impact sur le solde", "balance impact"), +} + +_PAYPAL_COMPLETED = {"effectue", "completed"} +_PAYPAL_CONVERSION_TYPES = { + "conversion de devise generale", + "general currency conversion", +} + + +def parse_paypal_csv( + config: dict, text: str, default_currency: str = "EUR" +) -> ParseResult: + reader = csv.reader( + io.StringIO(text), delimiter=config.get("delimiter", ","), quotechar='"' + ) + try: + raw_rows = list(reader) + except csv.Error as exc: + raise ImporterParseError("Fichier CSV PayPal illisible.") from exc + if not raw_rows: + raise ImporterParseError("Le fichier est vide.") + + header_map = {_norm_header(c): i for i, c in enumerate(raw_rows[0]) if c.strip()} + cols: dict[str, int | None] = {} + for key, aliases in _PAYPAL_ALIASES.items(): + cols[key] = next((header_map[a] for a in aliases if a in header_map), None) + if cols["date"] is None or cols["transaction_id"] is None: + raise ImporterParseError( + "En-têtes PayPal introuvables (Date, Numéro de transaction) — " + "est-ce bien un rapport d'activité PayPal ?" + ) + use_net = bool(config.get("use_net_amount", True)) + amount_col = ( + cols["net"] + if use_net and cols["net"] is not None + else cols["gross"] + if cols["gross"] is not None + else cols["amount"] + ) + if amount_col is None: + raise ImporterParseError("Colonne de montant PayPal introuvable (Net/Brut).") + + skip_types = {_norm_header(t) for t in (config.get("skip_types") or [])} + if config.get("conversion_as_skip", True): + skip_types |= _PAYPAL_CONVERSION_TYPES + only_completed = bool(config.get("skip_status_not_completed", True)) + target_currency = (default_currency or "EUR").upper() + + result = ParseResult() + for offset, row in enumerate(raw_rows[1:]): + row_index = offset + 2 + if not any(cell.strip() for cell in row): + continue + + result.rows_total += 1 + typ = _norm_header(_cell(row, cols["type"])) + status = _norm_header(_cell(row, cols["status"])) + impact = _norm_header(_cell(row, cols["balance_impact"])) + currency = _clean_cell(_cell(row, cols["currency"])).upper() + + if ( + (skip_types and typ in skip_types) + or ( + only_completed + and cols["status"] is not None + and status not in _PAYPAL_COMPLETED + ) + or (cols["balance_impact"] is not None and impact == "memo") + or (currency and currency != target_currency) + ): + result.rows_filtered += 1 + continue + + try: + booked = _parse_date(_cell(row, cols["date"]), config) + decimal_sep = config.get("decimal_separator", ",") + if decimal_sep == "auto": + decimal_sep = "," + amount = parse_amount(_cell(row, amount_col), decimal_sep) + + name = _clean_cell(_cell(row, cols["name"])) + type_label = _clean_cell(_cell(row, cols["type"])) + title = _clean_cell(_cell(row, cols["item_title"])) + label = " — ".join(p for p in (name, type_label, title) if p) + if not label: + raise ValueError("libellé vide") + + result.rows.append( + NormalizedRow( + booked_date=booked, + value_date=None, + amount=quantize2(amount), + currency=currency or target_currency, + label_raw=label, + counterparty=name or None, + external_id=_clean_cell(_cell(row, cols["transaction_id"])) or None, + source_row_index=row_index, + ) + ) + except ValueError as exc: + result.add_error(row_index, str(exc).capitalize()) + return result diff --git a/apps/api/app/modules/finance/pipeline.py b/apps/api/app/modules/finance/pipeline.py new file mode 100644 index 0000000..7aa2dd8 --- /dev/null +++ b/apps/api/app/modules/finance/pipeline.py @@ -0,0 +1,456 @@ +"""Import pipeline: decode -> parse -> normalize -> dedup -> rules -> insert. + +Reference: datamodel-finance.md §4. The whole run is one SQL transaction +(atomicity, §4.4) and is idempotent thanks to the occurrence-aware dedup hash. +Every imported row references the central `import_runs` row (FK ON DELETE +CASCADE), which is what makes `DELETE /api/imports/{id}` a real rollback. +""" + +import hashlib +import uuid +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import date, timedelta +from decimal import Decimal +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.errors import DomainValidationError +from app.core.importing.base import ImporterParseError +from app.core.timeutils import utcnow +from app.modules.finance.categorize import ( + apply_rules_to_tx, + detect_transfers, + get_transfer_category_id, + load_enabled_rules, +) +from app.modules.finance.enums import ImportStatus, SourceKind +from app.modules.finance.models import ( + FinAccount, + FinImportRun, + FinSourceProfile, + FinTransaction, +) +from app.modules.finance.normalize import ( + compute_dedup_hash, + light_clean, + occurrence_key, +) +from app.modules.finance.parsers import ( + NormalizedRow, + ParseResult, + decode_bytes, + parse_csv, + parse_ofx, + parse_paypal_csv, +) +from app.modules.imports.models import ImportRun + +TRANSFER_WINDOW_DAYS = 3 +MAX_STATS_ERRORS = 50 + +# Importer ids registered in importers.py, per source kind. +IMPORTER_ID_BY_KIND: dict[str, str] = { + SourceKind.CSV: "bank_generic_csv", + SourceKind.OFX: "bank_ofx", + SourceKind.PAYPAL_CSV: "paypal_csv", +} + + +def file_sha256(raw: bytes) -> str: + return hashlib.sha256(raw).hexdigest() + + +def parse_file( + kind: str, config: dict[str, Any], raw: bytes, default_currency: str = "EUR" +) -> ParseResult: + """Dispatch to the parser of the profile kind (§4.2).""" + if kind == SourceKind.OFX: + return parse_ofx(config or {}, raw, default_currency) + text = decode_bytes(raw, (config or {}).get("encoding", "auto")) + if kind == SourceKind.PAYPAL_CSV: + return parse_paypal_csv(config or {}, text, default_currency) + return parse_csv(config or {}, text, default_currency) + + +def prepare_rows( + account_id: uuid.UUID, rows: list[NormalizedRow] +) -> list[tuple[NormalizedRow, str]]: + """Number identical tuples (occurrence) then hash each row (§4.3).""" + counter: dict[tuple, int] = defaultdict(int) + prepared: list[tuple[NormalizedRow, str]] = [] + for row in rows: + key = occurrence_key(row.booked_date, row.amount, row.label_raw) + occurrence = counter[key] + counter[key] += 1 + prepared.append( + ( + row, + compute_dedup_hash( + account_id, row.booked_date, row.amount, row.label_raw, occurrence + ), + ) + ) + return prepared + + +def _existing_keys( + db: Session, + account_id: uuid.UUID, + date_min: date | None, + date_max: date | None, +) -> tuple[set[str], set[str]]: + """Set-based duplicate lookup: one query for hashes, one for external ids.""" + hash_stmt = select(FinTransaction.dedup_hash).where( + FinTransaction.account_id == account_id + ) + if date_min is not None and date_max is not None: + hash_stmt = hash_stmt.where( + FinTransaction.booked_date >= date_min, + FinTransaction.booked_date <= date_max, + ) + hashes = set(db.scalars(hash_stmt).all()) + ext_stmt = select(FinTransaction.external_id).where( + FinTransaction.account_id == account_id, + FinTransaction.external_id.is_not(None), + ) + externals = {e for e in db.scalars(ext_stmt).all() if e} + return hashes, externals + + +def _date_bounds(rows: list[NormalizedRow]) -> tuple[date | None, date | None]: + if not rows: + return None, None + dates = [row.booked_date for row in rows] + return min(dates), max(dates) + + +def build_transaction( + user_id: int, + account: FinAccount, + row: NormalizedRow, + dedup_hash: str, + import_run_id: int | None, +) -> FinTransaction: + return FinTransaction( + id=uuid.uuid4(), + user_id=user_id, + account_id=account.id, + booked_date=row.booked_date, + value_date=row.value_date, + amount=row.amount, + currency=(row.currency or account.currency or "EUR").upper()[:3], + label_raw=row.label_raw, + label_clean=light_clean(row.label_raw), + counterparty=row.counterparty, + external_id=row.external_id, + dedup_hash=dedup_hash, + import_run_id=import_run_id, + ) + + +@dataclass +class PreviewResult: + rows_preview: list[NormalizedRow] = field(default_factory=list) + rows_total: int = 0 + rows_error: int = 0 + rows_skipped_filtered: int = 0 + would_skip_duplicates: int = 0 + date_min: date | None = None + date_max: date | None = None + errors: list[dict[str, Any]] = field(default_factory=list) + duplicate_file_of: int | None = None + + +def preview_import( + db: Session, + user_id: int, + account: FinAccount, + profile: FinSourceProfile, + raw: bytes, + preview_size: int = 20, +) -> PreviewResult: + """Parse without writing anything (§9.6 `POST /imports/preview`).""" + if not raw: + raise DomainValidationError("Le fichier envoyé est vide.") + try: + parsed = parse_file(profile.kind, profile.config or {}, raw, account.currency) + except ImporterParseError as exc: + raise DomainValidationError(str(exc)) from exc + prepared = prepare_rows(account.id, parsed.rows) + date_min, date_max = _date_bounds(parsed.rows) + hashes, externals = _existing_keys(db, account.id, date_min, date_max) + + duplicates = 0 + seen: set[str] = set() + for row, dedup_hash in prepared: + if (row.external_id and row.external_id in externals) or dedup_hash in hashes: + duplicates += 1 + continue + if dedup_hash in seen: + duplicates += 1 + continue + seen.add(dedup_hash) + + previous = db.scalars( + select(FinImportRun.import_run_id) + .where( + FinImportRun.user_id == user_id, + FinImportRun.file_sha256 == file_sha256(raw), + FinImportRun.status == ImportStatus.COMPLETED, + ) + .limit(1) + ).first() + + return PreviewResult( + rows_preview=parsed.rows[:preview_size], + rows_total=parsed.rows_total, + rows_error=parsed.rows_error, + rows_skipped_filtered=parsed.rows_filtered, + would_skip_duplicates=duplicates, + date_min=date_min, + date_max=date_max, + errors=parsed.errors, + duplicate_file_of=previous, + ) + + +def _failed_run( + db: Session, + user_id: int, + account: FinAccount, + profile: FinSourceProfile, + filename: str, + raw: bytes, + message: str, +) -> FinImportRun: + """Persist a failed run after a rollback (no transaction inserted).""" + db.rollback() + started = utcnow() + central = ImportRun( + user_id=user_id, + importer_id=IMPORTER_ID_BY_KIND.get(profile.kind, "bank_generic_csv"), + domain="finance", + filename=filename, + file_size=len(raw), + status="failed", + error_details=[{"row": 0, "message": message}], + started_at=started, + finished_at=utcnow(), + ) + db.add(central) + db.flush() + run = FinImportRun( + id=uuid.uuid4(), + user_id=user_id, + import_run_id=central.id, + source_profile_id=profile.id, + account_id=account.id, + filename=filename, + file_sha256=file_sha256(raw), + status=ImportStatus.FAILED, + started_at=started, + finished_at=utcnow(), + stats={}, + error_message=message, + ) + db.add(run) + db.commit() + return run + + +def run_import( + db: Session, + user_id: int, + account: FinAccount, + profile: FinSourceProfile, + filename: str, + raw: bytes, +) -> FinImportRun: + """Execute one synchronous import run (§4.4).""" + if not raw: + raise DomainValidationError("Le fichier envoyé est vide.") + + started = utcnow() + checksum = file_sha256(raw) + duplicate_of = db.scalars( + select(FinImportRun.import_run_id) + .where( + FinImportRun.user_id == user_id, + FinImportRun.file_sha256 == checksum, + FinImportRun.status == ImportStatus.COMPLETED, + ) + .limit(1) + ).first() + + central = ImportRun( + user_id=user_id, + importer_id=IMPORTER_ID_BY_KIND.get(profile.kind, "bank_generic_csv"), + domain="finance", + filename=filename, + file_size=len(raw), + status="completed", + error_details=[], + started_at=started, + ) + db.add(central) + db.flush() + + run = FinImportRun( + id=uuid.uuid4(), + user_id=user_id, + import_run_id=central.id, + source_profile_id=profile.id, + account_id=account.id, + filename=filename, + file_sha256=checksum, + status=ImportStatus.RUNNING, + started_at=started, + stats={}, + ) + db.add(run) + + try: + parsed = parse_file(profile.kind, profile.config or {}, raw, account.currency) + except ImporterParseError as exc: + return _failed_run(db, user_id, account, profile, filename, raw, str(exc)) + except Exception: # noqa: BLE001 — a broken file must not 500 the API + return _failed_run( + db, + user_id, + account, + profile, + filename, + raw, + "Erreur inattendue pendant la lecture du fichier.", + ) + + # §4.2: the run only fails when EVERY data row errored. A file whose rows + # were all filtered out on purpose (PayPal authorisations, non-EUR lines…) + # is a legitimate empty import, not a failure. + if parsed.rows_total and parsed.rows_error == parsed.rows_total: + return _failed_run( + db, + user_id, + account, + profile, + filename, + raw, + "Aucune ligne exploitable — vérifiez le profil de source.", + ) + + prepared = prepare_rows(account.id, parsed.rows) + date_min, date_max = _date_bounds(parsed.rows) + hashes, externals = _existing_keys(db, account.id, date_min, date_max) + + rules = load_enabled_rules(db, user_id) + transfer_cat = get_transfer_category_id(db, user_id) + to_insert: list[FinTransaction] = [] + skipped_duplicate = 0 + rules_applied = 0 + + for row, dedup_hash in prepared: + if (row.external_id and row.external_id in externals) or dedup_hash in hashes: + skipped_duplicate += 1 + continue + hashes.add(dedup_hash) + if row.external_id: + externals.add(row.external_id) + tx = build_transaction(user_id, account, row, dedup_hash, central.id) + if apply_rules_to_tx(tx, rules, transfer_cat): + rules_applied += 1 + to_insert.append(tx) + + # Atomicity (§4.4): the whole run is one SQL transaction; anything going + # wrong rolls every row back and the run is stored as failed. + transfers = 0 + try: + db.add_all(to_insert) + db.flush() + if date_min is not None and date_max is not None and to_insert: + transfers = detect_transfers( + db, + user_id, + date_min - timedelta(days=TRANSFER_WINDOW_DAYS), + date_max + timedelta(days=TRANSFER_WINDOW_DAYS), + ) + except Exception: # noqa: BLE001 — never leave a half-written run behind + return _failed_run( + db, + user_id, + account, + profile, + filename, + raw, + "L'enregistrement des transactions a échoué : import annulé.", + ) + + stats: dict[str, Any] = { + "rows_total": parsed.rows_total, + "rows_imported": len(to_insert), + "rows_skipped_duplicate": skipped_duplicate, + "rows_skipped_filtered": parsed.rows_filtered, + "rows_error": parsed.rows_error, + "rules_applied": rules_applied, + "transfers_detected": transfers, + "date_min": date_min.isoformat() if date_min else None, + "date_max": date_max.isoformat() if date_max else None, + "errors": parsed.errors[:MAX_STATS_ERRORS], + } + if parsed.errors_truncated or len(parsed.errors) > MAX_STATS_ERRORS: + stats["errors_truncated"] = True + if duplicate_of is not None: + stats["duplicate_file_of"] = duplicate_of + + run.status = ImportStatus.COMPLETED + run.finished_at = utcnow() + run.stats = stats + + central.rows_total = parsed.rows_total + central.rows_inserted = len(to_insert) + central.rows_duplicates = skipped_duplicate + central.rows_errors = parsed.rows_error + central.error_details = parsed.errors[:MAX_STATS_ERRORS] + central.finished_at = run.finished_at + + try: + db.commit() + except Exception: # noqa: BLE001 — never leave a half-written run behind + return _failed_run( + db, + user_id, + account, + profile, + filename, + raw, + "L'enregistrement des transactions a échoué : import annulé.", + ) + return run + + +def transaction_dedup_hash( + db: Session, + account_id: uuid.UUID, + booked_date: date, + amount: Decimal, + label_raw: str, +) -> str: + """Hash for a manually entered transaction: occurrence = count of twins.""" + occurrence = 0 + stmt = select(FinTransaction.dedup_hash).where( + FinTransaction.account_id == account_id, + FinTransaction.booked_date == booked_date, + FinTransaction.amount == amount, + ) + existing = set(db.scalars(stmt).all()) + candidate = compute_dedup_hash( + account_id, booked_date, amount, label_raw, occurrence + ) + while candidate in existing: + occurrence += 1 + candidate = compute_dedup_hash( + account_id, booked_date, amount, label_raw, occurrence + ) + return candidate diff --git a/apps/api/app/modules/finance/presets.py b/apps/api/app/modules/finance/presets.py new file mode 100644 index 0000000..6813bbf --- /dev/null +++ b/apps/api/app/modules/finance/presets.py @@ -0,0 +1,439 @@ +"""Built-in seed data: source profiles (§3.4) and the default category tree (§2.2). + +Both are lazy-seeded on first access to the module (see `service.ensure_seed`) +because v1 has no Alembic data migration (DESIGN §9). +""" + +from copy import deepcopy +from typing import Any + +from app.modules.finance.enums import CategoryKind, SourceKind + +# Chart palette (ux-pages.md §7.5), fixed slot order, colour-blind validated. +PALETTE = ( + "#3987E5", + "#D95926", + "#199E70", + "#C98500", + "#D55181", + "#008300", + "#9085E9", + "#E66767", +) +MUTED_COLOR = "#898781" +UNCATEGORIZED_COLOR = "#9ca3af" +UNCATEGORIZED_LABEL = "Non catégorisé" +TRANSFER_CATEGORY_NAME = "Virements internes" +SAVINGS_NODE_NAME = "Épargne du mois" +DEFICIT_NODE_NAME = "Découvert / réserves" +INCOME_NODE_NAME = "Revenus" + +# -------------------------------------------------------------------------- +# Source profiles — presets shipped with the app (datamodel §3.4 + research §2) +# -------------------------------------------------------------------------- + +# Generic CSV defaults (§3.1). Every preset below is this dict updated. +GENERIC_CSV_CONFIG: dict[str, Any] = { + "encoding": "auto", + "delimiter": "auto", + "quote_char": '"', + "decimal_separator": ",", + "thousands_separator": " ", + "date_format": None, + "date_formats": ["%d/%m/%Y", "%Y-%m-%d", "%d.%m.%Y", "%Y-%m-%d %H:%M:%S"], + "has_header": True, + "skip_rows_top": 0, + "skip_rows_bottom": 0, + "skip_until_header_startswith": None, + "stop_on_non_date_row": False, + "columns": { + "booked_date": [ + "dateOp", + "Date", + "Date de comptabilisation", + "Date opération", + "Date de l'opération", + "date_comptabilisation", + "Booking Date", + "Completed Date", + ], + "value_date": ["dateVal", "Date valeur", "Date de valeur", "Value Date"], + "label": [ + "label", + "Libellé", + "Libelle operation", + "libellé_complet_operation", + "Détail de l'écriture", + "Description", + "Partner Name", + ], + "amount": [ + "amount", + "Montant", + "Montant(EUROS)", + "Montant de l'opération", + "montant_operation", + "Amount", + "Amount (EUR)", + ], + "debit": ["Débit", "Débit euros", "Debit", "Débit Euros"], + "credit": ["Crédit", "Crédit euros", "Credit", "Crédit Euros"], + "currency": ["Devise", "devise", "Currency"], + "external_id": None, + "counterparty": None, + "fee": None, + }, + "amount_mode": "auto", + "invert_sign": False, + "label_join": [], + "skip_row_if": [], +} + + +def _csv_config(**overrides: Any) -> dict[str, Any]: + config = deepcopy(GENERIC_CSV_CONFIG) + columns = config["columns"] + columns.update(overrides.pop("columns", {})) + config.update(overrides) + config["columns"] = columns + return config + + +BUILTIN_PROFILES: tuple[dict[str, Any], ...] = ( + { + "slug": "generic", + "name": "CSV générique", + "kind": SourceKind.CSV, + "config": _csv_config(), + }, + { + "slug": "boursobank", + "name": "BoursoBank / Boursorama (CSV)", + "kind": SourceKind.CSV, + "config": _csv_config( + encoding="utf-8-sig", + delimiter=";", + date_format="%Y-%m-%d", + amount_mode="signed", + label_join=["supplierFound"], + columns={ + "booked_date": "dateOp", + "value_date": "dateVal", + "label": "label", + "amount": "amount", + "debit": None, + "credit": None, + }, + ), + }, + { + "slug": "credit-agricole", + "name": "Crédit Agricole (CSV)", + "kind": SourceKind.CSV, + # Variable-length preamble: locate the header line instead of skipping + # a fixed number of rows; a footer may follow the data (research §2.2). + "config": _csv_config( + encoding="cp1252", + delimiter=";", + date_format="%d/%m/%Y", + amount_mode="split", + skip_until_header_startswith="Date;", + stop_on_non_date_row=True, + columns={ + "booked_date": "Date", + "value_date": "Date valeur", + "label": "Libellé", + "amount": None, + "debit": ["Débit euros", "Débit Euros", "Debit euros"], + "credit": ["Crédit euros", "Crédit Euros", "Credit euros"], + }, + ), + }, + { + "slug": "bnp-paribas", + "name": "BNP Paribas (CSV)", + "kind": SourceKind.CSV, + # First line = balance metadata, no column header: positional mapping. + "config": _csv_config( + encoding="cp1252", + delimiter=";", + date_format="%d/%m/%Y", + has_header=False, + skip_rows_top=1, + amount_mode="signed", + columns={ + "booked_date": 0, + "value_date": None, + "label": 3, + "amount": 4, + "debit": None, + "credit": None, + "currency": None, + }, + ), + }, + { + "slug": "societe-generale", + "name": "Société Générale (CSV)", + "kind": SourceKind.CSV, + "config": _csv_config( + encoding="cp1252", + delimiter=";", + date_format="%d/%m/%Y", + skip_rows_top=1, + amount_mode="signed", + columns={ + "booked_date": ["date_comptabilisation", "Date de l'opération", "Date"], + "value_date": None, + "label": [ + "libellé_complet_operation", + "Libellé", + "Détail de l'écriture", + ], + "amount": [ + "montant_operation", + "Montant de l'opération", + "Montant", + ], + "debit": None, + "credit": None, + "currency": ["devise", "Devise"], + }, + ), + }, + { + "slug": "banque-postale", + "name": "La Banque Postale (CSV)", + "kind": SourceKind.CSV, + "config": _csv_config( + encoding="cp1252", + delimiter=";", + date_format="%d/%m/%Y", + skip_until_header_startswith="Date;", + amount_mode="signed", + columns={ + "booked_date": "Date", + "value_date": None, + "label": "Libellé", + "amount": ["Montant(EUROS)", "Montant (EUROS)", "Montant"], + "debit": None, + "credit": None, + }, + ), + }, + { + "slug": "caisse-epargne", + "name": "Caisse d'Épargne (CSV)", + "kind": SourceKind.CSV, + # 2024+ layout: debit already negative, credit prefixed with '+'. + "config": _csv_config( + encoding="cp1252", + delimiter=";", + date_format="%d/%m/%Y", + amount_mode="split", + columns={ + "booked_date": ["Date de comptabilisation", "Date operation"], + "value_date": "Date de valeur", + "label": ["Libelle operation", "Libellé opération"], + "amount": None, + "debit": "Debit", + "credit": "Credit", + "counterparty": ["Libelle simplifie", "Libellé simplifié"], + }, + ), + }, + { + "slug": "fortuneo", + "name": "Fortuneo (CSV)", + "kind": SourceKind.CSV, + "config": _csv_config( + encoding="auto", + delimiter=";", + date_format="%d/%m/%Y", + amount_mode="split", + columns={ + "booked_date": ["Date opération", "Date operation"], + "value_date": "Date valeur", + "label": ["libellé", "Libellé"], + "amount": None, + "debit": "Débit", + "credit": "Crédit", + }, + ), + }, + { + "slug": "revolut", + "name": "Revolut (CSV)", + "kind": SourceKind.CSV, + # Amount is gross: Fee is subtracted to get the real balance impact. + "config": _csv_config( + encoding="utf-8", + delimiter=",", + decimal_separator=".", + thousands_separator="", + date_format="%Y-%m-%d %H:%M:%S", + amount_mode="signed", + skip_row_if=[{"column": "State", "not_equals": "COMPLETED"}], + columns={ + "booked_date": ["Completed Date", "Started Date"], + "value_date": None, + "label": "Description", + "amount": "Amount", + "debit": None, + "credit": None, + "currency": "Currency", + "fee": "Fee", + }, + ), + }, + { + "slug": "n26", + "name": "N26 (CSV)", + "kind": SourceKind.CSV, + "config": _csv_config( + encoding="utf-8", + delimiter=",", + decimal_separator=".", + thousands_separator="", + date_format="%Y-%m-%d", + amount_mode="signed", + # Column names are resolved case/accent-insensitively. + label_join=["Payment Reference"], + columns={ + "booked_date": ["Booking Date", "Date"], + "value_date": "Value Date", + "label": ["Partner Name", "Payee"], + "amount": ["Amount (EUR)", "Amount(EUR)"], + "debit": None, + "credit": None, + "counterparty": ["Partner Name", "Payee"], + }, + ), + }, + { + "slug": "ofx", + "name": "OFX (toutes banques)", + "kind": SourceKind.OFX, + "config": {"fallback_encoding": "cp1252", "account_match": None}, + }, + { + "slug": "paypal", + "name": "PayPal — rapport d'activité (CSV)", + "kind": SourceKind.PAYPAL_CSV, + "config": { + "encoding": "utf-8-sig", + "delimiter": ",", + "decimal_separator": "auto", + "date_format": "%d/%m/%Y", + "date_formats": ["%d/%m/%Y", "%m/%d/%Y", "%Y-%m-%d"], + "use_net_amount": True, + "skip_types": [ + "Autorisation", + "Authorization", + "Commande", + "Order", + "Annulation d'autorisation", + "Void of Authorization", + "Retenue pour vérification par PayPal", + "Payment Review Hold", + "Annulation de la retenue", + "Payment Review Release", + ], + "skip_status_not_completed": True, + "conversion_as_skip": True, + }, + }, +) + +# -------------------------------------------------------------------------- +# Default category tree (§2.2). (name, icon, children) +# -------------------------------------------------------------------------- + +EXPENSE_TREE: tuple[tuple[str, str, tuple[str, ...]], ...] = ( + ("Alimentation", "shopping-cart", ("Courses", "Restaurants & bars", "Livraison")), + ( + "Logement", + "home", + ( + "Loyer / Crédit", + "Énergie", + "Eau", + "Internet & mobile", + "Assurance habitation", + "Entretien", + ), + ), + ( + "Transports", + "car", + ( + "Carburant", + "Péages & parking", + "Transports en commun", + "Entretien véhicule", + "Assurance auto", + ), + ), + ("Santé", "heart-pulse", ("Pharmacie", "Médecin", "Mutuelle")), + ( + "Loisirs", + "gamepad-2", + ("Abonnements & streaming", "Jeux vidéo", "Sorties", "Sport", "Vacances"), + ), + ("Shopping", "shirt", ("Vêtements", "High-tech", "Maison")), + ("Vape & tabac", "cigarette", ()), + ("Banque & frais", "landmark", ("Frais bancaires", "Intérêts")), + ("Impôts & taxes", "receipt", ()), + ("Enfants & famille", "baby", ()), + ("Animaux", "paw-print", ()), + ("Dons & cadeaux", "gift", ()), + ("Autres dépenses", "circle-ellipsis", ()), +) + +INCOME_TREE: tuple[tuple[str, str, tuple[str, ...]], ...] = ( + ("Salaire", "wallet", ()), + ("Aides & prestations", "hand-coins", ()), + ("Remboursements", "undo-2", ("Santé", "Autres")), + ("Ventes", "tag", ()), + ("Intérêts & placements", "trending-up", ()), + ("Autres revenus", "circle-plus", ()), +) + +TRANSFER_TREE: tuple[tuple[str, str, tuple[str, ...]], ...] = ( + (TRANSFER_CATEGORY_NAME, "arrow-left-right", ()), +) + + +def default_category_tree() -> list[dict[str, Any]]: + """Flat description of the seed tree, roots first, in display order.""" + out: list[dict[str, Any]] = [] + order = 0 + palette_index = 0 + for kind, tree in ( + (CategoryKind.INCOME, INCOME_TREE), + (CategoryKind.EXPENSE, EXPENSE_TREE), + (CategoryKind.TRANSFER, TRANSFER_TREE), + ): + for name, icon, children in tree: + color = ( + MUTED_COLOR + if kind is CategoryKind.TRANSFER + else PALETTE[palette_index % len(PALETTE)] + ) + palette_index += 1 + out.append( + { + "name": name, + "icon": icon, + "color": color, + "kind": kind, + "sort_order": order, + "is_system": kind is CategoryKind.TRANSFER, + "children": [ + {"name": child, "sort_order": i} + for i, child in enumerate(children) + ], + } + ) + order += 1 + return out diff --git a/apps/api/app/modules/finance/router.py b/apps/api/app/modules/finance/router.py new file mode 100644 index 0000000..b030c06 --- /dev/null +++ b/apps/api/app/modules/finance/router.py @@ -0,0 +1,653 @@ +"""HTTP contract of the finance module (datamodel-finance.md §9). + +Mounted automatically under `/api/finance` by the module loader; every endpoint +depends on the JWT user and filters by `user.id` (CONVENTIONS C2.7). +""" + +import uuid +from datetime import date +from decimal import Decimal +from typing import Annotated, Any, Literal + +from fastapi import APIRouter, Depends, File, Form, Query, UploadFile +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.database import get_db +from app.core.dependencies import get_current_user +from app.core.errors import PayloadTooLargeError +from app.core.pagination import Page, PageParams, paginate +from app.core.timeutils import resolve_tz, utcnow +from app.modules.auth.models import User +from app.modules.finance import categorize, pipeline, service +from app.modules.finance import stats as stats_service +from app.modules.finance.schemas import ( + AccountCreate, + AccountRead, + AccountUpdate, + BudgetCreate, + BudgetProgressResponse, + BudgetRead, + BudgetUpdate, + BulkCategorizeRequest, + BulkCategorizeResponse, + CashflowResponse, + CategoryCreate, + CategoryRead, + CategoryUpdate, + DashboardResponse, + ImportPreviewResponse, + ImportRunRead, + MonthlyByCategoryResponse, + RecurringResponse, + RuleApplyRequest, + RuleApplyResponse, + RuleCreate, + RulePreviewRequest, + RulePreviewResponse, + RuleRead, + RuleReorderRequest, + RuleUpdate, + SankeyResponse, + SourceProfileCreate, + SourceProfileRead, + SourceProfileUpdate, + TopMerchantsResponse, + TransactionCreate, + TransactionRead, + TransactionUpdate, + TransferDetectRequest, + TransferDetectResponse, + TransferLinkRequest, + TransferLinkResponse, +) + +router = APIRouter(prefix="/finance", tags=["finance"]) + +DbDep = Annotated[Session, Depends(get_db)] + + +def seeded_user(db: DbDep, user: Annotated[User, Depends(get_current_user)]) -> User: + """Lazy seed of the built-in profiles and of the user's category tree.""" + service.ensure_seed(db, user.id) + return user + + +UserDep = Annotated[User, Depends(seeded_user)] +AccountFilter = Annotated[list[uuid.UUID] | None, Query(alias="account_id")] + + +def _today() -> date: + """Local civil day (Europe/Paris by default) used by month-based stats.""" + return utcnow().astimezone(resolve_tz(get_settings().timezone)).date() + + +# --------------------------------------------------------------------------- +# Accounts +# --------------------------------------------------------------------------- + + +@router.get("/accounts", response_model=list[AccountRead]) +def list_accounts( + db: DbDep, + user: UserDep, + include_archived: Annotated[bool, Query()] = False, +) -> list[AccountRead]: + return [ + AccountRead.model_validate(item) + for item in service.list_accounts(db, user.id, include_archived) + ] + + +@router.post("/accounts", response_model=AccountRead, status_code=201) +def create_account(payload: AccountCreate, db: DbDep, user: UserDep) -> AccountRead: + account = service.create_account(db, user.id, payload) + return AccountRead.model_validate(service.account_detail(db, user.id, account.id)) + + +@router.patch("/accounts/{account_id}", response_model=AccountRead) +def update_account( + account_id: uuid.UUID, payload: AccountUpdate, db: DbDep, user: UserDep +) -> AccountRead: + service.update_account(db, user.id, account_id, payload) + return AccountRead.model_validate(service.account_detail(db, user.id, account_id)) + + +@router.delete("/accounts/{account_id}", status_code=204) +def delete_account(account_id: uuid.UUID, db: DbDep, user: UserDep) -> None: + service.delete_account(db, user.id, account_id) + + +# --------------------------------------------------------------------------- +# Categories +# --------------------------------------------------------------------------- + + +@router.get("/categories", response_model=list[CategoryRead]) +def list_categories(db: DbDep, user: UserDep) -> list[CategoryRead]: + return [ + CategoryRead.model_validate(node) for node in service.category_tree(db, user.id) + ] + + +@router.post("/categories", response_model=CategoryRead, status_code=201) +def create_category(payload: CategoryCreate, db: DbDep, user: UserDep) -> CategoryRead: + return CategoryRead.model_validate(service.create_category(db, user.id, payload)) + + +@router.patch("/categories/{category_id}", response_model=CategoryRead) +def update_category( + category_id: uuid.UUID, payload: CategoryUpdate, db: DbDep, user: UserDep +) -> CategoryRead: + return CategoryRead.model_validate( + service.update_category(db, user.id, category_id, payload) + ) + + +@router.delete("/categories/{category_id}", status_code=204) +def delete_category(category_id: uuid.UUID, db: DbDep, user: UserDep) -> None: + service.delete_category(db, user.id, category_id) + + +# --------------------------------------------------------------------------- +# Source profiles +# --------------------------------------------------------------------------- + + +@router.get("/source-profiles", response_model=list[SourceProfileRead]) +def list_source_profiles(db: DbDep, user: UserDep) -> list[SourceProfileRead]: + return [ + SourceProfileRead.model_validate(profile) + for profile in service.list_source_profiles(db, user.id) + ] + + +@router.post("/source-profiles", response_model=SourceProfileRead, status_code=201) +def create_source_profile( + payload: SourceProfileCreate, db: DbDep, user: UserDep +) -> SourceProfileRead: + return SourceProfileRead.model_validate( + service.create_source_profile(db, user.id, payload) + ) + + +@router.post( + "/source-profiles/{profile_id}/clone", + response_model=SourceProfileRead, + status_code=201, +) +def clone_source_profile( + profile_id: uuid.UUID, db: DbDep, user: UserDep +) -> SourceProfileRead: + return SourceProfileRead.model_validate( + service.clone_source_profile(db, user.id, profile_id) + ) + + +@router.patch("/source-profiles/{profile_id}", response_model=SourceProfileRead) +def update_source_profile( + profile_id: uuid.UUID, payload: SourceProfileUpdate, db: DbDep, user: UserDep +) -> SourceProfileRead: + return SourceProfileRead.model_validate( + service.update_source_profile(db, user.id, profile_id, payload) + ) + + +@router.delete("/source-profiles/{profile_id}", status_code=204) +def delete_source_profile(profile_id: uuid.UUID, db: DbDep, user: UserDep) -> None: + service.delete_source_profile(db, user.id, profile_id) + + +# --------------------------------------------------------------------------- +# Transactions +# --------------------------------------------------------------------------- + + +@router.get("/transactions", response_model=Page[TransactionRead]) +def list_transactions( + db: DbDep, + user: UserDep, + params: Annotated[PageParams, Depends()], + date_from: Annotated[date | None, Query()] = None, + date_to: Annotated[date | None, Query()] = None, + account_id: AccountFilter = None, + category_id: Annotated[list[str] | None, Query()] = None, + q: Annotated[str | None, Query()] = None, + direction: Annotated[Literal["debit", "credit"] | None, Query()] = None, + amount_min: Annotated[Decimal | None, Query()] = None, + amount_max: Annotated[Decimal | None, Query()] = None, + is_transfer: Annotated[bool | None, Query()] = None, + import_run_id: Annotated[int | None, Query()] = None, + sort: Annotated[str | None, Query()] = None, +) -> Page[TransactionRead]: + stmt = service.transactions_query( + db, + user.id, + date_from=date_from, + date_to=date_to, + account_ids=account_id, + category_ids=category_id, + q=q, + direction=direction, + amount_min=amount_min, + amount_max=amount_max, + is_transfer=is_transfer, + import_run_id=import_run_id, + sort=sort, + ) + items, total = paginate(db, stmt, params) + return Page( + items=[ + TransactionRead.model_validate(row) + for row in service.serialize_transactions(db, user.id, items) + ], + total=total, + page=params.page, + page_size=params.page_size, + ) + + +@router.post("/transactions", response_model=TransactionRead, status_code=201) +def create_transaction( + payload: TransactionCreate, db: DbDep, user: UserDep +) -> TransactionRead: + tx = service.create_transaction(db, user.id, payload) + return TransactionRead.model_validate( + service.serialize_transactions(db, user.id, [tx])[0] + ) + + +@router.patch("/transactions/{transaction_id}", response_model=TransactionRead) +def update_transaction( + transaction_id: uuid.UUID, payload: TransactionUpdate, db: DbDep, user: UserDep +) -> TransactionRead: + tx = service.update_transaction(db, user.id, transaction_id, payload) + return TransactionRead.model_validate( + service.serialize_transactions(db, user.id, [tx])[0] + ) + + +@router.delete("/transactions/{transaction_id}", status_code=204) +def delete_transaction(transaction_id: uuid.UUID, db: DbDep, user: UserDep) -> None: + service.delete_transaction(db, user.id, transaction_id) + + +@router.post("/transactions/bulk-categorize", response_model=BulkCategorizeResponse) +def bulk_categorize( + payload: BulkCategorizeRequest, db: DbDep, user: UserDep +) -> BulkCategorizeResponse: + updated = service.bulk_categorize( + db, user.id, payload.transaction_ids, payload.category_id + ) + return BulkCategorizeResponse(updated=updated) + + +# --------------------------------------------------------------------------- +# Rules +# --------------------------------------------------------------------------- + + +@router.get("/rules", response_model=list[RuleRead]) +def list_rules(db: DbDep, user: UserDep) -> list[RuleRead]: + return [RuleRead.model_validate(rule) for rule in service.list_rules(db, user.id)] + + +@router.post("/rules", response_model=RuleRead, status_code=201) +def create_rule(payload: RuleCreate, db: DbDep, user: UserDep) -> RuleRead: + return RuleRead.model_validate(service.create_rule(db, user.id, payload)) + + +@router.patch("/rules/{rule_id}", response_model=RuleRead) +def update_rule( + rule_id: uuid.UUID, payload: RuleUpdate, db: DbDep, user: UserDep +) -> RuleRead: + return RuleRead.model_validate(service.update_rule(db, user.id, rule_id, payload)) + + +@router.delete("/rules/{rule_id}", status_code=204) +def delete_rule(rule_id: uuid.UUID, db: DbDep, user: UserDep) -> None: + service.delete_rule(db, user.id, rule_id) + + +@router.post("/rules/reorder", response_model=list[RuleRead]) +def reorder_rules( + payload: RuleReorderRequest, db: DbDep, user: UserDep +) -> list[RuleRead]: + service.reorder_rules(db, user.id, payload.ordered_ids) + return [RuleRead.model_validate(rule) for rule in service.list_rules(db, user.id)] + + +@router.post("/rules/apply", response_model=RuleApplyResponse) +def apply_rules( + payload: RuleApplyRequest, db: DbDep, user: UserDep +) -> RuleApplyResponse: + result = categorize.apply_rules( + db, + user.id, + scope=payload.scope, + date_from=payload.date_from, + date_to=payload.date_to, + account_id=payload.account_id, + rule_id=payload.rule_id, + dry_run=payload.dry_run, + force=payload.force, + ) + return RuleApplyResponse.model_validate( + { + "scanned": result.scanned, + "matched": result.matched, + "updated": result.updated, + "dry_run": result.dry_run, + "by_rule": result.by_rule, + } + ) + + +@router.post("/rules/preview", response_model=RulePreviewResponse) +def preview_rule( + payload: RulePreviewRequest, db: DbDep, user: UserDep +) -> RulePreviewResponse: + matches, total = categorize.preview_rule(db, user.id, payload.matchers) + return RulePreviewResponse( + items=[ + TransactionRead.model_validate(row) + for row in service.serialize_transactions(db, user.id, matches) + ], + total_matched=total, + ) + + +# --------------------------------------------------------------------------- +# Budgets +# --------------------------------------------------------------------------- + + +@router.get("/budgets", response_model=list[BudgetRead]) +def list_budgets( + db: DbDep, + user: UserDep, + month: Annotated[str | None, Query()] = None, +) -> list[BudgetRead]: + target = service.parse_month_param(month, _today()) + return [ + BudgetRead.model_validate(item) + for item in service.list_budgets(db, user.id, target) + ] + + +@router.post("/budgets", response_model=BudgetRead, status_code=201) +def create_budget(payload: BudgetCreate, db: DbDep, user: UserDep) -> BudgetRead: + budget = service.create_budget(db, user.id, payload) + return BudgetRead.model_validate(budget) + + +@router.patch("/budgets/{budget_id}", response_model=list[BudgetRead]) +def update_budget( + budget_id: uuid.UUID, payload: BudgetUpdate, db: DbDep, user: UserDep +) -> list[BudgetRead]: + budgets = service.update_budget(db, user.id, budget_id, payload) + return [BudgetRead.model_validate(budget) for budget in budgets] + + +@router.delete("/budgets/{budget_id}", status_code=204) +def delete_budget(budget_id: uuid.UUID, db: DbDep, user: UserDep) -> None: + service.delete_budget(db, user.id, budget_id) + + +# --------------------------------------------------------------------------- +# Imports +# --------------------------------------------------------------------------- + + +def _read_upload(file: UploadFile) -> bytes: + data = file.file.read() + if len(data) > get_settings().max_upload_bytes: + raise PayloadTooLargeError("Fichier trop volumineux (limite : 20 Mio).") + return data + + +@router.post("/imports/preview", response_model=ImportPreviewResponse) +def preview_import( + db: DbDep, + user: UserDep, + file: Annotated[UploadFile, File()], + account_id: Annotated[uuid.UUID, Form()], + source_profile_id: Annotated[uuid.UUID | None, Form()] = None, +) -> ImportPreviewResponse: + account = service.get_account(db, user.id, account_id) + profile = service.resolve_profile(db, user.id, source_profile_id) + result = pipeline.preview_import(db, user.id, account, profile, _read_upload(file)) + return ImportPreviewResponse.model_validate( + { + "rows_preview": result.rows_preview, + "rows_total": result.rows_total, + "rows_error": result.rows_error, + "rows_skipped_filtered": result.rows_skipped_filtered, + "would_skip_duplicates": result.would_skip_duplicates, + "date_min": result.date_min, + "date_max": result.date_max, + "errors": result.errors, + "duplicate_file_of": result.duplicate_file_of, + } + ) + + +@router.post("/imports", response_model=ImportRunRead, status_code=201) +def run_import( + db: DbDep, + user: UserDep, + file: Annotated[UploadFile, File()], + account_id: Annotated[uuid.UUID, Form()], + source_profile_id: Annotated[uuid.UUID | None, Form()] = None, +) -> ImportRunRead: + account = service.get_account(db, user.id, account_id) + profile = service.resolve_profile(db, user.id, source_profile_id) + run = pipeline.run_import( + db, + user.id, + account, + profile, + file.filename or "import.csv", + _read_upload(file), + ) + return ImportRunRead.model_validate(run) + + +@router.get("/imports", response_model=Page[ImportRunRead]) +def list_imports( + db: DbDep, + user: UserDep, + params: Annotated[PageParams, Depends()], +) -> Page[ImportRunRead]: + items, total = paginate(db, service.import_runs_query(user.id), params) + return Page( + items=[ImportRunRead.model_validate(run) for run in items], + total=total, + page=params.page, + page_size=params.page_size, + ) + + +@router.get("/imports/{run_id}", response_model=ImportRunRead) +def get_import(run_id: uuid.UUID, db: DbDep, user: UserDep) -> ImportRunRead: + return ImportRunRead.model_validate(service.get_import_run(db, user.id, run_id)) + + +@router.delete("/imports/{run_id}", status_code=204) +def rollback_import( + run_id: uuid.UUID, + db: DbDep, + user: UserDep, + force: Annotated[bool, Query()] = False, +) -> None: + service.rollback_import(db, user.id, run_id, force=force) + + +# --------------------------------------------------------------------------- +# Transfers +# --------------------------------------------------------------------------- + + +@router.post("/transfers/detect", response_model=TransferDetectResponse) +def detect_transfers( + db: DbDep, + user: UserDep, + payload: TransferDetectRequest | None = None, +) -> TransferDetectResponse: + payload = payload or TransferDetectRequest() + created = categorize.detect_transfers( + db, user.id, payload.date_from, payload.date_to + ) + db.commit() + return TransferDetectResponse(pairs_created=created) + + +@router.post("/transfers/link", response_model=TransferLinkResponse) +def link_transfer( + payload: TransferLinkRequest, db: DbDep, user: UserDep +) -> TransferLinkResponse: + group_id = categorize.link_transfer( + db, user.id, payload.transaction_id_a, payload.transaction_id_b + ) + return TransferLinkResponse(transfer_group_id=group_id) + + +@router.delete("/transfers/{transfer_group_id}", status_code=204) +def unlink_transfer(transfer_group_id: uuid.UUID, db: DbDep, user: UserDep) -> None: + categorize.unlink_transfer(db, user.id, transfer_group_id) + + +# --------------------------------------------------------------------------- +# Stats +# --------------------------------------------------------------------------- + + +@router.get("/stats/monthly-by-category", response_model=MonthlyByCategoryResponse) +def stats_monthly_by_category( + db: DbDep, + user: UserDep, + months: Annotated[int, Query(ge=1, le=60)] = 12, + level: Annotated[Literal["root", "child"], Query()] = "root", + direction: Annotated[Literal["debit", "credit"], Query()] = "debit", + account_id: AccountFilter = None, +) -> MonthlyByCategoryResponse: + return MonthlyByCategoryResponse.model_validate( + stats_service.monthly_by_category( + db, + user.id, + months=months, + level=level, + direction=direction, + account_ids=account_id, + today=_today(), + ) + ) + + +@router.get("/stats/cashflow", response_model=CashflowResponse) +def stats_cashflow( + db: DbDep, + user: UserDep, + months: Annotated[int, Query(ge=1, le=60)] = 12, + account_id: AccountFilter = None, +) -> CashflowResponse: + return CashflowResponse.model_validate( + stats_service.cashflow( + db, user.id, months=months, account_ids=account_id, today=_today() + ) + ) + + +@router.get("/stats/top-merchants", response_model=TopMerchantsResponse) +def stats_top_merchants( + db: DbDep, + user: UserDep, + months: Annotated[int, Query(ge=1, le=60)] = 3, + limit: Annotated[int, Query(ge=1, le=100)] = 15, + direction: Annotated[Literal["debit", "credit"], Query()] = "debit", + account_id: AccountFilter = None, +) -> TopMerchantsResponse: + data = stats_service.top_merchants( + db, + user.id, + months=months, + limit=limit, + direction=direction, + account_ids=account_id, + today=_today(), + ) + return TopMerchantsResponse.model_validate( + {"period": _period(data["period"]), "items": data["items"]} + ) + + +@router.get("/stats/recurring", response_model=RecurringResponse) +def stats_recurring( + db: DbDep, + user: UserDep, + direction: Annotated[Literal["debit", "credit"], Query()] = "debit", + include_inactive: Annotated[bool, Query()] = False, +) -> RecurringResponse: + return RecurringResponse.model_validate( + stats_service.recurring( + db, + user.id, + direction=direction, + include_inactive=include_inactive, + today=_today(), + ) + ) + + +@router.get("/stats/budget-progress", response_model=BudgetProgressResponse) +def stats_budget_progress( + db: DbDep, + user: UserDep, + month: Annotated[str | None, Query()] = None, + account_id: AccountFilter = None, +) -> BudgetProgressResponse: + today = _today() + target = service.parse_month_param(month, today) + return BudgetProgressResponse.model_validate( + stats_service.budget_progress( + db, user.id, target, account_ids=account_id, today=today + ) + ) + + +@router.get("/stats/sankey", response_model=SankeyResponse) +def stats_sankey( + db: DbDep, + user: UserDep, + month: Annotated[str | None, Query()] = None, + months: Annotated[int, Query(ge=1, le=60)] = 1, + account_id: AccountFilter = None, +) -> SankeyResponse: + today = _today() + target = service.parse_month_param(month, today) if month else None + data = stats_service.sankey( + db, + user.id, + month=target, + months=months, + account_ids=account_id, + today=today, + ) + return SankeyResponse.model_validate( + { + "period": _period(data["period"]), + "nodes": data["nodes"], + "links": data["links"], + } + ) + + +@router.get("/dashboard", response_model=DashboardResponse) +def dashboard(db: DbDep, user: UserDep) -> DashboardResponse: + return DashboardResponse.model_validate( + stats_service.dashboard(db, user.id, today=_today()) + ) + + +def _period(period: dict[str, Any]) -> dict[str, Any]: + return {"from": period["from"], "to": period["to"]} diff --git a/apps/api/app/modules/finance/schemas.py b/apps/api/app/modules/finance/schemas.py new file mode 100644 index 0000000..2d998e2 --- /dev/null +++ b/apps/api/app/modules/finance/schemas.py @@ -0,0 +1,501 @@ +"""Pydantic v2 schemas of the finance module (datamodel-finance.md §9). + +Amounts travel as JSON numbers with 2 decimals (`Money`), dates as +`YYYY-MM-DD`, months as `YYYY-MM`. +""" + +import uuid +from datetime import date +from decimal import Decimal +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, PlainSerializer, field_validator + +from app.core.timeutils import StoredUtcDatetime +from app.modules.finance.enums import ( + AccountKind, + CategoryKind, + CategorySource, + ImportStatus, + SourceKind, +) + +Money = Annotated[ + Decimal, + PlainSerializer( + lambda v: float(round(Decimal(v), 2)), return_type=float, when_used="json" + ), +] + +MAX_BULK_IDS = 500 + + +class ORMModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +# --------------------------------------------------------------------------- +# Accounts +# --------------------------------------------------------------------------- + + +class AccountCreate(BaseModel): + name: str = Field(min_length=1, max_length=100) + kind: AccountKind = AccountKind.CHECKING + currency: str = Field(default="EUR", min_length=3, max_length=3) + institution: str | None = Field(default=None, max_length=100) + iban_masked: str | None = Field(default=None, max_length=34) + initial_balance: Money = Decimal("0.00") + + +class AccountUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + kind: AccountKind | None = None + currency: str | None = Field(default=None, min_length=3, max_length=3) + institution: str | None = Field(default=None, max_length=100) + iban_masked: str | None = Field(default=None, max_length=34) + initial_balance: Money | None = None + is_archived: bool | None = None + + +class AccountRead(ORMModel): + id: uuid.UUID + name: str + kind: AccountKind + currency: str + institution: str | None + iban_masked: str | None + initial_balance: Money + is_archived: bool + balance: Money + transaction_count: int + last_transaction_date: date | None = None + + +# --------------------------------------------------------------------------- +# Categories +# --------------------------------------------------------------------------- + + +class CategoryCreate(BaseModel): + name: str = Field(min_length=1, max_length=80) + parent_id: uuid.UUID | None = None + icon: str | None = Field(default=None, max_length=50) + color: str | None = Field(default=None, max_length=7) + kind: CategoryKind = CategoryKind.EXPENSE + sort_order: int = 0 + + +class CategoryUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=80) + parent_id: uuid.UUID | None = None + icon: str | None = Field(default=None, max_length=50) + color: str | None = Field(default=None, max_length=7) + sort_order: int | None = None + + +class CategoryRead(ORMModel): + id: uuid.UUID + parent_id: uuid.UUID | None + name: str + icon: str | None + color: str | None + kind: CategoryKind + is_system: bool + sort_order: int + transaction_count: int = 0 + children: list["CategoryRead"] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Source profiles +# --------------------------------------------------------------------------- + + +class SourceProfileCreate(BaseModel): + name: str = Field(min_length=1, max_length=100) + kind: SourceKind + config: dict[str, Any] = Field(default_factory=dict) + + +class SourceProfileUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + config: dict[str, Any] | None = None + + +class SourceProfileRead(ORMModel): + id: uuid.UUID + name: str + kind: SourceKind + config: dict[str, Any] + is_builtin: bool + + +# --------------------------------------------------------------------------- +# Transactions +# --------------------------------------------------------------------------- + + +class TransactionRead(BaseModel): + id: uuid.UUID + account_id: uuid.UUID + account_name: str | None = None + booked_date: date + value_date: date | None = None + amount: Money + currency: str + label_raw: str + label_clean: str + counterparty: str | None = None + category_id: uuid.UUID | None = None + category_name: str | None = None + category_color: str | None = None + category_source: CategorySource | None = None + notes: str | None = None + transfer_group_id: uuid.UUID | None = None + external_id: str | None = None + import_run_id: int | None = None + + +class TransactionCreate(BaseModel): + account_id: uuid.UUID + booked_date: date + amount: Money + label_clean: str = Field(min_length=1) + value_date: date | None = None + currency: str | None = Field(default=None, min_length=3, max_length=3) + category_id: uuid.UUID | None = None + counterparty: str | None = Field(default=None, max_length=150) + notes: str | None = None + + +class TransactionUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + label_clean: str | None = Field(default=None, min_length=1) + counterparty: str | None = Field(default=None, max_length=150) + category_id: uuid.UUID | None = None + notes: str | None = None + booked_date: date | None = None + amount: Money | None = None + + +class BulkCategorizeRequest(BaseModel): + transaction_ids: list[uuid.UUID] = Field(min_length=1, max_length=MAX_BULK_IDS) + category_id: uuid.UUID | None = None + + +class BulkCategorizeResponse(BaseModel): + updated: int + + +# --------------------------------------------------------------------------- +# Rules +# --------------------------------------------------------------------------- + + +class RuleCreate(BaseModel): + name: str = Field(min_length=1, max_length=100) + priority: int = 100 + enabled: bool = True + stop: bool = True + matchers: dict[str, Any] + actions: dict[str, Any] + + +class RuleUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + priority: int | None = None + enabled: bool | None = None + stop: bool | None = None + matchers: dict[str, Any] | None = None + actions: dict[str, Any] | None = None + + +class RuleRead(ORMModel): + id: uuid.UUID + name: str + priority: int + enabled: bool + stop: bool + matchers: dict[str, Any] + actions: dict[str, Any] + hit_count: int + # Stored instants: aware on PostgreSQL, naive on SQLite -> always UTC "Z". + last_applied_at: StoredUtcDatetime | None + + +class RuleReorderRequest(BaseModel): + ordered_ids: list[uuid.UUID] = Field(min_length=1) + + +class RuleApplyRequest(BaseModel): + scope: Literal["uncategorized", "all_non_manual", "all"] = "uncategorized" + date_from: date | None = None + date_to: date | None = None + account_id: uuid.UUID | None = None + rule_id: uuid.UUID | None = None + dry_run: bool = False + force: bool = False + + +class RuleApplyByRule(BaseModel): + rule_id: uuid.UUID + name: str + matched: int + + +class RuleApplyResponse(BaseModel): + scanned: int + matched: int + updated: int + dry_run: bool + by_rule: list[RuleApplyByRule] + + +class RulePreviewRequest(BaseModel): + matchers: dict[str, Any] + + +class RulePreviewResponse(BaseModel): + items: list[TransactionRead] + total_matched: int + + +# --------------------------------------------------------------------------- +# Budgets +# --------------------------------------------------------------------------- + + +def _first_of_month(value: date | None) -> date | None: + if value is None: + return None + return value.replace(day=1) + + +class BudgetCreate(BaseModel): + category_id: uuid.UUID + monthly_amount: Money = Field(gt=0) + start_month: date + end_month: date | None = None + + @field_validator("start_month", "end_month") + @classmethod + def normalize_month(cls, value: date | None) -> date | None: + return _first_of_month(value) + + +class BudgetUpdate(BaseModel): + monthly_amount: Money | None = Field(default=None, gt=0) + end_month: date | None = None + effective_from: date | None = None + + @field_validator("end_month", "effective_from") + @classmethod + def normalize_month(cls, value: date | None) -> date | None: + return _first_of_month(value) + + +class BudgetRead(ORMModel): + id: uuid.UUID + category_id: uuid.UUID + category_name: str | None = None + category_color: str | None = None + monthly_amount: Money + start_month: date + end_month: date | None + actual: Money = Decimal("0.00") + remaining: Money = Decimal("0.00") + progress_pct: float = 0.0 + projected_eom: Money | None = None + status: str = "ok" + + +# --------------------------------------------------------------------------- +# Imports +# --------------------------------------------------------------------------- + + +class NormalizedRowRead(ORMModel): + booked_date: date + value_date: date | None + amount: Money + currency: str + label_raw: str + counterparty: str | None + external_id: str | None + source_row_index: int + + +class ImportPreviewResponse(BaseModel): + rows_preview: list[NormalizedRowRead] + rows_total: int + rows_error: int + rows_skipped_filtered: int + would_skip_duplicates: int + date_min: date | None + date_max: date | None + errors: list[dict[str, Any]] + duplicate_file_of: int | None = None + + +class ImportRunRead(ORMModel): + id: uuid.UUID + import_run_id: int + account_id: uuid.UUID + source_profile_id: uuid.UUID | None + filename: str + file_sha256: str + status: ImportStatus + # Stored instants: aware on PostgreSQL, naive on SQLite -> always UTC "Z". + started_at: StoredUtcDatetime + finished_at: StoredUtcDatetime | None + stats: dict[str, Any] + error_message: str | None + + +# --------------------------------------------------------------------------- +# Transfers +# --------------------------------------------------------------------------- + + +class TransferDetectRequest(BaseModel): + date_from: date | None = None + date_to: date | None = None + + +class TransferDetectResponse(BaseModel): + pairs_created: int + + +class TransferLinkRequest(BaseModel): + transaction_id_a: uuid.UUID + transaction_id_b: uuid.UUID + + +class TransferLinkResponse(BaseModel): + transfer_group_id: uuid.UUID + + +# --------------------------------------------------------------------------- +# Stats (ECharts-ready) +# --------------------------------------------------------------------------- + + +class MonthlySeries(BaseModel): + category_id: uuid.UUID | None + name: str + color: str + data: list[Money] + + +class MonthlyByCategoryResponse(BaseModel): + months: list[str] + series: list[MonthlySeries] + totals: list[Money] + + +class CashflowResponse(BaseModel): + months: list[str] + income: list[Money] + expenses: list[Money] + net: list[Money] + cumulative_net: list[Money] + + +class PeriodRead(BaseModel): + from_: date = Field(alias="from") + to: date + + model_config = ConfigDict(populate_by_name=True) + + +class MerchantRead(BaseModel): + merchant: str + total: Money + count: int + average: Money + category_name: str | None = None + category_color: str | None = None + + +class TopMerchantsResponse(BaseModel): + period: PeriodRead + items: list[MerchantRead] + + +class RecurringItem(BaseModel): + model_config = ConfigDict(from_attributes=True) + + merchant_key: str + label_display: str + category_id: uuid.UUID | None + category_name: str | None + periodicity: str + occurrences: int + average_amount: Money + expected_amount: Money + last_date: date + next_date_predicted: date + is_active: bool + + +class RecurringResponse(BaseModel): + items: list[RecurringItem] + monthly_total_estimate: Money + + +class BudgetProgressItem(BaseModel): + budget_id: uuid.UUID + category_id: uuid.UUID + category_name: str + category_color: str + budget: Money + actual: Money + remaining: Money + progress_pct: float + projected_eom: Money | None + status: str + + +class BudgetProgressTotals(BaseModel): + budget: Money + actual: Money + progress_pct: float + + +class BudgetProgressResponse(BaseModel): + month: str + items: list[BudgetProgressItem] + totals: BudgetProgressTotals + + +class SankeyNode(BaseModel): + name: str + color: str + + +class SankeyLink(BaseModel): + source: str + target: str + value: Money + + +class SankeyResponse(BaseModel): + period: PeriodRead + nodes: list[SankeyNode] + links: list[SankeyLink] + + +class DashboardResponse(BaseModel): + month: str + total_balance: Money + accounts_count: int + month_expenses: Money + month_income: Money + month_net: Money + average_expenses_6m: Money + budget_total: Money + budget_actual: Money + budget_progress_pct: float + uncategorized_count: int diff --git a/apps/api/app/modules/finance/service.py b/apps/api/app/modules/finance/service.py new file mode 100644 index 0000000..c6ea787 --- /dev/null +++ b/apps/api/app/modules/finance/service.py @@ -0,0 +1,1139 @@ +"""Business logic of the finance module (datamodel-finance.md §2 and §9). + +Every function filters by `user_id` and raises the French `AppError` subclasses +of the core; routers stay thin (CONVENTIONS C2.6). +""" + +import uuid +from datetime import date +from decimal import Decimal +from typing import Any + +from sqlalchemy import Select, delete, func, or_, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.errors import ( + ConflictError, + DomainValidationError, + ForbiddenError, + NotFoundError, +) +from app.modules.finance import stats as stats_service +from app.modules.finance.categorize import validate_actions, validate_matchers +from app.modules.finance.enums import ( + AccountKind, + CategoryKind, + CategorySource, + SourceKind, +) +from app.modules.finance.models import ( + FinAccount, + FinBudget, + FinCategory, + FinImportRun, + FinRule, + FinSourceProfile, + FinTransaction, +) +from app.modules.finance.normalize import add_months, month_str +from app.modules.finance.pipeline import transaction_dedup_hash +from app.modules.finance.presets import ( + BUILTIN_PROFILES, + TRANSFER_CATEGORY_NAME, + default_category_tree, +) +from app.modules.imports.models import ImportRun + +MAX_DEPTH_MESSAGE = "L'arbre des catégories est limité à deux niveaux." +SORTABLE_TRANSACTION_FIELDS = { + "booked_date": FinTransaction.booked_date, + "amount": FinTransaction.amount, + "label_clean": FinTransaction.label_clean, +} + + +# --------------------------------------------------------------------------- +# Lazy seeding (built-in profiles + default category tree) +# --------------------------------------------------------------------------- + + +def _commit_seed(db: Session) -> None: + """Commit seed rows, tolerating a concurrent request that seeded first.""" + try: + db.commit() + except IntegrityError: + db.rollback() + + +def ensure_builtin_profiles(db: Session) -> None: + existing = set( + db.scalars( + select(FinSourceProfile.name).where(FinSourceProfile.user_id.is_(None)) + ).all() + ) + created = False + for preset in BUILTIN_PROFILES: + if preset["name"] in existing: + continue + db.add( + FinSourceProfile( + id=uuid.uuid4(), + user_id=None, + name=preset["name"], + kind=preset["kind"], + config=preset["config"], + is_builtin=True, + ) + ) + created = True + if created: + _commit_seed(db) + + +def ensure_user_categories(db: Session, user_id: int) -> None: + exists = db.scalar( + select(func.count()) + .select_from(FinCategory) + .where(FinCategory.user_id == user_id) + ) + if exists: + return + for root in default_category_tree(): + parent = FinCategory( + id=uuid.uuid4(), + user_id=user_id, + parent_id=None, + name=root["name"], + icon=root["icon"], + color=root["color"], + kind=root["kind"], + is_system=root["is_system"], + sort_order=root["sort_order"], + ) + db.add(parent) + for child in root["children"]: + db.add( + FinCategory( + id=uuid.uuid4(), + user_id=user_id, + parent_id=parent.id, + name=child["name"], + icon=None, + color=None, + kind=root["kind"], + is_system=False, + sort_order=child["sort_order"], + ) + ) + _commit_seed(db) + + +def ensure_seed(db: Session, user_id: int) -> None: + ensure_builtin_profiles(db) + ensure_user_categories(db, user_id) + + +# --------------------------------------------------------------------------- +# Accounts (§9.1) +# --------------------------------------------------------------------------- + + +def get_account(db: Session, user_id: int, account_id: uuid.UUID) -> FinAccount: + account = db.get(FinAccount, account_id) + if account is None or account.user_id != user_id: + raise NotFoundError("Compte introuvable.") + return account + + +def _account_dict( + account: FinAccount, + balances: dict[uuid.UUID, Decimal], + counts: dict[uuid.UUID, int], + last_dates: dict[uuid.UUID, Any], +) -> dict[str, Any]: + data = { + column.name: getattr(account, column.name) + for column in FinAccount.__table__.columns + } + data["balance"] = stats_service.money( + stats_service.money(account.initial_balance) + + balances.get(account.id, Decimal("0.00")) + ) + data["transaction_count"] = counts.get(account.id, 0) + last = last_dates.get(account.id) + data["last_transaction_date"] = ( + date.fromisoformat(last) if isinstance(last, str) else last + ) + return data + + +def _last_transaction_dates(db: Session, user_id: int) -> dict[uuid.UUID, Any]: + return dict( + db.execute( + select(FinTransaction.account_id, func.max(FinTransaction.booked_date)) + .where(FinTransaction.user_id == user_id) + .group_by(FinTransaction.account_id) + ).all() + ) + + +def list_accounts( + db: Session, user_id: int, include_archived: bool = False +) -> list[dict[str, Any]]: + stmt = select(FinAccount).where(FinAccount.user_id == user_id) + if not include_archived: + stmt = stmt.where(FinAccount.is_archived.is_(False)) + accounts = list(db.scalars(stmt.order_by(FinAccount.name.asc())).all()) + balances = stats_service.account_balances(db, user_id) + counts = stats_service.account_transaction_counts(db, user_id) + last_dates = _last_transaction_dates(db, user_id) + return [ + _account_dict(account, balances, counts, last_dates) for account in accounts + ] + + +def account_detail(db: Session, user_id: int, account_id: uuid.UUID) -> dict[str, Any]: + account = get_account(db, user_id, account_id) + return _account_dict( + account, + stats_service.account_balances(db, user_id), + stats_service.account_transaction_counts(db, user_id), + _last_transaction_dates(db, user_id), + ) + + +def create_account(db: Session, user_id: int, payload: Any) -> FinAccount: + taken = db.scalar( + select(func.count()) + .select_from(FinAccount) + .where(FinAccount.user_id == user_id, FinAccount.name == payload.name) + ) + if taken: + raise ConflictError("Un compte porte déjà ce nom.") + account = FinAccount( + id=uuid.uuid4(), + user_id=user_id, + name=payload.name, + kind=payload.kind, + currency=payload.currency.upper(), + institution=payload.institution, + iban_masked=payload.iban_masked, + initial_balance=payload.initial_balance, + ) + db.add(account) + db.commit() + return account + + +def update_account( + db: Session, user_id: int, account_id: uuid.UUID, payload: Any +) -> FinAccount: + account = get_account(db, user_id, account_id) + data = payload.model_dump(exclude_unset=True) + if "name" in data and data["name"] != account.name: + taken = db.scalar( + select(func.count()) + .select_from(FinAccount) + .where( + FinAccount.user_id == user_id, + FinAccount.name == data["name"], + FinAccount.id != account_id, + ) + ) + if taken: + raise ConflictError("Un compte porte déjà ce nom.") + if data.get("currency"): + data["currency"] = data["currency"].upper() + for key, value in data.items(): + setattr(account, key, value) + db.commit() + return account + + +def delete_account(db: Session, user_id: int, account_id: uuid.UUID) -> None: + account = get_account(db, user_id, account_id) + count = db.scalar( + select(func.count()) + .select_from(FinTransaction) + .where(FinTransaction.account_id == account.id) + ) + if count: + raise ConflictError( + "Ce compte contient des transactions. Archivez le compte à la place." + ) + db.delete(account) + db.commit() + + +# --------------------------------------------------------------------------- +# Categories (§9.3) +# --------------------------------------------------------------------------- + + +def get_category(db: Session, user_id: int, category_id: uuid.UUID) -> FinCategory: + category = db.get(FinCategory, category_id) + if category is None or category.user_id != user_id: + raise NotFoundError("Catégorie introuvable.") + return category + + +def category_tree(db: Session, user_id: int) -> list[dict[str, Any]]: + categories = list( + db.scalars( + select(FinCategory) + .where(FinCategory.user_id == user_id) + .order_by(FinCategory.sort_order.asc(), FinCategory.name.asc()) + ).all() + ) + counts = dict( + db.execute( + select(FinTransaction.category_id, func.count()) + .where(FinTransaction.user_id == user_id) + .group_by(FinTransaction.category_id) + ).all() + ) + + def to_dict(category: FinCategory) -> dict[str, Any]: + return { + "id": category.id, + "parent_id": category.parent_id, + "name": category.name, + "icon": category.icon, + "color": category.color, + "kind": category.kind, + "is_system": category.is_system, + "sort_order": category.sort_order, + "transaction_count": counts.get(category.id, 0), + "children": [], + } + + nodes = {c.id: to_dict(c) for c in categories} + roots: list[dict[str, Any]] = [] + for category in categories: + node = nodes[category.id] + if category.parent_id and category.parent_id in nodes: + parent = nodes[category.parent_id] + parent["children"].append(node) + parent["transaction_count"] += node["transaction_count"] + else: + roots.append(node) + return roots + + +def create_category(db: Session, user_id: int, payload: Any) -> FinCategory: + kind = payload.kind + parent: FinCategory | None = None + if payload.parent_id is not None: + parent = get_category(db, user_id, payload.parent_id) + if parent.parent_id is not None: + raise DomainValidationError(MAX_DEPTH_MESSAGE) + kind = parent.kind + if kind == CategoryKind.TRANSFER: + raise DomainValidationError( + "La catégorie « Virements internes » est unique et gérée par l'application." + ) + sibling_clause = ( + FinCategory.parent_id.is_(None) + if payload.parent_id is None + else FinCategory.parent_id == payload.parent_id + ) + taken = db.scalar( + select(func.count()) + .select_from(FinCategory) + .where( + FinCategory.user_id == user_id, + FinCategory.name == payload.name, + sibling_clause, + ) + ) + if taken: + raise ConflictError("Une catégorie porte déjà ce nom à ce niveau.") + category = FinCategory( + id=uuid.uuid4(), + user_id=user_id, + parent_id=payload.parent_id, + name=payload.name, + icon=payload.icon, + color=payload.color, + kind=kind, + is_system=False, + sort_order=payload.sort_order, + ) + db.add(category) + db.commit() + return category + + +def update_category( + db: Session, user_id: int, category_id: uuid.UUID, payload: Any +) -> FinCategory: + category = get_category(db, user_id, category_id) + data = payload.model_dump(exclude_unset=True) + if category.is_system and "name" in data: + raise DomainValidationError( + "Cette catégorie système ne peut pas être renommée." + ) + if "parent_id" in data: + new_parent_id = data["parent_id"] + if new_parent_id is not None: + has_children = db.scalar( + select(func.count()) + .select_from(FinCategory) + .where(FinCategory.parent_id == category.id) + ) + if has_children: + raise DomainValidationError(MAX_DEPTH_MESSAGE) + parent = get_category(db, user_id, new_parent_id) + if parent.parent_id is not None or parent.id == category.id: + raise DomainValidationError(MAX_DEPTH_MESSAGE) + category.kind = parent.kind + for key, value in data.items(): + setattr(category, key, value) + db.commit() + return category + + +def delete_category(db: Session, user_id: int, category_id: uuid.UUID) -> None: + category = get_category(db, user_id, category_id) + if category.is_system: + raise DomainValidationError( + "Cette catégorie système ne peut pas être supprimée." + ) + child_ids = list( + db.scalars( + select(FinCategory.id).where(FinCategory.parent_id == category.id) + ).all() + ) + all_ids = [category.id, *child_ids] + db.execute( + update(FinTransaction) + .where( + FinTransaction.user_id == user_id, + FinTransaction.category_id.in_(all_ids), + ) + .values(category_id=None, category_source=None) + ) + db.execute( + delete(FinBudget).where( + FinBudget.user_id == user_id, FinBudget.category_id.in_(all_ids) + ) + ) + # Rules pointing at a deleted category lose their action (disabled if empty). + for rule in db.scalars(select(FinRule).where(FinRule.user_id == user_id)).all(): + actions = dict(rule.actions or {}) + target = actions.get("set_category_id") + if target and uuid.UUID(str(target)) in set(all_ids): + actions.pop("set_category_id", None) + rule.actions = actions + if not any( + actions.get(key) + for key in ("set_label_clean", "set_counterparty", "mark_transfer") + ): + rule.enabled = False + db.delete(category) + db.commit() + + +# --------------------------------------------------------------------------- +# Source profiles (§9.6) +# --------------------------------------------------------------------------- + + +def get_source_profile( + db: Session, user_id: int, profile_id: uuid.UUID +) -> FinSourceProfile: + profile = db.get(FinSourceProfile, profile_id) + if profile is None or (profile.user_id is not None and profile.user_id != user_id): + raise NotFoundError("Profil de source introuvable.") + return profile + + +def list_source_profiles(db: Session, user_id: int) -> list[FinSourceProfile]: + stmt = ( + select(FinSourceProfile) + .where( + or_( + FinSourceProfile.user_id.is_(None), + FinSourceProfile.user_id == user_id, + ) + ) + .order_by(FinSourceProfile.is_builtin.desc(), FinSourceProfile.name.asc()) + ) + return list(db.scalars(stmt).all()) + + +def find_profile_by_name(db: Session, name: str) -> FinSourceProfile | None: + return db.scalars( + select(FinSourceProfile).where( + FinSourceProfile.user_id.is_(None), FinSourceProfile.name == name + ) + ).first() + + +def _check_profile_name(db: Session, user_id: int, name: str) -> None: + taken = db.scalar( + select(func.count()) + .select_from(FinSourceProfile) + .where(FinSourceProfile.user_id == user_id, FinSourceProfile.name == name) + ) + if taken: + raise ConflictError("Un profil de source porte déjà ce nom.") + + +def create_source_profile(db: Session, user_id: int, payload: Any) -> FinSourceProfile: + _check_profile_name(db, user_id, payload.name) + profile = FinSourceProfile( + id=uuid.uuid4(), + user_id=user_id, + name=payload.name, + kind=payload.kind, + config=payload.config, + is_builtin=False, + ) + db.add(profile) + db.commit() + return profile + + +def clone_source_profile( + db: Session, user_id: int, profile_id: uuid.UUID +) -> FinSourceProfile: + source = get_source_profile(db, user_id, profile_id) + name = f"{source.name} (copie)" + suffix = 2 + while db.scalar( + select(func.count()) + .select_from(FinSourceProfile) + .where(FinSourceProfile.user_id == user_id, FinSourceProfile.name == name) + ): + name = f"{source.name} (copie {suffix})" + suffix += 1 + clone = FinSourceProfile( + id=uuid.uuid4(), + user_id=user_id, + name=name, + kind=source.kind, + config=dict(source.config or {}), + is_builtin=False, + ) + db.add(clone) + db.commit() + return clone + + +def update_source_profile( + db: Session, user_id: int, profile_id: uuid.UUID, payload: Any +) -> FinSourceProfile: + profile = get_source_profile(db, user_id, profile_id) + if profile.is_builtin: + raise ForbiddenError( + "Les profils intégrés sont en lecture seule : dupliquez-le pour " + "le modifier." + ) + data = payload.model_dump(exclude_unset=True) + if "name" in data and data["name"] != profile.name: + _check_profile_name(db, user_id, data["name"]) + for key, value in data.items(): + setattr(profile, key, value) + db.commit() + return profile + + +def delete_source_profile(db: Session, user_id: int, profile_id: uuid.UUID) -> None: + profile = get_source_profile(db, user_id, profile_id) + if profile.is_builtin: + raise ForbiddenError("Les profils intégrés ne peuvent pas être supprimés.") + db.delete(profile) + db.commit() + + +# --------------------------------------------------------------------------- +# Transactions (§9.2) +# --------------------------------------------------------------------------- + + +def transactions_query( + db: Session, + user_id: int, + *, + date_from: date | None = None, + date_to: date | None = None, + account_ids: list[uuid.UUID] | None = None, + category_ids: list[str] | None = None, + q: str | None = None, + direction: str | None = None, + amount_min: Decimal | None = None, + amount_max: Decimal | None = None, + is_transfer: bool | None = None, + import_run_id: int | None = None, + sort: str | None = None, +) -> Select: + stmt = select(FinTransaction).where(FinTransaction.user_id == user_id) + if date_from is not None: + stmt = stmt.where(FinTransaction.booked_date >= date_from) + if date_to is not None: + stmt = stmt.where(FinTransaction.booked_date <= date_to) + if account_ids: + stmt = stmt.where(FinTransaction.account_id.in_(account_ids)) + if category_ids: + wants_none = any(str(c).lower() == "none" for c in category_ids) + try: + explicit = [ + uuid.UUID(str(c)) for c in category_ids if str(c).lower() != "none" + ] + except ValueError as exc: + raise DomainValidationError( + "Filtre de catégorie invalide : identifiant ou « none » attendu." + ) from exc + expanded = set(explicit) + if explicit: + expanded |= set( + db.scalars( + select(FinCategory.id).where( + FinCategory.user_id == user_id, + FinCategory.parent_id.in_(explicit), + ) + ).all() + ) + clauses = [] + if expanded: + clauses.append(FinTransaction.category_id.in_(list(expanded))) + if wants_none: + clauses.append(FinTransaction.category_id.is_(None)) + if clauses: + stmt = stmt.where(or_(*clauses)) + if q: + pattern = f"%{q}%" + stmt = stmt.where( + or_( + FinTransaction.label_clean.ilike(pattern), + FinTransaction.label_raw.ilike(pattern), + FinTransaction.counterparty.ilike(pattern), + FinTransaction.notes.ilike(pattern), + ) + ) + if direction == "debit": + stmt = stmt.where(FinTransaction.amount < 0) + elif direction == "credit": + stmt = stmt.where(FinTransaction.amount > 0) + if amount_min is not None: + stmt = stmt.where(func.abs(FinTransaction.amount) >= amount_min) + if amount_max is not None: + stmt = stmt.where(func.abs(FinTransaction.amount) <= amount_max) + if is_transfer is True: + stmt = stmt.where(FinTransaction.transfer_group_id.is_not(None)) + elif is_transfer is False: + stmt = stmt.where(FinTransaction.transfer_group_id.is_(None)) + if import_run_id is not None: + stmt = stmt.where(FinTransaction.import_run_id == import_run_id) + + sort = sort or "-booked_date" + descending = sort.startswith("-") + column = SORTABLE_TRANSACTION_FIELDS.get(sort.lstrip("-")) + if column is None: + raise DomainValidationError( + "Champ de tri non autorisé.", details={"sort": sort} + ) + order = column.desc() if descending else column.asc() + return stmt.order_by(order, FinTransaction.id.desc()) + + +def serialize_transactions( + db: Session, user_id: int, transactions: list[FinTransaction] +) -> list[dict[str, Any]]: + if not transactions: + return [] + accounts = { + a.id: a + for a in db.scalars( + select(FinAccount).where(FinAccount.user_id == user_id) + ).all() + } + categories = { + c.id: c + for c in db.scalars( + select(FinCategory).where(FinCategory.user_id == user_id) + ).all() + } + out: list[dict[str, Any]] = [] + for tx in transactions: + account = accounts.get(tx.account_id) + category = categories.get(tx.category_id) if tx.category_id else None + color = None + if category is not None: + color = category.color + if color is None and category.parent_id: + parent = categories.get(category.parent_id) + color = parent.color if parent else None + out.append( + { + "id": tx.id, + "account_id": tx.account_id, + "account_name": account.name if account else None, + "booked_date": tx.booked_date, + "value_date": tx.value_date, + "amount": tx.amount, + "currency": tx.currency, + "label_raw": tx.label_raw, + "label_clean": tx.label_clean, + "counterparty": tx.counterparty, + "category_id": tx.category_id, + "category_name": category.name if category else None, + "category_color": color, + "category_source": tx.category_source, + "notes": tx.notes, + "transfer_group_id": tx.transfer_group_id, + "external_id": tx.external_id, + "import_run_id": tx.import_run_id, + } + ) + return out + + +def get_transaction( + db: Session, user_id: int, transaction_id: uuid.UUID +) -> FinTransaction: + tx = db.get(FinTransaction, transaction_id) + if tx is None or tx.user_id != user_id: + raise NotFoundError("Transaction introuvable.") + return tx + + +def create_transaction(db: Session, user_id: int, payload: Any) -> FinTransaction: + account = get_account(db, user_id, payload.account_id) + amount = Decimal(payload.amount).quantize(Decimal("0.01")) + label = payload.label_clean.strip() + dedup_hash = transaction_dedup_hash( + db, account.id, payload.booked_date, amount, label + ) + category_source = CategorySource.USER if payload.category_id else None + if payload.category_id: + get_category(db, user_id, payload.category_id) + tx = FinTransaction( + id=uuid.uuid4(), + user_id=user_id, + account_id=account.id, + booked_date=payload.booked_date, + value_date=payload.value_date, + amount=amount, + currency=(payload.currency or account.currency or "EUR").upper(), + label_raw=label, + label_clean=label, + counterparty=payload.counterparty, + category_id=payload.category_id, + category_source=category_source, + notes=payload.notes, + ) + tx.dedup_hash = dedup_hash + db.add(tx) + db.commit() + return tx + + +def update_transaction( + db: Session, user_id: int, transaction_id: uuid.UUID, payload: Any +) -> FinTransaction: + tx = get_transaction(db, user_id, transaction_id) + data = payload.model_dump(exclude_unset=True) + if ("amount" in data or "booked_date" in data) and tx.import_run_id is not None: + raise DomainValidationError( + "La date et le montant d'une transaction importée ne sont pas modifiables." + ) + if "category_id" in data: + if data["category_id"] is None: + tx.category_id = None + tx.category_source = None + else: + get_category(db, user_id, data["category_id"]) + tx.category_id = data["category_id"] + tx.category_source = CategorySource.USER + data.pop("category_id") + for key, value in data.items(): + setattr(tx, key, value) + db.commit() + return tx + + +def delete_transaction(db: Session, user_id: int, transaction_id: uuid.UUID) -> None: + tx = get_transaction(db, user_id, transaction_id) + if tx.import_run_id is not None: + raise DomainValidationError( + "Cette transaction provient d'un import : supprimez l'import " + "complet ou ignorez la ligne." + ) + db.delete(tx) + db.commit() + + +def bulk_categorize( + db: Session, + user_id: int, + transaction_ids: list[uuid.UUID], + category_id: uuid.UUID | None, +) -> int: + if category_id is not None: + get_category(db, user_id, category_id) + result = db.execute( + update(FinTransaction) + .where( + FinTransaction.user_id == user_id, + FinTransaction.id.in_(transaction_ids), + ) + .values( + category_id=category_id, + category_source=CategorySource.USER if category_id else None, + ) + ) + db.commit() + return int(result.rowcount or 0) + + +# --------------------------------------------------------------------------- +# Rules (§9.4) +# --------------------------------------------------------------------------- + + +def get_rule(db: Session, user_id: int, rule_id: uuid.UUID) -> FinRule: + rule = db.get(FinRule, rule_id) + if rule is None or rule.user_id != user_id: + raise NotFoundError("Règle introuvable.") + return rule + + +def list_rules(db: Session, user_id: int) -> list[FinRule]: + stmt = ( + select(FinRule) + .where(FinRule.user_id == user_id) + .order_by(FinRule.priority.asc(), FinRule.created_at.asc(), FinRule.id.asc()) + ) + return list(db.scalars(stmt).all()) + + +def _validate_rule_payload( + db: Session, user_id: int, matchers: dict[str, Any], actions: dict[str, Any] +) -> None: + validate_matchers(matchers) + validate_actions(actions) + category_id = actions.get("set_category_id") + if category_id: + get_category(db, user_id, uuid.UUID(str(category_id))) + account_id = matchers.get("account_id") + if account_id: + get_account(db, user_id, uuid.UUID(str(account_id))) + + +def create_rule(db: Session, user_id: int, payload: Any) -> FinRule: + _validate_rule_payload(db, user_id, payload.matchers, payload.actions) + rule = FinRule( + id=uuid.uuid4(), + user_id=user_id, + name=payload.name, + priority=payload.priority, + enabled=payload.enabled, + stop=payload.stop, + matchers=payload.matchers, + actions=payload.actions, + ) + db.add(rule) + db.commit() + return rule + + +def update_rule(db: Session, user_id: int, rule_id: uuid.UUID, payload: Any) -> FinRule: + rule = get_rule(db, user_id, rule_id) + data = payload.model_dump(exclude_unset=True) + matchers = data.get("matchers", rule.matchers) + actions = data.get("actions", rule.actions) + _validate_rule_payload(db, user_id, matchers, actions) + for key, value in data.items(): + setattr(rule, key, value) + db.commit() + return rule + + +def delete_rule(db: Session, user_id: int, rule_id: uuid.UUID) -> None: + rule = get_rule(db, user_id, rule_id) + db.delete(rule) + db.commit() + + +def reorder_rules(db: Session, user_id: int, ordered_ids: list[uuid.UUID]) -> None: + rules = {rule.id: rule for rule in list_rules(db, user_id)} + for index, rule_id in enumerate(ordered_ids): + rule = rules.get(rule_id) + if rule is None: + raise NotFoundError("Règle introuvable.") + rule.priority = index * 10 + db.commit() + + +# --------------------------------------------------------------------------- +# Budgets (§9.5) +# --------------------------------------------------------------------------- + + +def get_budget(db: Session, user_id: int, budget_id: uuid.UUID) -> FinBudget: + budget = db.get(FinBudget, budget_id) + if budget is None or budget.user_id != user_id: + raise NotFoundError("Budget introuvable.") + return budget + + +def _check_budget_overlap( + db: Session, + user_id: int, + category_id: uuid.UUID, + start_month: date, + end_month: date | None, + exclude_id: uuid.UUID | None = None, +) -> None: + stmt = select(FinBudget).where( + FinBudget.user_id == user_id, FinBudget.category_id == category_id + ) + if exclude_id is not None: + stmt = stmt.where(FinBudget.id != exclude_id) + for other in db.scalars(stmt).all(): + starts_after_other_ends = ( + other.end_month is not None and start_month > other.end_month + ) + ends_before_other_starts = ( + end_month is not None and end_month < other.start_month + ) + if not starts_after_other_ends and not ends_before_other_starts: + raise ConflictError( + "Un budget existe déjà pour cette catégorie sur cette période." + ) + + +def list_budgets( + db: Session, user_id: int, month: date, account_ids: list[uuid.UUID] | None = None +) -> list[dict[str, Any]]: + progress = stats_service.budget_progress( + db, user_id, month, account_ids=account_ids + ) + by_id = {item["budget_id"]: item for item in progress["items"]} + budgets = db.scalars( + select(FinBudget) + .where( + FinBudget.user_id == user_id, + FinBudget.start_month <= month.replace(day=1), + or_( + FinBudget.end_month.is_(None), + FinBudget.end_month >= month.replace(day=1), + ), + ) + .order_by(FinBudget.start_month.asc()) + ).all() + out: list[dict[str, Any]] = [] + for budget in budgets: + item = by_id.get(budget.id, {}) + out.append( + { + "id": budget.id, + "category_id": budget.category_id, + "category_name": item.get("category_name"), + "category_color": item.get("category_color"), + "monthly_amount": budget.monthly_amount, + "start_month": budget.start_month, + "end_month": budget.end_month, + "actual": item.get("actual", Decimal("0.00")), + "remaining": item.get( + "remaining", stats_service.money(budget.monthly_amount) + ), + "progress_pct": item.get("progress_pct", 0.0), + "projected_eom": item.get("projected_eom"), + "status": item.get("status", "ok"), + } + ) + return out + + +def create_budget(db: Session, user_id: int, payload: Any) -> FinBudget: + category = get_category(db, user_id, payload.category_id) + if category.kind != CategoryKind.EXPENSE: + raise DomainValidationError( + "Un budget ne peut cibler qu'une catégorie de dépense." + ) + if payload.end_month is not None and payload.end_month < payload.start_month: + raise DomainValidationError( + "Le mois de fin doit être postérieur au mois de début." + ) + _check_budget_overlap( + db, user_id, payload.category_id, payload.start_month, payload.end_month + ) + budget = FinBudget( + id=uuid.uuid4(), + user_id=user_id, + category_id=payload.category_id, + monthly_amount=payload.monthly_amount, + start_month=payload.start_month, + end_month=payload.end_month, + ) + db.add(budget) + db.commit() + return budget + + +def update_budget( + db: Session, user_id: int, budget_id: uuid.UUID, payload: Any +) -> list[FinBudget]: + budget = get_budget(db, user_id, budget_id) + data = payload.model_dump(exclude_unset=True) + effective_from = data.pop("effective_from", None) + if effective_from is not None: + # Close the current budget and open a new one from `effective_from`. + amount = data.get("monthly_amount", budget.monthly_amount) + if effective_from <= budget.start_month: + budget.monthly_amount = amount + db.commit() + return [budget] + budget.end_month = add_months(effective_from, -1) + successor = FinBudget( + id=uuid.uuid4(), + user_id=user_id, + category_id=budget.category_id, + monthly_amount=amount, + start_month=effective_from, + end_month=data.get("end_month"), + ) + db.add(successor) + db.commit() + return [budget, successor] + if "end_month" in data: + _check_budget_overlap( + db, + user_id, + budget.category_id, + budget.start_month, + data["end_month"], + exclude_id=budget.id, + ) + for key, value in data.items(): + setattr(budget, key, value) + db.commit() + return [budget] + + +def delete_budget(db: Session, user_id: int, budget_id: uuid.UUID) -> None: + budget = get_budget(db, user_id, budget_id) + db.delete(budget) + db.commit() + + +# --------------------------------------------------------------------------- +# Import runs (§9.6) +# --------------------------------------------------------------------------- + + +def import_runs_query(user_id: int) -> Select: + return ( + select(FinImportRun) + .where(FinImportRun.user_id == user_id) + .order_by(FinImportRun.started_at.desc(), FinImportRun.id.desc()) + ) + + +def get_import_run(db: Session, user_id: int, run_id: uuid.UUID) -> FinImportRun: + run = db.get(FinImportRun, run_id) + if run is None or run.user_id != user_id: + raise NotFoundError("Import introuvable.") + return run + + +def rollback_import( + db: Session, user_id: int, run_id: uuid.UUID, force: bool = False +) -> None: + """Delete the central run: transactions vanish through ON DELETE CASCADE.""" + run = get_import_run(db, user_id, run_id) + if not force: + touched = db.scalar( + select(func.count()) + .select_from(FinTransaction) + .where( + FinTransaction.import_run_id == run.import_run_id, + or_( + FinTransaction.category_source == CategorySource.USER, + FinTransaction.notes.is_not(None), + ), + ) + ) + if touched: + raise ConflictError( + "Des transactions de cet import ont été modifiées " + "manuellement. Relancez avec force=true pour les supprimer " + "malgré tout." + ) + central = db.get(ImportRun, run.import_run_id) + if central is not None: + db.delete(central) + else: + db.delete(run) + db.commit() + + +def resolve_profile( + db: Session, user_id: int, profile_id: uuid.UUID | None +) -> FinSourceProfile: + if profile_id is not None: + return get_source_profile(db, user_id, profile_id) + profile = find_profile_by_name(db, "CSV générique") + if profile is None: + raise NotFoundError("Profil de source introuvable.") + return profile + + +def current_month(today: date) -> date: + return today.replace(day=1) + + +def parse_month_param(value: str | None, today: date) -> date: + if not value: + return current_month(today) + try: + year, month = value.split("-") + return date(int(year), int(month), 1) + except (ValueError, TypeError) as exc: + raise DomainValidationError( + f"Mois invalide : « {value} » (format attendu AAAA-MM)." + ) from exc + + +def transfer_category(db: Session, user_id: int) -> FinCategory | None: + return db.scalars( + select(FinCategory).where( + FinCategory.user_id == user_id, + FinCategory.name == TRANSFER_CATEGORY_NAME, + ) + ).first() + + +def account_for_source_kind( + db: Session, user_id: int, kind: str, label: str +) -> FinAccount: + """Default target account used by the generic `POST /api/imports` route.""" + account = db.scalars( + select(FinAccount).where( + FinAccount.user_id == user_id, FinAccount.name == label + ) + ).first() + if account is not None: + return account + account = FinAccount( + id=uuid.uuid4(), + user_id=user_id, + name=label, + kind=AccountKind.PAYPAL + if kind == SourceKind.PAYPAL_CSV + else AccountKind.CHECKING, + currency="EUR", + institution=None, + initial_balance=Decimal("0.00"), + ) + db.add(account) + db.flush() + return account + + +def month_label(value: date) -> str: + return month_str(value) diff --git a/apps/api/app/modules/finance/stats.py b/apps/api/app/modules/finance/stats.py new file mode 100644 index 0000000..efa1d85 --- /dev/null +++ b/apps/api/app/modules/finance/stats.py @@ -0,0 +1,713 @@ +"""Chart-ready aggregates (datamodel-finance.md §8 and §9.8). + +Every expense figure is returned as a POSITIVE number (the semantics carry the +sign, §10.7). The common scope of §8.1 excludes internal transfers, non-EUR +transactions and archived accounts (unless accounts are given explicitly). +""" + +import uuid +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import date, timedelta +from decimal import ROUND_HALF_UP, Decimal +from typing import Any + +import sqlalchemy as sa +from sqlalchemy import Select, func, or_, select +from sqlalchemy.orm import Session, aliased + +from app.modules.finance.categorize import ( + detect_recurring, + monthly_total_estimate, +) +from app.modules.finance.enums import CategoryKind +from app.modules.finance.models import ( + FinAccount, + FinBudget, + FinCategory, + FinTransaction, +) +from app.modules.finance.normalize import add_months, merchant_key, month_str +from app.modules.finance.presets import ( + DEFICIT_NODE_NAME, + INCOME_NODE_NAME, + MUTED_COLOR, + PALETTE, + SAVINGS_NODE_NAME, + UNCATEGORIZED_COLOR, + UNCATEGORIZED_LABEL, +) + +ZERO = Decimal("0.00") +ONE_DAY = timedelta(days=1) +BUDGET_WARNING_RATIO = Decimal(80) +BUDGET_OVER_RATIO = Decimal(100) + + +def money(value: Any) -> Decimal: + """Coerce a SQL aggregate (float on SQLite, Decimal on PG) to 2 decimals.""" + if value is None: + return ZERO + if not isinstance(value, Decimal): + value = Decimal(str(value)) + return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + +def month_range(months: int, end: date) -> list[str]: + """The `months` last month keys ending with `end`'s month (inclusive).""" + first = add_months(end.replace(day=1), -(months - 1)) + return [month_str(add_months(first, i)) for i in range(months)] + + +@dataclass +class StatsScope: + """Aliases + predicates shared by every aggregate (§8.1).""" + + category: Any + parent: Any + stmt: Select + + +def scope(user_id: int, account_ids: list[uuid.UUID] | None) -> StatsScope: + category = aliased(FinCategory) + parent = aliased(FinCategory) + stmt = ( + select() + .select_from(FinTransaction) + .join(FinAccount, FinAccount.id == FinTransaction.account_id) + .outerjoin(category, category.id == FinTransaction.category_id) + .outerjoin(parent, parent.id == category.parent_id) + .where( + FinTransaction.user_id == user_id, + FinTransaction.currency == "EUR", + FinTransaction.transfer_group_id.is_(None), + or_(category.kind.is_(None), category.kind != CategoryKind.TRANSFER), + ) + ) + if account_ids: + stmt = stmt.where(FinTransaction.account_id.in_(account_ids)) + else: + stmt = stmt.where(FinAccount.is_archived.is_(False)) + return StatsScope(category=category, parent=parent, stmt=stmt) + + +def _year_month() -> tuple[Any, Any]: + """Portable month extraction (SQLite -> strftime, PostgreSQL -> EXTRACT).""" + return ( + sa.extract("year", FinTransaction.booked_date), + sa.extract("month", FinTransaction.booked_date), + ) + + +def _key(year: Any, month: Any) -> str: + return f"{int(year):04d}-{int(month):02d}" + + +def _direction_clause(direction: str) -> Any: + if direction == "credit": + return FinTransaction.amount > 0 + return FinTransaction.amount < 0 + + +def _color_for(index: int, color: str | None) -> str: + return color or PALETTE[index % len(PALETTE)] + + +# --------------------------------------------------------------------------- +# GET /stats/monthly-by-category +# --------------------------------------------------------------------------- + + +def monthly_by_category( + db: Session, + user_id: int, + *, + months: int = 12, + level: str = "root", + direction: str = "debit", + account_ids: list[uuid.UUID] | None = None, + today: date | None = None, +) -> dict[str, Any]: + today = today or date.today() # noqa: DTZ011 — civil day, no tz conversion + keys = month_range(months, today) + start = date.fromisoformat(f"{keys[0]}-01") + end = add_months(date.fromisoformat(f"{keys[-1]}-01"), 1) + + sc = scope(user_id, account_ids) + year_col, month_col = _year_month() + if level == "child": + cat_id = sc.category.id + cat_name = sc.category.name + cat_color = func.coalesce(sc.category.color, sc.parent.color) + else: + cat_id = func.coalesce(sc.parent.id, sc.category.id) + cat_name = func.coalesce(sc.parent.name, sc.category.name) + cat_color = func.coalesce(sc.parent.color, sc.category.color) + + stmt = ( + sc.stmt.add_columns( + year_col.label("y"), + month_col.label("m"), + cat_id.label("cid"), + cat_name.label("cname"), + cat_color.label("ccolor"), + func.sum(FinTransaction.amount).label("total"), + ) + .where( + FinTransaction.booked_date >= start, + FinTransaction.booked_date < end, + _direction_clause(direction), + ) + .group_by(year_col, month_col, cat_id, cat_name, cat_color) + ) + + buckets: dict[tuple[uuid.UUID | None, str, str | None], dict[str, Decimal]] = ( + defaultdict(lambda: defaultdict(lambda: ZERO)) + ) + for row in db.execute(stmt): + key = _key(row.y, row.m) + ident = (row.cid, row.cname or UNCATEGORIZED_LABEL, row.ccolor) + buckets[ident][key] = money(abs(money(row.total))) + + series: list[dict[str, Any]] = [] + for index, (ident, per_month) in enumerate( + sorted(buckets.items(), key=lambda kv: -sum(kv[1].values())) + ): + cid, name, color = ident + series.append( + { + "category_id": cid, + "name": name if cid else UNCATEGORIZED_LABEL, + "color": UNCATEGORIZED_COLOR if not cid else _color_for(index, color), + "data": [per_month.get(key, ZERO) for key in keys], + } + ) + totals = [ + money(sum((s["data"][i] for s in series), ZERO)) for i in range(len(keys)) + ] + return {"months": keys, "series": series, "totals": totals} + + +# --------------------------------------------------------------------------- +# GET /stats/cashflow +# --------------------------------------------------------------------------- + + +def cashflow( + db: Session, + user_id: int, + *, + months: int = 12, + account_ids: list[uuid.UUID] | None = None, + today: date | None = None, +) -> dict[str, Any]: + today = today or date.today() # noqa: DTZ011 — civil day, no tz conversion + keys = month_range(months, today) + start = date.fromisoformat(f"{keys[0]}-01") + end = add_months(date.fromisoformat(f"{keys[-1]}-01"), 1) + + sc = scope(user_id, account_ids) + year_col, month_col = _year_month() + positive = func.sum( + sa.case((FinTransaction.amount > 0, FinTransaction.amount), else_=0) + ) + negative = func.sum( + sa.case((FinTransaction.amount < 0, FinTransaction.amount), else_=0) + ) + stmt = ( + sc.stmt.add_columns( + year_col.label("y"), + month_col.label("m"), + positive.label("income"), + negative.label("expenses"), + ) + .where(FinTransaction.booked_date >= start, FinTransaction.booked_date < end) + .group_by(year_col, month_col) + ) + income_by_month: dict[str, Decimal] = {} + expenses_by_month: dict[str, Decimal] = {} + for row in db.execute(stmt): + key = _key(row.y, row.m) + income_by_month[key] = money(row.income) + expenses_by_month[key] = money(abs(money(row.expenses))) + + income = [income_by_month.get(key, ZERO) for key in keys] + expenses = [expenses_by_month.get(key, ZERO) for key in keys] + net = [money(i - e) for i, e in zip(income, expenses, strict=True)] + cumulative: list[Decimal] = [] + running = ZERO + for value in net: + running = money(running + value) + cumulative.append(running) + return { + "months": keys, + "income": income, + "expenses": expenses, + "net": net, + "cumulative_net": cumulative, + } + + +# --------------------------------------------------------------------------- +# GET /stats/top-merchants +# --------------------------------------------------------------------------- + + +def top_merchants( + db: Session, + user_id: int, + *, + months: int = 3, + limit: int = 15, + direction: str = "debit", + account_ids: list[uuid.UUID] | None = None, + today: date | None = None, +) -> dict[str, Any]: + today = today or date.today() # noqa: DTZ011 — civil day, no tz conversion + keys = month_range(months, today) + start = date.fromisoformat(f"{keys[0]}-01") + end = add_months(date.fromisoformat(f"{keys[-1]}-01"), 1) + + sc = scope(user_id, account_ids) + stmt = sc.stmt.add_columns( + FinTransaction.counterparty, + FinTransaction.label_clean, + FinTransaction.label_raw, + FinTransaction.amount, + sc.category.name.label("cname"), + func.coalesce(sc.category.color, sc.parent.color).label("ccolor"), + ).where( + FinTransaction.booked_date >= start, + FinTransaction.booked_date < end, + _direction_clause(direction), + ) + + groups: dict[str, dict[str, Any]] = {} + for row in db.execute(stmt): + name = row.counterparty or merchant_key(row.label_clean or row.label_raw) + if not name: + name = UNCATEGORIZED_LABEL + entry = groups.setdefault( + name, + { + "merchant": name, + "total": ZERO, + "count": 0, + "category_name": row.cname, + "category_color": row.ccolor, + }, + ) + entry["total"] = money(entry["total"] + abs(money(row.amount))) + entry["count"] += 1 + if entry["category_name"] is None and row.cname: + entry["category_name"] = row.cname + entry["category_color"] = row.ccolor + + items = sorted(groups.values(), key=lambda e: -e["total"])[:limit] + for entry in items: + entry["average"] = money(entry["total"] / entry["count"]) + return { + "period": {"from": start, "to": end - ONE_DAY}, + "items": items, + } + + +# --------------------------------------------------------------------------- +# GET /stats/budget-progress (§8.3) +# --------------------------------------------------------------------------- + + +def _children_map(db: Session, user_id: int) -> dict[uuid.UUID, list[uuid.UUID]]: + rows = db.execute( + select(FinCategory.id, FinCategory.parent_id).where( + FinCategory.user_id == user_id + ) + ).all() + children: dict[uuid.UUID, list[uuid.UUID]] = defaultdict(list) + for cid, parent_id in rows: + if parent_id is not None: + children[parent_id].append(cid) + return children + + +def expenses_by_category( + db: Session, + user_id: int, + start: date, + end: date, + account_ids: list[uuid.UUID] | None = None, +) -> dict[uuid.UUID | None, Decimal]: + """Absolute expense total per (leaf) category over [start, end).""" + sc = scope(user_id, account_ids) + stmt = ( + sc.stmt.add_columns( + FinTransaction.category_id.label("cid"), + func.sum(FinTransaction.amount).label("total"), + ) + .where( + FinTransaction.booked_date >= start, + FinTransaction.booked_date < end, + FinTransaction.amount < 0, + ) + .group_by(FinTransaction.category_id) + ) + return {row.cid: money(abs(money(row.total))) for row in db.execute(stmt)} + + +def budget_progress( + db: Session, + user_id: int, + month: date, + *, + account_ids: list[uuid.UUID] | None = None, + today: date | None = None, +) -> dict[str, Any]: + today = today or date.today() # noqa: DTZ011 — civil day, no tz conversion + start = month.replace(day=1) + end = add_months(start, 1) + budgets = list( + db.scalars( + select(FinBudget) + .where( + FinBudget.user_id == user_id, + FinBudget.start_month <= start, + or_(FinBudget.end_month.is_(None), FinBudget.end_month >= start), + ) + .order_by(FinBudget.start_month.asc()) + ).all() + ) + if not budgets: + return { + "month": month_str(start), + "items": [], + "totals": {"budget": ZERO, "actual": ZERO, "progress_pct": 0.0}, + } + + actuals = expenses_by_category(db, user_id, start, end, account_ids) + children = _children_map(db, user_id) + categories = { + c.id: c + for c in db.scalars( + select(FinCategory).where(FinCategory.user_id == user_id) + ).all() + } + + is_current = start <= today < end + days_elapsed = (today - start).days + 1 if is_current else None + days_in_month = (end - start).days + + items: list[dict[str, Any]] = [] + total_budget = ZERO + total_actual = ZERO + for budget in budgets: + category = categories.get(budget.category_id) + subtree = [budget.category_id, *children.get(budget.category_id, [])] + actual = money(sum((actuals.get(cid, ZERO) for cid in subtree), ZERO)) + amount = money(budget.monthly_amount) + progress = ( + float((actual / amount * 100).quantize(Decimal("0.1"))) if amount else 0.0 + ) + projected = None + if is_current and days_elapsed: + projected = money(actual / Decimal(days_elapsed) * Decimal(days_in_month)) + status = "ok" + if Decimal(str(progress)) > BUDGET_OVER_RATIO: + status = "over" + elif Decimal(str(progress)) >= BUDGET_WARNING_RATIO or ( + projected is not None and projected > amount + ): + status = "warning" + items.append( + { + "budget_id": budget.id, + "category_id": budget.category_id, + "category_name": category.name if category else UNCATEGORIZED_LABEL, + "category_color": (category.color if category else None) + or UNCATEGORIZED_COLOR, + "budget": amount, + "actual": actual, + "remaining": money(amount - actual), + "progress_pct": progress, + "projected_eom": projected, + "status": status, + } + ) + total_budget = money(total_budget + amount) + total_actual = money(total_actual + actual) + + totals_pct = ( + float((total_actual / total_budget * 100).quantize(Decimal("0.1"))) + if total_budget + else 0.0 + ) + return { + "month": month_str(start), + "items": items, + "totals": { + "budget": total_budget, + "actual": total_actual, + "progress_pct": totals_pct, + }, + } + + +# --------------------------------------------------------------------------- +# GET /stats/sankey (§9.8) +# --------------------------------------------------------------------------- + + +@dataclass +class _SankeyBuilder: + nodes: list[dict[str, Any]] = field(default_factory=list) + links: list[dict[str, Any]] = field(default_factory=list) + seen: set[str] = field(default_factory=set) + + def node(self, name: str, color: str) -> str: + if name not in self.seen: + self.seen.add(name) + self.nodes.append({"name": name, "color": color}) + return name + + def link(self, source: str, target: str, value: Decimal) -> None: + if value > 0: + self.links.append( + {"source": source, "target": target, "value": money(value)} + ) + + +def sankey( + db: Session, + user_id: int, + *, + month: date | None = None, + months: int = 1, + account_ids: list[uuid.UUID] | None = None, + today: date | None = None, +) -> dict[str, Any]: + today = today or date.today() # noqa: DTZ011 — civil day, no tz conversion + if month is not None: + start = month.replace(day=1) + end = add_months(start, 1) + else: + keys = month_range(months, today) + start = date.fromisoformat(f"{keys[0]}-01") + end = add_months(date.fromisoformat(f"{keys[-1]}-01"), 1) + + sc = scope(user_id, account_ids) + stmt = ( + sc.stmt.add_columns( + sc.category.id.label("cid"), + sc.category.name.label("cname"), + sc.category.kind.label("ckind"), + sc.category.color.label("ccolor"), + sc.parent.id.label("pid"), + sc.parent.name.label("pname"), + sc.parent.color.label("pcolor"), + func.sum(FinTransaction.amount).label("total"), + func.sum( + sa.case((FinTransaction.amount > 0, FinTransaction.amount), else_=0) + ).label("credit"), + func.sum( + sa.case((FinTransaction.amount < 0, -FinTransaction.amount), else_=0) + ).label("debit"), + ) + .where(FinTransaction.booked_date >= start, FinTransaction.booked_date < end) + .group_by( + sc.category.id, + sc.category.name, + sc.category.kind, + sc.category.color, + sc.parent.id, + sc.parent.name, + sc.parent.color, + ) + ) + + income_roots: dict[str, dict[str, Any]] = {} + expense_roots: dict[str, dict[str, Any]] = {} + children: dict[str, dict[str, Decimal]] = defaultdict( + lambda: defaultdict(lambda: ZERO) + ) + child_colors: dict[str, str] = {} + total_income = ZERO + total_expense = ZERO + + for row in db.execute(stmt): + root_name = row.pname or row.cname + root_color = row.pcolor or row.ccolor + # Uncategorized rows are TWO independent buckets (§9.8): credits feed + # "Autres revenus", debits feed "Non catégorisé". Netting them against + # each other would silently drop the uncategorized expenses AND shrink + # the income side by the same amount. A real category keeps netting so + # that a refund simply lowers that category's expense (and a category + # never ends up on both sides of the hub, which would build a cycle). + if row.cid is None: + credit, debit = money(row.credit), money(row.debit) + else: + total = money(row.total) + credit = total if total > 0 else ZERO + debit = -total if total < 0 else ZERO + + if credit > 0: + name = root_name or "Autres revenus" + entry = income_roots.setdefault( + name, {"total": ZERO, "color": root_color or PALETTE[0]} + ) + entry["total"] = money(entry["total"] + credit) + total_income = money(total_income + credit) + if debit > 0: + name = root_name or UNCATEGORIZED_LABEL + entry = expense_roots.setdefault( + name, + { + "total": ZERO, + "color": (root_color or UNCATEGORIZED_COLOR) + if row.cid + else UNCATEGORIZED_COLOR, + }, + ) + entry["total"] = money(entry["total"] + debit) + total_expense = money(total_expense + debit) + if row.pid is not None and row.cname: + child_name = row.cname + if child_name in expense_roots or child_name in income_roots: + child_name = f"{row.pname} · {row.cname}" + children[name][child_name] = money(children[name][child_name] + debit) + child_colors[child_name] = row.ccolor or row.pcolor or PALETTE[0] + + builder = _SankeyBuilder() + hub = builder.node(INCOME_NODE_NAME, MUTED_COLOR) + for index, (name, entry) in enumerate( + sorted(income_roots.items(), key=lambda kv: -kv[1]["total"]) + ): + builder.node(name, _color_for(index, entry["color"])) + builder.link(name, hub, entry["total"]) + if total_expense > total_income: + builder.node(DEFICIT_NODE_NAME, "#D03B3B") + builder.link(DEFICIT_NODE_NAME, hub, money(total_expense - total_income)) + for index, (name, entry) in enumerate( + sorted(expense_roots.items(), key=lambda kv: -kv[1]["total"]) + ): + builder.node(name, _color_for(index, entry["color"])) + builder.link(hub, name, entry["total"]) + for child_name, value in sorted( + children.get(name, {}).items(), key=lambda kv: -kv[1] + ): + builder.node(child_name, child_colors.get(child_name, PALETTE[index % 8])) + builder.link(name, child_name, value) + if total_income > total_expense: + builder.node(SAVINGS_NODE_NAME, "#0CA30C") + builder.link(hub, SAVINGS_NODE_NAME, money(total_income - total_expense)) + + return { + "period": {"from": start, "to": end - ONE_DAY}, + "nodes": builder.nodes, + "links": builder.links, + } + + +# --------------------------------------------------------------------------- +# GET /stats/recurring + GET /dashboard +# --------------------------------------------------------------------------- + + +def recurring( + db: Session, + user_id: int, + *, + direction: str = "debit", + include_inactive: bool = False, + today: date | None = None, +) -> dict[str, Any]: + series = detect_recurring( + db, + user_id, + direction=direction, + include_inactive=include_inactive, + today=today, + ) + return { + "items": series, + "monthly_total_estimate": monthly_total_estimate(series), + } + + +def account_balances(db: Session, user_id: int) -> dict[uuid.UUID, Decimal]: + rows = db.execute( + select( + FinTransaction.account_id, + func.sum(FinTransaction.amount), + ) + .where(FinTransaction.user_id == user_id) + .group_by(FinTransaction.account_id) + ).all() + return {account_id: money(total) for account_id, total in rows} + + +def account_transaction_counts(db: Session, user_id: int) -> dict[uuid.UUID, int]: + rows = db.execute( + select(FinTransaction.account_id, func.count()) + .where(FinTransaction.user_id == user_id) + .group_by(FinTransaction.account_id) + ).all() + return {account_id: int(count) for account_id, count in rows} + + +def dashboard( + db: Session, + user_id: int, + *, + today: date | None = None, +) -> dict[str, Any]: + """KPIs of the finance home tab (ux-pages §13.1).""" + today = today or date.today() # noqa: DTZ011 — civil day, no tz conversion + accounts = list( + db.scalars( + select(FinAccount).where( + FinAccount.user_id == user_id, FinAccount.is_archived.is_(False) + ) + ).all() + ) + balances = account_balances(db, user_id) + total_balance = money( + sum( + (money(a.initial_balance) + balances.get(a.id, ZERO) for a in accounts), + ZERO, + ) + ) + + flow = cashflow(db, user_id, months=6, today=today) + current_key = month_str(today) + index = flow["months"].index(current_key) + month_expenses = flow["expenses"][index] + month_income = flow["income"][index] + month_net = flow["net"][index] + past = [value for i, value in enumerate(flow["expenses"]) if i != index] + average_expenses = money(sum(past, ZERO) / Decimal(len(past))) if past else ZERO + + budgets = budget_progress(db, user_id, today.replace(day=1), today=today) + uncategorized = ( + db.scalar( + select(func.count()) + .select_from(FinTransaction) + .where( + FinTransaction.user_id == user_id, + FinTransaction.category_id.is_(None), + FinTransaction.transfer_group_id.is_(None), + ) + ) + or 0 + ) + return { + "month": current_key, + "total_balance": total_balance, + "accounts_count": len(accounts), + "month_expenses": month_expenses, + "month_income": month_income, + "month_net": month_net, + "average_expenses_6m": average_expenses, + "budget_total": budgets["totals"]["budget"], + "budget_actual": budgets["totals"]["actual"], + "budget_progress_pct": budgets["totals"]["progress_pct"], + "uncategorized_count": int(uncategorized), + } diff --git a/apps/api/app/modules/health/__init__.py b/apps/api/app/modules/health/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/modules/health/calculations.py b/apps/api/app/modules/health/calculations.py new file mode 100644 index 0000000..4667f9d --- /dev/null +++ b/apps/api/app/modules/health/calculations.py @@ -0,0 +1,496 @@ +"""Pure health/nutrition calculations (datamodel-health-vape.md §5). + +Every function here is side-effect free and unit-tested with exact values +(app/tests/modules/test_health_calculations.py). No SQLAlchemy imports. +""" + +import math +from dataclasses import dataclass, field +from datetime import date, timedelta + +# Alias for the annotations of dataclasses that own a field *named* `date` +# (Projection). In a class body Python binds the default value before it +# evaluates the annotation, so `date: date | None = None` would read the field +# itself (None) instead of datetime.date and raise at import time on Python +# 3.12 — the runtime of docker/api.Dockerfile. Python 3.14 only hides the bug +# by deferring annotation evaluation (PEP 649). +DateT = date + +# --- Central constants (datamodel §10) --------------------------------------- + +KCAL_PER_KG_FAT = 7700 # 1 kg of body mass ~ 7700 kcal +EMA_ALPHA = 0.1 # weight trend smoothing (Hacker's Diet) +MIN_SLOPE_KG_PER_DAY = 0.005 # below this, slope is considered null +ACTIVITY_FACTORS: dict[str, float] = { + "sedentary": 1.2, + "light": 1.375, + "moderate": 1.55, + "active": 1.725, + "very_active": 1.9, +} +CALORIE_FLOOR_MALE = 1500 +CALORIE_FLOOR_FEMALE = 1200 +WORKOUT_OVERLAP_THRESHOLD = 0.8 # cross-source workout dedup (§3.6) +ADAPTIVE_TDEE_MIN_DAYS = 21 # minimal tracked window for §5.7 +TDEE_SMOOTHING_DAYS = 7 # moving average window for the budget +MEASURED_TOTAL_MIN_RATIO = 0.8 # total_kcal plausibility guard vs BMR + +# Field-by-field merge priority for activity_daily (§3.5). Unknown sources +# rank after every listed one. +ACTIVITY_SOURCE_PRIORITY: list[str] = [ + "manual", + "health_connect", + "fitshow", + "csv_import", + "api", +] + +MERGE_FIELDS: tuple[str, ...] = ( + "steps", + "active_kcal", + "total_kcal", + "distance_m", + "active_minutes", + "floors", +) + + +def source_priority(source: str) -> int: + """Rank of a source in the merge order; unknown sources go last.""" + try: + return ACTIVITY_SOURCE_PRIORITY.index(source) + except ValueError: + return len(ACTIVITY_SOURCE_PRIORITY) + + +# --- BMR / TDEE / budget ------------------------------------------------------ + + +def age_on(day: date, birthdate: date) -> int: + """Completed years on `day`.""" + years = day.year - birthdate.year + if (day.month, day.day) < (birthdate.month, birthdate.day): + years -= 1 + return years + + +def bmr_mifflin(weight_kg: float, height_cm: float, age: int, sex: str) -> float: + """Mifflin-St Jeor BMR; sex in {male, female, other} (§5.1).""" + base = 10 * weight_kg + 6.25 * height_cm - 5 * age + offset = {"male": 5.0, "female": -161.0, "other": -78.0}[sex] + return base + offset + + +@dataclass +class TdeeResult: + kcal: float + method: str # "measured_total" | "bmr_plus_active" | "estimated" + + +def tdee_effective( + bmr: float, + activity_level: str, + total_kcal: float | None = None, + active_kcal: float | None = None, +) -> TdeeResult: + """Effective TDEE of one day, 3-tier preference (§5.2): + 1. measured total_kcal, if plausible (> 0.8 x BMR); + 2. BMR + active_kcal; + 3. BMR x activity factor. + """ + if total_kcal is not None and total_kcal > MEASURED_TOTAL_MIN_RATIO * bmr: + return TdeeResult(kcal=float(total_kcal), method="measured_total") + if active_kcal is not None: + return TdeeResult(kcal=bmr + float(active_kcal), method="bmr_plus_active") + return TdeeResult(kcal=bmr * ACTIVITY_FACTORS[activity_level], method="estimated") + + +def moving_average( + values: list[float | None], window: int = TDEE_SMOOTHING_DAYS +) -> list[float | None]: + """Trailing moving average over consecutive daily values; None entries are + skipped (a day stays None only when no value exists in its window).""" + out: list[float | None] = [] + for i in range(len(values)): + chunk = [v for v in values[max(0, i - window + 1) : i + 1] if v is not None] + out.append(sum(chunk) / len(chunk) if chunk else None) + return out + + +def clamp(value: float, lo: float, hi: float) -> float: + return max(lo, min(hi, value)) + + +@dataclass +class GoalRate: + weekly_rate_kg: float # positive = loss + rate_clamped: bool = False + + +def resolve_goal_rate( + mode: str, + day: date, + trend_now: float, + target_weight_kg: float, + weekly_rate_kg: float | None, + target_date: date | None, +) -> GoalRate: + """Weekly rate implied by the active goal on `day` (§5.3).""" + if mode == "maintain": + return GoalRate(0.0) + if mode == "weekly_rate": + return GoalRate(float(weekly_rate_kg or 0.0)) + # target_date: rate recomputed daily from the current trend. + if target_date is None: + return GoalRate(0.0) + weeks_left = max((target_date - day).days / 7, 1.0) + rate = (trend_now - target_weight_kg) / weeks_left + clamped = clamp(rate, -0.5, 1.0) + return GoalRate(clamped, rate_clamped=clamped != rate) + + +@dataclass +class BudgetResult: + kcal: float + deficit_target: float + floor_applied: bool + rate_clamped: bool = False + + +def daily_budget( + tdee_smoothed_kcal: float, + rate: GoalRate, + sex: str, + calorie_floor_kcal: int | None = None, +) -> BudgetResult: + """Daily calorie budget = TDEE - weekly_rate x 7700 / 7, floored (§5.3).""" + deficit = rate.weekly_rate_kg * KCAL_PER_KG_FAT / 7 # = rate x 1100 + budget = tdee_smoothed_kcal - deficit + floor = calorie_floor_kcal or ( + CALORIE_FLOOR_MALE if sex == "male" else CALORIE_FLOOR_FEMALE + ) + return BudgetResult( + kcal=max(budget, float(floor)), + deficit_target=deficit, + floor_applied=budget < floor, + rate_clamped=rate.rate_clamped, + ) + + +# --- Weight trend (EMA), slope, projection ------------------------------------ + + +def weight_trend( + entries: list[tuple[date, float]], alpha: float = EMA_ALPHA +) -> list[tuple[date, float]]: + """EMA trend with gap correction (§5.5). + + `entries` = one (day, weight) per local day, ascending — the FIRST + weigh-in of each day. Missing days are handled through + alpha_eff = 1 - (1 - alpha) ** gap_days. + Returned trend values are rounded to 2 decimals; the running trend keeps + full precision. + """ + out: list[tuple[date, float]] = [] + prev_date: date | None = None + trend: float | None = None + for d, w in entries: + if trend is None or prev_date is None: + trend = w + else: + gap = (d - prev_date).days + alpha_eff = 1 - (1 - alpha) ** gap + trend = trend + alpha_eff * (w - trend) + out.append((d, round(trend, 2))) + prev_date = d + return out + + +def trend_at_day(trend: list[tuple[date, float]], day: date) -> float | None: + """Trend value carried forward to `day` (last known point <= day).""" + value: float | None = None + for point_day, point_value in trend: + if point_day > day: + break + value = point_value + return value + + +def regression_slope(points: list[tuple[date, float]]) -> float | None: + """OLS slope in kg/day over (day, value) points; None if < 3 points (§5.5).""" + if len(points) < 3: + return None + t0 = points[0][0] + ts = [float((d - t0).days) for d, _ in points] + ws = [w for _, w in points] + t_mean = sum(ts) / len(ts) + w_mean = sum(ws) / len(ws) + denom = sum((t - t_mean) ** 2 for t in ts) + if denom == 0: + return None + num = sum((t - t_mean) * (w - w_mean) for t, w in zip(ts, ws, strict=True)) + return num / denom + + +@dataclass +class Projection: + status: str # "reached" | "not_converging" | "ok" + date: DateT | None = None + + +def project_target_date( + trend_now: float, + target_weight: float, + slope_kg_day: float | None, + today: date, +) -> Projection: + """Projected date of reaching the target at the observed slope (§5.6).""" + delta = trend_now - target_weight # > 0 while weight remains to lose + if abs(delta) < 0.1: + return Projection(status="reached") + losing_needed = delta > 0 + if ( + slope_kg_day is None + or abs(slope_kg_day) < MIN_SLOPE_KG_PER_DAY + or (losing_needed and slope_kg_day >= 0) + or (not losing_needed and slope_kg_day <= 0) + ): + return Projection(status="not_converging") + days = abs(delta / slope_kg_day) + if days > 3650: + return Projection(status="not_converging") + return Projection(status="ok", date=today + timedelta(days=round(days))) + + +# --- Energy balance & TDEE calibration (§5.4 / §5.7) -------------------------- + + +def energy_balance(intake_kcal: float | None, tdee_kcal: float | None) -> float | None: + """balance = intake - TDEE (negative = deficit); None when untracked.""" + if intake_kcal is None or tdee_kcal is None: + return None + return intake_kcal - tdee_kcal + + +@dataclass +class CalibrationResult: + status: str # "ok" | "insufficient_data" + tracked_days: int = 0 + expected_change_kg: float | None = None + actual_change_kg: float | None = None + gap_kg: float | None = None + tdee_adaptive_kcal: float | None = None + tdee_correction_kcal: float | None = None + + +def tdee_calibration( + tracked: list[tuple[float, float]], + trend_start: float | None, + trend_end: float | None, +) -> CalibrationResult: + """Cumulative deficit vs actual (trend) loss + adaptive TDEE (§5.7). + + `tracked` = [(intake_kcal, tdee_effective_kcal)] for tracked days only + (days with at least one food entry). Requires >= 21 tracked days. + """ + tracked_days = len(tracked) + if ( + tracked_days < ADAPTIVE_TDEE_MIN_DAYS + or trend_start is None + or trend_end is None + ): + return CalibrationResult(status="insufficient_data", tracked_days=tracked_days) + expected = sum(intake - tdee for intake, tdee in tracked) / KCAL_PER_KG_FAT + actual = trend_end - trend_start + mean_intake = sum(intake for intake, _ in tracked) / tracked_days + mean_tdee = sum(tdee for _, tdee in tracked) / tracked_days + tdee_adaptive = mean_intake - actual * KCAL_PER_KG_FAT / tracked_days + return CalibrationResult( + status="ok", + tracked_days=tracked_days, + expected_change_kg=expected, + actual_change_kg=actual, + gap_kg=actual - expected, + tdee_adaptive_kcal=tdee_adaptive, + tdee_correction_kcal=tdee_adaptive - mean_tdee, + ) + + +# --- Activity merge & workout overlap ----------------------------------------- + + +@dataclass +class MergedActivity: + date: date + steps: int | None = None + active_kcal: float | None = None + total_kcal: float | None = None + distance_m: int | None = None + active_minutes: int | None = None + floors: int | None = None + field_sources: dict[str, str] = field(default_factory=dict) + + +def merge_activity_day(rows: list) -> MergedActivity: + """Field-by-field merge of one day's rows by source priority (§3.5). + + `rows` = every activity row of the same (user, date), any source order; + objects only need `.date`, `.source` and the MERGE_FIELDS attributes. + Never sums two sources (double-count risk). + """ + ordered = sorted(rows, key=lambda r: (source_priority(r.source), r.source)) + merged = MergedActivity(date=ordered[0].date) + for field_name in MERGE_FIELDS: + for row in ordered: + value = getattr(row, field_name) + if value is not None: + if field_name in ("active_kcal", "total_kcal"): + value = float(value) + setattr(merged, field_name, value) + merged.field_sources[field_name] = row.source + break + return merged + + +def overlap_ratio(a_start, a_end, b_start, b_end) -> float: + """Overlap seconds / duration of the SHORTER interval (datetimes).""" + inter = (min(a_end, b_end) - max(a_start, b_start)).total_seconds() + if inter <= 0: + return 0.0 + shorter = min((a_end - a_start).total_seconds(), (b_end - b_start).total_seconds()) + if shorter <= 0: + return 0.0 + return inter / shorter + + +# --- Body composition --------------------------------------------------------- + + +def bmi(weight_kg: float, height_cm: float) -> float | None: + """Body mass index = kg / m².""" + if height_cm <= 0: + return None + height_m = height_cm / 100 + return weight_kg / (height_m * height_m) + + +def navy_body_fat_pct( + sex: str, + height_cm: float, + waist_cm: float | None, + neck_cm: float | None, + hips_cm: float | None = None, +) -> float | None: + """US Navy body-fat estimate (§3.3); None when inputs are missing.""" + if not waist_cm or not neck_cm or height_cm <= 0: + return None + if sex == "female": + if not hips_cm: + return None + inner = waist_cm + hips_cm - neck_cm + if inner <= 0: + return None + denom = 1.29579 - 0.35004 * math.log10(inner) + 0.22100 * math.log10(height_cm) + else: + inner = waist_cm - neck_cm + if inner <= 0: + return None + denom = 1.0324 - 0.19077 * math.log10(inner) + 0.15456 * math.log10(height_cm) + if denom == 0: + return None + return 495 / denom - 450 + + +# --- Planning / adherence (addendum-planning.md) ------------------------------ + +# Habit kinds; "done" is DERIVED from the data tables, there is no check-in +# table in v1: weigh_in -> WeightEntry, workout -> Workout, food_log -> FoodEntry. +SCHEDULE_KINDS: tuple[str, ...] = ("weigh_in", "workout", "food_log") + + +@dataclass +class DayAdherence: + day: date + planned: bool + done: bool + + @property + def missed(self) -> bool: + return self.planned and not self.done + + @property + def status(self) -> str: + """Calendar-heatmap status: planned+done / planned+missed / off-plan.""" + if self.planned: + return "done" if self.done else "missed" + return "done_unplanned" if self.done else "rest" + + +def is_planned(weekdays: list[int] | None, day: date, enabled: bool = True) -> bool: + """True when `day` falls on a planned weekday (0 = Monday … 6 = Sunday).""" + if not enabled or not weekdays: + return False + return day.weekday() in set(weekdays) + + +def date_range(start: date, end: date) -> list[date]: + """Inclusive list of local days from `start` to `end`.""" + if end < start: + return [] + return [start + timedelta(days=i) for i in range((end - start).days + 1)] + + +def build_adherence( + start: date, + end: date, + weekdays: list[int] | None, + enabled: bool, + done_days: set[date], +) -> list[DayAdherence]: + """Per-day planned/done status over an inclusive local-day range.""" + return [ + DayAdherence( + day=day, + planned=is_planned(weekdays, day, enabled), + done=day in done_days, + ) + for day in date_range(start, end) + ] + + +def adherence_pct(days: list[DayAdherence]) -> float | None: + """done ÷ planned, in percent; None when nothing was planned.""" + planned = [d for d in days if d.planned] + if not planned: + return None + return 100.0 * sum(1 for d in planned if d.done) / len(planned) + + +@dataclass +class StreakResult: + current: int = 0 + best: int = 0 + + +def compute_streaks(days: list[DayAdherence], today: date) -> StreakResult: + """Streaks over PLANNED days only (addendum-planning.md). + + `best` = longest run of consecutive planned-and-done days. `current` = run + ending at the most recent planned day; a planned day equal to `today` that + is not done yet does not break the streak (the day is not over). + """ + planned = sorted((d for d in days if d.planned), key=lambda d: d.day) + best = run = 0 + for entry in planned: + run = run + 1 if entry.done else 0 + best = max(best, run) + current = 0 + for entry in reversed(planned): + if entry.day > today: + continue # future planned days are not part of the current streak + if entry.day == today and not entry.done: + continue # grace period: today is still open + if not entry.done: + break + current += 1 + return StreakResult(current=current, best=best) diff --git a/apps/api/app/modules/health/foods.py b/apps/api/app/modules/health/foods.py new file mode 100644 index 0000000..d98f219 --- /dev/null +++ b/apps/api/app/modules/health/foods.py @@ -0,0 +1,231 @@ +"""Food referential: local `food_items` cache first, Open Food Facts proxy next. + +Rules from docs/research/nutrition-sources.md §4.3: +- every OFF call goes through the backend (never the browser); +- mandatory custom User-Agent `AppName/Version (ContactEmail)`; +- each fetched product is cached in `food_items` so it is never fetched twice; +- OFF unreachable must degrade gracefully (French 503 only when nothing local). + +Tests inject an `httpx.MockTransport` through `set_http_transport` — the suite +never touches the network. +""" + +from decimal import Decimal +from typing import Any + +import httpx +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.core.errors import AppError +from app.modules.health.models import FoodItem +from app.modules.health.schemas import FoodSearchItem, FoodSearchResponse + +OFF_SEARCH_URL = "https://search.openfoodfacts.org/search" +OFF_PRODUCT_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json" +OFF_USER_AGENT = "LifeTrack/1.0 (meejayproduction@gmail.com)" +OFF_TIMEOUT_S = 5.0 +OFF_SOURCE = "off" + +OFF_FIELDS = ( + "code,product_name,product_name_fr,brands,quantity,serving_size," + "serving_quantity,nutriments" +) + +_TRANSPORT: httpx.BaseTransport | None = None + + +class ServiceUnavailableError(AppError): + status_code, code = 503, "service_unavailable" + + +def set_http_transport(transport: httpx.BaseTransport | None) -> None: + """Test seam: inject an httpx transport (MockTransport) for OFF calls.""" + global _TRANSPORT + _TRANSPORT = transport + + +def _client() -> httpx.Client: + return httpx.Client( + timeout=OFF_TIMEOUT_S, + headers={"User-Agent": OFF_USER_AGENT}, + transport=_TRANSPORT, + ) + + +def _num(value: Any) -> Decimal | None: + if value is None or value == "": + return None + try: + return Decimal(str(float(value))) + except (TypeError, ValueError, ArithmeticError): + return None + + +def _text(value: Any) -> str: + """OFF fields are user-contributed: a field documented as a string sometimes + arrives as a list (multi-value) or a number. Coerce anything to a clean string.""" + if value is None: + return "" + if isinstance(value, (list, tuple)): + value = value[0] if value else "" + return str(value).strip() + + +def _kcal_100g(nutriments: dict[str, Any]) -> Decimal | None: + """kcal per 100 g, recomputed from kJ when the kcal field is missing.""" + kcal = _num(nutriments.get("energy-kcal_100g")) + if kcal is not None: + return kcal + kj = _num(nutriments.get("energy_100g")) or _num(nutriments.get("energy-kj_100g")) + if kj is None: + return None + return Decimal(str(round(float(kj) / 4.184, 1))) + + +def normalize_off_product(product: dict[str, Any]) -> dict[str, Any] | None: + """Map one OFF product payload to `food_items` columns (per 100 g).""" + name = _text(product.get("product_name_fr")) or _text(product.get("product_name")) + if not name: + return None + nutriments = product.get("nutriments") + if not isinstance(nutriments, dict): + nutriments = {} + return { + "source": OFF_SOURCE, + "source_id": _text(product.get("code")) or None, + "name": name[:200], + "brand": _text(product.get("brands")).split(",")[0].strip()[:100] or None, + "energy_kcal_100g": _kcal_100g(nutriments), + "protein_g_100g": _num(nutriments.get("proteins_100g")), + "carbs_g_100g": _num(nutriments.get("carbohydrates_100g")), + "sugar_g_100g": _num(nutriments.get("sugars_100g")), + "fat_g_100g": _num(nutriments.get("fat_100g")), + "sat_fat_g_100g": _num(nutriments.get("saturated-fat_100g")), + "fiber_g_100g": _num(nutriments.get("fiber_100g")), + "salt_g_100g": _num(nutriments.get("salt_100g")), + "serving_size_g": _num(product.get("serving_quantity")), + "raw": product, + } + + +def cache_product(db: Session, data: dict[str, Any]) -> FoodItem: + """Upsert one product into the local cache, keyed by (source, source_id).""" + item: FoodItem | None = None + if data["source_id"]: + item = db.scalar( + select(FoodItem).where( + FoodItem.source == data["source"], + FoodItem.source_id == data["source_id"], + ) + ) + if item is None: + item = FoodItem(source=data["source"], source_id=data["source_id"]) + db.add(item) + for key, value in data.items(): + if key not in {"source", "source_id"}: + setattr(item, key, value) + return item + + +def search_local(db: Session, query: str, limit: int) -> list[FoodItem]: + pattern = f"%{query.lower()}%" + stmt = ( + select(FoodItem) + .where( + or_( + func.lower(FoodItem.name).like(pattern), + func.lower(func.coalesce(FoodItem.brand, "")).like(pattern), + ) + ) + .order_by(FoodItem.name.asc()) + .limit(limit) + ) + return list(db.scalars(stmt).all()) + + +def fetch_off_search(query: str, limit: int) -> list[dict[str, Any]]: + """Full-text search on Open Food Facts (Search-a-licious).""" + with _client() as client: + response = client.get( + OFF_SEARCH_URL, + params={"q": query, "langs": "fr", "page_size": limit}, + ) + response.raise_for_status() + payload = response.json() + hits = payload.get("hits") + if hits is None: + hits = payload.get("products") or [] + return [hit for hit in hits if isinstance(hit, dict)] + + +def fetch_off_product(barcode: str) -> dict[str, Any] | None: + with _client() as client: + response = client.get( + OFF_PRODUCT_URL.format(barcode=barcode), params={"fields": OFF_FIELDS} + ) + response.raise_for_status() + payload = response.json() + if payload.get("status") in (0, "failure"): + return None + return payload.get("product") + + +def search_foods(db: Session, query: str, limit: int = 20) -> FoodSearchResponse: + """Local cache first, then Open Food Facts; caches every OFF hit.""" + query = query.strip() + if len(query) < 2: + raise AppError("Saisissez au moins 2 caractères pour rechercher un aliment.") + local = search_local(db, query, limit) + items = [FoodSearchItem.model_validate(row) for row in local] + if len(items) >= limit: + return FoodSearchResponse(query=query, items=items, origin="cache") + + known = {(row.source, row.source_id) for row in local} + try: + hits = fetch_off_search(query, limit - len(items)) + except (httpx.HTTPError, ValueError) as exc: + if items: + return FoodSearchResponse(query=query, items=items, origin="cache") + raise ServiceUnavailableError( + "La base Open Food Facts est momentanément injoignable. " + "Réessayez plus tard ou saisissez l'aliment manuellement." + ) from exc + + for hit in hits: + data = normalize_off_product(hit) + if data is None or (data["source"], data["source_id"]) in known: + continue + known.add((data["source"], data["source_id"])) + cached = cache_product(db, data) + items.append(FoodSearchItem.model_validate(cached)) + db.commit() + return FoodSearchResponse(query=query, items=items[:limit], origin="cache+off") + + +def food_by_barcode(db: Session, barcode: str) -> FoodSearchItem: + """Cache lookup, then OFF product endpoint.""" + barcode = barcode.strip() + cached = db.scalar( + select(FoodItem).where( + FoodItem.source == OFF_SOURCE, FoodItem.source_id == barcode + ) + ) + if cached is not None: + return FoodSearchItem.model_validate(cached) + try: + product = fetch_off_product(barcode) + except (httpx.HTTPError, ValueError) as exc: + raise ServiceUnavailableError( + "La base Open Food Facts est momentanément injoignable. " + "Réessayez plus tard ou saisissez l'aliment manuellement." + ) from exc + data = normalize_off_product(product or {}) + if data is None: + from app.core.errors import NotFoundError + + raise NotFoundError("Aucun produit ne correspond à ce code-barres.") + item = cache_product(db, data) + db.commit() + db.refresh(item) + return FoodSearchItem.model_validate(item) diff --git a/apps/api/app/modules/health/importers.py b/apps/api/app/modules/health/importers.py new file mode 100644 index 0000000..85f6648 --- /dev/null +++ b/apps/api/app/modules/health/importers.py @@ -0,0 +1,673 @@ +"""File importers for the health module (CONVENTIONS C3). + +Three profiles: +- `foodvisor_csv` : Foodvisor RGPD/in-app export -> FoodEntry; +- `health_sync_csv` : Health Sync CSV export -> WeightEntry / DailyActivity; +- `weight_generic_csv` : plain `date;poids` history -> WeightEntry. + +All of them are tolerant by design (docs/research/nutrition-sources.md §2.2 and +§7.3): utf-8-sig then cp1252, `,`/`;`/tab delimiters, comma decimals, FR/EN +header aliases, timestamps without offset interpreted in Europe/Paris. +When the source has no native id, `external_id = sha256(normalized row)[:32]`. +""" + +import csv +import datetime as dt +import hashlib +import io +import json +import re +import unicodedata +from collections.abc import Iterator +from decimal import Decimal +from typing import Any, ClassVar +from zoneinfo import ZoneInfo + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.importing.base import ( + BaseImporter, + ImporterParseError, + NormalizedRecord, + RowError, + UpsertOutcome, +) +from app.core.importing.hashing import content_hash +from app.core.importing.registry import register_importer +from app.modules.health.models import ( + DailyActivity, + FoodEntry, + MealType, + WeightEntry, +) + +DEFAULT_TZ = ZoneInfo("Europe/Paris") +EXTERNAL_ID_LEN = 32 + + +# --- Decoding / parsing helpers ----------------------------------------------- + + +def decode_bytes(data: bytes) -> str: + """utf-8-sig first, cp1252 as documented fallback (C3.3).""" + for encoding in ("utf-8-sig", "cp1252"): + try: + return data.decode(encoding) + except UnicodeDecodeError: + continue + return data.decode("utf-8", errors="replace") + + +def sniff_delimiter(sample: str) -> str: + header = sample.splitlines()[0] if sample.splitlines() else "" + counts = {sep: header.count(sep) for sep in (";", ",", "\t", "|")} + best = max(counts, key=lambda sep: counts[sep]) + return best if counts[best] > 0 else "," + + +def norm_key(value: str) -> str: + """Header normalisation: lowercase, accent-free, snake_case.""" + text = unicodedata.normalize("NFKD", value or "") + text = "".join(ch for ch in text if not unicodedata.combining(ch)) + text = re.sub(r"[^0-9a-zA-Z]+", "_", text.lower()) + return text.strip("_") + + +def pick(row: dict[str, Any], aliases: tuple[str, ...]) -> Any: + """First non-empty value among the normalized aliases (exact then prefix).""" + for alias in aliases: + if alias in row and str(row[alias]).strip() != "": + return row[alias] + for alias in aliases: + for key, value in row.items(): + if key.startswith(alias) and str(value).strip() != "": + return value + return None + + +def to_decimal(value: Any) -> Decimal | None: + """Tolerant number parsing: comma decimals, spaces, units, `traces`, `< x`.""" + if value is None: + return None + text = str(value).strip() + if text == "" or text in {"-", "--", "NA", "N/A", "null"}: + return None + if "trace" in text.lower(): + return Decimal(0) + text = text.replace("<", "").replace(" ", "").replace(" ", "") + text = text.replace(",", ".") + match = re.search(r"-?\d+(?:\.\d+)?", text) + if match is None: + return None + try: + return Decimal(match.group(0)) + except ArithmeticError: + return None + + +def to_float(value: Any) -> float | None: + decimal = to_decimal(value) + return None if decimal is None else float(decimal) + + +_DATE_FORMATS = ( + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + "%Y-%m-%d", + "%d/%m/%Y %H:%M:%S", + "%d/%m/%Y %H:%M", + "%d/%m/%Y", + "%d-%m-%Y %H:%M", + "%d-%m-%Y", + "%d.%m.%Y", + "%Y/%m/%d", +) + + +def parse_datetime(value: Any, tz: ZoneInfo = DEFAULT_TZ) -> dt.datetime | None: + """Parse a timestamp to aware UTC; naive input is read as local time.""" + if value is None: + return None + text = str(value).strip() + if not text: + return None + iso = text.replace("Z", "+00:00") + parsed: dt.datetime | None = None + try: + parsed = dt.datetime.fromisoformat(iso) + except ValueError: + for fmt in _DATE_FORMATS: + try: + # These legacy formats carry no offset on purpose; the tz is + # applied a few lines below (Europe/Paris by default). + parsed = dt.datetime.strptime(text, fmt) # noqa: DTZ007 + break + except ValueError: + continue + if parsed is None: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=tz) + return parsed.astimezone(dt.UTC) + + +def parse_date(value: Any, tz: ZoneInfo = DEFAULT_TZ) -> dt.date | None: + parsed = parse_datetime(value, tz) + return None if parsed is None else parsed.astimezone(tz).date() + + +def row_external_id(prefix: str, row: dict[str, Any]) -> str: + """sha256 of the normalized row (§1.5), namespaced by importer.""" + payload = json.dumps( + {key: str(value) for key, value in sorted(row.items())}, + sort_keys=True, + ensure_ascii=False, + ) + digest = hashlib.sha256(f"{prefix}|{payload}".encode()).hexdigest() + return digest[:EXTERNAL_ID_LEN] + + +def read_rows(data: bytes) -> tuple[list[str], list[dict[str, Any]]]: + """Decode + parse a CSV into normalized-header dicts.""" + text = decode_bytes(data) + if not text.strip(): + raise ImporterParseError("Fichier vide.") + delimiter = sniff_delimiter(text) + reader = csv.DictReader(io.StringIO(text), delimiter=delimiter) + if not reader.fieldnames: + raise ImporterParseError("En-têtes de colonnes introuvables.") + headers = [norm_key(name) for name in reader.fieldnames] + rows: list[dict[str, Any]] = [] + for raw in reader: + row = { + norm_key(key): ("" if value is None else value) + for key, value in raw.items() + if key is not None + } + if any(str(value).strip() for value in row.values()): + rows.append(row) + return headers, rows + + +def head_headers(head: bytes) -> set[str]: + """Normalized headers of the first line — used by `sniff` (never raises).""" + try: + text = decode_bytes(head) + first = text.splitlines()[0] if text.splitlines() else "" + delimiter = sniff_delimiter(text) + return {norm_key(part) for part in first.split(delimiter)} + except Exception: # noqa: BLE001 — sniff must never raise (C3.3) + return set() + + +def has_any(headers: set[str], aliases: tuple[str, ...]) -> bool: + return any( + header == alias or header.startswith(alias) + for header in headers + for alias in aliases + ) + + +# --- Column alias tables ------------------------------------------------------- + +DATE_ALIASES = ( + "date", + "jour", + "day", + "datetime", + "date_heure", + "horodatage", + "timestamp", +) +TIME_ALIASES = ("heure", "time", "hour") +MEAL_ALIASES = ("repas", "meal", "type_de_repas", "meal_type", "moment", "categorie") +NAME_ALIASES = ( + "aliment", + "nom", + "name", + "food", + "food_name", + "libelle", + "produit", + "product", +) +BRAND_ALIASES = ("marque", "brand") +QUANTITY_ALIASES = ("quantite", "quantity", "portion", "amount", "serving", "poids_g") +UNIT_ALIASES = ("unite", "unit") +KCAL_ALIASES = ("calories", "kcal", "energie", "energy", "energie_kcal", "energy_kcal") +PROTEIN_ALIASES = ("proteines", "protein", "proteins", "prot") +CARBS_ALIASES = ( + "glucides", + "carbs", + "carbohydrates", + "carbohydrate", + "total_carbohydrate", +) +FAT_ALIASES = ("lipides", "fat", "fats", "total_fat", "matieres_grasses") +FIBER_ALIASES = ("fibres", "fiber", "dietary_fiber", "fibre") +SUGAR_ALIASES = ("sucres", "sugar", "sugars", "sucre") +SATFAT_ALIASES = ("acides_gras_satures", "saturated_fat", "satures", "ags", "sat_fat") +SODIUM_ALIASES = ("sodium", "sel", "salt") +WEIGHT_ALIASES = ("poids", "weight", "poids_kg", "weight_kg", "masse", "body_weight") +STEPS_ALIASES = ("pas", "steps", "step_count", "nombre_de_pas") +DISTANCE_ALIASES = ("distance", "distance_m", "distance_km", "distance_metres") +ACTIVE_KCAL_ALIASES = ( + "calories_actives", + "active_calories", + "active_energy", + "active_kcal", + "calories_brulees", +) +TOTAL_KCAL_ALIASES = ( + "calories_totales", + "total_calories", + "total_energy", + "total_kcal", +) +ACTIVE_MIN_ALIASES = ( + "minutes_actives", + "active_minutes", + "move_minutes", + "duree_activite", +) +BODYFAT_ALIASES = ("masse_grasse", "body_fat", "fat_percentage", "graisse") + +MEAL_MAP: tuple[tuple[tuple[str, ...], MealType], ...] = ( + (("petit_dejeuner", "petit_dej", "breakfast", "matin"), MealType.BREAKFAST), + (("collation", "snack", "gouter", "encas", "en_cas"), MealType.SNACK), + (("dejeuner", "lunch", "midi", "déjeuner"), MealType.LUNCH), + (("diner", "dinner", "souper", "soir"), MealType.DINNER), +) + + +def map_meal(value: Any) -> MealType: + key = norm_key(str(value or "")) + for aliases, meal in MEAL_MAP: + if any(alias in key for alias in aliases): + return meal + return MealType.SNACK + + +# --- Shared upsert helpers ---------------------------------------------------- + + +def _find_event( + db: Session, + model: type, + user_id: int, + source: str, + external_id: str | None, + digest: str | None, +): + if external_id is not None: + row = db.scalar( + select(model).where( + model.user_id == user_id, + model.source == source, + model.external_id == external_id, + ) + ) + if row is not None: + return row + if digest is not None: + row = db.scalar( + select(model).where(model.user_id == user_id, model.content_hash == digest) + ) + if row is not None: + return row + return None + + +def _dedupe_keys(record: NormalizedRecord) -> tuple[str | None, str | None]: + """(external_id, content_hash) per architecture §5.1. + + The content hash is only the FALLBACK used when the source provides no id + — never both, otherwise the `(user_id, content_hash)` constraint would also + dedupe across importers/sources, which the spec keeps distinct. + """ + if record.external_id: + return record.external_id[:255], None + return None, content_hash(record) + + +def _weight_slot_taken( + db: Session, user_id: int, source: str, measured_at: dt.datetime +) -> bool: + """`uq_weight_entries_user_ts_source` guard — never let an import crash on it.""" + return ( + db.scalar( + select(WeightEntry).where( + WeightEntry.user_id == user_id, + WeightEntry.source == source, + WeightEntry.measured_at == measured_at, + ) + ) + is not None + ) + + +def _upsert_daily_activity( + db: Session, + user_id: int, + source: str, + day: dt.date, + values: dict[str, Any], + raw: dict[str, Any], + import_run_id: int | None, +) -> UpsertOutcome: + """Natural key (user, day, source) upsert-replacement (architecture §5.1). + + Returns DUPLICATE when every value is already identical (re-importing the + same file is a no-op), UPDATED when the source refreshed its numbers. + """ + row = db.scalar( + select(DailyActivity).where( + DailyActivity.user_id == user_id, + DailyActivity.date == day, + DailyActivity.source == source, + ) + ) + if row is None: + db.add( + DailyActivity( + user_id=user_id, + date=day, + source=source, + import_run_id=import_run_id, + raw=raw, + **values, + ) + ) + db.flush() # make the row visible to the next lookups of the same run + return UpsertOutcome.INSERTED + changed = False + for field, value in values.items(): + if value is None: + continue + if getattr(row, field) != value: + setattr(row, field, value) + changed = True + if not changed: + return UpsertOutcome.DUPLICATE + row.raw = raw + row.import_run_id = import_run_id + return UpsertOutcome.UPDATED + + +# --- foodvisor_csv ------------------------------------------------------------- + + +@register_importer +class FoodvisorCsvImporter(BaseImporter): + id: ClassVar[str] = "foodvisor_csv" + label: ClassVar[str] = "Foodvisor (export CSV)" + domain: ClassVar[str] = "health" + accepted_extensions: ClassVar[tuple[str, ...]] = (".csv",) + source: ClassVar[str] = "foodvisor" + + @classmethod + def sniff(cls, filename: str, head: bytes) -> bool: + if not filename.lower().endswith(".csv"): + return False + headers = head_headers(head) + return ( + has_any(headers, MEAL_ALIASES) + and has_any(headers, KCAL_ALIASES) + and has_any(headers, NAME_ALIASES) + ) + + def parse(self, data: bytes, filename: str) -> Iterator[NormalizedRecord]: + _, rows = read_rows(data) + if not rows: + raise ImporterParseError("Aucune ligne exploitable dans le fichier.") + for row in rows: + name = pick(row, NAME_ALIASES) + kcal = to_decimal(pick(row, KCAL_ALIASES)) + when = parse_datetime(pick(row, DATE_ALIASES)) + if when is None: + time_part = pick(row, TIME_ALIASES) + date_part = pick(row, DATE_ALIASES) + if date_part and time_part: + when = parse_datetime(f"{date_part} {time_part}") + if not name or kcal is None or when is None: + raise RowError( + "Ligne incomplète : date, aliment et calories sont requis." + ) + quantity = to_decimal(pick(row, QUANTITY_ALIASES)) or Decimal(100) + yield NormalizedRecord( + kind="food_entry", + external_id=str(pick(row, ("id", "entry_id", "uuid")) or "") + or row_external_id(self.id, row), + dedupe_fields=("eaten_at", "name", "kcal", "meal"), + data={ + "eaten_at": when, + "meal": map_meal(pick(row, MEAL_ALIASES)), + "name": str(name)[:200], + "brand": (str(pick(row, BRAND_ALIASES) or "") or None), + "quantity": quantity, + "unit": str(pick(row, UNIT_ALIASES) or "g")[:20], + "kcal": kcal, + "protein_g": to_decimal(pick(row, PROTEIN_ALIASES)), + "carbs_g": to_decimal(pick(row, CARBS_ALIASES)), + "fat_g": to_decimal(pick(row, FAT_ALIASES)), + "fiber_g": to_decimal(pick(row, FIBER_ALIASES)), + "sugar_g": to_decimal(pick(row, SUGAR_ALIASES)), + "sat_fat_g": to_decimal(pick(row, SATFAT_ALIASES)), + "sodium_mg": to_decimal(pick(row, SODIUM_ALIASES)), + "raw": {key: str(value) for key, value in row.items()}, + }, + ) + + def upsert( + self, db: Session, user_id: int, record: NormalizedRecord + ) -> UpsertOutcome: + external_id, digest = _dedupe_keys(record) + if _find_event(db, FoodEntry, user_id, self.source, external_id, digest): + return UpsertOutcome.DUPLICATE + data = dict(record.data) + raw = data.pop("raw", None) + db.add( + FoodEntry( + user_id=user_id, + source=self.source, + external_id=external_id, + content_hash=digest, + import_run_id=self.import_run_id, + raw=raw, + **data, + ) + ) + db.flush() + return UpsertOutcome.INSERTED + + +# --- health_sync_csv ----------------------------------------------------------- + + +@register_importer +class HealthSyncCsvImporter(BaseImporter): + """Health Sync exports: one row per day (or per measure) with a flexible + set of metric columns (poids, pas, distance, calories…).""" + + id: ClassVar[str] = "health_sync_csv" + label: ClassVar[str] = "Health Sync / Health Connect (export CSV)" + domain: ClassVar[str] = "health" + accepted_extensions: ClassVar[tuple[str, ...]] = (".csv",) + source: ClassVar[str] = "csv_import" + + _METRIC_GROUPS = ( + STEPS_ALIASES, + DISTANCE_ALIASES, + ACTIVE_KCAL_ALIASES, + TOTAL_KCAL_ALIASES, + ACTIVE_MIN_ALIASES, + WEIGHT_ALIASES, + ) + + @classmethod + def sniff(cls, filename: str, head: bytes) -> bool: + if not filename.lower().endswith(".csv"): + return False + headers = head_headers(head) + if not has_any(headers, DATE_ALIASES): + return False + if has_any(headers, MEAL_ALIASES): + return False # nutrition export -> foodvisor_csv + matched = sum(1 for group in cls._METRIC_GROUPS if has_any(headers, group)) + return matched >= 2 + + def parse(self, data: bytes, filename: str) -> Iterator[NormalizedRecord]: + _, rows = read_rows(data) + if not rows: + raise ImporterParseError("Aucune ligne exploitable dans le fichier.") + for row in rows: + when = parse_datetime(pick(row, DATE_ALIASES)) + if when is None: + raise RowError("Date illisible sur cette ligne.") + time_part = pick(row, TIME_ALIASES) + if time_part: + combined = parse_datetime( + f"{parse_date(pick(row, DATE_ALIASES))} {time_part}" + ) + if combined is not None: + when = combined + day = when.astimezone(DEFAULT_TZ).date() + raw = {key: str(value) for key, value in row.items()} + + weight = to_decimal(pick(row, WEIGHT_ALIASES)) + if weight is not None and 20 < float(weight) < 400: + yield NormalizedRecord( + kind="weight", + external_id=row_external_id(f"{self.id}:weight", row), + dedupe_fields=("measured_at", "weight_kg"), + data={ + "measured_at": when, + "weight_kg": weight, + "body_fat_pct": to_decimal(pick(row, BODYFAT_ALIASES)), + "raw": raw, + }, + ) + + distance_key = next( + (key for key in row if has_any({key}, DISTANCE_ALIASES)), None + ) + distance = to_decimal(row.get(distance_key)) if distance_key else None + if distance is not None and distance_key.endswith("km"): + distance = distance * 1000 # Health Sync exports km on some locales + values = { + "steps": int(to_float(pick(row, STEPS_ALIASES)) or 0) + if pick(row, STEPS_ALIASES) is not None + else None, + "active_kcal": to_decimal(pick(row, ACTIVE_KCAL_ALIASES)), + "total_kcal": to_decimal(pick(row, TOTAL_KCAL_ALIASES)), + "distance_m": int(distance) if distance is not None else None, + "active_minutes": int(to_float(pick(row, ACTIVE_MIN_ALIASES)) or 0) + if pick(row, ACTIVE_MIN_ALIASES) is not None + else None, + } + if any(value is not None for value in values.values()): + yield NormalizedRecord( + kind="daily_activity", + external_id=row_external_id(f"{self.id}:activity", row), + dedupe_fields=("day", "steps", "active_kcal", "distance_m"), + data={"day": day, **values, "raw": raw}, + ) + + def upsert( + self, db: Session, user_id: int, record: NormalizedRecord + ) -> UpsertOutcome: + external_id, digest = _dedupe_keys(record) + data = dict(record.data) + raw = data.pop("raw", None) + if record.kind == "weight": + if _find_event( + db, WeightEntry, user_id, self.source, external_id, digest + ) or _weight_slot_taken(db, user_id, self.source, data["measured_at"]): + return UpsertOutcome.DUPLICATE + db.add( + WeightEntry( + user_id=user_id, + source=self.source, + external_id=external_id, + content_hash=digest, + import_run_id=self.import_run_id, + raw=raw, + **data, + ) + ) + db.flush() + return UpsertOutcome.INSERTED + day = data.pop("day") + return _upsert_daily_activity( + db, user_id, self.source, day, data, raw, self.import_run_id + ) + + +# --- weight_generic_csv -------------------------------------------------------- + + +@register_importer +class WeightGenericCsvImporter(BaseImporter): + """Minimal `date;poids` history typed by the user.""" + + id: ClassVar[str] = "weight_generic_csv" + label: ClassVar[str] = "Historique de poids (CSV date/poids)" + domain: ClassVar[str] = "health" + accepted_extensions: ClassVar[tuple[str, ...]] = (".csv",) + source: ClassVar[str] = "csv_import" + + @classmethod + def sniff(cls, filename: str, head: bytes) -> bool: + if not filename.lower().endswith(".csv"): + return False + headers = {header for header in head_headers(head) if header} + if len(headers) > 3: + return False + return has_any(headers, DATE_ALIASES) and has_any(headers, WEIGHT_ALIASES) + + def parse(self, data: bytes, filename: str) -> Iterator[NormalizedRecord]: + _, rows = read_rows(data) + if not rows: + raise ImporterParseError("Aucune ligne exploitable dans le fichier.") + for row in rows: + when = parse_datetime(pick(row, DATE_ALIASES)) + weight = to_decimal(pick(row, WEIGHT_ALIASES)) + if when is None or weight is None: + raise RowError("Ligne incomplète : date et poids sont requis.") + if not 20 < float(weight) < 400: + raise RowError("Poids hors bornes (20-400 kg).") + yield NormalizedRecord( + kind="weight", + external_id=row_external_id(self.id, row), + dedupe_fields=("measured_at", "weight_kg"), + data={ + "measured_at": when, + "weight_kg": weight, + "raw": {key: str(value) for key, value in row.items()}, + }, + ) + + def upsert( + self, db: Session, user_id: int, record: NormalizedRecord + ) -> UpsertOutcome: + external_id, digest = _dedupe_keys(record) + data = dict(record.data) + raw = data.pop("raw", None) + if _find_event( + db, WeightEntry, user_id, self.source, external_id, digest + ) or _weight_slot_taken(db, user_id, self.source, data["measured_at"]): + return UpsertOutcome.DUPLICATE + db.add( + WeightEntry( + user_id=user_id, + source=self.source, + external_id=external_id, + content_hash=digest, + import_run_id=self.import_run_id, + raw=raw, + **data, + ) + ) + db.flush() + return UpsertOutcome.INSERTED diff --git a/apps/api/app/modules/health/ingest.py b/apps/api/app/modules/health/ingest.py new file mode 100644 index 0000000..da57f8a --- /dev/null +++ b/apps/api/app/modules/health/ingest.py @@ -0,0 +1,592 @@ +"""JSON ingestion handler for the health domain (CONVENTIONS C4). + +`POST /api/ingest/health` (device key scope `ingest:health`) accepts BOTH: + +1. the canonical normalized shape of architecture §5.5 + `{"type": "steps", "external_id": "...", "data": {"day": "2026-08-12", + "steps": 9421, "calories_kcal": 2350, "distance_m": 6800}}`; +2. the health-connect-webhook bridge shape (snake_case Health Connect records, + docs/research/health-connect.md §4.1) where the payload of one array item is + passed as `data` — `{"start_time", "end_time", "count"/"energy"/"volume", + "metadata": {"id": ...}, "origin_app": ...}`. + +The adapter below flattens both into the same field names. The full incoming +payload is always kept in the `raw` JSON column so the mapping can be replayed. +""" + +import datetime as dt +import hashlib +import json +from decimal import Decimal +from typing import Any, ClassVar +from zoneinfo import ZoneInfo + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.importing.base import RowError, UpsertOutcome +from app.core.ingest.base import BaseIngestHandler, IngestRecord +from app.core.ingest.registry import register_ingest_handler +from app.core.timeutils import resolve_tz +from app.modules.health.models import ( + DailyActivity, + FoodEntry, + MealType, + SportType, + WaterEntry, + WeightEntry, + Workout, +) + +DEFAULT_TZ = ZoneInfo("Europe/Paris") + +# Sender-declared source -> DataSource registry value (§1.5). Anything else is +# kept verbatim so an unknown bridge still ranks last in the merge priority. +SOURCE_ALIASES = { + "android_bridge": "health_connect", + "health-connect-webhook": "health_connect", + "health_connect_webhook": "health_connect", + "hc-webhook": "health_connect", + "hc_webhook": "health_connect", + "companion-app": "health_connect", + "companion_app": "health_connect", + "healthconnect": "health_connect", + "health_connect": "health_connect", + "fitshow": "fitshow", + "manual": "manual", + "ingest": "api", + "": "api", +} + +ACTIVITY_TYPES: dict[str, str] = { + "steps": "steps", + "distance": "distance_m", + "active_calories": "active_kcal", + "total_calories": "total_kcal", +} + +SPORT_ALIASES: dict[str, SportType] = { + "running_treadmill": SportType.TREADMILL_RUN, + "treadmill_run": SportType.TREADMILL_RUN, + "walking_treadmill": SportType.TREADMILL_WALK, + "treadmill_walk": SportType.TREADMILL_WALK, + "treadmill": SportType.TREADMILL_WALK, + "running": SportType.RUNNING, + "run": SportType.RUNNING, + "walking": SportType.WALKING, + "walk": SportType.WALKING, + "biking": SportType.CYCLING, + "biking_stationary": SportType.CYCLING, + "cycling": SportType.CYCLING, + "swimming_pool": SportType.SWIMMING, + "swimming_open_water": SportType.SWIMMING, + "swimming": SportType.SWIMMING, + "strength_training": SportType.STRENGTH, + "weightlifting": SportType.STRENGTH, + "strength": SportType.STRENGTH, + "high_intensity_interval_training": SportType.HIIT, + "hiit": SportType.HIIT, + "yoga": SportType.YOGA, + "hiking": SportType.HIKING, +} + +MEAL_BY_CODE = { + 1: MealType.BREAKFAST, + 2: MealType.LUNCH, + 3: MealType.DINNER, + 4: MealType.SNACK, +} +MEAL_BY_NAME = { + "breakfast": MealType.BREAKFAST, + "petit_dejeuner": MealType.BREAKFAST, + "lunch": MealType.LUNCH, + "dejeuner": MealType.LUNCH, + "dinner": MealType.DINNER, + "diner": MealType.DINNER, + "snack": MealType.SNACK, + "collation": MealType.SNACK, +} + + +# --- Adapter helpers ---------------------------------------------------------- + + +def normalize_source(source: str | None) -> str: + key = (source or "").strip().lower() + return SOURCE_ALIASES.get(key, key or "api")[:50] + + +def flatten(data: dict[str, Any]) -> dict[str, Any]: + """Merge the bridge's nested `value`/`metadata` objects into a flat view.""" + flat: dict[str, Any] = {} + nested = data.get("value") + if isinstance(nested, dict): + flat.update(nested) + metadata = data.get("metadata") + if isinstance(metadata, dict): + for key, value in metadata.items(): + flat[f"metadata_{key}"] = value + for key, value in data.items(): + if key not in {"value", "metadata"} or not isinstance(value, dict): + flat[key] = value + return flat + + +def first(flat: dict[str, Any], *keys: str) -> Any: + for key in keys: + value = flat.get(key) + if value is not None and value != "": + return value + return None + + +def as_decimal(value: Any) -> Decimal | None: + if value is None or value == "": + return None + try: + return Decimal(str(float(value))) + except (TypeError, ValueError, ArithmeticError): + return None + + +def as_int(value: Any) -> int | None: + number = as_decimal(value) + return None if number is None else int(number) + + +def as_datetime(value: Any) -> dt.datetime | None: + if value is None or value == "": + return None + if isinstance(value, dt.datetime): + parsed = value + else: + text = str(value).strip().replace("Z", "+00:00") + try: + parsed = dt.datetime.fromisoformat(text) + except ValueError: + try: # epoch millis/seconds sent by some bridges + number = float(text) + except ValueError: + return None + if number > 1e11: + number /= 1000 + parsed = dt.datetime.fromtimestamp(number, tz=dt.UTC) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=DEFAULT_TZ) + return parsed.astimezone(dt.UTC) + + +def resolve_day(flat: dict[str, Any], tz: ZoneInfo) -> dt.date | None: + raw_day = first(flat, "day", "date", "local_date") + if raw_day is not None: + try: + return dt.date.fromisoformat(str(raw_day)[:10]) + except ValueError: + return None + moment = as_datetime( + first(flat, "start_time", "started_at", "time", "measured_at", "end_time") + ) + return None if moment is None else moment.astimezone(tz).date() + + +def external_id_of(record: IngestRecord, flat: dict[str, Any]) -> str | None: + return record.external_id or first( + flat, "metadata_id", "external_id", "id", "uuid", "client_record_id" + ) + + +def content_digest(record_type: str, payload: dict[str, Any]) -> str: + """Fallback dedupe key when the source carries no id (architecture §5.1).""" + body = json.dumps(payload, sort_keys=True, default=str, ensure_ascii=False) + return hashlib.sha256(f"{record_type}|{body}".encode()).hexdigest() + + +def meal_of(flat: dict[str, Any], moment: dt.datetime, tz: ZoneInfo) -> MealType: + raw = first(flat, "meal_type", "meal", "meal_name") + if isinstance(raw, int) or (isinstance(raw, str) and raw.isdigit()): + mapped = MEAL_BY_CODE.get(int(raw)) + if mapped is not None: + return mapped + if isinstance(raw, str): + mapped = MEAL_BY_NAME.get(raw.strip().lower()) + if mapped is not None: + return mapped + hour = moment.astimezone(tz).hour + if hour < 11: + return MealType.BREAKFAST + if hour < 15: + return MealType.LUNCH + if hour < 18: + return MealType.SNACK + return MealType.DINNER + + +def sport_of(flat: dict[str, Any]) -> tuple[SportType, str | None]: + raw = first( + flat, "exercise_type", "sport_type", "activity_type", "exercise", "type" + ) + key = str(raw or "").strip().lower().replace("-", "_").replace(" ", "_") + mapped = SPORT_ALIASES.get(key) + if mapped is not None: + return mapped, None + return SportType.OTHER, (str(raw)[:100] if raw else None) + + +# --- Handler ------------------------------------------------------------------ + + +@register_ingest_handler +class HealthIngestHandler(BaseIngestHandler): + domain: ClassVar[str] = "health" + record_types: ClassVar[tuple[str, ...]] = ( + "weight", + "steps", + "distance", + "active_calories", + "total_calories", + "exercise_session", + "nutrition", + "hydration", + ) + + def apply(self, db: Session, user_id: int, record: IngestRecord) -> UpsertOutcome: + tz = self._user_tz(db, user_id) + source = normalize_source(record.source) + flat = flatten(record.data or {}) + if record.type in ACTIVITY_TYPES: + return self._apply_activity(db, user_id, source, record, flat, tz) + if record.type == "weight": + return self._apply_weight(db, user_id, source, record, flat) + if record.type == "exercise_session": + return self._apply_workout(db, user_id, source, record, flat) + if record.type == "nutrition": + return self._apply_nutrition(db, user_id, source, record, flat, tz) + if record.type == "hydration": + return self._apply_hydration(db, user_id, source, record, flat) + raise RowError("Type d'enregistrement non pris en charge.") + + # -- shared plumbing -- + + @staticmethod + def _user_tz(db: Session, user_id: int) -> ZoneInfo: + from app.modules.health.models import HealthProfile + + profile = db.scalar( + select(HealthProfile).where(HealthProfile.user_id == user_id) + ) + try: + return resolve_tz(profile.timezone if profile else None) + except Exception: # noqa: BLE001 — a broken profile tz must not fail ingest + return DEFAULT_TZ + + @staticmethod + def _dedupe_keys( + record: IngestRecord, flat: dict[str, Any], kind: str, payload: dict[str, Any] + ) -> tuple[str | None, str | None]: + """(external_id, content_hash) per architecture §5.1: the content hash + is only the FALLBACK when the source carries no id — never both, so two + sources describing the same event stay distinct rows (the cross-source + overlap rule handles workouts).""" + external_id = external_id_of(record, flat) + if external_id is not None: + return str(external_id)[:255], None + return None, content_digest(kind, payload) + + @staticmethod + def _existing( + db: Session, + model: type, + user_id: int, + source: str, + external_id: str | None, + digest: str | None, + ): + if external_id is not None: + return db.scalar( + select(model).where( + model.user_id == user_id, + model.source == source, + model.external_id == external_id, + ) + ) + return db.scalar( + select(model).where(model.user_id == user_id, model.content_hash == digest) + ) + + # -- record types -- + + def _apply_activity( + self, + db: Session, + user_id: int, + source: str, + record: IngestRecord, + flat: dict[str, Any], + tz: ZoneInfo, + ) -> UpsertOutcome: + day = resolve_day(flat, tz) + if day is None: + raise RowError("Jour introuvable dans l'enregistrement d'activité.") + distance = as_decimal(first(flat, "distance_m", "distance", "meters")) + if distance is None: + km = as_decimal(first(flat, "distance_km", "kilometers")) + distance = km * 1000 if km is not None else None + values: dict[str, Any] = { + "steps": as_int(first(flat, "steps", "count", "step_count")), + "distance_m": int(distance) if distance is not None else None, + "active_kcal": as_decimal( + first(flat, "active_kcal", "active_calories", "calories_kcal") + ), + "total_kcal": as_decimal( + first(flat, "total_kcal", "total_calories", "total_energy_kcal") + ), + "active_minutes": as_int(first(flat, "active_minutes", "move_minutes")), + "floors": as_int(first(flat, "floors", "floors_climbed")), + } + primary = ACTIVITY_TYPES[record.type] + if values[primary] is None: + fallback = first(flat, "value", "amount", "energy_kcal", "energy", "kcal") + if primary == "steps": + values[primary] = as_int(fallback) + elif primary == "distance_m": + values[primary] = ( + int(as_decimal(fallback)) + if as_decimal(fallback) is not None + else None + ) + else: + values[primary] = as_decimal(fallback) + if values[primary] is None: + raise RowError("Valeur manquante pour cet enregistrement d'activité.") + + row = db.scalar( + select(DailyActivity).where( + DailyActivity.user_id == user_id, + DailyActivity.date == day, + DailyActivity.source == source, + ) + ) + raw = dict(record.data or {}) + if row is None: + db.add( + DailyActivity( + user_id=user_id, + date=day, + source=source, + external_id=external_id_of(record, flat), + raw=raw, + **values, + ) + ) + db.flush() + return UpsertOutcome.INSERTED + changed = False + for field, value in values.items(): + if value is None: + continue + if getattr(row, field) != value: + setattr(row, field, value) + changed = True + if not changed: + return UpsertOutcome.DUPLICATE + row.raw = raw + db.flush() + return UpsertOutcome.UPDATED + + def _apply_weight( + self, + db: Session, + user_id: int, + source: str, + record: IngestRecord, + flat: dict[str, Any], + ) -> UpsertOutcome: + moment = as_datetime( + first(flat, "measured_at", "time", "start_time", "end_time", "date") + ) + weight = as_decimal(first(flat, "weight_kg", "weight", "kilograms", "value")) + if moment is None or weight is None: + raise RowError("Pesée incomplète : horodatage et poids sont requis.") + if not 20 < float(weight) < 400: + raise RowError("Poids hors bornes (20-400 kg).") + external_id, digest = self._dedupe_keys( + record, flat, "weight", {"t": moment.isoformat(), "w": str(weight)} + ) + if self._existing(db, WeightEntry, user_id, source, external_id, digest): + return UpsertOutcome.DUPLICATE + slot_taken = db.scalar( + select(WeightEntry).where( + WeightEntry.user_id == user_id, + WeightEntry.source == source, + WeightEntry.measured_at == moment, + ) + ) + if slot_taken is not None: + return UpsertOutcome.DUPLICATE + db.add( + WeightEntry( + user_id=user_id, + source=source, + external_id=external_id, + content_hash=digest, + measured_at=moment, + weight_kg=weight, + body_fat_pct=as_decimal(first(flat, "body_fat_pct", "body_fat")), + raw=dict(record.data or {}), + ) + ) + db.flush() + return UpsertOutcome.INSERTED + + def _apply_workout( + self, + db: Session, + user_id: int, + source: str, + record: IngestRecord, + flat: dict[str, Any], + ) -> UpsertOutcome: + started = as_datetime(first(flat, "start_time", "started_at", "begin")) + ended = as_datetime(first(flat, "end_time", "ended_at", "finish")) + if started is None: + raise RowError("Séance sans horodatage de début.") + if ended is None: + duration = as_int(first(flat, "duration_s", "duration_seconds")) + ended = started + dt.timedelta(seconds=duration) if duration else None + if ended is None or ended <= started: + raise RowError("Séance sans durée exploitable.") + external_id, digest = self._dedupe_keys( + record, + flat, + "exercise_session", + {"s": started.isoformat(), "e": ended.isoformat()}, + ) + if self._existing(db, Workout, user_id, source, external_id, digest): + return UpsertOutcome.DUPLICATE + sport, label = sport_of(flat) + distance = as_decimal(first(flat, "distance_m", "distance", "meters")) + workout = Workout( + user_id=user_id, + source=source, + external_id=external_id, + content_hash=digest, + started_at=started, + ended_at=ended, + sport_type=sport, + sport_label=label, + kcal=as_decimal( + first(flat, "energy_kcal", "calories_kcal", "kcal", "calories") + ), + distance_m=int(distance) if distance is not None else None, + steps=as_int(first(flat, "steps", "count")), + avg_hr=as_int( + first(flat, "avg_hr", "average_heart_rate", "heart_rate_avg") + ), + max_hr=as_int(first(flat, "max_hr", "max_heart_rate")), + raw=dict(record.data or {}), + ) + db.add(workout) + db.flush() + self._flag_overlaps(db, workout) + return UpsertOutcome.INSERTED + + @staticmethod + def _flag_overlaps(db: Session, workout: Workout) -> None: + from app.modules.health.service import flag_overlapping_duplicates + + flag_overlapping_duplicates(db, workout) + + def _apply_nutrition( + self, + db: Session, + user_id: int, + source: str, + record: IngestRecord, + flat: dict[str, Any], + tz: ZoneInfo, + ) -> UpsertOutcome: + moment = as_datetime(first(flat, "eaten_at", "start_time", "time", "date")) + kcal = as_decimal(first(flat, "energy_kcal", "energy", "calories", "kcal")) + if moment is None or kcal is None: + raise RowError("Repas incomplet : horodatage et calories sont requis.") + name = str(first(flat, "name", "food_name", "label") or "Repas")[:200] + sodium_mg = as_decimal(first(flat, "sodium_mg")) + if sodium_mg is None: + sodium_g = as_decimal(first(flat, "sodium_g", "sodium")) + sodium_mg = sodium_g * 1000 if sodium_g is not None else None + external_id, digest = self._dedupe_keys( + record, + flat, + "nutrition", + {"t": moment.isoformat(), "n": name, "k": str(kcal)}, + ) + if self._existing(db, FoodEntry, user_id, source, external_id, digest): + return UpsertOutcome.DUPLICATE + db.add( + FoodEntry( + user_id=user_id, + source=source, + external_id=external_id, + content_hash=digest, + eaten_at=moment, + meal=meal_of(flat, moment, tz), + name=name, + brand=(str(first(flat, "brand") or "")[:100] or None), + quantity=as_decimal(first(flat, "quantity", "serving_quantity")) + or Decimal(1), + unit=str(first(flat, "unit") or "portion")[:20], + kcal=kcal, + protein_g=as_decimal(first(flat, "protein_g", "protein")), + carbs_g=as_decimal( + first(flat, "carbs_g", "total_carbohydrate_g", "total_carbohydrate") + ), + fat_g=as_decimal(first(flat, "fat_g", "total_fat_g", "total_fat")), + fiber_g=as_decimal( + first(flat, "fiber_g", "dietary_fiber_g", "dietary_fiber") + ), + sugar_g=as_decimal(first(flat, "sugar_g", "sugar")), + sat_fat_g=as_decimal( + first(flat, "sat_fat_g", "saturated_fat_g", "saturated_fat") + ), + sodium_mg=sodium_mg, + raw=dict(record.data or {}), + ) + ) + db.flush() + return UpsertOutcome.INSERTED + + def _apply_hydration( + self, + db: Session, + user_id: int, + source: str, + record: IngestRecord, + flat: dict[str, Any], + ) -> UpsertOutcome: + moment = as_datetime(first(flat, "drunk_at", "start_time", "time", "date")) + volume = as_decimal(first(flat, "volume_ml", "volume", "milliliters")) + if volume is None: + liters = as_decimal(first(flat, "volume_liters", "liters", "value")) + volume = liters * 1000 if liters is not None else None + if moment is None or volume is None: + raise RowError("Hydratation incomplète : horodatage et volume requis.") + volume_ml = int(volume) + if not 0 < volume_ml <= 5000: + raise RowError("Volume hors bornes (1-5000 ml).") + external_id, digest = self._dedupe_keys( + record, flat, "hydration", {"t": moment.isoformat(), "v": volume_ml} + ) + if self._existing(db, WaterEntry, user_id, source, external_id, digest): + return UpsertOutcome.DUPLICATE + db.add( + WaterEntry( + user_id=user_id, + source=source, + external_id=external_id, + content_hash=digest, + drunk_at=moment, + volume_ml=volume_ml, + ) + ) + db.flush() + return UpsertOutcome.INSERTED diff --git a/apps/api/app/modules/health/models.py b/apps/api/app/modules/health/models.py new file mode 100644 index 0000000..b3679b1 --- /dev/null +++ b/apps/api/app/modules/health/models.py @@ -0,0 +1,499 @@ +"""Health module models (santé + nutrition + planning). + +Tables follow docs/design/datamodel-health-vape.md (§3-§4), the planning +addendum (docs/design/addendum-planning.md) and CONVENTIONS C2/C8: +- portable column types (suite runs on SQLite): non-native enums, JSONB_V; +- SourceMixin + (user_id, source, external_id) / (user_id, content_hash) + unique constraints on every connector-fed table; +- FK to import_runs.id with ondelete="CASCADE" (central import rollback). +""" + +import datetime as dt +import enum +from decimal import Decimal +from typing import Any + +from sqlalchemy import ( + Boolean, + CheckConstraint, + Date, + DateTime, + ForeignKey, + Index, + Integer, + Numeric, + String, + UniqueConstraint, + text, +) +from sqlalchemy import ( + Enum as SAEnum, +) +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import TypeDecorator + +from app.core.database import Base +from app.core.mixins import JSONB_V, SourceMixin, TimestampMixin + + +class UTCDateTime(TypeDecorator): + """`timestamptz` that stays UTC-aware on SQLite too (CONVENTIONS C8). + + SQLite has no timezone-aware storage: SQLAlchemy drops the offset on write + and returns naive datetimes on read, which would silently shift any input + sent with a non-UTC offset and break `astimezone()` on read. This decorator + normalizes to UTC on the way in and re-attaches UTC on the way out, so + business code always manipulates aware UTC datetimes on both backends. + """ + + impl = DateTime(timezone=True) + cache_ok = True + + def process_bind_param( + self, value: dt.datetime | None, dialect: Any + ) -> dt.datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=dt.UTC) + return value.astimezone(dt.UTC) + + def process_result_value( + self, value: dt.datetime | None, dialect: Any + ) -> dt.datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=dt.UTC) + return value.astimezone(dt.UTC) + + +def _enum(enum_cls: type[enum.Enum], name: str) -> SAEnum: + """Portable enum column: VARCHAR + CHECK, persists the *values* (C8.1). + + `create_constraint=True` is explicit: SQLAlchemy defaults it to False, and + without it the column is a bare VARCHAR with no CHECK — on PostgreSQL too. + """ + return SAEnum( + enum_cls, + name=name, + native_enum=False, + create_constraint=True, + validate_strings=True, + values_callable=lambda e: [m.value for m in e], + ) + + +class Sex(str, enum.Enum): + MALE = "male" + FEMALE = "female" + OTHER = "other" # BMR formula: average of male/female offsets (§5.1) + + +class ActivityLevel(str, enum.Enum): + SEDENTARY = "sedentary" # factor 1.2 + LIGHT = "light" # 1.375 + MODERATE = "moderate" # 1.55 + ACTIVE = "active" # 1.725 + VERY_ACTIVE = "very_active" # 1.9 + + +class MealType(str, enum.Enum): + BREAKFAST = "breakfast" + LUNCH = "lunch" + DINNER = "dinner" + SNACK = "snack" + + +class GoalMode(str, enum.Enum): + WEEKLY_RATE = "weekly_rate" # user sets kg/week -> budget derived + TARGET_DATE = "target_date" # user sets the date -> rate derived + MAINTAIN = "maintain" # budget = TDEE + + +class GoalStatus(str, enum.Enum): + ACTIVE = "active" + COMPLETED = "completed" + ABANDONED = "abandoned" + + +class SportType(str, enum.Enum): + TREADMILL_WALK = "treadmill_walk" + TREADMILL_RUN = "treadmill_run" + WALKING = "walking" + RUNNING = "running" + CYCLING = "cycling" + SWIMMING = "swimming" + STRENGTH = "strength" + HIIT = "hiit" + YOGA = "yoga" + HIKING = "hiking" + OTHER = "other" + + +class ScheduleKind(str, enum.Enum): + WEIGH_IN = "weigh_in" + WORKOUT = "workout" + FOOD_LOG = "food_log" + + +class HealthProfile(TimestampMixin, Base): + """One row per user (1-1): physiological parameters for BMR/TDEE.""" + + __tablename__ = "health_profiles" + __table_args__ = ( + UniqueConstraint("user_id", name="uq_health_profiles_user"), + CheckConstraint( + "height_cm > 0 AND height_cm < 300", name="ck_health_profiles_height" + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + height_cm: Mapped[Decimal] = mapped_column(Numeric(4, 1)) + sex: Mapped[Sex] = mapped_column(_enum(Sex, "sex")) + birthdate: Mapped[dt.date] = mapped_column(Date) + activity_level: Mapped[ActivityLevel] = mapped_column( + _enum(ActivityLevel, "activity_level"), default=ActivityLevel.SEDENTARY + ) + timezone: Mapped[str] = mapped_column(String(64), default="Europe/Paris") + water_goal_ml: Mapped[int | None] = mapped_column(Integer, default=2000) + calorie_floor_kcal: Mapped[int | None] = mapped_column(Integer, default=None) + + +class WeightEntry(TimestampMixin, SourceMixin, Base): + """Weigh-ins; trend uses the first weigh-in of each local day (§3.2).""" + + __tablename__ = "weight_entries" + __table_args__ = ( + UniqueConstraint( + "user_id", "source", "external_id", name="uq_weight_entries_external" + ), + UniqueConstraint("user_id", "content_hash", name="uq_weight_entries_hash"), + UniqueConstraint( + "user_id", "measured_at", "source", name="uq_weight_entries_user_ts_source" + ), + CheckConstraint( + "weight_kg > 20 AND weight_kg < 400", name="ck_weight_entries_range" + ), + Index("ix_weight_entries_user_measured", "user_id", "measured_at"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + import_run_id: Mapped[int | None] = mapped_column( + ForeignKey("import_runs.id", ondelete="CASCADE"), default=None + ) + measured_at: Mapped[dt.datetime] = mapped_column(UTCDateTime()) + weight_kg: Mapped[Decimal] = mapped_column(Numeric(5, 2)) + body_fat_pct: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + muscle_mass_kg: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), default=None) + water_pct: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + note: Mapped[str | None] = mapped_column(String(255), default=None) + raw: Mapped[dict[str, Any] | None] = mapped_column(JSONB_V, default=None) + + +class BodyMeasurement(TimestampMixin, SourceMixin, Base): + """One row = one measuring session; every site is optional (§3.3).""" + + __tablename__ = "body_measurements" + __table_args__ = ( + UniqueConstraint( + "user_id", "source", "external_id", name="uq_body_measurements_external" + ), + UniqueConstraint("user_id", "content_hash", name="uq_body_measurements_hash"), + Index("ix_body_measurements_user_measured", "user_id", "measured_at"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + import_run_id: Mapped[int | None] = mapped_column( + ForeignKey("import_runs.id", ondelete="CASCADE"), default=None + ) + measured_at: Mapped[dt.datetime] = mapped_column(UTCDateTime()) + neck_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + chest_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + waist_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + hips_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + biceps_left_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + biceps_right_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + thigh_left_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + thigh_right_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + calf_left_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + calf_right_cm: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + note: Mapped[str | None] = mapped_column(String(255), default=None) + + +class DailyActivity(TimestampMixin, SourceMixin, Base): + """Daily activity aggregates, one row per (user, local day, source) (§3.4). + + Cross-source merge happens at read time, field by field, by source + priority (calculations.ACTIVITY_SOURCE_PRIORITY) — rows from one source + never overwrite another source's row. + """ + + __tablename__ = "activity_daily" + __table_args__ = ( + UniqueConstraint( + "user_id", "date", "source", name="uq_activity_daily_user_date_source" + ), + CheckConstraint("steps IS NULL OR steps >= 0", name="ck_activity_daily_steps"), + Index("ix_activity_daily_user_date", "user_id", "date"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + import_run_id: Mapped[int | None] = mapped_column( + ForeignKey("import_runs.id", ondelete="CASCADE"), default=None + ) + date: Mapped[dt.date] = mapped_column(Date) # local civil day (Europe/Paris) + steps: Mapped[int | None] = mapped_column(Integer, default=None) + active_kcal: Mapped[Decimal | None] = mapped_column(Numeric(7, 1), default=None) + total_kcal: Mapped[Decimal | None] = mapped_column(Numeric(7, 1), default=None) + distance_m: Mapped[int | None] = mapped_column(Integer, default=None) + active_minutes: Mapped[int | None] = mapped_column(Integer, default=None) + floors: Mapped[int | None] = mapped_column(Integer, default=None) + raw: Mapped[dict[str, Any] | None] = mapped_column(JSONB_V, default=None) + + +class Workout(TimestampMixin, SourceMixin, Base): + """Sport sessions; cross-source 80 %-overlap dedup flags is_hidden (§3.6).""" + + __tablename__ = "workouts" + __table_args__ = ( + UniqueConstraint( + "user_id", "source", "external_id", name="uq_workouts_external" + ), + UniqueConstraint("user_id", "content_hash", name="uq_workouts_hash"), + CheckConstraint("ended_at > started_at", name="ck_workouts_duration"), + Index("ix_workouts_user_started", "user_id", "started_at"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + import_run_id: Mapped[int | None] = mapped_column( + ForeignKey("import_runs.id", ondelete="CASCADE"), default=None + ) + started_at: Mapped[dt.datetime] = mapped_column(UTCDateTime()) + ended_at: Mapped[dt.datetime] = mapped_column(UTCDateTime()) + sport_type: Mapped[SportType] = mapped_column(_enum(SportType, "sport_type")) + sport_label: Mapped[str | None] = mapped_column(String(100), default=None) + kcal: Mapped[Decimal | None] = mapped_column(Numeric(7, 1), default=None) + distance_m: Mapped[int | None] = mapped_column(Integer, default=None) + steps: Mapped[int | None] = mapped_column(Integer, default=None) + avg_hr: Mapped[int | None] = mapped_column(Integer, default=None) + max_hr: Mapped[int | None] = mapped_column(Integer, default=None) + avg_speed_kmh: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + elevation_m: Mapped[int | None] = mapped_column(Integer, default=None) + is_hidden: Mapped[bool] = mapped_column(Boolean, default=False) + note: Mapped[str | None] = mapped_column(String(255), default=None) + raw: Mapped[dict[str, Any] | None] = mapped_column(JSONB_V, default=None) + + @property + def duration_s(self) -> int: + """Session length in seconds (derived, never stored).""" + return int((self.ended_at - self.started_at).total_seconds()) + + +class Goal(TimestampMixin, Base): + """Weight goals, historised; a single active goal per user (§3.7).""" + + __tablename__ = "goals" + __table_args__ = ( + Index( + "uq_goals_user_active", + "user_id", + unique=True, + sqlite_where=text("status = 'active'"), + postgresql_where=text("status = 'active'"), + ), + CheckConstraint( + "weekly_rate_kg IS NULL OR " + "(weekly_rate_kg > -1.01 AND weekly_rate_kg <= 1.5)", + name="ck_goals_rate_sane", + ), + CheckConstraint( + "mode <> 'target_date' OR target_date IS NOT NULL", + name="ck_goals_target_date", + ), + CheckConstraint( + "mode <> 'weekly_rate' OR weekly_rate_kg IS NOT NULL", + name="ck_goals_weekly_rate", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + mode: Mapped[GoalMode] = mapped_column(_enum(GoalMode, "goal_mode")) + start_date: Mapped[dt.date] = mapped_column(Date) + start_weight_kg: Mapped[Decimal] = mapped_column(Numeric(5, 2)) + target_weight_kg: Mapped[Decimal] = mapped_column(Numeric(5, 2)) + target_date: Mapped[dt.date | None] = mapped_column(Date, default=None) + weekly_rate_kg: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), default=None) + status: Mapped[GoalStatus] = mapped_column( + _enum(GoalStatus, "goal_status"), default=GoalStatus.ACTIVE + ) + note: Mapped[str | None] = mapped_column(String(255), default=None) + + +class FoodEntry(TimestampMixin, SourceMixin, Base): + """Food journal; all nutritional values are ABSOLUTE for the eaten + quantity, never per-100 g (§4.1).""" + + __tablename__ = "food_entries" + __table_args__ = ( + UniqueConstraint( + "user_id", "source", "external_id", name="uq_food_entries_external" + ), + UniqueConstraint("user_id", "content_hash", name="uq_food_entries_hash"), + CheckConstraint("kcal >= 0 AND quantity > 0", name="ck_food_entries_positive"), + Index("ix_food_entries_user_eaten", "user_id", "eaten_at"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + import_run_id: Mapped[int | None] = mapped_column( + ForeignKey("import_runs.id", ondelete="CASCADE"), default=None + ) + eaten_at: Mapped[dt.datetime] = mapped_column(UTCDateTime()) + meal: Mapped[MealType] = mapped_column(_enum(MealType, "meal_type")) + name: Mapped[str] = mapped_column(String(200)) + brand: Mapped[str | None] = mapped_column(String(100), default=None) + quantity: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=Decimal(1)) + unit: Mapped[str] = mapped_column(String(20), default="g") + kcal: Mapped[Decimal] = mapped_column(Numeric(7, 1)) + protein_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + carbs_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + fat_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + fiber_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + sugar_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + sat_fat_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + sodium_mg: Mapped[Decimal | None] = mapped_column(Numeric(8, 1), default=None) + raw: Mapped[dict[str, Any] | None] = mapped_column(JSONB_V, default=None) + + +class FoodFavorite(TimestampMixin, Base): + """Favorite foods for quick entry; macros stored for default_quantity + (same absolute convention as food_entries, §4.2).""" + + __tablename__ = "food_favorites" + __table_args__ = ( + UniqueConstraint( + "user_id", "name", "brand", name="uq_food_favorites_user_name_brand" + ), + Index("ix_food_favorites_user_usage", "user_id", "use_count"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + name: Mapped[str] = mapped_column(String(200)) + brand: Mapped[str | None] = mapped_column(String(100), default=None) + default_quantity: Mapped[Decimal] = mapped_column(Numeric(8, 2)) + unit: Mapped[str] = mapped_column(String(20), default="g") + kcal: Mapped[Decimal] = mapped_column(Numeric(7, 1)) + protein_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + carbs_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + fat_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + fiber_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + default_meal: Mapped[MealType | None] = mapped_column( + _enum(MealType, "meal_type"), default=None + ) + use_count: Mapped[int] = mapped_column(Integer, default=0) + last_used_at: Mapped[dt.datetime | None] = mapped_column( + UTCDateTime(), default=None + ) + + +class WaterEntry(TimestampMixin, SourceMixin, Base): + """Hydration log (§4.3).""" + + __tablename__ = "water_entries" + __table_args__ = ( + UniqueConstraint( + "user_id", "source", "external_id", name="uq_water_entries_external" + ), + UniqueConstraint("user_id", "content_hash", name="uq_water_entries_hash"), + CheckConstraint( + "volume_ml > 0 AND volume_ml <= 5000", name="ck_water_entries_volume" + ), + Index("ix_water_entries_user_drunk", "user_id", "drunk_at"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + import_run_id: Mapped[int | None] = mapped_column( + ForeignKey("import_runs.id", ondelete="CASCADE"), default=None + ) + drunk_at: Mapped[dt.datetime] = mapped_column(UTCDateTime()) + volume_ml: Mapped[int] = mapped_column(Integer) + + +class FoodItem(TimestampMixin, Base): + """Local food referential/cache (Open Food Facts / CIQUAL results). + + Deliberately NOT user-scoped: it caches public product data shared by all + users (nutrition-sources.md §7.2), keyed by (source, source_id). All + values are per 100 g. + """ + + __tablename__ = "food_items" + __table_args__ = ( + UniqueConstraint("source", "source_id", name="uq_food_items_source_id"), + Index("ix_food_items_name", "name"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + source: Mapped[str] = mapped_column(String(32)) # 'off' | 'ciqual' | 'custom' + source_id: Mapped[str | None] = mapped_column(String(64), default=None) + name: Mapped[str] = mapped_column(String(200)) + brand: Mapped[str | None] = mapped_column(String(100), default=None) + energy_kcal_100g: Mapped[Decimal | None] = mapped_column( + Numeric(7, 1), default=None + ) + protein_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + carbs_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + sugar_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + fat_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + sat_fat_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + fiber_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + salt_g_100g: Mapped[Decimal | None] = mapped_column(Numeric(6, 2), default=None) + serving_size_g: Mapped[Decimal | None] = mapped_column(Numeric(6, 1), default=None) + raw: Mapped[dict[str, Any] | None] = mapped_column(JSONB_V, default=None) + + +class TrackingSchedule(TimestampMixin, Base): + """Weekly habit planning (addendum-planning.md): weigh-in / workout / + food-log days. done/not-done is DERIVED from the data tables — no + check-in table in v1.""" + + __tablename__ = "tracking_schedules" + __table_args__ = ( + UniqueConstraint("user_id", "kind", name="uq_tracking_schedules_user_kind"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + kind: Mapped[ScheduleKind] = mapped_column(_enum(ScheduleKind, "schedule_kind")) + # JSON list of ints, 0 = Monday … 6 = Sunday. + weekdays: Mapped[list[int]] = mapped_column(JSONB_V, default=list) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/apps/api/app/modules/health/router.py b/apps/api/app/modules/health/router.py new file mode 100644 index 0000000..bb795d9 --- /dev/null +++ b/apps/api/app/modules/health/router.py @@ -0,0 +1,670 @@ +"""HTTP surface of the health module — prefix /api/health (CONVENTIONS C2.1). + +Routers stay thin: dependencies + service/stats call + response schema. +Static sub-paths are declared before their `/{id}` siblings on purpose. +""" + +import datetime as dt +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.dependencies import get_current_user +from app.core.pagination import Page, PageParams, paginate +from app.modules.auth.models import User +from app.modules.health import calculations as calc +from app.modules.health import foods as foods_service +from app.modules.health import service, stats +from app.modules.health.models import ( + GoalStatus, + MealType, + ScheduleKind, + SportType, +) +from app.modules.health.schemas import ( + ActiveGoalRead, + AdherenceResponse, + BodyMeasurementCreate, + BodyMeasurementRead, + BodyMeasurementUpdate, + DailyActivityRead, + DailyActivityUpsert, + DashboardResponse, + FoodEntryCreate, + FoodEntryRead, + FoodEntryUpdate, + FoodFavoriteCreate, + FoodFavoriteRead, + FoodFavoriteUpdate, + FoodSearchItem, + FoodSearchResponse, + GoalCreate, + GoalRead, + GoalUpdate, + HealthProfileRead, + HealthProfileUpdate, + MergedActivityRead, + NutritionDayDetail, + NutritionDayRead, + RecentFoodRead, + ScheduleRead, + ScheduleUpdate, + StatsResponse, + TodayResponse, + WaterEntryCreate, + WaterEntryRead, + WeightEntryCreate, + WeightEntryRead, + WeightEntryUpdate, + WorkoutCreate, + WorkoutRead, + WorkoutUpdate, +) + +router = APIRouter(prefix="/health", tags=["health"]) + +DbDep = Annotated[Session, Depends(get_db)] +UserDep = Annotated[User, Depends(get_current_user)] +PageDep = Annotated[PageParams, Depends()] + +FromQ = Annotated[dt.date | None, Query(alias="from")] +ToQ = Annotated[dt.date | None, Query()] +TzQ = Annotated[str | None, Query()] +SortQ = Annotated[str | None, Query()] + + +def _page(items: list, total: int, params: PageParams, schema) -> Page: + return Page( + items=[schema.model_validate(item) for item in items], + total=total, + page=params.page, + page_size=params.page_size, + ) + + +# --- Profile ------------------------------------------------------------------ + + +def _profile_read(db: Session, user_id: int) -> HealthProfileRead: + profile = service.require_profile(db, user_id) + read = HealthProfileRead.model_validate(profile) + tz = service.profile_tz(db, user_id, None) + today = service.today_local(tz) + read.age = calc.age_on(today, profile.birthdate) + trend = calc.weight_trend(service.daily_weights(db, user_id, tz)) + weight = trend[-1][1] if trend else None + read.current_weight_kg = None if weight is None else round(weight, 2) + if weight is not None: + read.bmr_kcal = round( + calc.bmr_mifflin( + weight, float(profile.height_cm), read.age, profile.sex.value + ), + 1, + ) + read.tdee_estimated_kcal = round( + read.bmr_kcal * calc.ACTIVITY_FACTORS[profile.activity_level.value], 1 + ) + bmi = calc.bmi(weight, float(profile.height_cm)) + read.bmi = None if bmi is None else round(bmi, 1) + return read + + +@router.get("/profile", response_model=HealthProfileRead) +def get_profile(db: DbDep, user: UserDep) -> HealthProfileRead: + return _profile_read(db, user.id) + + +@router.put("/profile", response_model=HealthProfileRead) +def put_profile( + payload: HealthProfileUpdate, db: DbDep, user: UserDep +) -> HealthProfileRead: + service.upsert_profile(db, user.id, payload) + return _profile_read(db, user.id) + + +# --- Weights ------------------------------------------------------------------ + + +@router.get("/weights/stats", response_model=StatsResponse) +def weights_stats( + db: DbDep, + user: UserDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, +) -> StatsResponse: + zone = service.profile_tz(db, user.id, tz) + start, end = service.resolve_range(from_, to, zone, default_days=90) + return stats.weight_stats(db, user.id, start, end, zone) + + +@router.get("/weights", response_model=Page[WeightEntryRead]) +def list_weights( + db: DbDep, + user: UserDep, + params: PageDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, + sort: SortQ = None, +) -> Page[WeightEntryRead]: + zone = service.profile_tz(db, user.id, tz) + stmt = service.weights_query(user.id, from_, to, zone, sort) + items, total = paginate(db, stmt, params) + return _page(items, total, params, WeightEntryRead) + + +@router.post("/weights", response_model=WeightEntryRead, status_code=201) +def create_weight( + payload: WeightEntryCreate, db: DbDep, user: UserDep +) -> WeightEntryRead: + return WeightEntryRead.model_validate(service.create_weight(db, user.id, payload)) + + +@router.patch("/weights/{entry_id}", response_model=WeightEntryRead) +@router.put("/weights/{entry_id}", response_model=WeightEntryRead) +def update_weight( + entry_id: int, payload: WeightEntryUpdate, db: DbDep, user: UserDep +) -> WeightEntryRead: + return WeightEntryRead.model_validate( + service.update_weight(db, user.id, entry_id, payload) + ) + + +@router.delete("/weights/{entry_id}", status_code=204) +def delete_weight(entry_id: int, db: DbDep, user: UserDep) -> None: + service.delete_weight(db, user.id, entry_id) + + +# --- Body measurements -------------------------------------------------------- + + +@router.get("/measurements/stats", response_model=StatsResponse) +def measurements_stats( + db: DbDep, + user: UserDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, +) -> StatsResponse: + zone = service.profile_tz(db, user.id, tz) + start, end = service.resolve_range(from_, to, zone, default_days=180) + return stats.measurement_stats(db, user.id, start, end, zone) + + +@router.get("/measurements", response_model=Page[BodyMeasurementRead]) +def list_measurements( + db: DbDep, + user: UserDep, + params: PageDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, + sort: SortQ = None, +) -> Page[BodyMeasurementRead]: + zone = service.profile_tz(db, user.id, tz) + stmt = service.measurements_query(user.id, from_, to, zone, sort) + items, total = paginate(db, stmt, params) + return _page(items, total, params, BodyMeasurementRead) + + +@router.post("/measurements", response_model=BodyMeasurementRead, status_code=201) +def create_measurement( + payload: BodyMeasurementCreate, db: DbDep, user: UserDep +) -> BodyMeasurementRead: + return BodyMeasurementRead.model_validate( + service.create_measurement(db, user.id, payload) + ) + + +@router.patch("/measurements/{row_id}", response_model=BodyMeasurementRead) +@router.put("/measurements/{row_id}", response_model=BodyMeasurementRead) +def update_measurement( + row_id: int, payload: BodyMeasurementUpdate, db: DbDep, user: UserDep +) -> BodyMeasurementRead: + return BodyMeasurementRead.model_validate( + service.update_measurement(db, user.id, row_id, payload) + ) + + +@router.delete("/measurements/{row_id}", status_code=204) +def delete_measurement(row_id: int, db: DbDep, user: UserDep) -> None: + service.delete_measurement(db, user.id, row_id) + + +# --- Daily activity ----------------------------------------------------------- + + +@router.get("/activity/stats", response_model=StatsResponse) +def activity_stats( + db: DbDep, + user: UserDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, +) -> StatsResponse: + zone = service.profile_tz(db, user.id, tz) + start, end = service.resolve_range(from_, to, zone) + return stats.activity_stats(db, user.id, start, end, zone) + + +@router.get("/activity", response_model=None) +def list_activity( + db: DbDep, + user: UserDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, + raw: Annotated[bool, Query()] = False, +) -> list[MergedActivityRead] | list[DailyActivityRead]: + zone = service.profile_tz(db, user.id, tz) + start, end = service.resolve_range(from_, to, zone) + if raw: + return [ + DailyActivityRead.model_validate(row) + for row in service.activity_rows(db, user.id, start, end) + ] + merged = service.merged_activity(db, user.id, start, end) + return [ + MergedActivityRead( + date=day, + steps=value.steps, + active_kcal=value.active_kcal, + total_kcal=value.total_kcal, + distance_m=value.distance_m, + active_minutes=value.active_minutes, + floors=value.floors, + field_sources=value.field_sources, + ) + for day, value in sorted(merged.items()) + ] + + +@router.post("/activity", response_model=DailyActivityRead, status_code=201) +def upsert_activity( + payload: DailyActivityUpsert, db: DbDep, user: UserDep +) -> DailyActivityRead: + return DailyActivityRead.model_validate( + service.upsert_manual_activity(db, user.id, payload) + ) + + +@router.delete("/activity/{row_id}", status_code=204) +def delete_activity(row_id: int, db: DbDep, user: UserDep) -> None: + service.delete_activity(db, user.id, row_id) + + +# --- Workouts ----------------------------------------------------------------- + + +@router.get("/workouts/stats", response_model=StatsResponse) +def workouts_stats( + db: DbDep, + user: UserDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, +) -> StatsResponse: + zone = service.profile_tz(db, user.id, tz) + start, end = service.resolve_range(from_, to, zone, default_days=90) + return stats.workout_stats(db, user.id, start, end, zone) + + +@router.get("/workouts", response_model=Page[WorkoutRead]) +def list_workouts( + db: DbDep, + user: UserDep, + params: PageDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, + sport_type: Annotated[SportType | None, Query()] = None, + include_hidden: Annotated[bool, Query()] = False, + sort: SortQ = None, +) -> Page[WorkoutRead]: + zone = service.profile_tz(db, user.id, tz) + stmt = service.workouts_query( + user.id, + from_, + to, + zone, + sport_type.value if sport_type else None, + include_hidden, + sort, + ) + items, total = paginate(db, stmt, params) + return _page(items, total, params, WorkoutRead) + + +@router.post("/workouts", response_model=WorkoutRead, status_code=201) +def create_workout(payload: WorkoutCreate, db: DbDep, user: UserDep) -> WorkoutRead: + return WorkoutRead.model_validate(service.create_workout(db, user.id, payload)) + + +@router.patch("/workouts/{workout_id}", response_model=WorkoutRead) +@router.put("/workouts/{workout_id}", response_model=WorkoutRead) +def update_workout( + workout_id: int, payload: WorkoutUpdate, db: DbDep, user: UserDep +) -> WorkoutRead: + return WorkoutRead.model_validate( + service.update_workout(db, user.id, workout_id, payload) + ) + + +@router.delete("/workouts/{workout_id}", status_code=204) +def delete_workout(workout_id: int, db: DbDep, user: UserDep) -> None: + service.delete_workout(db, user.id, workout_id) + + +# --- Goals -------------------------------------------------------------------- + + +@router.get("/goals/active", response_model=ActiveGoalRead) +def get_active_goal(db: DbDep, user: UserDep, tz: TzQ = None) -> ActiveGoalRead: + zone = service.profile_tz(db, user.id, tz) + return stats.active_goal_view(db, user.id, zone) + + +@router.get("/goals", response_model=Page[GoalRead]) +def list_goals( + db: DbDep, + user: UserDep, + params: PageDep, + status: Annotated[GoalStatus | None, Query()] = None, + sort: SortQ = None, +) -> Page[GoalRead]: + stmt = service.goals_query(user.id, status.value if status else None, sort) + items, total = paginate(db, stmt, params) + return _page(items, total, params, GoalRead) + + +@router.post("/goals", response_model=GoalRead, status_code=201) +def create_goal( + payload: GoalCreate, + db: DbDep, + user: UserDep, + replace_active: Annotated[bool, Query()] = False, +) -> GoalRead: + return GoalRead.model_validate( + service.create_goal(db, user.id, payload, replace_active) + ) + + +@router.post("/goals/{goal_id}/activate", response_model=GoalRead) +def activate_goal(goal_id: int, db: DbDep, user: UserDep) -> GoalRead: + return GoalRead.model_validate(service.activate_goal(db, user.id, goal_id)) + + +@router.patch("/goals/{goal_id}", response_model=GoalRead) +@router.put("/goals/{goal_id}", response_model=GoalRead) +def update_goal( + goal_id: int, payload: GoalUpdate, db: DbDep, user: UserDep +) -> GoalRead: + return GoalRead.model_validate(service.update_goal(db, user.id, goal_id, payload)) + + +@router.delete("/goals/{goal_id}", status_code=204) +def delete_goal(goal_id: int, db: DbDep, user: UserDep) -> None: + service.delete_goal(db, user.id, goal_id) + + +# --- Energy balance & dashboard ----------------------------------------------- + + +@router.get("/energy-balance", response_model=StatsResponse) +def energy_balance( + db: DbDep, + user: UserDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, +) -> StatsResponse: + zone = service.profile_tz(db, user.id, tz) + start, end = service.resolve_range(from_, to, zone) + return stats.energy_balance_stats(db, user.id, start, end, zone) + + +@router.get("/dashboard", response_model=DashboardResponse) +def dashboard(db: DbDep, user: UserDep, tz: TzQ = None) -> DashboardResponse: + zone = service.profile_tz(db, user.id, tz) + return stats.dashboard(db, user.id, zone) + + +# --- Nutrition: journal -------------------------------------------------------- + + +@router.get("/nutrition/stats", response_model=StatsResponse) +def nutrition_stats( + db: DbDep, + user: UserDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, +) -> StatsResponse: + zone = service.profile_tz(db, user.id, tz) + start, end = service.resolve_range(from_, to, zone) + return stats.nutrition_stats(db, user.id, start, end, zone) + + +@router.get("/nutrition/days", response_model=list[NutritionDayRead]) +def nutrition_days( + db: DbDep, + user: UserDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, +) -> list[NutritionDayRead]: + zone = service.profile_tz(db, user.id, tz) + start, end = service.resolve_range(from_, to, zone) + return stats.nutrition_days(db, user.id, start, end, zone) + + +@router.get("/nutrition/days/{day}", response_model=NutritionDayDetail) +def nutrition_day( + day: dt.date, db: DbDep, user: UserDep, tz: TzQ = None +) -> NutritionDayDetail: + zone = service.profile_tz(db, user.id, tz) + return stats.nutrition_day_detail(db, user.id, day, zone) + + +@router.get("/nutrition/recent", response_model=list[RecentFoodRead]) +def recent_foods( + db: DbDep, + user: UserDep, + limit: Annotated[int, Query(ge=1, le=50)] = 20, +) -> list[RecentFoodRead]: + return [ + RecentFoodRead( + name=entry.name, + brand=entry.brand, + unit=entry.unit, + quantity=float(entry.quantity), + kcal=float(entry.kcal), + protein_g=float(entry.protein_g) if entry.protein_g is not None else None, + carbs_g=float(entry.carbs_g) if entry.carbs_g is not None else None, + fat_g=float(entry.fat_g) if entry.fat_g is not None else None, + meal=entry.meal, + last_eaten_at=entry.eaten_at, + ) + for entry in service.recent_foods(db, user.id, limit) + ] + + +@router.get("/nutrition/favorites", response_model=Page[FoodFavoriteRead]) +def list_favorites( + db: DbDep, user: UserDep, params: PageDep, sort: SortQ = None +) -> Page[FoodFavoriteRead]: + stmt = service.favorites_query(user.id, sort) + items, total = paginate(db, stmt, params) + return _page(items, total, params, FoodFavoriteRead) + + +@router.post("/nutrition/favorites", response_model=FoodFavoriteRead, status_code=201) +def create_favorite( + payload: FoodFavoriteCreate, db: DbDep, user: UserDep +) -> FoodFavoriteRead: + return FoodFavoriteRead.model_validate( + service.create_favorite(db, user.id, payload) + ) + + +@router.patch("/nutrition/favorites/{favorite_id}", response_model=FoodFavoriteRead) +@router.put("/nutrition/favorites/{favorite_id}", response_model=FoodFavoriteRead) +def update_favorite( + favorite_id: int, payload: FoodFavoriteUpdate, db: DbDep, user: UserDep +) -> FoodFavoriteRead: + return FoodFavoriteRead.model_validate( + service.update_favorite(db, user.id, favorite_id, payload) + ) + + +@router.delete("/nutrition/favorites/{favorite_id}", status_code=204) +def delete_favorite(favorite_id: int, db: DbDep, user: UserDep) -> None: + service.delete_favorite(db, user.id, favorite_id) + + +@router.get("/nutrition/water/stats", response_model=StatsResponse) +def water_stats( + db: DbDep, + user: UserDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, +) -> StatsResponse: + zone = service.profile_tz(db, user.id, tz) + start, end = service.resolve_range(from_, to, zone) + return stats.water_stats(db, user.id, start, end, zone) + + +@router.get("/nutrition/water", response_model=Page[WaterEntryRead]) +def list_water( + db: DbDep, + user: UserDep, + params: PageDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, + sort: SortQ = None, +) -> Page[WaterEntryRead]: + zone = service.profile_tz(db, user.id, tz) + stmt = service.water_query(user.id, from_, to, zone, sort) + items, total = paginate(db, stmt, params) + return _page(items, total, params, WaterEntryRead) + + +@router.post("/nutrition/water", response_model=WaterEntryRead, status_code=201) +def create_water(payload: WaterEntryCreate, db: DbDep, user: UserDep) -> WaterEntryRead: + return WaterEntryRead.model_validate(service.create_water(db, user.id, payload)) + + +@router.delete("/nutrition/water/{entry_id}", status_code=204) +def delete_water(entry_id: int, db: DbDep, user: UserDep) -> None: + service.delete_water(db, user.id, entry_id) + + +@router.get("/nutrition/entries", response_model=Page[FoodEntryRead]) +def list_food_entries( + db: DbDep, + user: UserDep, + params: PageDep, + day: Annotated[dt.date | None, Query()] = None, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, + meal: Annotated[MealType | None, Query()] = None, + q: Annotated[str | None, Query()] = None, + sort: SortQ = None, +) -> Page[FoodEntryRead]: + zone = service.profile_tz(db, user.id, tz) + stmt = service.food_entries_query( + user.id, zone, day, from_, to, meal.value if meal else None, q, sort + ) + items, total = paginate(db, stmt, params) + return _page(items, total, params, FoodEntryRead) + + +@router.post("/nutrition/entries", response_model=FoodEntryRead, status_code=201) +def create_food_entry( + payload: FoodEntryCreate, db: DbDep, user: UserDep +) -> FoodEntryRead: + return FoodEntryRead.model_validate(service.create_food_entry(db, user.id, payload)) + + +@router.patch("/nutrition/entries/{entry_id}", response_model=FoodEntryRead) +@router.put("/nutrition/entries/{entry_id}", response_model=FoodEntryRead) +def update_food_entry( + entry_id: int, payload: FoodEntryUpdate, db: DbDep, user: UserDep +) -> FoodEntryRead: + return FoodEntryRead.model_validate( + service.update_food_entry(db, user.id, entry_id, payload) + ) + + +@router.delete("/nutrition/entries/{entry_id}", status_code=204) +def delete_food_entry(entry_id: int, db: DbDep, user: UserDep) -> None: + service.delete_food_entry(db, user.id, entry_id) + + +# --- Food referential (Open Food Facts proxy + local cache) ------------------- + + +@router.get("/foods/search", response_model=FoodSearchResponse) +def search_foods( + db: DbDep, + user: UserDep, + q: Annotated[str, Query(min_length=1, max_length=100)], + limit: Annotated[int, Query(ge=1, le=50)] = 20, +) -> FoodSearchResponse: + return foods_service.search_foods(db, q, limit) + + +@router.get("/foods/barcode/{barcode}", response_model=FoodSearchItem) +def food_by_barcode(barcode: str, db: DbDep, user: UserDep) -> FoodSearchItem: + return foods_service.food_by_barcode(db, barcode) + + +# --- Planning (addendum-planning.md) ------------------------------------------ + + +@router.get("/schedules", response_model=list[ScheduleRead]) +def list_schedules(db: DbDep, user: UserDep) -> list[ScheduleRead]: + return [ + ScheduleRead( + kind=row.kind, weekdays=list(row.weekdays or []), enabled=row.enabled + ) + for row in service.list_schedules(db, user.id) + ] + + +@router.put("/schedules/{kind}", response_model=ScheduleRead) +def put_schedule( + kind: ScheduleKind, payload: ScheduleUpdate, db: DbDep, user: UserDep +) -> ScheduleRead: + row = service.upsert_schedule(db, user.id, kind, payload) + return ScheduleRead( + kind=row.kind, weekdays=list(row.weekdays or []), enabled=row.enabled + ) + + +@router.get("/today", response_model=TodayResponse) +def today(db: DbDep, user: UserDep, tz: TzQ = None) -> TodayResponse: + zone = service.profile_tz(db, user.id, tz) + return stats.today_view(db, user.id, zone) + + +@router.get("/stats/adherence", response_model=AdherenceResponse) +def adherence( + db: DbDep, + user: UserDep, + from_: FromQ = None, + to: ToQ = None, + tz: TzQ = None, + kind: Annotated[ScheduleKind | None, Query()] = None, +) -> AdherenceResponse: + zone = service.profile_tz(db, user.id, tz) + start, end = service.resolve_range(from_, to, zone, default_days=90) + return stats.adherence_stats( + db, user.id, start, end, zone, kind.value if kind else None + ) diff --git a/apps/api/app/modules/health/schemas.py b/apps/api/app/modules/health/schemas.py new file mode 100644 index 0000000..f45aad7 --- /dev/null +++ b/apps/api/app/modules/health/schemas.py @@ -0,0 +1,604 @@ +"""Pydantic v2 schemas for the health module (CONVENTIONS C2.5). + +Numeric columns are exposed as `float` on purpose: the API feeds ECharts +directly and JSON numbers are required (Pydantic serialises Decimal as a +string). Precision stays in the database (Numeric columns). +""" + +import datetime as dt +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from app.core.timeutils import UtcDatetime +from app.modules.health.models import ( + ActivityLevel, + GoalMode, + GoalStatus, + MealType, + ScheduleKind, + Sex, + SportType, +) + +ORM = ConfigDict(from_attributes=True) + + +# --- Profile ------------------------------------------------------------------ + + +class HealthProfileUpdate(BaseModel): + """PUT /health/profile — full singleton upsert.""" + + height_cm: float = Field(gt=0, lt=300) + sex: Sex + birthdate: dt.date + activity_level: ActivityLevel = ActivityLevel.SEDENTARY + timezone: str = Field(default="Europe/Paris", max_length=64) + water_goal_ml: int | None = Field(default=2000, ge=0, le=20000) + calorie_floor_kcal: int | None = Field(default=None, ge=800, le=5000) + + +class HealthProfileRead(BaseModel): + model_config = ORM + + id: int + height_cm: float + sex: Sex + birthdate: dt.date + activity_level: ActivityLevel + timezone: str + water_goal_ml: int | None + calorie_floor_kcal: int | None + # Derived (not columns) + age: int | None = None + bmr_kcal: float | None = None + tdee_estimated_kcal: float | None = None + current_weight_kg: float | None = None + bmi: float | None = None + + +# --- Weights ------------------------------------------------------------------ + + +class WeightEntryCreate(BaseModel): + measured_at: UtcDatetime + weight_kg: float = Field(gt=20, lt=400) + body_fat_pct: float | None = Field(default=None, ge=0, le=100) + muscle_mass_kg: float | None = Field(default=None, ge=0, le=200) + water_pct: float | None = Field(default=None, ge=0, le=100) + note: str | None = Field(default=None, max_length=255) + + +class WeightEntryUpdate(BaseModel): + measured_at: UtcDatetime | None = None + weight_kg: float | None = Field(default=None, gt=20, lt=400) + body_fat_pct: float | None = Field(default=None, ge=0, le=100) + muscle_mass_kg: float | None = Field(default=None, ge=0, le=200) + water_pct: float | None = Field(default=None, ge=0, le=100) + note: str | None = Field(default=None, max_length=255) + + +class WeightEntryRead(BaseModel): + model_config = ORM + + id: int + measured_at: dt.datetime + weight_kg: float + body_fat_pct: float | None + muscle_mass_kg: float | None + water_pct: float | None + note: str | None + source: str + + +# --- Body measurements -------------------------------------------------------- + +MEASUREMENT_SITES: tuple[str, ...] = ( + "neck_cm", + "chest_cm", + "waist_cm", + "hips_cm", + "biceps_left_cm", + "biceps_right_cm", + "thigh_left_cm", + "thigh_right_cm", + "calf_left_cm", + "calf_right_cm", +) + + +class BodyMeasurementCreate(BaseModel): + measured_at: UtcDatetime + neck_cm: float | None = Field(default=None, gt=0, lt=300) + chest_cm: float | None = Field(default=None, gt=0, lt=300) + waist_cm: float | None = Field(default=None, gt=0, lt=300) + hips_cm: float | None = Field(default=None, gt=0, lt=300) + biceps_left_cm: float | None = Field(default=None, gt=0, lt=300) + biceps_right_cm: float | None = Field(default=None, gt=0, lt=300) + thigh_left_cm: float | None = Field(default=None, gt=0, lt=300) + thigh_right_cm: float | None = Field(default=None, gt=0, lt=300) + calf_left_cm: float | None = Field(default=None, gt=0, lt=300) + calf_right_cm: float | None = Field(default=None, gt=0, lt=300) + note: str | None = Field(default=None, max_length=255) + + +class BodyMeasurementUpdate(BodyMeasurementCreate): + measured_at: UtcDatetime | None = None + + +class BodyMeasurementRead(BaseModel): + model_config = ORM + + id: int + measured_at: dt.datetime + neck_cm: float | None + chest_cm: float | None + waist_cm: float | None + hips_cm: float | None + biceps_left_cm: float | None + biceps_right_cm: float | None + thigh_left_cm: float | None + thigh_right_cm: float | None + calf_left_cm: float | None + calf_right_cm: float | None + note: str | None + source: str + + +# --- Daily activity ----------------------------------------------------------- + + +class DailyActivityUpsert(BaseModel): + """POST /health/activity — manual upsert of one local day.""" + + date: dt.date + steps: int | None = Field(default=None, ge=0) + active_kcal: float | None = Field(default=None, ge=0) + total_kcal: float | None = Field(default=None, ge=0) + distance_m: int | None = Field(default=None, ge=0) + active_minutes: int | None = Field(default=None, ge=0) + floors: int | None = Field(default=None, ge=0) + + +class DailyActivityRead(BaseModel): + model_config = ORM + + id: int + date: dt.date + steps: int | None + active_kcal: float | None + total_kcal: float | None + distance_m: int | None + active_minutes: int | None + floors: int | None + source: str + + +class MergedActivityRead(BaseModel): + """Cross-source merged day (§3.5) with per-field provenance.""" + + date: dt.date + steps: int | None = None + active_kcal: float | None = None + total_kcal: float | None = None + distance_m: int | None = None + active_minutes: int | None = None + floors: int | None = None + field_sources: dict[str, str] = Field(default_factory=dict) + + +# --- Workouts ----------------------------------------------------------------- + + +class WorkoutCreate(BaseModel): + started_at: UtcDatetime + ended_at: UtcDatetime + sport_type: SportType + sport_label: str | None = Field(default=None, max_length=100) + kcal: float | None = Field(default=None, ge=0) + distance_m: int | None = Field(default=None, ge=0) + steps: int | None = Field(default=None, ge=0) + avg_hr: int | None = Field(default=None, ge=0, le=300) + max_hr: int | None = Field(default=None, ge=0, le=300) + avg_speed_kmh: float | None = Field(default=None, ge=0, le=100) + elevation_m: int | None = None + note: str | None = Field(default=None, max_length=255) + + +class WorkoutUpdate(BaseModel): + started_at: UtcDatetime | None = None + ended_at: UtcDatetime | None = None + sport_type: SportType | None = None + sport_label: str | None = Field(default=None, max_length=100) + kcal: float | None = Field(default=None, ge=0) + distance_m: int | None = Field(default=None, ge=0) + steps: int | None = Field(default=None, ge=0) + avg_hr: int | None = Field(default=None, ge=0, le=300) + max_hr: int | None = Field(default=None, ge=0, le=300) + avg_speed_kmh: float | None = Field(default=None, ge=0, le=100) + elevation_m: int | None = None + note: str | None = Field(default=None, max_length=255) + is_hidden: bool | None = None + + +class WorkoutRead(BaseModel): + model_config = ORM + + id: int + started_at: dt.datetime + ended_at: dt.datetime + duration_s: int + sport_type: SportType + sport_label: str | None + kcal: float | None + distance_m: int | None + steps: int | None + avg_hr: int | None + max_hr: int | None + avg_speed_kmh: float | None + elevation_m: int | None + is_hidden: bool + note: str | None + source: str + + +# --- Goals -------------------------------------------------------------------- + + +class GoalCreate(BaseModel): + mode: GoalMode + start_date: dt.date | None = None + start_weight_kg: float | None = Field(default=None, gt=20, lt=400) + target_weight_kg: float = Field(gt=20, lt=400) + target_date: dt.date | None = None + weekly_rate_kg: float | None = Field(default=None, gt=-1.01, le=1.5) + note: str | None = Field(default=None, max_length=255) + + +class GoalUpdate(BaseModel): + mode: GoalMode | None = None + start_date: dt.date | None = None + start_weight_kg: float | None = Field(default=None, gt=20, lt=400) + target_weight_kg: float | None = Field(default=None, gt=20, lt=400) + target_date: dt.date | None = None + weekly_rate_kg: float | None = Field(default=None, gt=-1.01, le=1.5) + status: GoalStatus | None = None + note: str | None = Field(default=None, max_length=255) + + +class GoalRead(BaseModel): + model_config = ORM + + id: int + mode: GoalMode + start_date: dt.date + start_weight_kg: float + target_weight_kg: float + target_date: dt.date | None + weekly_rate_kg: float | None + status: GoalStatus + note: str | None + + +class ProjectionRead(BaseModel): + status: str # "ok" | "reached" | "not_converging" + date: dt.date | None = None + + +class BudgetRead(BaseModel): + kcal: float + deficit_target_kcal: float + floor_applied: bool + rate_clamped: bool = False + tdee_kcal: float + tdee_method: str + + +class ActiveGoalRead(BaseModel): + goal: GoalRead | None = None + budget: BudgetRead | None = None + projection: ProjectionRead | None = None + trend_weight_kg: float | None = None + done_kg: float | None = None + remaining_kg: float | None = None + progress_pct: float | None = None + + +# --- Nutrition ---------------------------------------------------------------- + + +class FoodEntryCreate(BaseModel): + eaten_at: UtcDatetime + meal: MealType + name: str = Field(min_length=1, max_length=200) + brand: str | None = Field(default=None, max_length=100) + quantity: float = Field(default=100.0, gt=0) + unit: str = Field(default="g", max_length=20) + kcal: float = Field(ge=0) + protein_g: float | None = Field(default=None, ge=0) + carbs_g: float | None = Field(default=None, ge=0) + fat_g: float | None = Field(default=None, ge=0) + fiber_g: float | None = Field(default=None, ge=0) + sugar_g: float | None = Field(default=None, ge=0) + sat_fat_g: float | None = Field(default=None, ge=0) + sodium_mg: float | None = Field(default=None, ge=0) + # Optional shortcut: build the entry from a favorite, prorated on quantity. + favorite_id: int | None = None + + +class FoodEntryUpdate(BaseModel): + eaten_at: UtcDatetime | None = None + meal: MealType | None = None + name: str | None = Field(default=None, min_length=1, max_length=200) + brand: str | None = Field(default=None, max_length=100) + quantity: float | None = Field(default=None, gt=0) + unit: str | None = Field(default=None, max_length=20) + kcal: float | None = Field(default=None, ge=0) + protein_g: float | None = Field(default=None, ge=0) + carbs_g: float | None = Field(default=None, ge=0) + fat_g: float | None = Field(default=None, ge=0) + fiber_g: float | None = Field(default=None, ge=0) + sugar_g: float | None = Field(default=None, ge=0) + sat_fat_g: float | None = Field(default=None, ge=0) + sodium_mg: float | None = Field(default=None, ge=0) + + +class FoodEntryRead(BaseModel): + model_config = ORM + + id: int + eaten_at: dt.datetime + meal: MealType + name: str + brand: str | None + quantity: float + unit: str + kcal: float + protein_g: float | None + carbs_g: float | None + fat_g: float | None + fiber_g: float | None + sugar_g: float | None + sat_fat_g: float | None + sodium_mg: float | None + source: str + + +class FoodFavoriteCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + brand: str | None = Field(default=None, max_length=100) + default_quantity: float = Field(default=100.0, gt=0) + unit: str = Field(default="g", max_length=20) + kcal: float = Field(ge=0) + protein_g: float | None = Field(default=None, ge=0) + carbs_g: float | None = Field(default=None, ge=0) + fat_g: float | None = Field(default=None, ge=0) + fiber_g: float | None = Field(default=None, ge=0) + default_meal: MealType | None = None + + +class FoodFavoriteUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=200) + brand: str | None = Field(default=None, max_length=100) + default_quantity: float | None = Field(default=None, gt=0) + unit: str | None = Field(default=None, max_length=20) + kcal: float | None = Field(default=None, ge=0) + protein_g: float | None = Field(default=None, ge=0) + carbs_g: float | None = Field(default=None, ge=0) + fat_g: float | None = Field(default=None, ge=0) + fiber_g: float | None = Field(default=None, ge=0) + default_meal: MealType | None = None + + +class FoodFavoriteRead(BaseModel): + model_config = ORM + + id: int + name: str + brand: str | None + default_quantity: float + unit: str + kcal: float + protein_g: float | None + carbs_g: float | None + fat_g: float | None + fiber_g: float | None + default_meal: MealType | None + use_count: int + last_used_at: dt.datetime | None + + +class RecentFoodRead(BaseModel): + """Distinct (name, brand) recently logged — computed from food_entries.""" + + name: str + brand: str | None = None + unit: str = "g" + quantity: float | None = None + kcal: float | None = None + protein_g: float | None = None + carbs_g: float | None = None + fat_g: float | None = None + meal: MealType | None = None + last_eaten_at: dt.datetime + + +class WaterEntryCreate(BaseModel): + drunk_at: UtcDatetime + volume_ml: int = Field(gt=0, le=5000) + + +class WaterEntryRead(BaseModel): + model_config = ORM + + id: int + drunk_at: dt.datetime + volume_ml: int + source: str + + +class MealTotals(BaseModel): + meal: MealType + kcal: float = 0.0 + protein_g: float = 0.0 + carbs_g: float = 0.0 + fat_g: float = 0.0 + entries: list[FoodEntryRead] = Field(default_factory=list) + + +class NutritionDayRead(BaseModel): + date: dt.date + kcal: float | None = None + protein_g: float | None = None + carbs_g: float | None = None + fat_g: float | None = None + fiber_g: float | None = None + budget_kcal: float | None = None + vs_budget_kcal: float | None = None + + +class NutritionDayDetail(BaseModel): + date: dt.date + totals: NutritionDayRead + water_ml: int = 0 + water_goal_ml: int | None = None + meals: list[MealTotals] = Field(default_factory=list) + + +# --- Food referential / search ------------------------------------------------- + + +class FoodSearchItem(BaseModel): + model_config = ORM + + id: int | None = None + source: str + source_id: str | None = None + name: str + brand: str | None = None + energy_kcal_100g: float | None = None + protein_g_100g: float | None = None + carbs_g_100g: float | None = None + sugar_g_100g: float | None = None + fat_g_100g: float | None = None + sat_fat_g_100g: float | None = None + fiber_g_100g: float | None = None + salt_g_100g: float | None = None + serving_size_g: float | None = None + + +class FoodSearchResponse(BaseModel): + query: str + items: list[FoodSearchItem] = Field(default_factory=list) + # "cache" when Open Food Facts was not called, "cache+off" otherwise. + origin: str = "cache" + + +# --- Planning (addendum-planning.md) ------------------------------------------ + + +class ScheduleRead(BaseModel): + model_config = ORM + + kind: ScheduleKind + weekdays: list[int] = Field(default_factory=list) + enabled: bool = False + + +class ScheduleUpdate(BaseModel): + weekdays: list[int] = Field(default_factory=list) + enabled: bool = True + + +class StreakRead(BaseModel): + current: int = 0 + best: int = 0 + + +class TodayItem(BaseModel): + kind: ScheduleKind + planned: bool + done: bool + value: float | None = None # kg / sessions count / kcal + + +class TodayResponse(BaseModel): + date: dt.date + items: list[TodayItem] = Field(default_factory=list) + streaks: dict[str, StreakRead] = Field(default_factory=dict) + + +class AdherenceDay(BaseModel): + date: dt.date + planned: bool + done: bool + status: str # "done" | "missed" | "done_unplanned" | "rest" + + +class AdherenceKind(BaseModel): + kind: ScheduleKind + weekdays: list[int] = Field(default_factory=list) + enabled: bool = False + planned_days: int = 0 + done_days: int = 0 + missed_days: int = 0 + adherence_pct: float | None = None + streak: StreakRead = Field(default_factory=StreakRead) + days: list[AdherenceDay] = Field(default_factory=list) + + +# --- Chart-ready stats contract (§8.1) ---------------------------------------- + + +class Series(BaseModel): + name: str + type: str # "line" | "bar" | "scatter" | "pie" | "heatmap" + points: list[list[Any]] = Field(default_factory=list) # [[date_iso, value|null]] + + +class StatsResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + from_: dt.date = Field(alias="from") + to: dt.date + unit: str + series: list[Series] = Field(default_factory=list) + meta: dict[str, Any] = Field(default_factory=dict) + + +class AdherenceResponse(StatsResponse): + """`/health/stats/adherence` — a `stats/*` endpoint, so it carries the §8.1 + envelope (one calendar-heatmap series per habit) plus the richer per-habit + breakdown of addendum-planning.md that the planning editor needs.""" + + kinds: list[AdherenceKind] = Field(default_factory=list) + + +# --- Dashboard ---------------------------------------------------------------- + + +class DashboardResponse(BaseModel): + date: dt.date + weight_kg: float | None = None + weight_measured_at: dt.datetime | None = None + trend_weight_kg: float | None = None + trend_delta_7d_kg: float | None = None + bmi: float | None = None + intake_kcal: float | None = None + budget_kcal: float | None = None + remaining_kcal: float | None = None + tdee_kcal: float | None = None + tdee_method: str | None = None + balance_kcal: float | None = None + cumulative_balance_30d_kcal: float | None = None + steps: int | None = None + active_kcal: float | None = None + distance_m: int | None = None + water_ml: int = 0 + water_goal_ml: int | None = None + workouts_this_week: int = 0 + goal: GoalRead | None = None + projection: ProjectionRead | None = None + weight_series: list[list[Any]] = Field(default_factory=list) + today: TodayResponse | None = None diff --git a/apps/api/app/modules/health/service.py b/apps/api/app/modules/health/service.py new file mode 100644 index 0000000..0ab9827 --- /dev/null +++ b/apps/api/app/modules/health/service.py @@ -0,0 +1,1093 @@ +"""Business logic for the health module (CONVENTIONS C2.6). + +Every function takes (db, user_id, ...) and filters on user_id. Errors are +AppError subclasses with French messages; routers stay thin. +""" + +import datetime as dt +from decimal import Decimal +from zoneinfo import ZoneInfo + +from sqlalchemy import Select, and_, or_, select +from sqlalchemy.orm import Session + +from app.core.errors import ConflictError, DomainValidationError, NotFoundError +from app.core.timeutils import resolve_tz, utcnow +from app.modules.health import calculations as calc +from app.modules.health.models import ( + BodyMeasurement, + DailyActivity, + FoodEntry, + FoodFavorite, + Goal, + GoalStatus, + HealthProfile, + MealType, + ScheduleKind, + TrackingSchedule, + WaterEntry, + WeightEntry, + Workout, +) +from app.modules.health.schemas import ( + BodyMeasurementCreate, + BodyMeasurementUpdate, + DailyActivityUpsert, + FoodEntryCreate, + FoodEntryUpdate, + FoodFavoriteCreate, + FoodFavoriteUpdate, + GoalCreate, + GoalUpdate, + HealthProfileUpdate, + ScheduleUpdate, + WaterEntryCreate, + WeightEntryCreate, + WeightEntryUpdate, + WorkoutCreate, + WorkoutUpdate, +) + +MANUAL_SOURCE = "manual" +DEFAULT_WEEKDAYS: list[int] = [] +# Day-series endpoints return one point per day: cap the window (~3 years). +MAX_RANGE_DAYS = 1100 + + +# --- Generic helpers ---------------------------------------------------------- + + +def _dec(value: float | Decimal | None) -> Decimal | None: + """Portable float -> Decimal conversion (avoids binary float artefacts).""" + if value is None: + return None + return Decimal(str(value)) + + +def to_utc(value: dt.datetime | None) -> dt.datetime | None: + """Every stored instant is UTC-aware (C2.3); naive input is read as UTC.""" + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=dt.UTC) + return value.astimezone(dt.UTC) + + +def local_day_of(value: dt.datetime, tz: ZoneInfo) -> dt.date: + """Local civil day of an instant (§1.3 aggregation convention).""" + return to_utc(value).astimezone(tz).date() + + +_DATETIME_FIELDS = frozenset( + {"measured_at", "eaten_at", "drunk_at", "started_at", "ended_at"} +) + + +def _apply_sort(stmt: Select, sort: str | None, allowed: dict, default: str) -> Select: + """`?sort=field` / `-field` with an explicit whitelist (C2.8).""" + raw = sort or default + descending = raw.startswith("-") + name = raw.lstrip("-") + column = allowed.get(name) + if column is None: + raise DomainValidationError("Champ de tri non autorisé.", details={"sort": raw}) + return stmt.order_by(column.desc() if descending else column.asc()) + + +def utc_window( + start: dt.date, end: dt.date, tz: ZoneInfo +) -> tuple[dt.datetime, dt.datetime]: + """UTC bounds [start 00:00 local, end+1 00:00 local) for a local-day range.""" + begin = dt.datetime.combine(start, dt.time.min, tzinfo=tz) + stop = dt.datetime.combine(end + dt.timedelta(days=1), dt.time.min, tzinfo=tz) + return begin.astimezone(dt.UTC), stop.astimezone(dt.UTC) + + +def resolve_range( + from_: dt.date | None, + to: dt.date | None, + tz: ZoneInfo, + default_days: int = 30, + max_days: int | None = MAX_RANGE_DAYS, +) -> tuple[dt.date, dt.date]: + """Inclusive local-day range; defaults to the trailing `default_days`. + + `max_days` caps day-series endpoints (one point per day, no pagination); + paginated list queries pass `max_days=None`. + """ + end = to or today_local(tz) + start = from_ or end - dt.timedelta(days=default_days - 1) + if start > end: + raise DomainValidationError( + "La date de début doit précéder la date de fin.", + details={"from": str(start), "to": str(end)}, + ) + if max_days is not None and (end - start).days + 1 > max_days: + raise DomainValidationError( + f"La période demandée est trop longue (maximum {max_days} jours).", + details={"from": str(start), "to": str(end), "max_days": max_days}, + ) + return start, end + + +def today_local(tz: ZoneInfo) -> dt.date: + return utcnow().astimezone(tz).date() + + +def profile_tz(db: Session, user_id: int, tz: str | None) -> ZoneInfo: + """Explicit ?tz= wins, else the profile timezone, else the app default.""" + if tz: + return resolve_tz(tz) + profile = get_profile(db, user_id) + return resolve_tz(profile.timezone if profile else None) + + +# --- Profile ------------------------------------------------------------------ + + +def get_profile(db: Session, user_id: int) -> HealthProfile | None: + return db.scalar(select(HealthProfile).where(HealthProfile.user_id == user_id)) + + +def require_profile(db: Session, user_id: int) -> HealthProfile: + profile = get_profile(db, user_id) + if profile is None: + raise NotFoundError( + "Profil santé non configuré. Renseignez taille, sexe et date de naissance." + ) + return profile + + +def upsert_profile( + db: Session, user_id: int, payload: HealthProfileUpdate +) -> HealthProfile: + resolve_tz(payload.timezone) # validates the IANA name (French error otherwise) + profile = get_profile(db, user_id) + if profile is None: + profile = HealthProfile(user_id=user_id) + db.add(profile) + profile.height_cm = _dec(payload.height_cm) + profile.sex = payload.sex + profile.birthdate = payload.birthdate + profile.activity_level = payload.activity_level + profile.timezone = payload.timezone + profile.water_goal_ml = payload.water_goal_ml + profile.calorie_floor_kcal = payload.calorie_floor_kcal + db.commit() + db.refresh(profile) + return profile + + +# --- Weight entries ----------------------------------------------------------- + +WEIGHT_SORTS = { + "measured_at": WeightEntry.measured_at, + "weight_kg": WeightEntry.weight_kg, +} + + +def weights_query( + user_id: int, + from_: dt.date | None, + to: dt.date | None, + tz: ZoneInfo, + sort: str | None, +) -> Select: + stmt = select(WeightEntry).where(WeightEntry.user_id == user_id) + if from_ or to: + start, end = resolve_range(from_, to, tz, default_days=3650, max_days=None) + begin, stop = utc_window(start, end, tz) + stmt = stmt.where( + WeightEntry.measured_at >= begin, WeightEntry.measured_at < stop + ) + return _apply_sort(stmt, sort, WEIGHT_SORTS, "-measured_at") + + +def create_weight(db: Session, user_id: int, payload: WeightEntryCreate) -> WeightEntry: + existing = db.scalar( + select(WeightEntry).where( + WeightEntry.user_id == user_id, + WeightEntry.measured_at == payload.measured_at, + WeightEntry.source == MANUAL_SOURCE, + ) + ) + if existing is not None: + raise ConflictError("Une pesée existe déjà à cet horodatage.") + entry = WeightEntry( + user_id=user_id, + source=MANUAL_SOURCE, + measured_at=to_utc(payload.measured_at), + weight_kg=_dec(payload.weight_kg), + body_fat_pct=_dec(payload.body_fat_pct), + muscle_mass_kg=_dec(payload.muscle_mass_kg), + water_pct=_dec(payload.water_pct), + note=payload.note, + ) + db.add(entry) + db.commit() + db.refresh(entry) + return entry + + +def get_weight(db: Session, user_id: int, entry_id: int) -> WeightEntry: + entry = db.get(WeightEntry, entry_id) + if entry is None or entry.user_id != user_id: + raise NotFoundError("Pesée introuvable.") + return entry + + +def update_weight( + db: Session, user_id: int, entry_id: int, payload: WeightEntryUpdate +) -> WeightEntry: + entry = get_weight(db, user_id, entry_id) + data = payload.model_dump(exclude_unset=True) + for field, value in data.items(): + if field in {"weight_kg", "body_fat_pct", "muscle_mass_kg", "water_pct"}: + value = _dec(value) + elif field in _DATETIME_FIELDS: + value = to_utc(value) + setattr(entry, field, value) + db.commit() + db.refresh(entry) + return entry + + +def delete_weight(db: Session, user_id: int, entry_id: int) -> None: + db.delete(get_weight(db, user_id, entry_id)) + db.commit() + + +def daily_weights( + db: Session, + user_id: int, + tz: ZoneInfo, + until: dt.date | None = None, +) -> list[tuple[dt.date, float]]: + """One point per local day = FIRST weigh-in of the day (§5.5).""" + stmt = select(WeightEntry).where(WeightEntry.user_id == user_id) + if until is not None: + _, stop = utc_window(until, until, tz) + stmt = stmt.where(WeightEntry.measured_at < stop) + rows = db.scalars(stmt.order_by(WeightEntry.measured_at.asc())).all() + per_day: dict[dt.date, float] = {} + for row in rows: + day = local_day_of(row.measured_at, tz) + if day not in per_day: # rows are ascending -> first of the day wins + per_day[day] = float(row.weight_kg) + return sorted(per_day.items()) + + +# --- Body measurements -------------------------------------------------------- + +MEASUREMENT_SORTS = {"measured_at": BodyMeasurement.measured_at} + + +def measurements_query( + user_id: int, + from_: dt.date | None, + to: dt.date | None, + tz: ZoneInfo, + sort: str | None, +) -> Select: + stmt = select(BodyMeasurement).where(BodyMeasurement.user_id == user_id) + if from_ or to: + start, end = resolve_range(from_, to, tz, default_days=3650, max_days=None) + begin, stop = utc_window(start, end, tz) + stmt = stmt.where( + BodyMeasurement.measured_at >= begin, BodyMeasurement.measured_at < stop + ) + return _apply_sort(stmt, sort, MEASUREMENT_SORTS, "-measured_at") + + +def create_measurement( + db: Session, user_id: int, payload: BodyMeasurementCreate +) -> BodyMeasurement: + data = payload.model_dump() + note = data.pop("note", None) + measured_at = data.pop("measured_at") + row = BodyMeasurement( + user_id=user_id, + source=MANUAL_SOURCE, + measured_at=to_utc(measured_at), + note=note, + **{key: _dec(value) for key, value in data.items()}, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def get_measurement(db: Session, user_id: int, row_id: int) -> BodyMeasurement: + row = db.get(BodyMeasurement, row_id) + if row is None or row.user_id != user_id: + raise NotFoundError("Mensuration introuvable.") + return row + + +def update_measurement( + db: Session, user_id: int, row_id: int, payload: BodyMeasurementUpdate +) -> BodyMeasurement: + row = get_measurement(db, user_id, row_id) + for field, value in payload.model_dump(exclude_unset=True).items(): + if field.endswith("_cm"): + value = _dec(value) + elif field in _DATETIME_FIELDS: + value = to_utc(value) + setattr(row, field, value) + db.commit() + db.refresh(row) + return row + + +def delete_measurement(db: Session, user_id: int, row_id: int) -> None: + db.delete(get_measurement(db, user_id, row_id)) + db.commit() + + +def measurements_between( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> list[BodyMeasurement]: + begin, stop = utc_window(start, end, tz) + return list( + db.scalars( + select(BodyMeasurement) + .where( + BodyMeasurement.user_id == user_id, + BodyMeasurement.measured_at >= begin, + BodyMeasurement.measured_at < stop, + ) + .order_by(BodyMeasurement.measured_at.asc()) + ).all() + ) + + +# --- Daily activity ----------------------------------------------------------- + + +def activity_rows( + db: Session, user_id: int, start: dt.date, end: dt.date +) -> list[DailyActivity]: + return list( + db.scalars( + select(DailyActivity) + .where( + DailyActivity.user_id == user_id, + DailyActivity.date >= start, + DailyActivity.date <= end, + ) + .order_by(DailyActivity.date.asc()) + ).all() + ) + + +def merged_activity( + db: Session, user_id: int, start: dt.date, end: dt.date +) -> dict[dt.date, calc.MergedActivity]: + """Field-by-field cross-source merge, one entry per day with data (§3.5).""" + per_day: dict[dt.date, list[DailyActivity]] = {} + for row in activity_rows(db, user_id, start, end): + per_day.setdefault(row.date, []).append(row) + return {day: calc.merge_activity_day(rows) for day, rows in per_day.items()} + + +def upsert_manual_activity( + db: Session, user_id: int, payload: DailyActivityUpsert +) -> DailyActivity: + row = db.scalar( + select(DailyActivity).where( + DailyActivity.user_id == user_id, + DailyActivity.date == payload.date, + DailyActivity.source == MANUAL_SOURCE, + ) + ) + if row is None: + row = DailyActivity(user_id=user_id, date=payload.date, source=MANUAL_SOURCE) + db.add(row) + row.steps = payload.steps + row.active_kcal = _dec(payload.active_kcal) + row.total_kcal = _dec(payload.total_kcal) + row.distance_m = payload.distance_m + row.active_minutes = payload.active_minutes + row.floors = payload.floors + db.commit() + db.refresh(row) + return row + + +def delete_activity(db: Session, user_id: int, row_id: int) -> None: + row = db.get(DailyActivity, row_id) + if row is None or row.user_id != user_id: + raise NotFoundError("Journée d'activité introuvable.") + db.delete(row) + db.commit() + + +# --- Workouts ----------------------------------------------------------------- + +WORKOUT_SORTS = {"started_at": Workout.started_at, "kcal": Workout.kcal} + + +def workouts_query( + user_id: int, + from_: dt.date | None, + to: dt.date | None, + tz: ZoneInfo, + sport_type: str | None, + include_hidden: bool, + sort: str | None, +) -> Select: + stmt = select(Workout).where(Workout.user_id == user_id) + if not include_hidden: + stmt = stmt.where(Workout.is_hidden.is_(False)) + if sport_type: + stmt = stmt.where(Workout.sport_type == sport_type) + if from_ or to: + start, end = resolve_range(from_, to, tz, default_days=3650, max_days=None) + begin, stop = utc_window(start, end, tz) + stmt = stmt.where(Workout.started_at >= begin, Workout.started_at < stop) + return _apply_sort(stmt, sort, WORKOUT_SORTS, "-started_at") + + +def flag_overlapping_duplicates(db: Session, workout: Workout) -> None: + """Cross-source dedup (§3.6): >= 80 % overlap hides the lower-priority row.""" + others = db.scalars( + select(Workout).where( + Workout.user_id == workout.user_id, + Workout.id != workout.id, + Workout.source != workout.source, + Workout.is_hidden.is_(False), + Workout.started_at < workout.ended_at, + Workout.ended_at > workout.started_at, + ) + ).all() + for other in others: + ratio = calc.overlap_ratio( + to_utc(workout.started_at), + to_utc(workout.ended_at), + to_utc(other.started_at), + to_utc(other.ended_at), + ) + if ratio < calc.WORKOUT_OVERLAP_THRESHOLD: + continue + if calc.source_priority(workout.source) <= calc.source_priority(other.source): + other.is_hidden = True + else: + workout.is_hidden = True + + +def create_workout(db: Session, user_id: int, payload: WorkoutCreate) -> Workout: + if payload.ended_at <= payload.started_at: + raise DomainValidationError("La fin de séance doit suivre le début.") + workout = Workout( + user_id=user_id, + source=MANUAL_SOURCE, + started_at=to_utc(payload.started_at), + ended_at=to_utc(payload.ended_at), + sport_type=payload.sport_type, + sport_label=payload.sport_label, + kcal=_dec(payload.kcal), + distance_m=payload.distance_m, + steps=payload.steps, + avg_hr=payload.avg_hr, + max_hr=payload.max_hr, + avg_speed_kmh=_dec(payload.avg_speed_kmh), + elevation_m=payload.elevation_m, + note=payload.note, + ) + db.add(workout) + db.flush() + flag_overlapping_duplicates(db, workout) + db.commit() + db.refresh(workout) + return workout + + +def get_workout(db: Session, user_id: int, workout_id: int) -> Workout: + workout = db.get(Workout, workout_id) + if workout is None or workout.user_id != user_id: + raise NotFoundError("Séance introuvable.") + return workout + + +def update_workout( + db: Session, user_id: int, workout_id: int, payload: WorkoutUpdate +) -> Workout: + workout = get_workout(db, user_id, workout_id) + for field, value in payload.model_dump(exclude_unset=True).items(): + if field in {"kcal", "avg_speed_kmh"}: + value = _dec(value) + elif field in _DATETIME_FIELDS: + value = to_utc(value) + setattr(workout, field, value) + if workout.ended_at <= workout.started_at: + raise DomainValidationError("La fin de séance doit suivre le début.") + db.commit() + db.refresh(workout) + return workout + + +def delete_workout(db: Session, user_id: int, workout_id: int) -> None: + db.delete(get_workout(db, user_id, workout_id)) + db.commit() + + +def workout_duration_s(workout: Workout) -> int: + return workout.duration_s + + +# --- Goals -------------------------------------------------------------------- + + +def goals_query(user_id: int, status: str | None, sort: str | None) -> Select: + stmt = select(Goal).where(Goal.user_id == user_id) + if status: + stmt = stmt.where(Goal.status == status) + return _apply_sort( + stmt, sort, {"start_date": Goal.start_date, "id": Goal.id}, "-start_date" + ) + + +def active_goal(db: Session, user_id: int) -> Goal | None: + return db.scalar( + select(Goal).where(Goal.user_id == user_id, Goal.status == GoalStatus.ACTIVE) + ) + + +def create_goal( + db: Session, user_id: int, payload: GoalCreate, replace_active: bool +) -> Goal: + if payload.mode == "target_date" and payload.target_date is None: + raise DomainValidationError("Une date cible est requise pour ce mode.") + if payload.mode == "weekly_rate" and payload.weekly_rate_kg is None: + raise DomainValidationError("Un rythme hebdomadaire est requis pour ce mode.") + current = active_goal(db, user_id) + if current is not None: + if not replace_active: + raise ConflictError( + "Un objectif est déjà actif. Terminez-le ou utilisez « remplacer »." + ) + current.status = GoalStatus.ABANDONED + db.flush() + tz = resolve_tz( + get_profile(db, user_id).timezone if get_profile(db, user_id) else None + ) + start_date = payload.start_date or today_local(tz) + start_weight = payload.start_weight_kg + if start_weight is None: + trend = calc.weight_trend(daily_weights(db, user_id, tz)) + start_weight = trend[-1][1] if trend else None + if start_weight is None: + raise DomainValidationError( + "Poids de départ inconnu : ajoutez une pesée ou renseignez-le." + ) + goal = Goal( + user_id=user_id, + mode=payload.mode, + start_date=start_date, + start_weight_kg=_dec(start_weight), + target_weight_kg=_dec(payload.target_weight_kg), + target_date=payload.target_date, + weekly_rate_kg=_dec(payload.weekly_rate_kg), + status=GoalStatus.ACTIVE, + note=payload.note, + ) + db.add(goal) + db.commit() + db.refresh(goal) + return goal + + +def get_goal(db: Session, user_id: int, goal_id: int) -> Goal: + goal = db.get(Goal, goal_id) + if goal is None or goal.user_id != user_id: + raise NotFoundError("Objectif introuvable.") + return goal + + +def update_goal(db: Session, user_id: int, goal_id: int, payload: GoalUpdate) -> Goal: + goal = get_goal(db, user_id, goal_id) + data = payload.model_dump(exclude_unset=True) + if data.get("status") == GoalStatus.ACTIVE and goal.status != GoalStatus.ACTIVE: + other = active_goal(db, user_id) + if other is not None and other.id != goal.id: + raise ConflictError("Un autre objectif est déjà actif.") + for field, value in data.items(): + if field in {"start_weight_kg", "target_weight_kg", "weekly_rate_kg"}: + value = _dec(value) + setattr(goal, field, value) + if goal.mode == "target_date" and goal.target_date is None: + raise DomainValidationError("Une date cible est requise pour ce mode.") + if goal.mode == "weekly_rate" and goal.weekly_rate_kg is None: + raise DomainValidationError("Un rythme hebdomadaire est requis pour ce mode.") + db.commit() + db.refresh(goal) + return goal + + +def activate_goal(db: Session, user_id: int, goal_id: int) -> Goal: + goal = get_goal(db, user_id, goal_id) + current = active_goal(db, user_id) + if current is not None and current.id != goal.id: + current.status = GoalStatus.ABANDONED + db.flush() + goal.status = GoalStatus.ACTIVE + db.commit() + db.refresh(goal) + return goal + + +def delete_goal(db: Session, user_id: int, goal_id: int) -> None: + db.delete(get_goal(db, user_id, goal_id)) + db.commit() + + +# --- Food entries ------------------------------------------------------------- + +FOOD_SORTS = {"eaten_at": FoodEntry.eaten_at, "kcal": FoodEntry.kcal} +_FOOD_MACROS = ( + "quantity", + "kcal", + "protein_g", + "carbs_g", + "fat_g", + "fiber_g", + "sugar_g", + "sat_fat_g", + "sodium_mg", +) + + +def food_entries_query( + user_id: int, + tz: ZoneInfo, + day: dt.date | None, + from_: dt.date | None, + to: dt.date | None, + meal: str | None, + q: str | None, + sort: str | None, +) -> Select: + stmt = select(FoodEntry).where(FoodEntry.user_id == user_id) + if day is not None: + from_, to = day, day + if from_ or to: + start, end = resolve_range(from_, to, tz, default_days=3650, max_days=None) + begin, stop = utc_window(start, end, tz) + stmt = stmt.where(FoodEntry.eaten_at >= begin, FoodEntry.eaten_at < stop) + if meal: + stmt = stmt.where(FoodEntry.meal == meal) + if q: + pattern = f"%{q.lower()}%" + stmt = stmt.where( + or_( + FoodEntry.name.ilike(pattern), + FoodEntry.brand.ilike(pattern), + ) + ) + return _apply_sort(stmt, sort, FOOD_SORTS, "-eaten_at") + + +def create_food_entry(db: Session, user_id: int, payload: FoodEntryCreate) -> FoodEntry: + data = payload.model_dump() + favorite_id = data.pop("favorite_id", None) + if favorite_id is not None: + favorite = get_favorite(db, user_id, favorite_id) + # The favorite is the source of truth for identity and macros (§4.2): + # only the eaten quantity comes from the payload, macros are prorated. + ratio = float(payload.quantity) / float(favorite.default_quantity or 1) + data["name"] = favorite.name + data["brand"] = favorite.brand + data["unit"] = favorite.unit + data["kcal"] = float(favorite.kcal) * ratio + for macro in ("protein_g", "carbs_g", "fat_g", "fiber_g"): + value = getattr(favorite, macro) + data[macro] = float(value) * ratio if value is not None else None + favorite.use_count += 1 + favorite.last_used_at = utcnow() + entry = FoodEntry( + user_id=user_id, + source=MANUAL_SOURCE, + eaten_at=to_utc(data["eaten_at"]), + meal=data["meal"], + name=data["name"], + brand=data["brand"], + unit=data["unit"], + **{key: _dec(data[key]) for key in _FOOD_MACROS}, + ) + db.add(entry) + db.commit() + db.refresh(entry) + return entry + + +def get_food_entry(db: Session, user_id: int, entry_id: int) -> FoodEntry: + entry = db.get(FoodEntry, entry_id) + if entry is None or entry.user_id != user_id: + raise NotFoundError("Entrée alimentaire introuvable.") + return entry + + +def update_food_entry( + db: Session, user_id: int, entry_id: int, payload: FoodEntryUpdate +) -> FoodEntry: + entry = get_food_entry(db, user_id, entry_id) + for field, value in payload.model_dump(exclude_unset=True).items(): + if field in _FOOD_MACROS: + value = _dec(value) + elif field in _DATETIME_FIELDS: + value = to_utc(value) + setattr(entry, field, value) + db.commit() + db.refresh(entry) + return entry + + +def delete_food_entry(db: Session, user_id: int, entry_id: int) -> None: + db.delete(get_food_entry(db, user_id, entry_id)) + db.commit() + + +def food_entries_between( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> list[FoodEntry]: + begin, stop = utc_window(start, end, tz) + return list( + db.scalars( + select(FoodEntry) + .where( + FoodEntry.user_id == user_id, + FoodEntry.eaten_at >= begin, + FoodEntry.eaten_at < stop, + ) + .order_by(FoodEntry.eaten_at.asc()) + ).all() + ) + + +def recent_foods(db: Session, user_id: int, limit: int = 20) -> list[FoodEntry]: + """Distinct (name, brand) most recently logged (§8.3 /nutrition/recent).""" + rows = db.scalars( + select(FoodEntry) + .where(FoodEntry.user_id == user_id) + .order_by(FoodEntry.eaten_at.desc()) + .limit(limit * 10) + ).all() + seen: set[tuple[str, str | None]] = set() + out: list[FoodEntry] = [] + for row in rows: + key = (row.name.strip().lower(), (row.brand or "").strip().lower() or None) + if key in seen: + continue + seen.add(key) + out.append(row) + if len(out) >= limit: + break + return out + + +# --- Favorites ---------------------------------------------------------------- + + +def favorites_query(user_id: int, sort: str | None) -> Select: + stmt = select(FoodFavorite).where(FoodFavorite.user_id == user_id) + return _apply_sort( + stmt, + sort, + { + "use_count": FoodFavorite.use_count, + "name": FoodFavorite.name, + "last_used_at": FoodFavorite.last_used_at, + }, + "-use_count", + ) + + +def create_favorite( + db: Session, user_id: int, payload: FoodFavoriteCreate +) -> FoodFavorite: + existing = db.scalar( + select(FoodFavorite).where( + FoodFavorite.user_id == user_id, + FoodFavorite.name == payload.name, + FoodFavorite.brand.is_(None) + if payload.brand is None + else FoodFavorite.brand == payload.brand, + ) + ) + if existing is not None: + raise ConflictError("Cet aliment favori existe déjà.") + favorite = FoodFavorite( + user_id=user_id, + name=payload.name, + brand=payload.brand, + unit=payload.unit, + default_meal=payload.default_meal, + default_quantity=_dec(payload.default_quantity), + kcal=_dec(payload.kcal), + protein_g=_dec(payload.protein_g), + carbs_g=_dec(payload.carbs_g), + fat_g=_dec(payload.fat_g), + fiber_g=_dec(payload.fiber_g), + ) + db.add(favorite) + db.commit() + db.refresh(favorite) + return favorite + + +def get_favorite(db: Session, user_id: int, favorite_id: int) -> FoodFavorite: + favorite = db.get(FoodFavorite, favorite_id) + if favorite is None or favorite.user_id != user_id: + raise NotFoundError("Aliment favori introuvable.") + return favorite + + +def update_favorite( + db: Session, user_id: int, favorite_id: int, payload: FoodFavoriteUpdate +) -> FoodFavorite: + favorite = get_favorite(db, user_id, favorite_id) + for field, value in payload.model_dump(exclude_unset=True).items(): + if field in { + "default_quantity", + "kcal", + "protein_g", + "carbs_g", + "fat_g", + "fiber_g", + }: + value = _dec(value) + setattr(favorite, field, value) + db.commit() + db.refresh(favorite) + return favorite + + +def delete_favorite(db: Session, user_id: int, favorite_id: int) -> None: + db.delete(get_favorite(db, user_id, favorite_id)) + db.commit() + + +# --- Water -------------------------------------------------------------------- + + +def water_query( + user_id: int, + from_: dt.date | None, + to: dt.date | None, + tz: ZoneInfo, + sort: str | None, +) -> Select: + stmt = select(WaterEntry).where(WaterEntry.user_id == user_id) + if from_ or to: + start, end = resolve_range(from_, to, tz, default_days=3650, max_days=None) + begin, stop = utc_window(start, end, tz) + stmt = stmt.where(WaterEntry.drunk_at >= begin, WaterEntry.drunk_at < stop) + return _apply_sort(stmt, sort, {"drunk_at": WaterEntry.drunk_at}, "-drunk_at") + + +def create_water(db: Session, user_id: int, payload: WaterEntryCreate) -> WaterEntry: + entry = WaterEntry( + user_id=user_id, + source=MANUAL_SOURCE, + drunk_at=to_utc(payload.drunk_at), + volume_ml=payload.volume_ml, + ) + db.add(entry) + db.commit() + db.refresh(entry) + return entry + + +def delete_water(db: Session, user_id: int, entry_id: int) -> None: + entry = db.get(WaterEntry, entry_id) + if entry is None or entry.user_id != user_id: + raise NotFoundError("Entrée d'hydratation introuvable.") + db.delete(entry) + db.commit() + + +def water_between( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> list[WaterEntry]: + begin, stop = utc_window(start, end, tz) + return list( + db.scalars( + select(WaterEntry).where( + WaterEntry.user_id == user_id, + WaterEntry.drunk_at >= begin, + WaterEntry.drunk_at < stop, + ) + ).all() + ) + + +# --- Planning (addendum-planning.md) ------------------------------------------ + + +def list_schedules(db: Session, user_id: int) -> list[TrackingSchedule]: + """The three habits; missing ones are returned disabled with no weekday.""" + rows = { + row.kind: row + for row in db.scalars( + select(TrackingSchedule).where(TrackingSchedule.user_id == user_id) + ).all() + } + out: list[TrackingSchedule] = [] + for kind in calc.SCHEDULE_KINDS: + existing = rows.get(ScheduleKind(kind)) + out.append( + existing + if existing is not None + else TrackingSchedule( + user_id=user_id, + kind=ScheduleKind(kind), + weekdays=list(DEFAULT_WEEKDAYS), + enabled=False, + ) + ) + return out + + +def upsert_schedule( + db: Session, user_id: int, kind: ScheduleKind, payload: ScheduleUpdate +) -> TrackingSchedule: + weekdays = sorted({int(day) for day in payload.weekdays}) + if any(day < 0 or day > 6 for day in weekdays): + raise DomainValidationError( + "Les jours doivent être compris entre 0 (lundi) et 6 (dimanche)." + ) + row = db.scalar( + select(TrackingSchedule).where( + TrackingSchedule.user_id == user_id, TrackingSchedule.kind == kind + ) + ) + if row is None: + row = TrackingSchedule(user_id=user_id, kind=kind) + db.add(row) + row.weekdays = weekdays + row.enabled = payload.enabled + db.commit() + db.refresh(row) + return row + + +def done_days_by_kind( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> dict[str, dict[dt.date, float]]: + """Derived "done" days + the value shown by the UI, per habit kind. + + weigh_in -> first weight of the day (kg); workout -> session count; + food_log -> total kcal logged that day. + """ + begin, stop = utc_window(start, end, tz) + out: dict[str, dict[dt.date, float]] = { + "weigh_in": {}, + "workout": {}, + "food_log": {}, + } + weights = db.scalars( + select(WeightEntry) + .where( + WeightEntry.user_id == user_id, + WeightEntry.measured_at >= begin, + WeightEntry.measured_at < stop, + ) + .order_by(WeightEntry.measured_at.asc()) + ).all() + for row in weights: + day = local_day_of(row.measured_at, tz) + out["weigh_in"].setdefault(day, float(row.weight_kg)) + workouts = db.scalars( + select(Workout).where( + Workout.user_id == user_id, + Workout.is_hidden.is_(False), + Workout.started_at >= begin, + Workout.started_at < stop, + ) + ).all() + for workout in workouts: + day = local_day_of(workout.started_at, tz) + out["workout"][day] = out["workout"].get(day, 0.0) + 1 + for entry in food_entries_between(db, user_id, start, end, tz): + day = local_day_of(entry.eaten_at, tz) + out["food_log"][day] = out["food_log"].get(day, 0.0) + float(entry.kcal) + return out + + +def schedule_map(db: Session, user_id: int) -> dict[str, TrackingSchedule]: + return {str(row.kind.value): row for row in list_schedules(db, user_id)} + + +# --- Cross-cutting aggregation helpers (used by stats.py) --------------------- + + +def intake_by_day( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> dict[dt.date, dict[str, float]]: + """Per local day nutritional totals; days without any entry are absent.""" + totals: dict[dt.date, dict[str, float]] = {} + for entry in food_entries_between(db, user_id, start, end, tz): + day = local_day_of(entry.eaten_at, tz) + bucket = totals.setdefault( + day, + { + "kcal": 0.0, + "protein_g": 0.0, + "carbs_g": 0.0, + "fat_g": 0.0, + "fiber_g": 0.0, + }, + ) + bucket["kcal"] += float(entry.kcal) + for macro in ("protein_g", "carbs_g", "fat_g", "fiber_g"): + value = getattr(entry, macro) + if value is not None: + bucket[macro] += float(value) + return totals + + +def meal_kcal_by_day( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> dict[dt.date, dict[str, float]]: + per_day: dict[dt.date, dict[str, float]] = {} + for entry in food_entries_between(db, user_id, start, end, tz): + day = local_day_of(entry.eaten_at, tz) + bucket = per_day.setdefault(day, {meal.value: 0.0 for meal in MealType}) + bucket[entry.meal.value] += float(entry.kcal) + return per_day + + +def top_foods( + db: Session, + user_id: int, + start: dt.date, + end: dt.date, + tz: ZoneInfo, + limit: int = 10, +) -> list[dict]: + stats: dict[str, dict] = {} + for entry in food_entries_between(db, user_id, start, end, tz): + key = entry.name.strip() + bucket = stats.setdefault(key, {"name": key, "kcal": 0.0, "count": 0}) + bucket["kcal"] += float(entry.kcal) + bucket["count"] += 1 + ranked = sorted(stats.values(), key=lambda item: item["kcal"], reverse=True) + return ranked[:limit] + + +def workouts_between( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> list[Workout]: + begin, stop = utc_window(start, end, tz) + return list( + db.scalars( + select(Workout) + .where( + Workout.user_id == user_id, + Workout.is_hidden.is_(False), + and_(Workout.started_at >= begin, Workout.started_at < stop), + ) + .order_by(Workout.started_at.asc()) + ).all() + ) diff --git a/apps/api/app/modules/health/stats.py b/apps/api/app/modules/health/stats.py new file mode 100644 index 0000000..6b6801b --- /dev/null +++ b/apps/api/app/modules/health/stats.py @@ -0,0 +1,945 @@ +"""Chart-ready aggregations for the health module. + +Every `stats/*` response follows the contract of datamodel-health-vape.md §8.1: +{from, to, unit, series:[{name, type, points:[[date, value|null]]}], meta}. +Points are ready for `dataset.source` in ECharts; series names are stable +identifiers (the UI maps them to French labels). +""" + +import datetime as dt +from dataclasses import dataclass +from zoneinfo import ZoneInfo + +from sqlalchemy.orm import Session + +from app.modules.health import calculations as calc +from app.modules.health import service +from app.modules.health.models import Goal, HealthProfile, MealType, ScheduleKind +from app.modules.health.schemas import ( + AdherenceDay, + AdherenceKind, + AdherenceResponse, + BudgetRead, + DashboardResponse, + GoalRead, + MealTotals, + NutritionDayDetail, + NutritionDayRead, + ProjectionRead, + Series, + StatsResponse, + StreakRead, + TodayItem, + TodayResponse, +) + +STREAK_WINDOW_DAYS = 365 +MACRO_KCAL = {"protein_g": 4.0, "carbs_g": 4.0, "fat_g": 9.0} + +# Calendar-heatmap encoding of an adherence day. `None` (rest) keeps the cell +# empty in ECharts; the UI colours 0/1/2 via visualMap pieces. +ADHERENCE_STATUS_CODES: dict[str, int | None] = { + "rest": None, + "missed": 0, + "done_unplanned": 1, + "done": 2, +} + + +def _iso(day: dt.date) -> str: + return day.isoformat() + + +def _round(value: float | None, digits: int = 1) -> float | None: + return None if value is None else round(value, digits) + + +# --- Daily energy model ------------------------------------------------------- + + +@dataclass +class DailyModel: + day: dt.date + trend_weight_kg: float | None = None + bmr_kcal: float | None = None + tdee_kcal: float | None = None + tdee_method: str | None = None + tdee_smoothed_kcal: float | None = None + intake_kcal: float | None = None + budget_kcal: float | None = None + deficit_target_kcal: float | None = None + balance_kcal: float | None = None + steps: int | None = None + active_kcal: float | None = None + total_kcal: float | None = None + distance_m: int | None = None + floor_applied: bool = False + rate_clamped: bool = False + + +def build_daily_models( + db: Session, + user_id: int, + start: dt.date, + end: dt.date, + tz: ZoneInfo, + profile: HealthProfile | None = None, + goal: Goal | None = None, +) -> list[DailyModel]: + """One model per local day of [start, end], TDEE smoothed over 7 days.""" + if profile is None: + profile = service.get_profile(db, user_id) + if goal is None: + goal = service.active_goal(db, user_id) + warmup = start - dt.timedelta(days=calc.TDEE_SMOOTHING_DAYS - 1) + days = calc.date_range(warmup, end) + trend = calc.weight_trend(service.daily_weights(db, user_id, tz, until=end)) + activity = service.merged_activity(db, user_id, warmup, end) + intake = service.intake_by_day(db, user_id, warmup, end, tz) + + models: list[DailyModel] = [] + for day in days: + model = DailyModel(day=day) + merged = activity.get(day) + if merged is not None: + model.steps = merged.steps + model.active_kcal = merged.active_kcal + model.total_kcal = merged.total_kcal + model.distance_m = merged.distance_m + model.trend_weight_kg = calc.trend_at_day(trend, day) + if profile is not None and model.trend_weight_kg is not None: + model.bmr_kcal = calc.bmr_mifflin( + model.trend_weight_kg, + float(profile.height_cm), + calc.age_on(day, profile.birthdate), + profile.sex.value, + ) + tdee = calc.tdee_effective( + model.bmr_kcal, + profile.activity_level.value, + total_kcal=model.total_kcal, + active_kcal=model.active_kcal, + ) + model.tdee_kcal = tdee.kcal + model.tdee_method = tdee.method + bucket = intake.get(day) + model.intake_kcal = bucket["kcal"] if bucket else None + models.append(model) + + smoothed = calc.moving_average( + [m.tdee_kcal for m in models], calc.TDEE_SMOOTHING_DAYS + ) + for model, value in zip(models, smoothed, strict=True): + model.tdee_smoothed_kcal = value + model.balance_kcal = calc.energy_balance(model.intake_kcal, model.tdee_kcal) + if profile is None or value is None: + continue + rate = calc.resolve_goal_rate( + mode=goal.mode.value if goal else "maintain", + day=model.day, + trend_now=model.trend_weight_kg or 0.0, + target_weight_kg=float(goal.target_weight_kg) if goal else 0.0, + weekly_rate_kg=float(goal.weekly_rate_kg) + if goal and goal.weekly_rate_kg is not None + else None, + target_date=goal.target_date if goal else None, + ) + budget = calc.daily_budget( + value, rate, profile.sex.value, profile.calorie_floor_kcal + ) + model.budget_kcal = budget.kcal + model.deficit_target_kcal = budget.deficit_target + model.floor_applied = budget.floor_applied + model.rate_clamped = budget.rate_clamped + return [m for m in models if m.day >= start] + + +def plan_end_date(goal: Goal) -> dt.date | None: + """Day the PLAN reaches the target weight (§5.6b) — never the chart bound. + + `target_date` goals carry it; `weekly_rate` goals derive it from the rate + (`days = delta / (weekly_rate_kg / 7)`). Returns None when the plan cannot + converge (maintain, zero rate, or a rate pushing away from the target). + """ + if goal.target_date is not None: + return goal.target_date + rate = float(goal.weekly_rate_kg) if goal.weekly_rate_kg is not None else 0.0 + delta = float(goal.start_weight_kg) - float(goal.target_weight_kg) + if rate == 0.0 or delta == 0.0 or delta / rate <= 0: + return None + return goal.start_date + dt.timedelta(days=round(delta / rate * 7)) + + +# --- Weight ------------------------------------------------------------------- + + +def weight_stats( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> StatsResponse: + raw_all = service.daily_weights(db, user_id, tz, until=end) + trend_all = calc.weight_trend(raw_all) + raw = [(d, w) for d, w in raw_all if start <= d <= end] + trend = [(d, w) for d, w in trend_all if start <= d <= end] + goal = service.active_goal(db, user_id) + + series = [ + Series( + name="weight_raw", + type="scatter", + points=[[_iso(d), w] for d, w in raw], + ), + Series( + name="weight_trend", + type="line", + points=[[_iso(d), w] for d, w in trend], + ), + ] + + slope_14 = calc.regression_slope( + [p for p in trend_all if p[0] > end - dt.timedelta(days=14)] + ) + slope_30 = calc.regression_slope( + [p for p in trend_all if p[0] > end - dt.timedelta(days=30)] + ) + trend_now = trend_all[-1][1] if trend_all else None + + projection = calc.Projection(status="not_converging") + if trend_now is not None and goal is not None: + projection = calc.project_target_date( + trend_now, float(goal.target_weight_kg), slope_30 or slope_14, end + ) + plan_end = plan_end_date(goal) + if plan_end is not None: + series.append( + Series( + name="plan_line", + type="line", + points=[ + [_iso(goal.start_date), float(goal.start_weight_kg)], + [_iso(plan_end), float(goal.target_weight_kg)], + ], + ) + ) + if projection.status == "ok" and projection.date is not None: + series.append( + Series( + name="projection", + type="line", + points=[ + [_iso(trend_all[-1][0]), trend_now], + [_iso(projection.date), float(goal.target_weight_kg)], + ], + ) + ) + + profile = service.get_profile(db, user_id) + bmi = ( + calc.bmi(trend_now, float(profile.height_cm)) + if trend_now is not None and profile is not None + else None + ) + meta = { + "trend_now_kg": _round(trend_now, 2), + "last_weight_kg": _round(raw_all[-1][1], 2) if raw_all else None, + "slope_14d_kg_day": _round(slope_14, 4), + "slope_14d_kg_week": _round(slope_14 * 7, 3) if slope_14 is not None else None, + "slope_30d_kg_day": _round(slope_30, 4), + "slope_30d_kg_week": _round(slope_30 * 7, 3) if slope_30 is not None else None, + "total_change_kg": _round(trend[-1][1] - trend[0][1], 2) + if len(trend) > 1 + else None, + "bmi": _round(bmi, 1), + "target_weight_kg": float(goal.target_weight_kg) if goal else None, + "plan_end_date": _iso(plan_end_date(goal)) + if goal and plan_end_date(goal) + else None, + "projection": { + "status": projection.status, + "date": _iso(projection.date) if projection.date else None, + }, + "count": len(raw), + } + return StatsResponse(from_=start, to=end, unit="kg", series=series, meta=meta) + + +def measurement_stats( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> StatsResponse: + from app.modules.health.schemas import MEASUREMENT_SITES + + rows = service.measurements_between(db, user_id, start, end, tz) + series: list[Series] = [] + for site in MEASUREMENT_SITES: + points = [ + [_iso(service.local_day_of(row.measured_at, tz)), float(getattr(row, site))] + for row in rows + if getattr(row, site) is not None + ] + if points: + series.append(Series(name=site, type="line", points=points)) + + profile = service.get_profile(db, user_id) + navy_points: list[list] = [] + if profile is not None: + for row in rows: + value = calc.navy_body_fat_pct( + profile.sex.value, + float(profile.height_cm), + float(row.waist_cm) if row.waist_cm is not None else None, + float(row.neck_cm) if row.neck_cm is not None else None, + float(row.hips_cm) if row.hips_cm is not None else None, + ) + if value is not None: + navy_points.append( + [_iso(service.local_day_of(row.measured_at, tz)), round(value, 1)] + ) + if navy_points: + series.append(Series(name="body_fat_navy_pct", type="line", points=navy_points)) + return StatsResponse( + from_=start, + to=end, + unit="cm", + series=series, + meta={"count": len(rows), "sites": [s.name for s in series]}, + ) + + +# --- Activity ----------------------------------------------------------------- + + +def activity_stats( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> StatsResponse: + merged = service.merged_activity(db, user_id, start, end) + days = calc.date_range(start, end) + fields = ("steps", "active_kcal", "total_kcal", "distance_m") + series: list[Series] = [] + meta: dict = {} + for field in fields: + values: list[float | None] = [] + for day in days: + row = merged.get(day) + value = getattr(row, field) if row else None + values.append(float(value) if value is not None else None) + series.append( + Series( + name=field, + type="bar" if field in {"steps", "active_kcal"} else "line", + points=[[_iso(d), v] for d, v in zip(days, values, strict=True)], + ) + ) + ma = calc.moving_average(values, 7) + series.append( + Series( + name=f"{field}_ma7", + type="line", + points=[[_iso(d), _round(v)] for d, v in zip(days, ma, strict=True)], + ) + ) + tracked = [v for v in values if v is not None] + meta[f"avg_{field}"] = _round(sum(tracked) / len(tracked)) if tracked else None + meta[f"total_{field}"] = _round(sum(tracked)) if tracked else None + meta["tracked_days"] = len(merged) + meta["days"] = len(days) + return StatsResponse(from_=start, to=end, unit="mixed", series=series, meta=meta) + + +def workout_stats( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> StatsResponse: + workouts = service.workouts_between(db, user_id, start, end, tz) + weekly: dict[dt.date, dict[str, float]] = {} + by_sport: dict[str, dict[str, float]] = {} + for workout in workouts: + day = service.local_day_of(workout.started_at, tz) + week_start = day - dt.timedelta(days=day.weekday()) + bucket = weekly.setdefault( + week_start, + { + "sessions_count": 0.0, + "total_kcal": 0.0, + "total_distance_m": 0.0, + "total_duration_min": 0.0, + }, + ) + bucket["sessions_count"] += 1 + bucket["total_kcal"] += float(workout.kcal or 0) + bucket["total_distance_m"] += float(workout.distance_m or 0) + bucket["total_duration_min"] += service.workout_duration_s(workout) / 60 + sport = workout.sport_type.value + sbucket = by_sport.setdefault( + sport, {"sessions": 0.0, "duration_min": 0.0, "kcal": 0.0} + ) + sbucket["sessions"] += 1 + sbucket["duration_min"] += service.workout_duration_s(workout) / 60 + sbucket["kcal"] += float(workout.kcal or 0) + + weeks = sorted(weekly) + series = [ + Series( + name=name, + type="bar", + points=[[_iso(week), _round(weekly[week][name])] for week in weeks], + ) + for name in ( + "sessions_count", + "total_kcal", + "total_distance_m", + "total_duration_min", + ) + ] + series.append( + Series( + name="by_sport_duration_min", + type="pie", + points=[ + [sport, _round(values["duration_min"])] + for sport, values in sorted( + by_sport.items(), + key=lambda item: item[1]["duration_min"], + reverse=True, + ) + ], + ) + ) + total_duration = sum(service.workout_duration_s(w) for w in workouts) / 60 + meta = { + "sessions": len(workouts), + "total_duration_min": _round(total_duration), + "total_kcal": _round(sum(float(w.kcal or 0) for w in workouts)), + "total_distance_m": _round(sum(float(w.distance_m or 0) for w in workouts)), + "by_sport": by_sport, + } + return StatsResponse(from_=start, to=end, unit="mixed", series=series, meta=meta) + + +# --- Energy balance ----------------------------------------------------------- + + +def energy_balance_stats( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> StatsResponse: + profile = service.get_profile(db, user_id) + models = build_daily_models(db, user_id, start, end, tz, profile=profile) + days = [m.day for m in models] + + cumulative: list[float | None] = [] + running = 0.0 + for model in models: + if model.balance_kcal is not None: + running += model.balance_kcal + cumulative.append(round(running, 1)) + + series = [ + Series( + name="intake_kcal", + type="bar", + points=[[_iso(m.day), _round(m.intake_kcal)] for m in models], + ), + Series( + name="tdee_kcal", + type="line", + points=[[_iso(m.day), _round(m.tdee_kcal)] for m in models], + ), + Series( + name="balance_kcal", + type="bar", + points=[[_iso(m.day), _round(m.balance_kcal)] for m in models], + ), + Series( + name="budget_kcal", + type="line", + points=[[_iso(m.day), _round(m.budget_kcal)] for m in models], + ), + Series( + name="cumulative_balance_kcal", + type="line", + points=[[_iso(d), v] for d, v in zip(days, cumulative, strict=True)], + ), + ] + + tracked = [ + (m.intake_kcal, m.tdee_kcal) + for m in models + if m.intake_kcal is not None and m.tdee_kcal is not None + ] + trend_all = calc.weight_trend(service.daily_weights(db, user_id, tz, until=end)) + calibration = calc.tdee_calibration( + tracked, + calc.trend_at_day(trend_all, start), + calc.trend_at_day(trend_all, end), + ) + meta = { + "tdee_methods": { + _iso(m.day): m.tdee_method for m in models if m.tdee_method is not None + }, + "cumulative_balance_kcal": _round(running), + "cumulative_kg_equivalent": _round(running / calc.KCAL_PER_KG_FAT, 2), + # Daily deficit aimed at by the active goal on the last day of the range + # (the UI draws it as the target line of the balance chart). + "deficit_target_kcal": _round(models[-1].deficit_target_kcal) + if models + else None, + "tracked_days": calibration.tracked_days, + "days": len(models), + "profile_missing": profile is None, + "calibration_status": calibration.status, + "expected_change_kg": _round(calibration.expected_change_kg, 2), + "actual_change_kg": _round(calibration.actual_change_kg, 2), + "gap_kg": _round(calibration.gap_kg, 2), + "tdee_adaptive_kcal": _round(calibration.tdee_adaptive_kcal), + "tdee_correction_kcal": _round(calibration.tdee_correction_kcal), + } + return StatsResponse(from_=start, to=end, unit="kcal", series=series, meta=meta) + + +# --- Nutrition ---------------------------------------------------------------- + + +def nutrition_stats( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> StatsResponse: + models = build_daily_models(db, user_id, start, end, tz) + budgets = {m.day: m.budget_kcal for m in models} + totals = service.intake_by_day(db, user_id, start, end, tz) + meals = service.meal_kcal_by_day(db, user_id, start, end, tz) + days = calc.date_range(start, end) + + series = [ + Series( + name="kcal", + type="bar", + points=[ + [_iso(d), _round(totals[d]["kcal"]) if d in totals else None] + for d in days + ], + ), + Series( + name="budget_kcal", + type="line", + points=[[_iso(d), _round(budgets.get(d))] for d in days], + ), + ] + for macro, factor in MACRO_KCAL.items(): + series.append( + Series( + name=f"{macro}_kcal", + type="bar", + points=[ + [ + _iso(d), + _round(totals[d][macro] * factor) if d in totals else None, + ] + for d in days + ], + ) + ) + series.append( + Series( + name=macro, + type="bar", + points=[ + [_iso(d), _round(totals[d][macro]) if d in totals else None] + for d in days + ], + ) + ) + for meal in MealType: + series.append( + Series( + name=f"meal_{meal.value}_kcal", + type="bar", + points=[ + [ + _iso(d), + _round(meals[d][meal.value]) if d in meals else None, + ] + for d in days + ], + ) + ) + ranked = service.top_foods(db, user_id, start, end, tz) + series.append( + Series( + name="top_foods_kcal", + type="bar", + points=[[item["name"], _round(item["kcal"])] for item in ranked], + ) + ) + + tracked = [totals[d] for d in days if d in totals] + tracked_kcal = [t["kcal"] for t in tracked] + macro_totals = {macro: sum(t[macro] for t in tracked) for macro in MACRO_KCAL} + macro_kcal_total = sum(macro_totals[m] * MACRO_KCAL[m] for m in MACRO_KCAL) + gaps = [ + totals[d]["kcal"] - budgets[d] + for d in days + if d in totals and budgets.get(d) is not None + ] + meta = { + "tracked_days": len(tracked), + "days": len(days), + "avg_kcal": _round(sum(tracked_kcal) / len(tracked_kcal)) + if tracked_kcal + else None, + "avg_protein_g": _round(macro_totals["protein_g"] / len(tracked)) + if tracked + else None, + "avg_carbs_g": _round(macro_totals["carbs_g"] / len(tracked)) + if tracked + else None, + "avg_fat_g": _round(macro_totals["fat_g"] / len(tracked)) if tracked else None, + "macro_split_pct": { + macro: _round( + 100 * macro_totals[macro] * MACRO_KCAL[macro] / macro_kcal_total + ) + for macro in MACRO_KCAL + } + if macro_kcal_total > 0 + else {}, + "days_in_budget": sum(1 for gap in gaps if gap <= 0), + "days_with_budget": len(gaps), + "avg_gap_kcal": _round(sum(gaps) / len(gaps)) if gaps else None, + # Rounded like every other kcal figure: raw float sums otherwise leak + # binary artefacts (3667.9000000000005) straight into the UI. + "top_foods": [{**item, "kcal": _round(item["kcal"])} for item in ranked], + } + return StatsResponse(from_=start, to=end, unit="kcal", series=series, meta=meta) + + +def nutrition_days( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> list[NutritionDayRead]: + models = build_daily_models(db, user_id, start, end, tz) + totals = service.intake_by_day(db, user_id, start, end, tz) + out: list[NutritionDayRead] = [] + for model in models: + bucket = totals.get(model.day) + vs_budget = ( + bucket["kcal"] - model.budget_kcal + if bucket is not None and model.budget_kcal is not None + else None + ) + out.append( + NutritionDayRead( + date=model.day, + kcal=_round(bucket["kcal"]) if bucket else None, + protein_g=_round(bucket["protein_g"]) if bucket else None, + carbs_g=_round(bucket["carbs_g"]) if bucket else None, + fat_g=_round(bucket["fat_g"]) if bucket else None, + fiber_g=_round(bucket["fiber_g"]) if bucket else None, + budget_kcal=_round(model.budget_kcal), + vs_budget_kcal=_round(vs_budget), + ) + ) + return out + + +def nutrition_day_detail( + db: Session, user_id: int, day: dt.date, tz: ZoneInfo +) -> NutritionDayDetail: + from app.modules.health.schemas import FoodEntryRead + + entries = service.food_entries_between(db, user_id, day, day, tz) + meals: dict[str, MealTotals] = { + meal.value: MealTotals(meal=meal) for meal in MealType + } + for entry in entries: + bucket = meals[entry.meal.value] + bucket.kcal += float(entry.kcal) + bucket.protein_g += float(entry.protein_g or 0) + bucket.carbs_g += float(entry.carbs_g or 0) + bucket.fat_g += float(entry.fat_g or 0) + bucket.entries.append(FoodEntryRead.model_validate(entry)) + for bucket in meals.values(): + bucket.kcal = round(bucket.kcal, 1) + bucket.protein_g = round(bucket.protein_g, 1) + bucket.carbs_g = round(bucket.carbs_g, 1) + bucket.fat_g = round(bucket.fat_g, 1) + totals = nutrition_days(db, user_id, day, day, tz)[0] + profile = service.get_profile(db, user_id) + water = sum( + row.volume_ml for row in service.water_between(db, user_id, day, day, tz) + ) + return NutritionDayDetail( + date=day, + totals=totals, + water_ml=water, + water_goal_ml=profile.water_goal_ml if profile else None, + meals=list(meals.values()), + ) + + +def water_stats( + db: Session, user_id: int, start: dt.date, end: dt.date, tz: ZoneInfo +) -> StatsResponse: + rows = service.water_between(db, user_id, start, end, tz) + per_day: dict[dt.date, int] = {} + for row in rows: + day = service.local_day_of(row.drunk_at, tz) + per_day[day] = per_day.get(day, 0) + row.volume_ml + days = calc.date_range(start, end) + profile = service.get_profile(db, user_id) + goal_ml = profile.water_goal_ml if profile else None + series = [ + Series( + name="volume_ml", + type="bar", + points=[[_iso(d), per_day.get(d, 0)] for d in days], + ), + Series( + name="goal_ml", + type="line", + points=[[_iso(d), goal_ml] for d in days], + ), + ] + tracked = [v for v in per_day.values()] + meta = { + "goal_ml": goal_ml, + "avg_ml": _round(sum(tracked) / len(tracked)) if tracked else None, + "tracked_days": len(tracked), + "days_goal_reached": sum(1 for v in tracked if goal_ml and v >= goal_ml), + } + return StatsResponse(from_=start, to=end, unit="ml", series=series, meta=meta) + + +# --- Planning: today & adherence ---------------------------------------------- + + +def today_view(db: Session, user_id: int, tz: ZoneInfo) -> TodayResponse: + today = service.today_local(tz) + start = today - dt.timedelta(days=STREAK_WINDOW_DAYS) + schedules = service.schedule_map(db, user_id) + done = service.done_days_by_kind(db, user_id, start, today, tz) + + items: list[TodayItem] = [] + streaks: dict[str, StreakRead] = {} + for kind in calc.SCHEDULE_KINDS: + schedule = schedules[kind] + days = calc.build_adherence( + start, today, schedule.weekdays, schedule.enabled, set(done[kind]) + ) + streak = calc.compute_streaks(days, today) + streaks[kind] = StreakRead(current=streak.current, best=streak.best) + items.append( + TodayItem( + kind=ScheduleKind(kind), + planned=calc.is_planned(schedule.weekdays, today, schedule.enabled), + done=today in done[kind], + value=_round(done[kind].get(today), 2), + ) + ) + return TodayResponse(date=today, items=items, streaks=streaks) + + +def adherence_stats( + db: Session, + user_id: int, + start: dt.date, + end: dt.date, + tz: ZoneInfo, + kind: str | None = None, +) -> AdherenceResponse: + today = service.today_local(tz) + schedules = service.schedule_map(db, user_id) + # Streaks look further back than the requested window on purpose. + streak_start = min(start, end - dt.timedelta(days=STREAK_WINDOW_DAYS)) + done = service.done_days_by_kind(db, user_id, streak_start, end, tz) + + kinds: list[AdherenceKind] = [] + for name in calc.SCHEDULE_KINDS: + if kind and name != kind: + continue + schedule = schedules[name] + done_days = set(done[name]) + window = calc.build_adherence( + start, end, schedule.weekdays, schedule.enabled, done_days + ) + full = calc.build_adherence( + streak_start, end, schedule.weekdays, schedule.enabled, done_days + ) + streak = calc.compute_streaks(full, today) + planned = [d for d in window if d.planned] + kinds.append( + AdherenceKind( + kind=ScheduleKind(name), + weekdays=list(schedule.weekdays or []), + enabled=schedule.enabled, + planned_days=len(planned), + done_days=sum(1 for d in planned if d.done), + missed_days=sum(1 for d in planned if d.missed), + adherence_pct=_round(calc.adherence_pct(window)), + streak=StreakRead(current=streak.current, best=streak.best), + days=[ + AdherenceDay( + date=d.day, planned=d.planned, done=d.done, status=d.status + ) + for d in window + ], + ) + ) + + # §8.1 envelope: one calendar-heatmap series per habit, points [day, code]. + series = [ + Series( + name=item.kind.value, + type="heatmap", + points=[ + [_iso(d.date), ADHERENCE_STATUS_CODES[d.status]] for d in item.days + ], + ) + for item in kinds + ] + meta = { + "status_codes": ADHERENCE_STATUS_CODES, + "kinds": { + item.kind.value: { + "enabled": item.enabled, + "weekdays": item.weekdays, + "planned_days": item.planned_days, + "done_days": item.done_days, + "missed_days": item.missed_days, + "adherence_pct": item.adherence_pct, + "streak_current": item.streak.current, + "streak_best": item.streak.best, + } + for item in kinds + }, + } + return AdherenceResponse( + from_=start, to=end, unit="day", series=series, meta=meta, kinds=kinds + ) + + +# --- Dashboard ---------------------------------------------------------------- + + +def dashboard(db: Session, user_id: int, tz: ZoneInfo) -> DashboardResponse: + today = service.today_local(tz) + start = today - dt.timedelta(days=29) + profile = service.get_profile(db, user_id) + goal = service.active_goal(db, user_id) + models = build_daily_models( + db, user_id, start, today, tz, profile=profile, goal=goal + ) + current = models[-1] if models else DailyModel(day=today) + + raw_all = service.daily_weights(db, user_id, tz, until=today) + trend_all = calc.weight_trend(raw_all) + trend_now = trend_all[-1][1] if trend_all else None + trend_7d_ago = calc.trend_at_day(trend_all, today - dt.timedelta(days=7)) + last_entry = db.scalar( + service.weights_query(user_id, None, None, tz, "-measured_at").limit(1) + ) + + cumulative = sum(m.balance_kcal for m in models if m.balance_kcal is not None) + week_start = today - dt.timedelta(days=today.weekday()) + workouts_week = service.workouts_between(db, user_id, week_start, today, tz) + water_today = sum( + row.volume_ml for row in service.water_between(db, user_id, today, today, tz) + ) + + projection = None + if goal is not None and trend_now is not None: + slope = calc.regression_slope( + [p for p in trend_all if p[0] > today - dt.timedelta(days=30)] + ) or calc.regression_slope( + [p for p in trend_all if p[0] > today - dt.timedelta(days=14)] + ) + result = calc.project_target_date( + trend_now, float(goal.target_weight_kg), slope, today + ) + projection = ProjectionRead(status=result.status, date=result.date) + + remaining = ( + current.budget_kcal - current.intake_kcal + if current.budget_kcal is not None and current.intake_kcal is not None + else current.budget_kcal + ) + return DashboardResponse( + date=today, + weight_kg=_round(float(last_entry.weight_kg), 2) if last_entry else None, + weight_measured_at=last_entry.measured_at if last_entry else None, + trend_weight_kg=_round(trend_now, 2), + trend_delta_7d_kg=_round(trend_now - trend_7d_ago, 2) + if trend_now is not None and trend_7d_ago is not None + else None, + bmi=_round(calc.bmi(trend_now, float(profile.height_cm)), 1) + if trend_now is not None and profile is not None + else None, + intake_kcal=_round(current.intake_kcal), + budget_kcal=_round(current.budget_kcal), + remaining_kcal=_round(remaining), + tdee_kcal=_round(current.tdee_kcal), + tdee_method=current.tdee_method, + balance_kcal=_round(current.balance_kcal), + cumulative_balance_30d_kcal=_round(cumulative), + steps=current.steps, + active_kcal=_round(current.active_kcal), + distance_m=current.distance_m, + water_ml=water_today, + water_goal_ml=profile.water_goal_ml if profile else None, + workouts_this_week=len(workouts_week), + goal=GoalRead.model_validate(goal) if goal else None, + projection=projection, + weight_series=[[_iso(d), w] for d, w in trend_all if d >= start], + today=today_view(db, user_id, tz), + ) + + +def active_goal_view(db: Session, user_id: int, tz: ZoneInfo): + """Active goal + budget of the day + projection + progress (§8.2).""" + from app.modules.health.schemas import ActiveGoalRead + + goal = service.active_goal(db, user_id) + if goal is None: + return ActiveGoalRead() + today = service.today_local(tz) + profile = service.get_profile(db, user_id) + models = build_daily_models( + db, user_id, today, today, tz, profile=profile, goal=goal + ) + model = models[-1] if models else DailyModel(day=today) + trend_all = calc.weight_trend(service.daily_weights(db, user_id, tz, until=today)) + trend_now = trend_all[-1][1] if trend_all else None + + budget = None + if model.budget_kcal is not None and model.tdee_kcal is not None: + budget = BudgetRead( + kcal=_round(model.budget_kcal), + # The deficit AIMED AT by the goal (rate x 7700 / 7), not the one the + # floored budget happens to leave — §5.3 BudgetResult.deficit_target. + deficit_target_kcal=_round(model.deficit_target_kcal or 0.0), + floor_applied=model.floor_applied, + rate_clamped=model.rate_clamped, + tdee_kcal=_round(model.tdee_kcal), + tdee_method=model.tdee_method or "estimated", + ) + projection = None + if trend_now is not None: + slope = calc.regression_slope( + [p for p in trend_all if p[0] > today - dt.timedelta(days=30)] + ) + result = calc.project_target_date( + trend_now, float(goal.target_weight_kg), slope, today + ) + projection = ProjectionRead(status=result.status, date=result.date) + + start_w = float(goal.start_weight_kg) + target_w = float(goal.target_weight_kg) + done_kg = remaining_kg = progress = None + if trend_now is not None: + done_kg = start_w - trend_now + remaining_kg = trend_now - target_w + span = start_w - target_w + progress = 100 * done_kg / span if span else None + progress = None if progress is None else max(0.0, min(100.0, progress)) + return ActiveGoalRead( + goal=GoalRead.model_validate(goal), + budget=budget, + projection=projection, + trend_weight_kg=_round(trend_now, 2), + done_kg=_round(done_kg, 2), + remaining_kg=_round(remaining_kg, 2), + progress_pct=_round(progress), + ) diff --git a/apps/api/app/modules/imports/__init__.py b/apps/api/app/modules/imports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/modules/imports/models.py b/apps/api/app/modules/imports/models.py new file mode 100644 index 0000000..dac031b --- /dev/null +++ b/apps/api/app/modules/imports/models.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import Any + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.core.mixins import JSONB_V, TimestampMixin + + +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) + # [{row, message}] capped at 100 + error_details: Mapped[list[dict[str, Any]]] = mapped_column(JSONB_V, default=list) + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/apps/api/app/modules/imports/router.py b/apps/api/app/modules/imports/router.py new file mode 100644 index 0000000..6ece40a --- /dev/null +++ b/apps/api/app/modules/imports/router.py @@ -0,0 +1,89 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, File, Form, Header, Query, UploadFile +from fastapi.security import HTTPAuthorizationCredentials +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.database import get_db +from app.core.dependencies import authenticate_ingest, bearer, get_current_user +from app.core.errors import PayloadTooLargeError +from app.core.pagination import Page, PageParams, paginate +from app.modules.auth.models import User +from app.modules.imports import service +from app.modules.imports.schemas import ( + ImportRunRead, + IngestPayload, + IngestResponse, + SourceInfo, +) + +router = APIRouter(prefix="/imports", tags=["imports"]) +ingest_router = APIRouter(prefix="/ingest", tags=["ingest"]) + +# Mounted by the module loader in addition to `router` (architecture §5.5). +extra_routers = [ingest_router] + +DbDep = Annotated[Session, Depends(get_db)] +UserDep = Annotated[User, Depends(get_current_user)] + + +@router.get("/sources", response_model=list[SourceInfo]) +def list_sources(user: UserDep) -> list[SourceInfo]: + return service.list_sources() + + +@router.post("", response_model=ImportRunRead, status_code=201) +def upload_import( + db: DbDep, + user: UserDep, + file: Annotated[UploadFile, File()], + source: Annotated[str, Form()], +) -> ImportRunRead: + data = file.file.read() + if len(data) > get_settings().max_upload_bytes: + raise PayloadTooLargeError("Fichier trop volumineux (limite : 20 Mio).") + filename = file.filename or "import" + importer_id = service.resolve_importer_id(source, filename, data[:4096]) + run = service.run_import(db, user, importer_id, filename, data) + return ImportRunRead.model_validate(run) + + +@router.get("", response_model=Page[ImportRunRead]) +def list_imports( + db: DbDep, + user: UserDep, + params: Annotated[PageParams, Depends()], + domain: Annotated[str | None, Query()] = None, + sort: Annotated[str | None, Query()] = None, +) -> Page[ImportRunRead]: + stmt = service.runs_query(user.id, domain, sort) + items, total = paginate(db, stmt, params) + return Page( + items=[ImportRunRead.model_validate(run) for run in items], + total=total, + page=params.page, + page_size=params.page_size, + ) + + +@router.get("/{run_id}", response_model=ImportRunRead) +def get_import(run_id: int, db: DbDep, user: UserDep) -> ImportRunRead: + return ImportRunRead.model_validate(service.get_run(db, user.id, run_id)) + + +@router.delete("/{run_id}", status_code=204) +def rollback_import(run_id: int, db: DbDep, user: UserDep) -> None: + service.rollback_run(db, user.id, run_id) + + +@ingest_router.post("/{domain}", response_model=IngestResponse) +def ingest( + domain: str, + payload: IngestPayload, + db: DbDep, + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], + x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, +) -> IngestResponse: + user = authenticate_ingest(db, credentials, x_api_key, f"ingest:{domain}") + return service.run_ingest(db, user, domain, payload) diff --git a/apps/api/app/modules/imports/schemas.py b/apps/api/app/modules/imports/schemas.py new file mode 100644 index 0000000..0a5672c --- /dev/null +++ b/apps/api/app/modules/imports/schemas.py @@ -0,0 +1,60 @@ +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from app.core.timeutils import StoredUtcDatetime + +MAX_INGEST_RECORDS = 1000 + + +class SourceInfo(BaseModel): + id: str + label: str + domain: str + accepted_extensions: tuple[str, ...] + + +class ImportRunRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + importer_id: str + domain: str + filename: str + file_size: int + status: str + rows_total: int + rows_inserted: int + rows_updated: int + rows_duplicates: int + rows_errors: int + error_details: list[dict[str, Any]] + started_at: StoredUtcDatetime + finished_at: StoredUtcDatetime | None + created_at: StoredUtcDatetime + + +class IngestRecordIn(BaseModel): + type: str + data: dict[str, Any] + external_id: str | None = None + + +class IngestPayload(BaseModel): + source: str = Field(default="ingest", min_length=1, max_length=50) + records: list[IngestRecordIn] = Field(min_length=1, max_length=MAX_INGEST_RECORDS) + + +class IngestRecordError(BaseModel): + index: int + type: str + message: str + + +class IngestResponse(BaseModel): + domain: str + received: int + inserted: int + updated: int + duplicates: int + errors: list[IngestRecordError] diff --git a/apps/api/app/modules/imports/service.py b/apps/api/app/modules/imports/service.py new file mode 100644 index 0000000..7dc27b9 --- /dev/null +++ b/apps/api/app/modules/imports/service.py @@ -0,0 +1,246 @@ +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.errors import DomainValidationError, NotFoundError +from app.core.importing.base import ( + ImporterParseError, + RowError, + UpsertOutcome, +) +from app.core.importing.registry import IMPORTER_REGISTRY, detect_importer +from app.core.ingest.base import IngestRecord +from app.core.ingest.registry import INGEST_REGISTRY +from app.core.timeutils import utcnow +from app.modules.auth.models import User +from app.modules.imports.models import ImportRun +from app.modules.imports.schemas import ( + IngestPayload, + IngestRecordError, + IngestResponse, + SourceInfo, +) + +MAX_ERROR_DETAILS = 100 + + +def list_sources() -> list[SourceInfo]: + return [ + SourceInfo( + id=cls.id, + label=cls.label, + domain=cls.domain, + accepted_extensions=cls.accepted_extensions, + ) + for cls in sorted(IMPORTER_REGISTRY.values(), key=lambda c: c.id) + ] + + +def resolve_importer_id(source: str, filename: str, head: bytes) -> str: + """Resolve the form field `source` ("auto" or an importer id) to an id.""" + if source == "auto": + detected = detect_importer(filename, head) + if detected is None: + raise DomainValidationError( + "Format non reconnu, choisissez un profil de source." + ) + return detected.id + if source not in IMPORTER_REGISTRY: + raise DomainValidationError( + "Profil de source inconnu.", details={"source": source} + ) + return source + + +def run_import( + db: Session, user: User, importer_id: str, filename: str, data: bytes +) -> ImportRun: + """Orchestrate one synchronous import run (see architecture §5.3). + + Row-level errors increment counters without stopping the run; a fatal + parse error marks the run as failed (partial domain rows rolled back). + A single commit ends the run. + """ + importer_cls = IMPORTER_REGISTRY[importer_id] + run = ImportRun( + user_id=user.id, + importer_id=importer_id, + domain=importer_cls.domain, + filename=filename, + file_size=len(data), + status="completed", + started_at=utcnow(), + ) + db.add(run) + db.flush() # assign run.id so imported rows can reference it + + importer = importer_cls() + importer.import_run_id = run.id + + counters = {outcome: 0 for outcome in UpsertOutcome} + error_details: list[dict[str, Any]] = [] + rows_total = 0 + fatal_message: str | None = None + + def record_error(row: int, message: str) -> None: + counters[UpsertOutcome.ERROR] += 1 + if len(error_details) < MAX_ERROR_DETAILS: + error_details.append({"row": row, "message": message}) + + iterator = importer.parse(data, filename) + row = 0 + while fatal_message is None: + row += 1 + try: + record = next(iterator) + except StopIteration: + break + except RowError as exc: + rows_total += 1 + record_error(row, str(exc)) + continue + except ImporterParseError as exc: + fatal_message = str(exc) or "Fichier illisible pour ce profil de source." + break + except Exception: # noqa: BLE001 — importer bug must fail the run, not the API + fatal_message = "Erreur inattendue pendant la lecture du fichier." + break + rows_total += 1 + try: + outcome = importer.upsert(db, user.id, record) + except RowError as exc: + record_error(row, str(exc)) + continue + except Exception: # noqa: BLE001 — a broken row must not stop the run + record_error(row, "Erreur inattendue sur cette ligne.") + continue + if outcome is UpsertOutcome.ERROR: + record_error(row, "Ligne rejetée par l'importeur.") + else: + counters[outcome] += 1 + + if fatal_message is not None: + # Drop partial domain rows AND the flushed run, then persist a failed run. + db.rollback() + run = ImportRun( + user_id=user.id, + importer_id=importer_id, + domain=importer_cls.domain, + filename=filename, + file_size=len(data), + status="failed", + rows_total=rows_total, + rows_inserted=0, + rows_updated=0, + rows_duplicates=0, + rows_errors=counters[UpsertOutcome.ERROR], + error_details=(error_details + [{"row": row, "message": fatal_message}])[ + :MAX_ERROR_DETAILS + ], + started_at=utcnow(), + finished_at=utcnow(), + ) + db.add(run) + db.commit() + return run + + run.rows_total = rows_total + run.rows_inserted = counters[UpsertOutcome.INSERTED] + run.rows_updated = counters[UpsertOutcome.UPDATED] + run.rows_duplicates = counters[UpsertOutcome.DUPLICATE] + run.rows_errors = counters[UpsertOutcome.ERROR] + run.error_details = error_details + run.finished_at = utcnow() + db.commit() + return run + + +def get_run(db: Session, user_id: int, run_id: int) -> ImportRun: + run = db.get(ImportRun, run_id) + if run is None or run.user_id != user_id: + raise NotFoundError("Import introuvable.") + return run + + +def runs_query(user_id: int, domain: str | None, sort: str | None): + stmt = select(ImportRun).where(ImportRun.user_id == user_id) + if domain: + stmt = stmt.where(ImportRun.domain == domain) + sort = sort or "-started_at" + allowed = {"started_at": ImportRun.started_at} + descending = sort.startswith("-") + field_name = sort.lstrip("-") + column = allowed.get(field_name) + if column is None: + raise DomainValidationError( + "Champ de tri non autorisé.", details={"sort": sort} + ) + order = column.desc() if descending else column.asc() + return stmt.order_by(order, ImportRun.id.desc()) + + +def rollback_run(db: Session, user_id: int, run_id: int) -> None: + """Delete a run; imported domain rows disappear via FK ON DELETE CASCADE.""" + run = get_run(db, user_id, run_id) + db.delete(run) + db.commit() + + +def run_ingest( + db: Session, user: User, domain: str, payload: IngestPayload +) -> IngestResponse: + handler = INGEST_REGISTRY.get(domain) + if handler is None: + raise NotFoundError("Domaine d'ingestion inconnu.", details={"domain": domain}) + counters = {outcome: 0 for outcome in UpsertOutcome} + errors: list[IngestRecordError] = [] + for index, item in enumerate(payload.records): + if item.type not in handler.record_types: + errors.append( + IngestRecordError( + index=index, + type=item.type, + message="Type d'enregistrement non pris en charge.", + ) + ) + continue + record = IngestRecord( + type=item.type, + data=item.data, + external_id=item.external_id, + source=payload.source, + ) + try: + outcome = handler.apply(db, user.id, record) + except RowError as exc: + errors.append( + IngestRecordError(index=index, type=item.type, message=str(exc)) + ) + continue + except Exception: # noqa: BLE001 — a broken record must not fail the batch + errors.append( + IngestRecordError( + index=index, + type=item.type, + message="Erreur inattendue sur cet enregistrement.", + ) + ) + continue + if outcome is UpsertOutcome.ERROR: + errors.append( + IngestRecordError( + index=index, type=item.type, message="Enregistrement rejeté." + ) + ) + else: + counters[outcome] += 1 + db.commit() + return IngestResponse( + domain=domain, + received=len(payload.records), + inserted=counters[UpsertOutcome.INSERTED], + updated=counters[UpsertOutcome.UPDATED], + duplicates=counters[UpsertOutcome.DUPLICATE], + errors=errors, + ) diff --git a/apps/api/app/modules/vape/__init__.py b/apps/api/app/modules/vape/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/modules/vape/calculations.py b/apps/api/app/modules/vape/calculations.py new file mode 100644 index 0000000..add0a57 --- /dev/null +++ b/apps/api/app/modules/vape/calculations.py @@ -0,0 +1,548 @@ +"""Pure vape calculations (datamodel-health-vape.md §7) — unit-testable. + +Money is expressed in euro cents; derived rates (cost/ml, cost/day, savings) +may be fractional cents (floats/Decimals), only stored amounts are ints. +Every function here is pure: no database, no HTTP, no application errors. +""" + +from collections.abc import Iterable, Iterator, Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, date, datetime, time, timedelta +from decimal import Decimal +from itertools import pairwise +from math import floor +from statistics import fmean +from zoneinfo import ZoneInfo + +DEFAULT_COIL_LIFESPAN_DAYS = 14.0 +COIL_AVG_LAST_N = 5 +NICOTINE_MG_PER_CIG = 12.0 +MINUTES_PER_CIG = 11 +NICOTINE_MISMATCH_TOLERANCE = 0.10 # 10 % gap target vs recomputed +TREND_SLOPE_THRESHOLD = 0.05 # mg/day per day: below = stable +PURCHASE_FALLBACK_WINDOW_DAYS = 90 # §7.1 fallback cost/ml window + +SECONDS_PER_DAY = 86400.0 + + +def date_range(start: date, end: date) -> Iterator[date]: + """Every local day from `start` to `end`, both bounds included.""" + day = start + while day <= end: + yield day + day += timedelta(days=1) + + +# --------------------------------------------------------------------------- +# Mix / recipe costing (§6.3, §7.1) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ComponentInput: + """One recipe line: quantity in the product's size_unit + catalog price.""" + + quantity: Decimal + price_cents: int + size_value: Decimal + nicotine_mg_ml: Decimal | None = None # boosters only + vg_pct: Decimal | None = None + + +def component_cost_cents(component: ComponentInput) -> Decimal: + """quantity × (package price / package size), in cents.""" + return component.quantity * Decimal(component.price_cents) / component.size_value + + +def mix_cost_total_cents(components: Iterable[ComponentInput]) -> Decimal: + """Σ quantity × (price / size_value), in cents.""" + total = Decimal(0) + for c in components: + total += component_cost_cents(c) + return total + + +def mix_cost_per_ml_cents( + components: Iterable[ComponentInput], total_ml: Decimal +) -> Decimal | None: + if total_ml <= 0: + return None + return mix_cost_total_cents(components) / total_ml + + +def mix_nicotine_check_mg_ml( + components: Iterable[ComponentInput], total_ml: Decimal +) -> Decimal: + """Σ (booster qty × booster mg/ml) / total_ml (§6.3).""" + if total_ml <= 0: + return Decimal(0) + total_mg = Decimal(0) + for c in components: + if c.nicotine_mg_ml is not None: + total_mg += c.quantity * c.nicotine_mg_ml + return total_mg / total_ml + + +def mix_vg_pct( + components: Iterable[ComponentInput], total_ml: Decimal +) -> Decimal | None: + """Weighted VG % — only when every component declares vg_pct.""" + comps = list(components) + if total_ml <= 0 or not comps or any(c.vg_pct is None for c in comps): + return None + weighted = sum( + (c.quantity * c.vg_pct for c in comps if c.vg_pct is not None), + Decimal(0), + ) + return weighted / total_ml + + +def nicotine_mismatch_warning( + target_mg_ml: Decimal, check_mg_ml: Decimal +) -> str | None: + """'nicotine_mismatch' when the recomputed rate drifts > 10 % from target.""" + tolerance = abs(target_mg_ml) * Decimal(str(NICOTINE_MISMATCH_TOLERANCE)) + if abs(check_mg_ml - target_mg_ml) > tolerance: + return "nicotine_mismatch" + return None + + +@dataclass(frozen=True) +class RecipeQuantities: + booster_ml: Decimal + aroma_ml: Decimal + base_ml: Decimal + + +def recipe_quantities( + total_ml: Decimal, + target_nicotine_mg_ml: Decimal, + booster_nicotine_mg_ml: Decimal, + aroma_pct: Decimal, +) -> RecipeQuantities: + """Stateless recipe assistant (§6.3). + + booster_ml = total × target / n_booster ; aroma_ml = total × aroma_pct/100 ; + base_ml = total − booster − aroma. Raises ValueError when the recipe is + impossible (booster too weak, aroma dosage too high). + """ + if total_ml <= 0: + raise ValueError("total_ml must be > 0") + if booster_nicotine_mg_ml <= 0: + raise ValueError("booster nicotine rate must be > 0") + booster_ml = total_ml * target_nicotine_mg_ml / booster_nicotine_mg_ml + aroma_ml = total_ml * aroma_pct / Decimal(100) + base_ml = total_ml - booster_ml - aroma_ml + if base_ml <= 0: + raise ValueError("base volume would be <= 0") + return RecipeQuantities(booster_ml=booster_ml, aroma_ml=aroma_ml, base_ml=base_ml) + + +def fallback_cost_per_ml_cents( + purchases: Iterable[tuple[Decimal, Decimal]], +) -> Decimal | None: + """Weighted average cost/ml of liquid purchases (§7.1 fallback). + + `purchases` = (total_cents, ml_bought) pairs. None when nothing was bought. + """ + total_cents = Decimal(0) + total_ml = Decimal(0) + for cents, ml in purchases: + total_cents += cents + total_ml += ml + if total_ml <= 0: + return None + return total_cents / total_ml + + +# --------------------------------------------------------------------------- +# Daily consumption (§6.4, §7.2) +# --------------------------------------------------------------------------- + + +def effective_daily_ml( + entries: Iterable[tuple[str, Decimal]], +) -> Decimal | None: + """Daily consumption for ONE day: a daily_total overrides the refill sum. + + `entries` = (kind, ml) pairs of that day. None = untracked day. + """ + rows = list(entries) + totals = [ml for kind, ml in rows if kind == "daily_total"] + if totals: + return totals[0] + refills = [ml for kind, ml in rows if kind == "refill"] + if not refills: + return None + return sum(refills, Decimal(0)) + + +def mean_ml_per_day(values: Iterable[Decimal | None]) -> Decimal | None: + """Mean over tracked days only (§7.2); None when nothing is tracked.""" + vals = [v for v in values if v is not None] + if not vals: + return None + return sum(vals, Decimal(0)) / Decimal(len(vals)) + + +def tracked_days_ratio(values: Sequence[Decimal | None]) -> float: + """Share of tracked days in the window — reliability indicator (§7.2).""" + if not values: + return 0.0 + return sum(1 for v in values if v is not None) / len(values) + + +def sum_ml_between( + daily_ml_by_date: Mapping[date, Decimal | None], start: date, end: date +) -> Decimal | None: + """Σ tracked daily ml over [start, end) — volume through one coil (§6.5). + + None when no day of the interval is tracked. + """ + values = [ + daily_ml_by_date.get(day) + for day in date_range(start, end - timedelta(days=1)) + if daily_ml_by_date.get(day) is not None + ] + if not values: + return None + return sum((v for v in values if v is not None), Decimal(0)) + + +# --------------------------------------------------------------------------- +# Coils (§7.3) +# --------------------------------------------------------------------------- + + +def coil_intervals_days(changed_ats: Sequence[datetime]) -> list[float]: + """Days between consecutive changes, oldest first.""" + ordered = sorted(changed_ats) + return [ + (nxt - prev).total_seconds() / SECONDS_PER_DAY + for prev, nxt in pairwise(ordered) + ] + + +def avg_coil_lifespan_days( + intervals_days: Sequence[float], + last_n: int = COIL_AVG_LAST_N, + default: float = DEFAULT_COIL_LIFESPAN_DAYS, +) -> tuple[float, bool]: + """Mean of the last `last_n` cycles; (default, True) when < 2 changes.""" + if not intervals_days: + return default, True + recent = list(intervals_days)[-last_n:] + return fmean(recent), False + + +def coil_cost_per_day_cents( + coil_unit_price_cents: float | Decimal | None, avg_lifespan_days: float +) -> float: + """Amortized coil cost; 0 when no coil product/price is known.""" + if coil_unit_price_cents is None or avg_lifespan_days <= 0: + return 0.0 + return float(coil_unit_price_cents) / avg_lifespan_days + + +def coil_age_days(changed_at: datetime, now_utc: datetime) -> float: + """Provisional lifespan of the coil currently installed.""" + return (now_utc - changed_at).total_seconds() / SECONDS_PER_DAY + + +# --------------------------------------------------------------------------- +# Daily costs & cigarette baseline (§7.4, §7.5) +# --------------------------------------------------------------------------- + + +def vape_cost_per_day_cents( + ml_per_day: Decimal | float | None, + cost_per_ml_cents: Decimal | float | None, + coil_cpd_cents: float, +) -> float | None: + """ml/day × cost/ml + coil amortization; None when cost/ml is unknown.""" + if cost_per_ml_cents is None or ml_per_day is None: + return None + return float(ml_per_day) * float(cost_per_ml_cents) + coil_cpd_cents + + +def cig_cost_per_day_cents( + cigs_per_day: Decimal | float, + cigs_per_pack: int, + pack_price_cents: int, +) -> float: + """Frozen cigarette baseline: cigs/day ÷ cigs/pack × pack price.""" + if cigs_per_pack <= 0: + return 0.0 + return float(cigs_per_day) / cigs_per_pack * pack_price_cents + + +def savings_per_day_cents( + cig_cpd_cents: float, vape_cpd_cents: float | None +) -> float | None: + """Daily gain of vaping over the frozen cigarette baseline (§7.5).""" + if vape_cpd_cents is None: + return None + return cig_cpd_cents - vape_cpd_cents + + +def real_cost_per_day_cents(total_spend_cents: float, window_days: int) -> float | None: + """Purchase-based cost/day over a window (§7.4).""" + if window_days <= 0: + return None + return total_spend_cents / window_days + + +def theoretical_vape_cost_cents( + day_ml: Decimal | None, + imputed_ml_per_day: Decimal | None, + cost_per_ml_cents: Decimal | float | None, + coil_cpd_cents: float, +) -> float: + """Theoretical cost of one day, mean-imputed when untracked (§7.5). + + Untracked days use ml_per_day(30) so tracking gaps do not inflate savings. + An unknown cost/ml contributes 0 (cost metrics themselves are null'ed + upstream per §7.1) — coil amortization still applies. + """ + ml = day_ml if day_ml is not None else (imputed_ml_per_day or Decimal(0)) + liquid = float(ml) * float(cost_per_ml_cents or 0) + return liquid + coil_cpd_cents + + +def cumulative_savings_theoretical( + quit_date: date, + today: date, + daily_ml_by_date: Mapping[date, Decimal | None], + imputed_ml_per_day: Decimal | None, + cost_per_ml_cents: Decimal | float | None, + coil_cpd_cents: float, + cig_cpd_cents: float, +) -> list[tuple[date, float]]: + """Frozen-baseline theoretical savings, cumulative point per day (§7.5). + + cum(d) = cig_cpd × (d − quit_date).days − Σ_{x=quit}^{d} vape_cost(x) + """ + if today < quit_date: + return [] + out: list[tuple[date, float]] = [] + vape_sum = 0.0 + for day in date_range(quit_date, today): + vape_sum += theoretical_vape_cost_cents( + daily_ml_by_date.get(day), + imputed_ml_per_day, + cost_per_ml_cents, + coil_cpd_cents, + ) + elapsed = (day - quit_date).days + out.append((day, cig_cpd_cents * elapsed - vape_sum)) + return out + + +def cumulative_savings_real( + quit_date: date, + today: date, + spend_cents_by_date: Mapping[date, Decimal | int | float], + cig_cpd_cents: float, +) -> list[tuple[date, float]]: + """Purchase-based savings: cig baseline minus real spending, cumulative.""" + if today < quit_date: + return [] + out: list[tuple[date, float]] = [] + spent = 0.0 + for day in date_range(quit_date, today): + spent += float(spend_cents_by_date.get(day, 0)) + elapsed = (day - quit_date).days + out.append((day, cig_cpd_cents * elapsed - spent)) + return out + + +# --------------------------------------------------------------------------- +# Nicotine (§7.6) & avoided cigarettes (§7.7) +# --------------------------------------------------------------------------- + + +def nicotine_mg_for_day( + entries: Iterable[tuple[Decimal, Decimal | None]], +) -> float | None: + """Σ ml × effective mg/ml over the day's entries; None if untracked. + + Entries without a resolvable nicotine rate contribute 0 mg. + """ + rows = list(entries) + if not rows: + return None + return float(sum((ml * (nic or Decimal(0)) for ml, nic in rows), Decimal(0))) + + +def cig_equivalent(nicotine_mg: float) -> float: + """Informative equivalence (~12 mg nicotine per cigarette).""" + return nicotine_mg / NICOTINE_MG_PER_CIG + + +def days_since_quit(quit_date: date, today: date) -> int: + """Whole local days elapsed since the quit date (never negative).""" + return max(0, (today - quit_date).days) + + +def cigarettes_avoided(elapsed_days: int, cigs_per_day: Decimal | float) -> int: + return floor(elapsed_days * float(cigs_per_day)) + + +def packs_avoided(cigs_avoided: int, cigs_per_pack: int) -> float: + if cigs_per_pack <= 0: + return 0.0 + return cigs_avoided / cigs_per_pack + + +def time_regained_minutes(cigs_avoided: int) -> int: + return cigs_avoided * MINUTES_PER_CIG + + +# --------------------------------------------------------------------------- +# Series helpers +# --------------------------------------------------------------------------- + + +def moving_average( + values: Sequence[float | None], window: int = 7 +) -> list[float | None]: + """Trailing moving average ignoring None gaps (None when window is empty).""" + out: list[float | None] = [] + for i in range(len(values)): + chunk = [v for v in values[max(0, i - window + 1) : i + 1] if v is not None] + out.append(fmean(chunk) if chunk else None) + return out + + +def regression_slope(points: Sequence[tuple[date, float]]) -> float | None: + """OLS slope in unit/day over (day, value) points (§5.5); None if < 3 pts.""" + if len(points) < 3: + return None + t0 = points[0][0] + ts = [float((d - t0).days) for d, _ in points] + ws = [w for _, w in points] + t_mean = fmean(ts) + w_mean = fmean(ws) + denom = sum((t - t_mean) ** 2 for t in ts) + if denom == 0: + return None + num = sum((t - t_mean) * (w - w_mean) for t, w in zip(ts, ws, strict=True)) + return num / denom + + +def trend_status(slope: float | None, threshold: float = TREND_SLOPE_THRESHOLD) -> str: + """'down' / 'stable' / 'up' — stable identifiers mapped to labels by the UI.""" + if slope is None or abs(slope) < threshold: + return "stable" + return "down" if slope < 0 else "up" + + +# --------------------------------------------------------------------------- +# Health milestones (§7.8 — WHO-style timeline, static, French labels) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Milestone: + code: str + offset: timedelta + label_fr: str + + +MILESTONES: tuple[Milestone, ...] = ( + Milestone( + "hr_bp_normal", + timedelta(minutes=20), + "Fréquence cardiaque et tension redescendent", + ), + Milestone( + "co_halved", + timedelta(hours=8), + "Le monoxyde de carbone sanguin diminue de moitié", + ), + Milestone( + "co_normal", + timedelta(hours=24), + "Monoxyde de carbone éliminé ; les poumons commencent à évacuer les résidus", + ), + Milestone( + "nicotine_out", + timedelta(hours=48), + "Plus de nicotine dans le corps ; goût et odorat s'améliorent", + ), + Milestone( + "breathing_easier", + timedelta(hours=72), + "Respiration plus facile, énergie en hausse (bronches détendues)", + ), + Milestone("circulation", timedelta(days=14), "Circulation sanguine améliorée"), + Milestone( + "lung_function", + timedelta(days=90), + "Fonction pulmonaire améliorée jusqu'à +30 %", + ), + Milestone( + "cilia_recovery", + timedelta(days=270), + "Cils bronchiques régénérés ; toux et essoufflement diminuent", + ), + Milestone( + "chd_risk_half", + timedelta(days=365), + "Risque de maladie coronarienne réduit de moitié", + ), + Milestone( + "stroke_risk_normal", + timedelta(days=5 * 365), + "Risque d'AVC ramené à celui d'un non-fumeur", + ), + Milestone( + "lung_cancer_half", + timedelta(days=10 * 365), + "Risque de cancer du poumon réduit de moitié", + ), + Milestone( + "chd_risk_normal", + timedelta(days=15 * 365), + "Risque coronarien équivalent à celui d'un non-fumeur", + ), +) + + +@dataclass(frozen=True) +class MilestoneStatus: + code: str + label_fr: str + reached_at: datetime # UTC + achieved: bool + progress_pct: float + + +def milestone_statuses( + quit_date: date, tz: ZoneInfo, now_utc: datetime +) -> list[MilestoneStatus]: + """Milestones measured from local midnight of quit_date (§7.8).""" + t0 = datetime.combine(quit_date, time(0, 0), tzinfo=tz) + elapsed = (now_utc - t0).total_seconds() + out: list[MilestoneStatus] = [] + for m in MILESTONES: + reached_at = (t0 + m.offset).astimezone(UTC) + progress = 100.0 * elapsed / m.offset.total_seconds() + out.append( + MilestoneStatus( + code=m.code, + label_fr=m.label_fr, + reached_at=reached_at, + achieved=now_utc >= reached_at, + progress_pct=max(0.0, min(100.0, progress)), + ) + ) + return out + + +def next_milestone(statuses: Sequence[MilestoneStatus]) -> MilestoneStatus | None: + """First milestone not reached yet (None once the timeline is complete).""" + for status in statuses: + if not status.achieved: + return status + return None diff --git a/apps/api/app/modules/vape/models.py b/apps/api/app/modules/vape/models.py new file mode 100644 index 0000000..c5008ab --- /dev/null +++ b/apps/api/app/modules/vape/models.py @@ -0,0 +1,291 @@ +"""Vape module tables (datamodel-health-vape.md §6, money in euro cents). + +Portable column types (CONVENTIONS C8): non-native enums, partial unique +indexes declared with BOTH sqlite_where and postgresql_where. +""" + +import enum +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import ( + CheckConstraint, + Date, + DateTime, + Enum, + ForeignKey, + Index, + Integer, + Numeric, + String, + UniqueConstraint, + text, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base +from app.core.mixins import SourceMixin, TimestampMixin + + +class ProductKind(str, enum.Enum): + COIL = "coil" # résistance + BASE = "base" # base PG/VG + BOOSTER = "booster" # booster de nicotine + AROMA = "aroma" # arôme concentré + HARDWARE = "hardware" # matériel (box, clearomiseur…) — hors coût/ml + POD = "pod" # cartouche pod + + +class SizeUnit(str, enum.Enum): + ML = "ml" + UNIT = "unit" # à l'unité (boîte de 5 résistances -> size_value=5) + G = "g" + + +class LiquidEntryKind(str, enum.Enum): + REFILL = "refill" + DAILY_TOTAL = "daily_total" + + +#: Product kinds that make up a DIY recipe (hardware/coil/pod are excluded). +LIQUID_KINDS = (ProductKind.BASE, ProductKind.BOOSTER, ProductKind.AROMA) + + +def _enum(enum_cls: type[enum.Enum], name: str) -> Enum: + """Portable string enum persisting the *values* (lowercase), never native. + + VARCHAR + CHECK on both engines: `create_constraint=True` is required, its + SQLAlchemy default is False (bare VARCHAR, no integrity check). + """ + return Enum( + enum_cls, + name=name, + native_enum=False, + create_constraint=True, + validate_strings=True, + values_callable=lambda e: [m.value for m in e], + ) + + +class VapeSettings(TimestampMixin, Base): + """Singleton per user: quit reference + defaults (§6.1).""" + + __tablename__ = "vape_settings" + __table_args__ = ( + UniqueConstraint("user_id", name="uq_vape_settings_user"), + CheckConstraint( + "cigs_per_pack > 0 AND cig_pack_price_cents >= 0" + " AND cigs_per_day_before >= 0", + name="ck_vape_settings_pack", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + quit_date: Mapped[date] = mapped_column(Date) + cigs_per_day_before: Mapped[Decimal] = mapped_column(Numeric(4, 1)) + cig_pack_price_cents: Mapped[int] = mapped_column(Integer) + cigs_per_pack: Mapped[int] = mapped_column(Integer, default=20) + default_nicotine_mg_ml: Mapped[Decimal] = mapped_column(Numeric(4, 1)) + currency: Mapped[str] = mapped_column(String(3), default="EUR") + + +class Product(TimestampMixin, Base): + """Vape catalog: coils, bases, boosters, aromas, hardware, pods (§6.2).""" + + __tablename__ = "products" + __table_args__ = ( + UniqueConstraint( + "user_id", + "kind", + "name", + "brand", + name="uq_products_user_kind_name_brand", + ), + Index("ix_products_user_kind", "user_id", "kind"), + CheckConstraint( + "price_cents >= 0 AND size_value > 0", name="ck_products_price_size" + ), + CheckConstraint( + "kind <> 'booster' OR nicotine_mg_ml IS NOT NULL", + name="ck_products_booster_nic", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + kind: Mapped[ProductKind] = mapped_column(_enum(ProductKind, "product_kind")) + name: Mapped[str] = mapped_column(String(150)) + brand: Mapped[str | None] = mapped_column(String(100), default=None) + price_cents: Mapped[int] = mapped_column(Integer) # package catalog price (cents) + size_value: Mapped[Decimal] = mapped_column(Numeric(8, 2)) + size_unit: Mapped[SizeUnit] = mapped_column(_enum(SizeUnit, "size_unit")) + nicotine_mg_ml: Mapped[Decimal | None] = mapped_column( + Numeric(4, 1), default=None + ) # boosters only + vg_pct: Mapped[Decimal | None] = mapped_column(Numeric(4, 1), default=None) + ohm: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), default=None) + is_archived: Mapped[bool] = mapped_column(default=False) + note: Mapped[str | None] = mapped_column(String(255), default=None) + + @property + def unit_price_cents(self) -> Decimal: + """Cents per ml/unit/g of the package (derived, never stored).""" + return Decimal(self.price_cents) / Decimal(self.size_value) + + +class Mix(TimestampMixin, Base): + """DIY recipe; cost is always computed from catalog prices (§6.3).""" + + __tablename__ = "mixes" + __table_args__ = ( + Index( + "uq_mixes_user_active", + "user_id", + unique=True, + postgresql_where=text("is_active"), + sqlite_where=text("is_active"), + ), + CheckConstraint("total_ml > 0", name="ck_mixes_total_ml"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + name: Mapped[str] = mapped_column(String(150)) + total_ml: Mapped[Decimal] = mapped_column(Numeric(7, 1)) + target_nicotine_mg_ml: Mapped[Decimal] = mapped_column(Numeric(4, 1)) + is_active: Mapped[bool] = mapped_column(default=False) + is_archived: Mapped[bool] = mapped_column(default=False) + note: Mapped[str | None] = mapped_column(String(255), default=None) + + components: Mapped[list["MixComponent"]] = relationship( + back_populates="mix", + cascade="all, delete-orphan", + order_by="MixComponent.id", + lazy="selectin", + ) + + +class MixComponent(Base): + """One product line of a recipe; ownership goes through mixes.user_id.""" + + __tablename__ = "mix_components" + __table_args__ = ( + UniqueConstraint("mix_id", "product_id", name="uq_mix_components_mix_product"), + CheckConstraint("quantity > 0", name="ck_mix_components_qty"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + mix_id: Mapped[int] = mapped_column( + ForeignKey("mixes.id", ondelete="CASCADE"), index=True + ) + product_id: Mapped[int] = mapped_column( + ForeignKey("products.id", ondelete="RESTRICT") + ) + quantity: Mapped[Decimal] = mapped_column(Numeric(7, 2)) # in product.size_unit + + mix: Mapped[Mix] = relationship(back_populates="components") + product: Mapped[Product] = relationship(lazy="joined") + + +class LiquidEntry(TimestampMixin, SourceMixin, Base): + """Liquid consumption journal: refills or direct daily totals (§6.4).""" + + __tablename__ = "liquid_entries" + __table_args__ = ( + Index("ix_liquid_entries_user_date", "user_id", "entry_date"), + Index( + "uq_liquid_entries_user_date_dailytotal", + "user_id", + "entry_date", + unique=True, + postgresql_where=text("kind = 'daily_total'"), + sqlite_where=text("kind = 'daily_total'"), + ), + UniqueConstraint( + "user_id", "source", "external_id", name="uq_liquid_entries_external" + ), + UniqueConstraint("user_id", "content_hash", name="uq_liquid_entries_hash"), + CheckConstraint("ml > 0 AND ml <= 100", name="ck_liquid_entries_ml"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + import_run_id: Mapped[int | None] = mapped_column( + ForeignKey("import_runs.id", ondelete="CASCADE"), default=None + ) + entry_date: Mapped[date] = mapped_column(Date) # local civil day + kind: Mapped[LiquidEntryKind] = mapped_column( + _enum(LiquidEntryKind, "liquid_entry_kind"), + default=LiquidEntryKind.REFILL, + ) + ml: Mapped[Decimal] = mapped_column(Numeric(6, 2)) + nicotine_mg_ml: Mapped[Decimal | None] = mapped_column( + Numeric(4, 1), default=None + ) # override; fallback: mix target, then settings default + mix_id: Mapped[int | None] = mapped_column( + ForeignKey("mixes.id", ondelete="SET NULL"), default=None + ) + note: Mapped[str | None] = mapped_column(String(255), default=None) + + +class CoilChange(TimestampMixin, Base): + """A new coil installed at `changed_at`; lifespan derived between rows (§6.5).""" + + __tablename__ = "coil_changes" + __table_args__ = (Index("ix_coil_changes_user_changed", "user_id", "changed_at"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + changed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + product_id: Mapped[int | None] = mapped_column( + ForeignKey("products.id", ondelete="RESTRICT"), default=None + ) + reason: Mapped[str | None] = mapped_column(String(50), default=None) + note: Mapped[str | None] = mapped_column(String(255), default=None) + + product: Mapped[Product | None] = relationship(lazy="joined") + + +class Purchase(TimestampMixin, Base): + """Real vape spending, for real savings and real cost/ml (§6.6).""" + + __tablename__ = "purchases" + __table_args__ = ( + Index("ix_purchases_user_date", "user_id", "purchased_on"), + CheckConstraint("qty > 0", name="ck_purchases_qty"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + purchased_on: Mapped[date] = mapped_column(Date) + product_id: Mapped[int] = mapped_column( + ForeignKey("products.id", ondelete="RESTRICT") + ) + qty: Mapped[Decimal] = mapped_column(Numeric(6, 2), default=Decimal(1)) + unit_price_cents: Mapped[int | None] = mapped_column(Integer, default=None) + note: Mapped[str | None] = mapped_column(String(255), default=None) + + product: Mapped[Product] = relationship(lazy="joined") + + @property + def total_cents(self) -> Decimal: + """qty × coalesce(unit_price_cents, product.price_cents), derived.""" + unit = ( + Decimal(self.unit_price_cents) + if self.unit_price_cents is not None + else Decimal(self.product.price_cents) + ) + return Decimal(self.qty) * unit diff --git a/apps/api/app/modules/vape/router.py b/apps/api/app/modules/vape/router.py new file mode 100644 index 0000000..5c6b6cc --- /dev/null +++ b/apps/api/app/modules/vape/router.py @@ -0,0 +1,322 @@ +"""Vape API (datamodel-health-vape.md §8.4) — thin routing layer. + +Static sub-paths (/mixes/calculator, /coils/stats, /coils/change) are declared +before their `{id}` siblings so FastAPI never routes them to the item handler. +""" + +from datetime import date +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.dependencies import get_current_user +from app.core.pagination import Page, PageParams +from app.modules.auth.models import User +from app.modules.vape import service +from app.modules.vape.models import LiquidEntryKind, ProductKind +from app.modules.vape.schemas import ( + CoilChangeCreate, + CoilChangeCreated, + CoilChangeRead, + CoilChangeUpdate, + LiquidEntryCreate, + LiquidEntryRead, + LiquidEntryUpdate, + MilestoneRead, + MixCalculatorRequest, + MixCalculatorResult, + MixCreate, + MixRead, + MixUpdate, + ProductCreate, + ProductRead, + ProductUpdate, + PurchaseCreate, + PurchaseRead, + PurchaseUpdate, + StatsResponse, + VapeDashboard, + VapeSettingsPut, + VapeSettingsRead, +) + +router = APIRouter(prefix="/vape", tags=["vape"]) + +DbDep = Annotated[Session, Depends(get_db)] +UserDep = Annotated[User, Depends(get_current_user)] +PageDep = Annotated[PageParams, Depends()] +FromQuery = Annotated[date | None, Query(alias="from")] +ToQuery = Annotated[date | None, Query()] +TzQuery = Annotated[str | None, Query()] +SortQuery = Annotated[str | None, Query()] + + +# --------------------------------------------------------------------- settings + + +@router.get("/settings", response_model=VapeSettingsRead) +def read_settings(db: DbDep, user: UserDep) -> VapeSettingsRead: + return VapeSettingsRead.model_validate(service.get_settings(db, user.id)) + + +@router.put("/settings", response_model=VapeSettingsRead) +def write_settings( + payload: VapeSettingsPut, db: DbDep, user: UserDep +) -> VapeSettingsRead: + return VapeSettingsRead.model_validate(service.put_settings(db, user.id, payload)) + + +# --------------------------------------------------------------------- products + + +@router.get("/products", response_model=Page[ProductRead]) +def list_products( + db: DbDep, + user: UserDep, + params: PageDep, + kind: Annotated[ProductKind | None, Query()] = None, + include_archived: Annotated[bool, Query()] = False, + sort: SortQuery = None, +) -> Page[ProductRead]: + return service.list_products(db, user.id, params, kind, include_archived, sort) + + +@router.post("/products", response_model=ProductRead, status_code=201) +def create_product(payload: ProductCreate, db: DbDep, user: UserDep) -> ProductRead: + return service.create_product(db, user.id, payload) + + +@router.patch("/products/{product_id}", response_model=ProductRead) +def update_product( + product_id: int, payload: ProductUpdate, db: DbDep, user: UserDep +) -> ProductRead: + return service.update_product(db, user.id, product_id, payload) + + +@router.delete("/products/{product_id}", status_code=204) +def delete_product(product_id: int, db: DbDep, user: UserDep) -> None: + service.delete_product(db, user.id, product_id) + + +# ------------------------------------------------------------------------ mixes + + +@router.get("/mixes", response_model=Page[MixRead]) +def list_mixes( + db: DbDep, + user: UserDep, + params: PageDep, + include_archived: Annotated[bool, Query()] = False, + sort: SortQuery = None, +) -> Page[MixRead]: + return service.list_mixes(db, user.id, params, include_archived, sort) + + +@router.post("/mixes", response_model=MixRead, status_code=201) +def create_mix(payload: MixCreate, db: DbDep, user: UserDep) -> MixRead: + return service.create_mix(db, user.id, payload) + + +@router.post("/mixes/calculator", response_model=MixCalculatorResult) +def mix_calculator( + payload: MixCalculatorRequest, db: DbDep, user: UserDep +) -> MixCalculatorResult: + """Stateless recipe assistant — persists nothing (§6.3).""" + return service.mix_calculator(db, user.id, payload) + + +@router.post("/mixes/{mix_id}/activate", response_model=MixRead) +def activate_mix(mix_id: int, db: DbDep, user: UserDep) -> MixRead: + return service.activate_mix(db, user.id, mix_id) + + +@router.patch("/mixes/{mix_id}", response_model=MixRead) +def update_mix(mix_id: int, payload: MixUpdate, db: DbDep, user: UserDep) -> MixRead: + return service.update_mix(db, user.id, mix_id, payload) + + +@router.delete("/mixes/{mix_id}", status_code=204) +def delete_mix(mix_id: int, db: DbDep, user: UserDep) -> None: + service.delete_mix(db, user.id, mix_id) + + +# ---------------------------------------------------------------------- liquids + + +@router.get("/liquids", response_model=Page[LiquidEntryRead]) +def list_liquids( + db: DbDep, + user: UserDep, + params: PageDep, + from_: FromQuery = None, + to: ToQuery = None, + kind: Annotated[LiquidEntryKind | None, Query()] = None, + sort: SortQuery = None, +) -> Page[LiquidEntryRead]: + return service.list_liquids(db, user.id, params, from_, to, kind, sort) + + +@router.post("/liquids", response_model=LiquidEntryRead, status_code=201) +def create_liquid( + payload: LiquidEntryCreate, db: DbDep, user: UserDep +) -> LiquidEntryRead: + return service.create_liquid(db, user.id, payload) + + +@router.patch("/liquids/{entry_id}", response_model=LiquidEntryRead) +def update_liquid( + entry_id: int, payload: LiquidEntryUpdate, db: DbDep, user: UserDep +) -> LiquidEntryRead: + return service.update_liquid(db, user.id, entry_id, payload) + + +@router.delete("/liquids/{entry_id}", status_code=204) +def delete_liquid(entry_id: int, db: DbDep, user: UserDep) -> None: + service.delete_liquid(db, user.id, entry_id) + + +# ------------------------------------------------------------------------ coils + + +@router.get("/coils", response_model=Page[CoilChangeRead]) +def list_coils( + db: DbDep, + user: UserDep, + params: PageDep, + from_: FromQuery = None, + to: ToQuery = None, + sort: SortQuery = None, + tz: TzQuery = None, +) -> Page[CoilChangeRead]: + return service.list_coils(db, user.id, params, from_, to, sort, tz) + + +@router.get("/coils/stats", response_model=StatsResponse) +def coil_stats(db: DbDep, user: UserDep, tz: TzQuery = None) -> StatsResponse: + return service.coil_stats(db, user.id, tz) + + +@router.post("/coils/change", response_model=CoilChangeCreated, status_code=201) +def quick_coil_change( + db: DbDep, + user: UserDep, + payload: CoilChangeCreate | None = None, + tz: TzQuery = None, +) -> CoilChangeCreated: + """One-click « Résistance changée » : registers a change at `now` (§12.6).""" + return service.create_coil(db, user.id, payload or CoilChangeCreate(), tz) + + +@router.post("/coils", response_model=CoilChangeCreated, status_code=201) +def create_coil( + payload: CoilChangeCreate, db: DbDep, user: UserDep, tz: TzQuery = None +) -> CoilChangeCreated: + return service.create_coil(db, user.id, payload, tz) + + +@router.patch("/coils/{coil_id}", response_model=CoilChangeRead) +def update_coil( + coil_id: int, + payload: CoilChangeUpdate, + db: DbDep, + user: UserDep, + tz: TzQuery = None, +) -> CoilChangeRead: + return service.update_coil(db, user.id, coil_id, payload, tz) + + +@router.delete("/coils/{coil_id}", status_code=204) +def delete_coil(coil_id: int, db: DbDep, user: UserDep) -> None: + service.delete_coil(db, user.id, coil_id) + + +# -------------------------------------------------------------------- purchases + + +@router.get("/purchases", response_model=Page[PurchaseRead]) +def list_purchases( + db: DbDep, + user: UserDep, + params: PageDep, + from_: FromQuery = None, + to: ToQuery = None, + product_id: Annotated[int | None, Query()] = None, + sort: SortQuery = None, +) -> Page[PurchaseRead]: + return service.list_purchases(db, user.id, params, from_, to, product_id, sort) + + +@router.post("/purchases", response_model=PurchaseRead, status_code=201) +def create_purchase(payload: PurchaseCreate, db: DbDep, user: UserDep) -> PurchaseRead: + return service.create_purchase(db, user.id, payload) + + +@router.patch("/purchases/{purchase_id}", response_model=PurchaseRead) +def update_purchase( + purchase_id: int, payload: PurchaseUpdate, db: DbDep, user: UserDep +) -> PurchaseRead: + return service.update_purchase(db, user.id, purchase_id, payload) + + +@router.delete("/purchases/{purchase_id}", status_code=204) +def delete_purchase(purchase_id: int, db: DbDep, user: UserDep) -> None: + service.delete_purchase(db, user.id, purchase_id) + + +# ------------------------------------------------------------- stats & overview + + +@router.get("/stats/consumption", response_model=StatsResponse) +def consumption_stats( + db: DbDep, + user: UserDep, + from_: FromQuery = None, + to: ToQuery = None, + tz: TzQuery = None, +) -> StatsResponse: + return service.consumption_stats(db, user.id, from_, to, tz) + + +@router.get("/stats/nicotine", response_model=StatsResponse) +def nicotine_stats( + db: DbDep, + user: UserDep, + from_: FromQuery = None, + to: ToQuery = None, + tz: TzQuery = None, +) -> StatsResponse: + return service.nicotine_stats(db, user.id, from_, to, tz) + + +@router.get("/stats/costs", response_model=StatsResponse) +def cost_stats( + db: DbDep, + user: UserDep, + from_: FromQuery = None, + to: ToQuery = None, + tz: TzQuery = None, +) -> StatsResponse: + return service.cost_stats(db, user.id, from_, to, tz) + + +@router.get("/stats/savings", response_model=StatsResponse) +def savings_stats( + db: DbDep, + user: UserDep, + from_: FromQuery = None, + to: ToQuery = None, + tz: TzQuery = None, +) -> StatsResponse: + return service.savings_stats(db, user.id, from_, to, tz) + + +@router.get("/milestones", response_model=list[MilestoneRead]) +def milestones(db: DbDep, user: UserDep, tz: TzQuery = None) -> list[MilestoneRead]: + return service.milestones(db, user.id, tz) + + +@router.get("/dashboard", response_model=VapeDashboard) +def dashboard(db: DbDep, user: UserDep, tz: TzQuery = None) -> VapeDashboard: + return service.dashboard(db, user.id, tz) diff --git a/apps/api/app/modules/vape/schemas.py b/apps/api/app/modules/vape/schemas.py new file mode 100644 index 0000000..c2d2d64 --- /dev/null +++ b/apps/api/app/modules/vape/schemas.py @@ -0,0 +1,329 @@ +"""Pydantic v2 schemas for the vape module (API contract §8.4). + +Money crosses the API in euro cents: integers for stored amounts, +floats for derived rates (cost/ml, cost/day, cumulative savings). +""" + +from datetime import date, datetime +from decimal import Decimal +from typing import Annotated, Any + +from pydantic import BaseModel, ConfigDict, Field, PlainSerializer + +from app.core.timeutils import UtcDatetime +from app.modules.vape.models import LiquidEntryKind, ProductKind, SizeUnit + +# `Numeric` columns (ml, mg/ml, %, Ω, quantities) cross the API as JSON *numbers*, +# never strings: Pydantic serialises `Decimal` as a string by default, which would +# break the fr-FR formatters and the ECharts series of the frontend. Validation +# still runs on Decimal, so precision is kept end to end — same trade-off as the +# `Money` alias of the finance module and the float columns of `health`. +Quantity = Annotated[ + Decimal, PlainSerializer(float, return_type=float, when_used="json") +] + +# --------------------------------------------------------------------------- +# Settings (singleton per user) +# --------------------------------------------------------------------------- + + +class VapeSettingsPut(BaseModel): + quit_date: date + cigs_per_day_before: Quantity = Field(ge=0, le=200) + cig_pack_price_cents: int = Field(ge=0) + cigs_per_pack: int = Field(default=20, gt=0) + default_nicotine_mg_ml: Quantity = Field(ge=0, le=100) + currency: str = Field(default="EUR", min_length=3, max_length=3) + + +class VapeSettingsRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + quit_date: date + cigs_per_day_before: Quantity + cig_pack_price_cents: int + cigs_per_pack: int + default_nicotine_mg_ml: Quantity + currency: str + + +# --------------------------------------------------------------------------- +# Products +# --------------------------------------------------------------------------- + + +class ProductCreate(BaseModel): + kind: ProductKind + name: str = Field(min_length=1, max_length=150) + brand: str | None = Field(default=None, max_length=100) + price_cents: int = Field(ge=0) + size_value: Quantity = Field(gt=0) + size_unit: SizeUnit + nicotine_mg_ml: Quantity | None = Field(default=None, ge=0, le=100) + vg_pct: Quantity | None = Field(default=None, ge=0, le=100) + ohm: Quantity | None = Field(default=None, gt=0) + is_archived: bool = False + note: str | None = Field(default=None, max_length=255) + + +class ProductUpdate(BaseModel): + kind: ProductKind | None = None + name: str | None = Field(default=None, min_length=1, max_length=150) + brand: str | None = Field(default=None, max_length=100) + price_cents: int | None = Field(default=None, ge=0) + size_value: Quantity | None = Field(default=None, gt=0) + size_unit: SizeUnit | None = None + nicotine_mg_ml: Quantity | None = Field(default=None, ge=0, le=100) + vg_pct: Quantity | None = Field(default=None, ge=0, le=100) + ohm: Quantity | None = Field(default=None, gt=0) + is_archived: bool | None = None + note: str | None = Field(default=None, max_length=255) + + +class ProductRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + kind: ProductKind + name: str + brand: str | None + price_cents: int + size_value: Quantity + size_unit: SizeUnit + nicotine_mg_ml: Quantity | None + vg_pct: Quantity | None + ohm: Quantity | None + is_archived: bool + note: str | None + unit_price_cents: float # derived: price_cents / size_value + + +# --------------------------------------------------------------------------- +# Mixes +# --------------------------------------------------------------------------- + + +class MixComponentIn(BaseModel): + product_id: int + quantity: Quantity = Field(gt=0) + + +class MixCreate(BaseModel): + name: str = Field(min_length=1, max_length=150) + total_ml: Quantity = Field(gt=0) + target_nicotine_mg_ml: Quantity = Field(ge=0, le=100) + note: str | None = Field(default=None, max_length=255) + components: list[MixComponentIn] = Field(default_factory=list) + + +class MixUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=150) + total_ml: Quantity | None = Field(default=None, gt=0) + target_nicotine_mg_ml: Quantity | None = Field(default=None, ge=0, le=100) + is_archived: bool | None = None + note: str | None = Field(default=None, max_length=255) + # Full replacement when provided (§8.4) + components: list[MixComponentIn] | None = None + + +class MixComponentRead(BaseModel): + id: int + product_id: int + product_name: str + product_kind: ProductKind + quantity: Quantity + cost_cents: float + + +class MixRead(BaseModel): + id: int + name: str + total_ml: Quantity + target_nicotine_mg_ml: Quantity + is_active: bool + is_archived: bool + note: str | None + components: list[MixComponentRead] + cost_total_cents: float + cost_per_ml_cents: float | None + nicotine_check_mg_ml: float + vg_pct_mix: float | None + warning: str | None # "nicotine_mismatch" when > 10 % off target + + +class MixCalculatorRequest(BaseModel): + total_ml: Quantity = Field(gt=0) + target_nicotine_mg_ml: Quantity = Field(ge=0, le=100) + booster_product_id: int + base_product_id: int + aroma_pct: Quantity = Field(default=Decimal(0), ge=0, le=100) + aroma_product_id: int | None = None + + +class MixCalculatorResult(BaseModel): + total_ml: Quantity + target_nicotine_mg_ml: Quantity + booster_ml: float + aroma_ml: float + base_ml: float + nicotine_check_mg_ml: float + cost_total_cents: float + cost_per_ml_cents: float + + +# --------------------------------------------------------------------------- +# Liquid entries +# --------------------------------------------------------------------------- + + +class LiquidEntryCreate(BaseModel): + entry_date: date + ml: Quantity = Field(gt=0, le=100) + kind: LiquidEntryKind = LiquidEntryKind.REFILL + nicotine_mg_ml: Quantity | None = Field(default=None, ge=0, le=100) + mix_id: int | None = None + note: str | None = Field(default=None, max_length=255) + + +class LiquidEntryUpdate(BaseModel): + entry_date: date | None = None + ml: Quantity | None = Field(default=None, gt=0, le=100) + kind: LiquidEntryKind | None = None + nicotine_mg_ml: Quantity | None = Field(default=None, ge=0, le=100) + mix_id: int | None = None + note: str | None = Field(default=None, max_length=255) + + +class LiquidEntryRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + entry_date: date + kind: LiquidEntryKind + ml: Quantity + nicotine_mg_ml: Quantity | None + nicotine_effective_mg_ml: float | None # override -> mix -> settings default + nicotine_mg: float | None # ml × effective rate + mix_id: int | None + source: str + note: str | None + + +# --------------------------------------------------------------------------- +# Coil changes +# --------------------------------------------------------------------------- + + +class CoilChangeCreate(BaseModel): + changed_at: UtcDatetime | None = None # default: now (one-click) + product_id: int | None = None + reason: str | None = Field(default=None, max_length=50) + note: str | None = Field(default=None, max_length=255) + + +class CoilChangeUpdate(BaseModel): + changed_at: UtcDatetime | None = None + product_id: int | None = None + reason: str | None = Field(default=None, max_length=50) + note: str | None = Field(default=None, max_length=255) + + +class CoilChangeRead(BaseModel): + id: int + changed_at: datetime + product_id: int | None + product_name: str | None + reason: str | None + note: str | None + is_current: bool + lifespan_days: float | None # provisional (now − changed_at) for the current one + ml_through: float | None # Σ daily ml over the coil's interval + + +class CoilChangeCreated(CoilChangeRead): + previous_lifespan_days: float | None # for the one-click toast + + +# --------------------------------------------------------------------------- +# Purchases +# --------------------------------------------------------------------------- + + +class PurchaseCreate(BaseModel): + purchased_on: date + product_id: int + qty: Quantity = Field(default=Decimal(1), gt=0) + unit_price_cents: int | None = Field(default=None, ge=0) + note: str | None = Field(default=None, max_length=255) + + +class PurchaseUpdate(BaseModel): + purchased_on: date | None = None + product_id: int | None = None + qty: Quantity | None = Field(default=None, gt=0) + unit_price_cents: int | None = Field(default=None, ge=0) + note: str | None = Field(default=None, max_length=255) + + +class PurchaseRead(BaseModel): + id: int + purchased_on: date + product_id: int + product_name: str + qty: Quantity + unit_price_cents: int | None + total_cents: float # qty × coalesce(unit_price_cents, catalog price) + note: str | None + + +# --------------------------------------------------------------------------- +# Stats (ECharts-ready, §8.1) / milestones / dashboard +# --------------------------------------------------------------------------- + + +class SeriesModel(BaseModel): + name: str + type: str # "line" | "scatter" | "bar" + points: list[tuple[str, float | None]] + + +class StatsResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + from_: date = Field(alias="from") + to: date + unit: str # "ml" | "mg" | "cents" | "days" + series: list[SeriesModel] + meta: dict[str, Any] + + +class MilestoneRead(BaseModel): + code: str + label_fr: str + reached_at: datetime + achieved: bool + progress_pct: float + + +class VapeDashboard(BaseModel): + quit_date: date + days_since_quit: int + ml_today: float | None + ml_per_day_7: float | None + ml_per_day_30: float | None + nicotine_today_mg: float | None + cost_per_ml_cents: float | None + coil_cost_per_day_cents: float + vape_cost_per_day_cents: float | None + cig_cost_per_day_cents: float + savings_theoretical_cents: float + savings_real_cents: float + savings_display_cents: float # real when a purchase exists, else theoretical + has_purchases: bool + cigarettes_avoided: int + packs_avoided: float + current_coil_age_days: float | None + coil_avg_lifespan_days: float + coil_lifespan_is_default: bool + next_milestone: MilestoneRead | None diff --git a/apps/api/app/modules/vape/service.py b/apps/api/app/modules/vape/service.py new file mode 100644 index 0000000..da2ca00 --- /dev/null +++ b/apps/api/app/modules/vape/service.py @@ -0,0 +1,1590 @@ +"""Vape business logic (datamodel-health-vape.md §6-§7, endpoints §8.4). + +Every function filters by `user_id`; user-facing messages are French. +Money is handled in euro cents everywhere. +""" + +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal +from typing import Any +from zoneinfo import ZoneInfo + +from sqlalchemy import Select, func, select +from sqlalchemy.orm import Session + +from app.core.errors import ConflictError, DomainValidationError, NotFoundError +from app.core.pagination import Page, PageParams, paginate +from app.core.timeutils import local_day, resolve_tz, utcnow +from app.modules.vape import calculations as calc +from app.modules.vape.models import ( + LIQUID_KINDS, + CoilChange, + LiquidEntry, + LiquidEntryKind, + Mix, + MixComponent, + Product, + ProductKind, + Purchase, + SizeUnit, + VapeSettings, +) +from app.modules.vape.schemas import ( + CoilChangeCreate, + CoilChangeCreated, + CoilChangeRead, + CoilChangeUpdate, + LiquidEntryCreate, + LiquidEntryRead, + LiquidEntryUpdate, + MilestoneRead, + MixCalculatorRequest, + MixCalculatorResult, + MixComponentRead, + MixCreate, + MixRead, + MixUpdate, + ProductCreate, + ProductRead, + ProductUpdate, + PurchaseCreate, + PurchaseRead, + PurchaseUpdate, + SeriesModel, + StatsResponse, + VapeDashboard, + VapeSettingsPut, +) + +DEFAULT_WINDOW_DAYS = 30 +SHORT_WINDOW_DAYS = 7 +DEFAULT_COST_MONTHS = 12 +NICOTINE_TREND_WINDOW_DAYS = 30 + + +# --------------------------------------------------------------------------- +# Small helpers +# --------------------------------------------------------------------------- + + +def _round(value: float | Decimal | None, digits: int = 2) -> float | None: + return None if value is None else round(float(value), digits) + + +def _iso(day: date) -> str: + return day.isoformat() + + +def _as_utc(value: datetime) -> datetime: + """SQLite gives naive datetimes back; stored values are always UTC.""" + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +def _apply_sort( + stmt: Select, + sort: str | None, + allowed: dict[str, Any], + default: str, + tiebreak: Any, +) -> Select: + """`?sort=field` / `?sort=-field` with an explicit whitelist (C2.8).""" + raw = sort or default + descending = raw.startswith("-") + name = raw.lstrip("-") + column = allowed.get(name) + if column is None: + raise DomainValidationError("Champ de tri non autorisé.", details={"sort": raw}) + return stmt.order_by(column.desc() if descending else column.asc(), tiebreak) + + +def _resolve_period( + tz_name: str | None, from_: date | None, to: date | None, window: int +) -> tuple[ZoneInfo, date, date]: + """(tz, from, to) with inclusive bounds; defaults to the last `window` days.""" + tz = resolve_tz(tz_name) + today = local_day(utcnow(), tz) + end = to or today + start = from_ or end - timedelta(days=window - 1) + if start > end: + raise DomainValidationError( + "La date de début doit précéder la date de fin.", + details={"from": _iso(start), "to": _iso(end)}, + ) + return tz, start, end + + +# --------------------------------------------------------------------------- +# Settings (§6.1) +# --------------------------------------------------------------------------- + + +def find_settings(db: Session, user_id: int) -> VapeSettings | None: + return db.scalar(select(VapeSettings).where(VapeSettings.user_id == user_id)) + + +def get_settings(db: Session, user_id: int) -> VapeSettings: + settings = find_settings(db, user_id) + if settings is None: + raise NotFoundError( + "Paramètres vape non configurés.", details={"setup_required": True} + ) + return settings + + +def put_settings(db: Session, user_id: int, payload: VapeSettingsPut) -> VapeSettings: + """Upsert of the per-user singleton (§8.4 PUT /vape/settings).""" + settings = find_settings(db, user_id) + if settings is None: + settings = VapeSettings(user_id=user_id, **payload.model_dump()) + db.add(settings) + else: + for field, value in payload.model_dump().items(): + setattr(settings, field, value) + db.commit() + db.refresh(settings) + return settings + + +# --------------------------------------------------------------------------- +# Products (§6.2) +# --------------------------------------------------------------------------- + + +def _product_read(product: Product) -> ProductRead: + return ProductRead( + id=product.id, + kind=product.kind, + name=product.name, + brand=product.brand, + price_cents=product.price_cents, + size_value=product.size_value, + size_unit=product.size_unit, + nicotine_mg_ml=product.nicotine_mg_ml, + vg_pct=product.vg_pct, + ohm=product.ohm, + is_archived=product.is_archived, + note=product.note, + unit_price_cents=float(product.unit_price_cents), + ) + + +def get_product(db: Session, user_id: int, product_id: int) -> Product: + product = db.get(Product, product_id) + if product is None or product.user_id != user_id: + raise NotFoundError("Produit introuvable.") + return product + + +def list_products( + db: Session, + user_id: int, + params: PageParams, + kind: ProductKind | None = None, + include_archived: bool = False, + sort: str | None = None, +) -> Page[ProductRead]: + stmt = select(Product).where(Product.user_id == user_id) + if kind is not None: + stmt = stmt.where(Product.kind == kind) + if not include_archived: + stmt = stmt.where(Product.is_archived.is_(False)) + stmt = _apply_sort( + stmt, + sort, + { + "name": Product.name, + "kind": Product.kind, + "price_cents": Product.price_cents, + }, + "name", + Product.id.desc(), + ) + rows, total = paginate(db, stmt, params) + return Page( + items=[_product_read(row) for row in rows], + total=total, + page=params.page, + page_size=params.page_size, + ) + + +def _check_product_consistency( + kind: ProductKind, nicotine_mg_ml: Decimal | None +) -> None: + if kind is ProductKind.BOOSTER and nicotine_mg_ml is None: + raise DomainValidationError( + "Un booster doit indiquer son taux de nicotine (mg/ml)." + ) + + +def _check_product_unique( + db: Session, + user_id: int, + kind: ProductKind, + name: str, + brand: str | None, + exclude_id: int | None = None, +) -> None: + stmt = select(Product.id).where( + Product.user_id == user_id, + Product.kind == kind, + Product.name == name, + Product.brand.is_(None) if brand is None else Product.brand == brand, + ) + if exclude_id is not None: + stmt = stmt.where(Product.id != exclude_id) + if db.scalar(stmt) is not None: + raise ConflictError("Un produit identique existe déjà dans le catalogue.") + + +def create_product(db: Session, user_id: int, payload: ProductCreate) -> ProductRead: + _check_product_consistency(payload.kind, payload.nicotine_mg_ml) + _check_product_unique(db, user_id, payload.kind, payload.name, payload.brand) + product = Product(user_id=user_id, **payload.model_dump()) + db.add(product) + db.commit() + db.refresh(product) + return _product_read(product) + + +def update_product( + db: Session, user_id: int, product_id: int, payload: ProductUpdate +) -> ProductRead: + product = get_product(db, user_id, product_id) + changes = payload.model_dump(exclude_unset=True) + kind = changes.get("kind", product.kind) + nicotine = changes.get("nicotine_mg_ml", product.nicotine_mg_ml) + _check_product_consistency(kind, nicotine) + _check_product_unique( + db, + user_id, + kind, + changes.get("name", product.name), + changes.get("brand", product.brand), + exclude_id=product.id, + ) + for field, value in changes.items(): + setattr(product, field, value) + db.commit() + db.refresh(product) + return _product_read(product) + + +def _product_references(db: Session, product_id: int) -> int: + counts = ( + db.scalar( + select(func.count()) + .select_from(MixComponent) + .where(MixComponent.product_id == product_id) + ) + or 0 + ) + counts += ( + db.scalar( + select(func.count()) + .select_from(CoilChange) + .where(CoilChange.product_id == product_id) + ) + or 0 + ) + counts += ( + db.scalar( + select(func.count()) + .select_from(Purchase) + .where(Purchase.product_id == product_id) + ) + or 0 + ) + return counts + + +def delete_product(db: Session, user_id: int, product_id: int) -> None: + product = get_product(db, user_id, product_id) + if _product_references(db, product_id): + raise ConflictError( + "Ce produit est utilisé par une recette, un achat ou un changement " + "de résistance : archivez-le plutôt que de le supprimer.", + details={"product_id": product_id}, + ) + db.delete(product) + db.commit() + + +# --------------------------------------------------------------------------- +# Mixes (§6.3) +# --------------------------------------------------------------------------- + + +def _component_inputs(mix: Mix) -> list[calc.ComponentInput]: + return [ + calc.ComponentInput( + quantity=component.quantity, + price_cents=component.product.price_cents, + size_value=component.product.size_value, + nicotine_mg_ml=component.product.nicotine_mg_ml, + vg_pct=component.product.vg_pct, + ) + for component in mix.components + ] + + +def _mix_read(mix: Mix) -> MixRead: + inputs = _component_inputs(mix) + cost_total = calc.mix_cost_total_cents(inputs) + cost_per_ml = calc.mix_cost_per_ml_cents(inputs, mix.total_ml) + check = calc.mix_nicotine_check_mg_ml(inputs, mix.total_ml) + vg = calc.mix_vg_pct(inputs, mix.total_ml) + return MixRead( + id=mix.id, + name=mix.name, + total_ml=mix.total_ml, + target_nicotine_mg_ml=mix.target_nicotine_mg_ml, + is_active=mix.is_active, + is_archived=mix.is_archived, + note=mix.note, + components=[ + MixComponentRead( + id=component.id, + product_id=component.product_id, + product_name=component.product.name, + product_kind=component.product.kind, + quantity=component.quantity, + cost_cents=_round( + calc.component_cost_cents( + calc.ComponentInput( + quantity=component.quantity, + price_cents=component.product.price_cents, + size_value=component.product.size_value, + ) + ) + ) + or 0.0, + ) + for component in mix.components + ], + cost_total_cents=_round(cost_total) or 0.0, + cost_per_ml_cents=_round(cost_per_ml, 4), + nicotine_check_mg_ml=_round(check, 3) or 0.0, + vg_pct_mix=_round(vg), + warning=calc.nicotine_mismatch_warning(mix.target_nicotine_mg_ml, check), + ) + + +def get_mix(db: Session, user_id: int, mix_id: int) -> Mix: + mix = db.get(Mix, mix_id) + if mix is None or mix.user_id != user_id: + raise NotFoundError("Recette introuvable.") + return mix + + +def active_mix(db: Session, user_id: int) -> Mix | None: + return db.scalar(select(Mix).where(Mix.user_id == user_id, Mix.is_active.is_(True))) + + +def list_mixes( + db: Session, + user_id: int, + params: PageParams, + include_archived: bool = False, + sort: str | None = None, +) -> Page[MixRead]: + stmt = select(Mix).where(Mix.user_id == user_id) + if not include_archived: + stmt = stmt.where(Mix.is_archived.is_(False)) + stmt = _apply_sort( + stmt, + sort, + {"name": Mix.name, "created_at": Mix.created_at}, + "-created_at", + Mix.id.desc(), + ) + rows, total = paginate(db, stmt, params) + return Page( + items=[_mix_read(row) for row in rows], + total=total, + page=params.page, + page_size=params.page_size, + ) + + +def _load_components( + db: Session, user_id: int, lines: Sequence[Any] +) -> list[MixComponent]: + seen: set[int] = set() + components: list[MixComponent] = [] + for line in lines: + if line.product_id in seen: + raise DomainValidationError( + "Un même produit ne peut apparaître qu'une fois dans la recette.", + details={"product_id": line.product_id}, + ) + seen.add(line.product_id) + product = get_product(db, user_id, line.product_id) + if product.kind not in LIQUID_KINDS: + raise DomainValidationError( + "Une recette n'accepte que des bases, boosters et arômes.", + details={"product_id": product.id, "kind": product.kind.value}, + ) + components.append(MixComponent(product_id=product.id, quantity=line.quantity)) + return components + + +def create_mix(db: Session, user_id: int, payload: MixCreate) -> MixRead: + mix = Mix( + user_id=user_id, + name=payload.name, + total_ml=payload.total_ml, + target_nicotine_mg_ml=payload.target_nicotine_mg_ml, + note=payload.note, + ) + mix.components = _load_components(db, user_id, payload.components) + db.add(mix) + db.commit() + db.refresh(mix) + return _mix_read(mix) + + +def update_mix(db: Session, user_id: int, mix_id: int, payload: MixUpdate) -> MixRead: + mix = get_mix(db, user_id, mix_id) + changes = payload.model_dump(exclude_unset=True) + components = changes.pop("components", None) + for field, value in changes.items(): + setattr(mix, field, value) + if mix.is_archived and mix.is_active: + # An archived recipe is hidden from the lists: leaving it active would + # keep it driving cost_per_ml while the UI shows no active recipe. + mix.is_active = False + if components is not None: + # Full replacement of the recipe lines (§8.4). The old rows must be + # deleted before the new ones are inserted, otherwise re-using the same + # product violates uq_mix_components_mix_product during the flush. + replacement = _load_components(db, user_id, payload.components or []) + mix.components.clear() + db.flush() + mix.components = replacement + db.commit() + db.refresh(mix) + return _mix_read(mix) + + +def delete_mix(db: Session, user_id: int, mix_id: int) -> None: + mix = get_mix(db, user_id, mix_id) + db.delete(mix) # components cascade; liquid_entries.mix_id -> NULL + db.commit() + + +def activate_mix(db: Session, user_id: int, mix_id: int) -> MixRead: + """Single active mix per user (partial unique index).""" + mix = get_mix(db, user_id, mix_id) + if mix.is_archived: + raise ConflictError("Une recette archivée ne peut pas être activée.") + for other in db.scalars( + select(Mix).where( + Mix.user_id == user_id, Mix.is_active.is_(True), Mix.id != mix.id + ) + ): + other.is_active = False + db.flush() # release the partial unique index before claiming it + mix.is_active = True + db.commit() + db.refresh(mix) + return _mix_read(mix) + + +def mix_calculator( + db: Session, user_id: int, payload: MixCalculatorRequest +) -> MixCalculatorResult: + """Stateless recipe assistant (§6.3) — persists nothing.""" + booster = get_product(db, user_id, payload.booster_product_id) + base = get_product(db, user_id, payload.base_product_id) + aroma = ( + get_product(db, user_id, payload.aroma_product_id) + if payload.aroma_product_id is not None + else None + ) + if booster.nicotine_mg_ml is None or booster.nicotine_mg_ml <= 0: + raise DomainValidationError( + "Le booster sélectionné doit avoir un taux de nicotine supérieur à 0." + ) + try: + quantities = calc.recipe_quantities( + payload.total_ml, + payload.target_nicotine_mg_ml, + booster.nicotine_mg_ml, + payload.aroma_pct, + ) + except ValueError as exc: + raise DomainValidationError( + "Recette impossible : le volume de base serait nul ou négatif, " + "réduisez le taux de nicotine ou le dosage d'arôme." + ) from exc + + components = [ + calc.ComponentInput( + quantity=quantities.booster_ml, + price_cents=booster.price_cents, + size_value=booster.size_value, + nicotine_mg_ml=booster.nicotine_mg_ml, + ), + calc.ComponentInput( + quantity=quantities.base_ml, + price_cents=base.price_cents, + size_value=base.size_value, + ), + ] + if aroma is not None and quantities.aroma_ml > 0: + components.append( + calc.ComponentInput( + quantity=quantities.aroma_ml, + price_cents=aroma.price_cents, + size_value=aroma.size_value, + ) + ) + cost_total = calc.mix_cost_total_cents(components) + cost_per_ml = calc.mix_cost_per_ml_cents(components, payload.total_ml) + check = calc.mix_nicotine_check_mg_ml(components, payload.total_ml) + return MixCalculatorResult( + total_ml=payload.total_ml, + target_nicotine_mg_ml=payload.target_nicotine_mg_ml, + booster_ml=_round(quantities.booster_ml) or 0.0, + aroma_ml=_round(quantities.aroma_ml) or 0.0, + base_ml=_round(quantities.base_ml) or 0.0, + nicotine_check_mg_ml=_round(check, 3) or 0.0, + cost_total_cents=_round(cost_total) or 0.0, + cost_per_ml_cents=_round(cost_per_ml, 4) or 0.0, + ) + + +# --------------------------------------------------------------------------- +# Liquid entries (§6.4) +# --------------------------------------------------------------------------- + + +def _entries_between( + db: Session, user_id: int, start: date, end: date +) -> list[LiquidEntry]: + return list( + db.scalars( + select(LiquidEntry) + .where( + LiquidEntry.user_id == user_id, + LiquidEntry.entry_date >= start, + LiquidEntry.entry_date <= end, + ) + .order_by(LiquidEntry.entry_date, LiquidEntry.id) + ) + ) + + +def _group_by_day(entries: Iterable[LiquidEntry]) -> dict[date, list[LiquidEntry]]: + grouped: dict[date, list[LiquidEntry]] = {} + for entry in entries: + grouped.setdefault(entry.entry_date, []).append(entry) + return grouped + + +def _counting_entries(entries: Sequence[LiquidEntry]) -> list[LiquidEntry]: + """The entries that actually count for a day: daily_total wins over refills.""" + totals = [e for e in entries if e.kind is LiquidEntryKind.DAILY_TOTAL] + if totals: + return totals[:1] + return [e for e in entries if e.kind is LiquidEntryKind.REFILL] + + +def _daily_ml_map( + db: Session, user_id: int, start: date, end: date +) -> dict[date, Decimal]: + """Tracked days only: {day: effective ml} (§6.4 daily_total override).""" + grouped = _group_by_day(_entries_between(db, user_id, start, end)) + out: dict[date, Decimal] = {} + for day, entries in grouped.items(): + value = calc.effective_daily_ml((e.kind.value, e.ml) for e in entries) + if value is not None: + out[day] = value + return out + + +def _effective_nicotine( + entry: LiquidEntry, + mix_targets: dict[int, Decimal], + settings: VapeSettings | None, +) -> Decimal | None: + """entry override -> mix target -> settings default (§6.4).""" + if entry.nicotine_mg_ml is not None: + return entry.nicotine_mg_ml + if entry.mix_id is not None and entry.mix_id in mix_targets: + return mix_targets[entry.mix_id] + if settings is not None: + return settings.default_nicotine_mg_ml + return None + + +def _mix_targets(db: Session, user_id: int) -> dict[int, Decimal]: + rows = db.execute( + select(Mix.id, Mix.target_nicotine_mg_ml).where(Mix.user_id == user_id) + ).all() + return {row[0]: row[1] for row in rows} + + +def _daily_nicotine_map( + db: Session, + user_id: int, + start: date, + end: date, + settings: VapeSettings | None, +) -> dict[date, float]: + grouped = _group_by_day(_entries_between(db, user_id, start, end)) + targets = _mix_targets(db, user_id) + out: dict[date, float] = {} + for day, entries in grouped.items(): + counting = _counting_entries(entries) + value = calc.nicotine_mg_for_day( + (e.ml, _effective_nicotine(e, targets, settings)) for e in counting + ) + if value is not None: + out[day] = value + return out + + +def _liquid_read( + entry: LiquidEntry, + mix_targets: dict[int, Decimal], + settings: VapeSettings | None, +) -> LiquidEntryRead: + effective = _effective_nicotine(entry, mix_targets, settings) + return LiquidEntryRead( + id=entry.id, + entry_date=entry.entry_date, + kind=entry.kind, + ml=entry.ml, + nicotine_mg_ml=entry.nicotine_mg_ml, + nicotine_effective_mg_ml=_round(effective, 1), + nicotine_mg=_round(entry.ml * effective) if effective is not None else None, + mix_id=entry.mix_id, + source=entry.source, + note=entry.note, + ) + + +def get_liquid(db: Session, user_id: int, entry_id: int) -> LiquidEntry: + entry = db.get(LiquidEntry, entry_id) + if entry is None or entry.user_id != user_id: + raise NotFoundError("Saisie de consommation introuvable.") + return entry + + +def list_liquids( + db: Session, + user_id: int, + params: PageParams, + from_: date | None = None, + to: date | None = None, + kind: LiquidEntryKind | None = None, + sort: str | None = None, +) -> Page[LiquidEntryRead]: + stmt = select(LiquidEntry).where(LiquidEntry.user_id == user_id) + if from_ is not None: + stmt = stmt.where(LiquidEntry.entry_date >= from_) + if to is not None: + stmt = stmt.where(LiquidEntry.entry_date <= to) + if kind is not None: + stmt = stmt.where(LiquidEntry.kind == kind) + stmt = _apply_sort( + stmt, + sort, + {"entry_date": LiquidEntry.entry_date, "ml": LiquidEntry.ml}, + "-entry_date", + LiquidEntry.id.desc(), + ) + rows, total = paginate(db, stmt, params) + settings = find_settings(db, user_id) + targets = _mix_targets(db, user_id) + return Page( + items=[_liquid_read(row, targets, settings) for row in rows], + total=total, + page=params.page, + page_size=params.page_size, + ) + + +def _assert_single_daily_total( + db: Session, user_id: int, day: date, exclude_id: int | None = None +) -> None: + stmt = select(LiquidEntry.id).where( + LiquidEntry.user_id == user_id, + LiquidEntry.entry_date == day, + LiquidEntry.kind == LiquidEntryKind.DAILY_TOTAL, + ) + if exclude_id is not None: + stmt = stmt.where(LiquidEntry.id != exclude_id) + if db.scalar(stmt) is not None: + raise ConflictError( + "Un total quotidien existe déjà pour ce jour : modifiez-le.", + details={"entry_date": _iso(day)}, + ) + + +def create_liquid( + db: Session, user_id: int, payload: LiquidEntryCreate +) -> LiquidEntryRead: + if payload.kind is LiquidEntryKind.DAILY_TOTAL: + _assert_single_daily_total(db, user_id, payload.entry_date) + mix_id = payload.mix_id + if mix_id is not None: + get_mix(db, user_id, mix_id) + else: + current = active_mix(db, user_id) + mix_id = current.id if current is not None else None + entry = LiquidEntry( + user_id=user_id, + entry_date=payload.entry_date, + kind=payload.kind, + ml=payload.ml, + nicotine_mg_ml=payload.nicotine_mg_ml, + mix_id=mix_id, + note=payload.note, + source="manual", + ) + db.add(entry) + db.commit() + db.refresh(entry) + return _liquid_read(entry, _mix_targets(db, user_id), find_settings(db, user_id)) + + +def update_liquid( + db: Session, user_id: int, entry_id: int, payload: LiquidEntryUpdate +) -> LiquidEntryRead: + entry = get_liquid(db, user_id, entry_id) + changes = payload.model_dump(exclude_unset=True) + kind = changes.get("kind", entry.kind) + day = changes.get("entry_date", entry.entry_date) + if kind is LiquidEntryKind.DAILY_TOTAL: + _assert_single_daily_total(db, user_id, day, exclude_id=entry.id) + if changes.get("mix_id") is not None: + get_mix(db, user_id, changes["mix_id"]) + for field, value in changes.items(): + setattr(entry, field, value) + db.commit() + db.refresh(entry) + return _liquid_read(entry, _mix_targets(db, user_id), find_settings(db, user_id)) + + +def delete_liquid(db: Session, user_id: int, entry_id: int) -> None: + entry = get_liquid(db, user_id, entry_id) + db.delete(entry) + db.commit() + + +# --------------------------------------------------------------------------- +# Cost model (§7.1) and coils (§7.3) +# --------------------------------------------------------------------------- + + +def _liquid_purchase_pairs( + db: Session, user_id: int, until: date, window_days: int +) -> list[tuple[Decimal, Decimal]]: + """(total_cents, ml) of the liquid purchases of the fallback window.""" + since = until - timedelta(days=window_days) + rows = db.scalars( + select(Purchase) + .join(Product, Purchase.product_id == Product.id) + .where( + Purchase.user_id == user_id, + Purchase.purchased_on >= since, + Purchase.purchased_on <= until, + Product.kind.in_(LIQUID_KINDS), + Product.size_unit == SizeUnit.ML, + ) + ) + return [(row.total_cents, row.qty * row.product.size_value) for row in rows] + + +def cost_per_ml_cents(db: Session, user_id: int, today: date) -> Decimal | None: + """Active mix cost/ml, else weighted purchases fallback, else None (§7.1).""" + mix = active_mix(db, user_id) + if mix is not None and mix.components: + value = calc.mix_cost_per_ml_cents(_component_inputs(mix), mix.total_ml) + if value is not None: + return value + return calc.fallback_cost_per_ml_cents( + _liquid_purchase_pairs(db, user_id, today, calc.PURCHASE_FALLBACK_WINDOW_DAYS) + ) + + +@dataclass +class CoilContext: + changes: list[CoilChange] + intervals_days: list[float] + avg_lifespan_days: float + is_default: bool + unit_price_cents: float | None + cost_per_day_cents: float + current_age_days: float | None + current_product_id: int | None + + +def _coil_changes(db: Session, user_id: int) -> list[CoilChange]: + return list( + db.scalars( + select(CoilChange) + .where(CoilChange.user_id == user_id) + .order_by(CoilChange.changed_at, CoilChange.id) + ) + ) + + +def coil_context(db: Session, user_id: int, now_utc: datetime) -> CoilContext: + changes = _coil_changes(db, user_id) + intervals = calc.coil_intervals_days([_as_utc(c.changed_at) for c in changes]) + avg_days, is_default = calc.avg_coil_lifespan_days(intervals) + unit_price: float | None = None + for change in reversed(changes): + if change.product is not None: + unit_price = float(change.product.unit_price_cents) + break + current = changes[-1] if changes else None + return CoilContext( + changes=changes, + intervals_days=intervals, + avg_lifespan_days=avg_days, + is_default=is_default, + unit_price_cents=unit_price, + cost_per_day_cents=calc.coil_cost_per_day_cents(unit_price, avg_days), + current_age_days=( + calc.coil_age_days(_as_utc(current.changed_at), now_utc) + if current is not None + else None + ), + current_product_id=current.product_id if current is not None else None, + ) + + +def _coil_derived( + changes: Sequence[CoilChange], + daily_ml: dict[date, Decimal], + tz: ZoneInfo, + now_utc: datetime, +) -> dict[int, tuple[float, float | None, bool]]: + """{coil_change_id: (lifespan_days, ml_through, is_current)}.""" + out: dict[int, tuple[float, float | None, bool]] = {} + for index, change in enumerate(changes): + is_current = index == len(changes) - 1 + started_at = _as_utc(change.changed_at) + end_dt = now_utc if is_current else _as_utc(changes[index + 1].changed_at) + lifespan = (end_dt - started_at).total_seconds() / calc.SECONDS_PER_DAY + start_day = local_day(started_at, tz) + end_day = local_day(end_dt, tz) + # [start_day, end_day) so two consecutive coils never share a day; + # the coil in use also counts the current (unfinished) day. + last_day = end_day + timedelta(days=1) if is_current else end_day + ml = calc.sum_ml_between(daily_ml, start_day, last_day) + out[change.id] = (lifespan, float(ml) if ml is not None else None, is_current) + return out + + +def _coil_read( + change: CoilChange, derived: dict[int, tuple[float, float | None, bool]] +) -> CoilChangeRead: + lifespan, ml_through, is_current = derived.get(change.id, (0.0, None, False)) + return CoilChangeRead( + id=change.id, + changed_at=_as_utc(change.changed_at), + product_id=change.product_id, + product_name=change.product.name if change.product is not None else None, + reason=change.reason, + note=change.note, + is_current=is_current, + lifespan_days=_round(lifespan), + ml_through=_round(ml_through), + ) + + +def get_coil(db: Session, user_id: int, coil_id: int) -> CoilChange: + change = db.get(CoilChange, coil_id) + if change is None or change.user_id != user_id: + raise NotFoundError("Changement de résistance introuvable.") + return change + + +def _coil_derived_all( + db: Session, user_id: int, tz_name: str | None +) -> tuple[dict[int, tuple[float, float | None, bool]], list[CoilChange]]: + tz = resolve_tz(tz_name) + now = utcnow() + changes = _coil_changes(db, user_id) + if not changes: + return {}, changes + start_day = local_day(_as_utc(changes[0].changed_at), tz) + daily_ml = _daily_ml_map(db, user_id, start_day, local_day(now, tz)) + return _coil_derived(changes, daily_ml, tz, now), changes + + +def list_coils( + db: Session, + user_id: int, + params: PageParams, + from_: date | None = None, + to: date | None = None, + sort: str | None = None, + tz_name: str | None = None, +) -> Page[CoilChangeRead]: + derived, _ = _coil_derived_all(db, user_id, tz_name) + stmt = select(CoilChange).where(CoilChange.user_id == user_id) + if from_ is not None: + stmt = stmt.where(CoilChange.changed_at >= _start_of_day(from_, tz_name)) + if to is not None: + stmt = stmt.where(CoilChange.changed_at < _start_of_day(to, tz_name, offset=1)) + stmt = _apply_sort( + stmt, + sort, + {"changed_at": CoilChange.changed_at}, + "-changed_at", + CoilChange.id.desc(), + ) + rows, total = paginate(db, stmt, params) + return Page( + items=[_coil_read(row, derived) for row in rows], + total=total, + page=params.page, + page_size=params.page_size, + ) + + +def _start_of_day(day: date, tz_name: str | None, offset: int = 0) -> datetime: + """Local midnight of `day` (+offset days) expressed in UTC, for filters.""" + tz = resolve_tz(tz_name) + return datetime.combine( + day + timedelta(days=offset), datetime.min.time(), tzinfo=tz + ).astimezone(UTC) + + +def _created_coil_read( + db: Session, user_id: int, change: CoilChange, tz_name: str | None +) -> CoilChangeCreated: + derived, changes = _coil_derived_all(db, user_id, tz_name) + previous: float | None = None + for index, item in enumerate(changes): + if item.id == change.id and index > 0: + previous_change = changes[index - 1] + previous = _round( + ( + _as_utc(item.changed_at) - _as_utc(previous_change.changed_at) + ).total_seconds() + / calc.SECONDS_PER_DAY + ) + base = _coil_read(change, derived) + return CoilChangeCreated(**base.model_dump(), previous_lifespan_days=previous) + + +def create_coil( + db: Session, + user_id: int, + payload: CoilChangeCreate, + tz_name: str | None = None, +) -> CoilChangeCreated: + """CRUD create and the one-click « Résistance changée » action (§12.6).""" + if payload.product_id is not None: + product = get_product(db, user_id, payload.product_id) + if product.kind not in (ProductKind.COIL, ProductKind.POD): + raise DomainValidationError( + "Le produit posé doit être une résistance ou un pod." + ) + change = CoilChange( + user_id=user_id, + # always persisted in UTC, whatever offset the client sent + changed_at=_as_utc(payload.changed_at).astimezone(UTC) + if payload.changed_at is not None + else utcnow(), + product_id=payload.product_id, + reason=payload.reason, + note=payload.note, + ) + db.add(change) + db.commit() + db.refresh(change) + return _created_coil_read(db, user_id, change, tz_name) + + +def update_coil( + db: Session, + user_id: int, + coil_id: int, + payload: CoilChangeUpdate, + tz_name: str | None = None, +) -> CoilChangeRead: + change = get_coil(db, user_id, coil_id) + changes = payload.model_dump(exclude_unset=True) + if changes.get("product_id") is not None: + get_product(db, user_id, changes["product_id"]) + if changes.get("changed_at") is not None: + changes["changed_at"] = _as_utc(changes["changed_at"]).astimezone(UTC) + for field, value in changes.items(): + setattr(change, field, value) + db.commit() + db.refresh(change) + derived, _ = _coil_derived_all(db, user_id, tz_name) + return _coil_read(change, derived) + + +def delete_coil(db: Session, user_id: int, coil_id: int) -> None: + change = get_coil(db, user_id, coil_id) + db.delete(change) + db.commit() + + +def coil_stats(db: Session, user_id: int, tz_name: str | None = None) -> StatsResponse: + """Lifespan history + amortization metrics (§7.3).""" + tz = resolve_tz(tz_name) + now = utcnow() + today = local_day(now, tz) + ctx = coil_context(db, user_id, now) + derived, changes = _coil_derived_all(db, user_id, tz_name) + points: list[tuple[str, float | None]] = [] + ml_points: list[tuple[str, float | None]] = [] + start = local_day(_as_utc(changes[0].changed_at), tz) if changes else today + for change in changes: + lifespan, ml_through, _is_current = derived[change.id] + day = local_day(_as_utc(change.changed_at), tz) + points.append((_iso(day), _round(lifespan))) + ml_points.append((_iso(day), _round(ml_through))) + # §7.3: the average volume is computed on *completed* cycles only (the coil + # in use has been vaped for a few days at most and would drag the mean down), + # and like avg_lifespan_days it looks at the last COIL_AVG_LAST_N of them. + completed_ml: list[float] = [] + for change in changes: + _lifespan, ml_through, is_current = derived[change.id] + if not is_current and ml_through is not None: + completed_ml.append(ml_through) + ml_values = completed_ml[-calc.COIL_AVG_LAST_N :] + return StatsResponse( + from_=start, + to=today, + unit="days", + series=[ + SeriesModel(name="lifespan_days", type="bar", points=points), + SeriesModel(name="ml_through", type="line", points=ml_points), + ], + meta={ + "avg_lifespan_days": _round(ctx.avg_lifespan_days), + "avg_lifespan_is_default": ctx.is_default, + "avg_ml_through_coil": ( + _round(sum(ml_values) / len(ml_values)) if ml_values else None + ), + "current_coil_age_days": _round(ctx.current_age_days), + "current_coil_product_id": ctx.current_product_id, + "coil_unit_price_cents": _round(ctx.unit_price_cents), + "coil_cost_per_day_cents": _round(ctx.cost_per_day_cents), + "changes_count": len(changes), + }, + ) + + +# --------------------------------------------------------------------------- +# Purchases (§6.6) +# --------------------------------------------------------------------------- + + +def _purchase_read(purchase: Purchase) -> PurchaseRead: + return PurchaseRead( + id=purchase.id, + purchased_on=purchase.purchased_on, + product_id=purchase.product_id, + product_name=purchase.product.name, + qty=purchase.qty, + unit_price_cents=purchase.unit_price_cents, + total_cents=_round(purchase.total_cents) or 0.0, + note=purchase.note, + ) + + +def get_purchase(db: Session, user_id: int, purchase_id: int) -> Purchase: + purchase = db.get(Purchase, purchase_id) + if purchase is None or purchase.user_id != user_id: + raise NotFoundError("Achat introuvable.") + return purchase + + +def list_purchases( + db: Session, + user_id: int, + params: PageParams, + from_: date | None = None, + to: date | None = None, + product_id: int | None = None, + sort: str | None = None, +) -> Page[PurchaseRead]: + stmt = select(Purchase).where(Purchase.user_id == user_id) + if from_ is not None: + stmt = stmt.where(Purchase.purchased_on >= from_) + if to is not None: + stmt = stmt.where(Purchase.purchased_on <= to) + if product_id is not None: + stmt = stmt.where(Purchase.product_id == product_id) + stmt = _apply_sort( + stmt, + sort, + {"purchased_on": Purchase.purchased_on, "qty": Purchase.qty}, + "-purchased_on", + Purchase.id.desc(), + ) + rows, total = paginate(db, stmt, params) + return Page( + items=[_purchase_read(row) for row in rows], + total=total, + page=params.page, + page_size=params.page_size, + ) + + +def create_purchase(db: Session, user_id: int, payload: PurchaseCreate) -> PurchaseRead: + get_product(db, user_id, payload.product_id) + purchase = Purchase(user_id=user_id, **payload.model_dump()) + db.add(purchase) + db.commit() + db.refresh(purchase) + return _purchase_read(purchase) + + +def update_purchase( + db: Session, user_id: int, purchase_id: int, payload: PurchaseUpdate +) -> PurchaseRead: + purchase = get_purchase(db, user_id, purchase_id) + changes = payload.model_dump(exclude_unset=True) + if "product_id" in changes and changes["product_id"] is not None: + get_product(db, user_id, changes["product_id"]) + for field, value in changes.items(): + setattr(purchase, field, value) + db.commit() + db.refresh(purchase) + return _purchase_read(purchase) + + +def delete_purchase(db: Session, user_id: int, purchase_id: int) -> None: + purchase = get_purchase(db, user_id, purchase_id) + db.delete(purchase) + db.commit() + + +def _spend_by_date( + db: Session, user_id: int, start: date, end: date +) -> dict[date, float]: + out: dict[date, float] = {} + rows = db.scalars( + select(Purchase).where( + Purchase.user_id == user_id, + Purchase.purchased_on >= start, + Purchase.purchased_on <= end, + ) + ) + for row in rows: + out[row.purchased_on] = out.get(row.purchased_on, 0.0) + float(row.total_cents) + return out + + +def _has_purchases(db: Session, user_id: int) -> bool: + return ( + db.scalar( + select(func.count()) + .select_from(Purchase) + .where(Purchase.user_id == user_id) + ) + or 0 + ) > 0 + + +# --------------------------------------------------------------------------- +# Stats (§8.1 chart-ready payloads) +# --------------------------------------------------------------------------- + + +def consumption_stats( + db: Session, + user_id: int, + from_: date | None = None, + to: date | None = None, + tz_name: str | None = None, +) -> StatsResponse: + """ml/day + 7-day moving average (§7.2).""" + tz, start, end = _resolve_period(tz_name, from_, to, DEFAULT_WINDOW_DAYS) + today = local_day(utcnow(), tz) + daily = _daily_ml_map(db, user_id, start, end) + days = list(calc.date_range(start, end)) + values: list[float | None] = [ + float(daily[day]) if day in daily else None for day in days + ] + ma7 = calc.moving_average(values, SHORT_WINDOW_DAYS) + window7 = _daily_ml_map( + db, user_id, today - timedelta(days=SHORT_WINDOW_DAYS - 1), today + ) + window30 = _daily_ml_map( + db, user_id, today - timedelta(days=DEFAULT_WINDOW_DAYS - 1), today + ) + tracked = [daily.get(day) for day in days] + return StatsResponse( + from_=start, + to=end, + unit="ml", + series=[ + SeriesModel( + name="ml", + type="line", + points=[ + (_iso(d), _round(v)) for d, v in zip(days, values, strict=True) + ], + ), + SeriesModel( + name="ml_ma7", + type="line", + points=[(_iso(d), _round(v)) for d, v in zip(days, ma7, strict=True)], + ), + ], + meta={ + "ml_per_day_7": _round(calc.mean_ml_per_day(window7.values())), + "ml_per_day_30": _round(calc.mean_ml_per_day(window30.values())), + "ml_per_day_period": _round(calc.mean_ml_per_day(tracked)), + "tracked_days_ratio": _round(calc.tracked_days_ratio(tracked), 3), + "tracked_days": sum(1 for v in tracked if v is not None), + "total_ml": _round(sum(float(v) for v in daily.values())), + }, + ) + + +def nicotine_stats( + db: Session, + user_id: int, + from_: date | None = None, + to: date | None = None, + tz_name: str | None = None, +) -> StatsResponse: + """mg/day + moving average, 30-day trend and cigarette equivalent (§7.6).""" + _tz, start, end = _resolve_period(tz_name, from_, to, DEFAULT_WINDOW_DAYS) + settings = find_settings(db, user_id) + daily = _daily_nicotine_map(db, user_id, start, end, settings) + days = list(calc.date_range(start, end)) + values: list[float | None] = [daily.get(day) for day in days] + ma7 = calc.moving_average(values, SHORT_WINDOW_DAYS) + trend_start = max(start, end - timedelta(days=NICOTINE_TREND_WINDOW_DAYS - 1)) + trend_points = [ + (day, daily[day]) for day in calc.date_range(trend_start, end) if day in daily + ] + slope = calc.regression_slope(trend_points) + tracked_values = [v for v in values if v is not None] + mean_mg = sum(tracked_values) / len(tracked_values) if tracked_values else None + return StatsResponse( + from_=start, + to=end, + unit="mg", + series=[ + SeriesModel( + name="nicotine_mg", + type="bar", + points=[ + (_iso(d), _round(v)) for d, v in zip(days, values, strict=True) + ], + ), + SeriesModel( + name="nicotine_mg_ma7", + type="line", + points=[(_iso(d), _round(v)) for d, v in zip(days, ma7, strict=True)], + ), + ], + meta={ + "nicotine_mg_per_day": _round(mean_mg), + "slope_30d_mg_per_day": _round(slope, 4), + "trend_status": calc.trend_status(slope), + "cig_equivalent_per_day": ( + _round(calc.cig_equivalent(mean_mg)) if mean_mg is not None else None + ), + "nicotine_mg_per_cig": calc.NICOTINE_MG_PER_CIG, + }, + ) + + +def _is_vaping_day(day: date, today: date, settings: VapeSettings | None) -> bool: + """Whether an *untracked* day may be mean-imputed (§7.5). + + Only days the user could actually have vaped count: from the quit date (when + it is known) up to today — never the future. + """ + if day > today: + return False + return settings is None or day >= settings.quit_date + + +def _month_start(day: date) -> date: + return day.replace(day=1) + + +def _add_month(day: date) -> date: + return (day.replace(day=28) + timedelta(days=4)).replace(day=1) + + +def cost_stats( + db: Session, + user_id: int, + from_: date | None = None, + to: date | None = None, + tz_name: str | None = None, +) -> StatsResponse: + """Monthly theoretical cost vs real spending (§7.4).""" + tz = resolve_tz(tz_name) + today = local_day(utcnow(), tz) + end = to or today + if from_ is None: + start = _month_start(end) + for _ in range(DEFAULT_COST_MONTHS - 1): + start = _month_start(start - timedelta(days=1)) + else: + start = from_ + if start > end: + raise DomainValidationError("La date de début doit précéder la date de fin.") + + daily = _daily_ml_map(db, user_id, start, end) + cpm = cost_per_ml_cents(db, user_id, today) + ctx = coil_context(db, user_id, utcnow()) + window30 = _daily_ml_map( + db, user_id, today - timedelta(days=DEFAULT_WINDOW_DAYS - 1), today + ) + imputed = calc.mean_ml_per_day(window30.values()) + spend = _spend_by_date(db, user_id, start, end) + settings = find_settings(db, user_id) + + theoretical: dict[date, float] = {} + for day in calc.date_range(start, end): + if daily.get(day) is None and not _is_vaping_day(day, today, settings): + # Mean-imputation fills tracking *gaps* (§7.5); it must not invent a + # vape cost before the quit date or in the future. + continue + month = _month_start(day) + theoretical[month] = theoretical.get( + month, 0.0 + ) + calc.theoretical_vape_cost_cents( + daily.get(day), imputed, cpm, ctx.cost_per_day_cents + ) + real: dict[date, float] = {} + for day, amount in spend.items(): + month = _month_start(day) + real[month] = real.get(month, 0.0) + amount + + months: list[date] = [] + cursor = _month_start(start) + while cursor <= end: + months.append(cursor) + cursor = _add_month(cursor) + + window_days = (end - start).days + 1 + ml_per_day = calc.mean_ml_per_day( + [daily.get(day) for day in calc.date_range(start, end)] + ) + vape_cpd = calc.vape_cost_per_day_cents(ml_per_day, cpm, ctx.cost_per_day_cents) + return StatsResponse( + from_=start, + to=end, + unit="cents", + series=[ + SeriesModel( + name="theoretical_cost", + type="bar", + points=[(_iso(m), _round(theoretical.get(m, 0.0))) for m in months], + ), + SeriesModel( + name="real_spend", + type="bar", + points=[(_iso(m), _round(real.get(m, 0.0))) for m in months], + ), + ], + meta={ + "cost_per_ml_cents": _round(cpm, 4), + "cost_per_ml_source": ( + "active_mix" if active_mix(db, user_id) is not None else "purchases" + ), + "vape_cost_per_day_cents": _round(vape_cpd), + "coil_cost_per_day_cents": _round(ctx.cost_per_day_cents), + "coil_avg_lifespan_days": _round(ctx.avg_lifespan_days), + "real_cost_per_day_cents": _round( + calc.real_cost_per_day_cents(sum(spend.values()), window_days) + ), + "total_real_spend_cents": _round(sum(spend.values())), + }, + ) + + +def savings_stats( + db: Session, + user_id: int, + from_: date | None = None, + to: date | None = None, + tz_name: str | None = None, +) -> StatsResponse: + """Cumulative savings since quit_date: theoretical vs real (§7.5).""" + settings = get_settings(db, user_id) + tz = resolve_tz(tz_name) + today = local_day(utcnow(), tz) + # Cumulative savings stop today: a `to` in the future would project imputed + # days and inflate the headline « économies » (§7.5). + end = min(to, today) if to is not None else today + quit_date = settings.quit_date + start = from_ or quit_date + + daily = _daily_ml_map(db, user_id, quit_date, end) + window30 = _daily_ml_map( + db, user_id, today - timedelta(days=DEFAULT_WINDOW_DAYS - 1), today + ) + imputed = calc.mean_ml_per_day(window30.values()) + cpm = cost_per_ml_cents(db, user_id, today) + ctx = coil_context(db, user_id, utcnow()) + cig_cpd = calc.cig_cost_per_day_cents( + settings.cigs_per_day_before, + settings.cigs_per_pack, + settings.cig_pack_price_cents, + ) + theoretical = calc.cumulative_savings_theoretical( + quit_date, end, daily, imputed, cpm, ctx.cost_per_day_cents, cig_cpd + ) + real = calc.cumulative_savings_real( + quit_date, end, _spend_by_date(db, user_id, quit_date, end), cig_cpd + ) + elapsed = calc.days_since_quit(quit_date, today) + avoided = calc.cigarettes_avoided(elapsed, settings.cigs_per_day_before) + ml_per_day = calc.mean_ml_per_day(window30.values()) + vape_cpd = calc.vape_cost_per_day_cents(ml_per_day, cpm, ctx.cost_per_day_cents) + return StatsResponse( + from_=max(start, quit_date), + to=end, + unit="cents", + series=[ + SeriesModel( + name="savings_theoretical", + type="line", + points=[(_iso(d), _round(v)) for d, v in theoretical if d >= start], + ), + SeriesModel( + name="savings_real", + type="line", + points=[(_iso(d), _round(v)) for d, v in real if d >= start], + ), + ], + meta={ + "quit_date": _iso(quit_date), + "days_since_quit": elapsed, + "cig_cost_per_day_cents": _round(cig_cpd), + "vape_cost_per_day_cents": _round(vape_cpd), + "savings_per_day_cents": _round( + calc.savings_per_day_cents(cig_cpd, vape_cpd) + ), + "savings_theoretical_cents": _round( + theoretical[-1][1] if theoretical else 0.0 + ), + "savings_real_cents": _round(real[-1][1] if real else 0.0), + "has_purchases": _has_purchases(db, user_id), + "cigarettes_avoided": avoided, + "packs_avoided": _round( + calc.packs_avoided(avoided, settings.cigs_per_pack) + ), + "time_regained_minutes": calc.time_regained_minutes(avoided), + }, + ) + + +# --------------------------------------------------------------------------- +# Milestones (§7.8) and dashboard +# --------------------------------------------------------------------------- + + +def milestones( + db: Session, user_id: int, tz_name: str | None = None +) -> list[MilestoneRead]: + settings = get_settings(db, user_id) + tz = resolve_tz(tz_name) + statuses = calc.milestone_statuses(settings.quit_date, tz, utcnow()) + return [ + MilestoneRead( + code=status.code, + label_fr=status.label_fr, + reached_at=status.reached_at, + achieved=status.achieved, + progress_pct=_round(status.progress_pct) or 0.0, + ) + for status in statuses + ] + + +def dashboard(db: Session, user_id: int, tz_name: str | None = None) -> VapeDashboard: + """One call for the home screen KPIs (§8.4 GET /vape/dashboard).""" + settings = get_settings(db, user_id) + tz = resolve_tz(tz_name) + now = utcnow() + today = local_day(now, tz) + quit_date = settings.quit_date + + daily = _daily_ml_map(db, user_id, quit_date, today) + window7 = { + day: value + for day, value in daily.items() + if day >= today - timedelta(days=SHORT_WINDOW_DAYS - 1) + } + window30 = { + day: value + for day, value in daily.items() + if day >= today - timedelta(days=DEFAULT_WINDOW_DAYS - 1) + } + imputed = calc.mean_ml_per_day(window30.values()) + cpm = cost_per_ml_cents(db, user_id, today) + ctx = coil_context(db, user_id, now) + cig_cpd = calc.cig_cost_per_day_cents( + settings.cigs_per_day_before, + settings.cigs_per_pack, + settings.cig_pack_price_cents, + ) + theoretical = calc.cumulative_savings_theoretical( + quit_date, today, daily, imputed, cpm, ctx.cost_per_day_cents, cig_cpd + ) + real = calc.cumulative_savings_real( + quit_date, today, _spend_by_date(db, user_id, quit_date, today), cig_cpd + ) + savings_theoretical = theoretical[-1][1] if theoretical else 0.0 + savings_real = real[-1][1] if real else 0.0 + has_purchases = _has_purchases(db, user_id) + nicotine_today = _daily_nicotine_map(db, user_id, today, today, settings).get(today) + elapsed = calc.days_since_quit(quit_date, today) + avoided = calc.cigarettes_avoided(elapsed, settings.cigs_per_day_before) + upcoming = calc.next_milestone(calc.milestone_statuses(quit_date, tz, now)) + vape_cpd = calc.vape_cost_per_day_cents(imputed, cpm, ctx.cost_per_day_cents) + return VapeDashboard( + quit_date=quit_date, + days_since_quit=elapsed, + ml_today=_round(daily.get(today)), + ml_per_day_7=_round(calc.mean_ml_per_day(window7.values())), + ml_per_day_30=_round(imputed), + nicotine_today_mg=_round(nicotine_today), + cost_per_ml_cents=_round(cpm, 4), + coil_cost_per_day_cents=_round(ctx.cost_per_day_cents) or 0.0, + vape_cost_per_day_cents=_round(vape_cpd), + cig_cost_per_day_cents=_round(cig_cpd) or 0.0, + savings_theoretical_cents=_round(savings_theoretical) or 0.0, + savings_real_cents=_round(savings_real) or 0.0, + savings_display_cents=_round( + savings_real if has_purchases else savings_theoretical + ) + or 0.0, + has_purchases=has_purchases, + cigarettes_avoided=avoided, + packs_avoided=_round(calc.packs_avoided(avoided, settings.cigs_per_pack)) + or 0.0, + current_coil_age_days=_round(ctx.current_age_days), + coil_avg_lifespan_days=_round(ctx.avg_lifespan_days) or 0.0, + coil_lifespan_is_default=ctx.is_default, + next_milestone=( + MilestoneRead( + code=upcoming.code, + label_fr=upcoming.label_fr, + reached_at=upcoming.reached_at, + achieved=upcoming.achieved, + progress_pct=_round(upcoming.progress_pct) or 0.0, + ) + if upcoming is not None + else None + ), + ) diff --git a/apps/api/app/tests/__init__.py b/apps/api/app/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/tests/conftest.py b/apps/api/app/tests/conftest.py new file mode 100644 index 0000000..728987c --- /dev/null +++ b/apps/api/app/tests/conftest.py @@ -0,0 +1,103 @@ +"""Shared test fixtures. + +The suite runs on SQLite in-memory (StaticPool, see app.core.database), so the +environment MUST be configured before any app import. Works with any +LIFETRACK_MODULES subset: `auth` and `imports` are always forced in because the +shared fixtures (user, tokens, device keys, import runs) rely on them. +""" + +import os + +os.environ.setdefault("LIFETRACK_DATABASE_URL", "sqlite+pysqlite:///:memory:") +os.environ.setdefault("LIFETRACK_JWT_SECRET", "test-secret-not-for-production-32byte") +_mods = os.environ.get("LIFETRACK_MODULES", "").strip() +if _mods: + _names = {part.strip() for part in _mods.split(",") if part.strip()} + _names.update({"auth", "imports"}) + os.environ["LIFETRACK_MODULES"] = ",".join(sorted(_names)) + +from collections.abc import Callable, Iterator +from dataclasses import dataclass + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.core.database import Base, SessionLocal, engine +from app.core.module_loader import import_side_modules +from app.core.security import create_access_token +from app.main import app +from app.modules.auth import service as auth_service +from app.modules.auth.models import User +from app.modules.auth.schemas import DeviceKeyCreate, SetupRequest + +TEST_USER_EMAIL = "test@lifetrack.local" +TEST_USER_PASSWORD = "correct-horse-battery" + + +@pytest.fixture() +def _schema() -> Iterator[None]: + """Fresh tables for every test (all modules' models are in the metadata).""" + import_side_modules() + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + yield + + +@pytest.fixture() +def client(_schema: None) -> Iterator[TestClient]: + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture() +def db(_schema: None) -> Iterator[Session]: + with SessionLocal() as session: + yield session + + +@pytest.fixture() +def user(db: Session) -> User: + return auth_service.create_first_user( + db, + SetupRequest( + email=TEST_USER_EMAIL, + password=TEST_USER_PASSWORD, + display_name="Testeur", + ), + ) + + +@pytest.fixture() +def make_auth_headers() -> Callable[[int], dict[str, str]]: + """Helper building an Authorization header for any user id.""" + + def _make(user_id: int) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(user_id)}"} + + return _make + + +@pytest.fixture() +def auth_headers( + user: User, make_auth_headers: Callable[[int], dict[str, str]] +) -> dict[str, str]: + return make_auth_headers(user.id) + + +@dataclass +class DeviceKeyFixture: + id: int + plaintext: str + headers: dict[str, str] + + +@pytest.fixture() +def device_key(db: Session, user: User) -> DeviceKeyFixture: + """A device API key holding the wildcard ingest scope.""" + key, plaintext = auth_service.create_device_key( + db, user.id, DeviceKeyCreate(name="Clé de test", scopes=["ingest:*"]) + ) + return DeviceKeyFixture( + id=key.id, plaintext=plaintext, headers={"X-API-Key": plaintext} + ) diff --git a/apps/api/app/tests/fixtures/.gitkeep b/apps/api/app/tests/fixtures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/tests/fixtures/finance/banque_postale.csv b/apps/api/app/tests/fixtures/finance/banque_postale.csv new file mode 100644 index 0000000..e65c8af --- /dev/null +++ b/apps/api/app/tests/fixtures/finance/banque_postale.csv @@ -0,0 +1,10 @@ +Numro Compte ;12345678901 +Type ;COMPTE +Compte tenu en ;euros +Date ;30/06/2026 +Solde (EUROS) ;1 234,56 +Solde (FRANCS) ;8 098,12 + +Date;Libell;Montant(EUROS);Montant(FRANCS) +20/06/2026;ACHAT CB LIDL 1906;-32,15;-210,89 +21/06/2026;VIR SEPA CAF PRESTATIONS;185,00;1213,52 diff --git a/apps/api/app/tests/fixtures/finance/bnp_paribas.csv b/apps/api/app/tests/fixtures/finance/bnp_paribas.csv new file mode 100644 index 0000000..54a23aa --- /dev/null +++ b/apps/api/app/tests/fixtures/finance/bnp_paribas.csv @@ -0,0 +1,4 @@ +"Crédit immobilier";"Cr&eacute;dit immobilier";****1234;18/03/2026;;-123 456,78 +05/01/2026;;; AMORTISSEMENT PRET 1234;-70,93 +06/01/2026;;; CARTE 05/01 LECLERC;-45,20 +07/01/2026;;; VIREMENT SALAIRE;2 450,00 diff --git a/apps/api/app/tests/fixtures/finance/boursobank.csv b/apps/api/app/tests/fixtures/finance/boursobank.csv new file mode 100644 index 0000000..3042147 --- /dev/null +++ b/apps/api/app/tests/fixtures/finance/boursobank.csv @@ -0,0 +1,9 @@ +dateOp;dateVal;label;category;categoryParent;amount;comment;accountNum;accountLabel;accountbalance +2026-06-01;2026-06-01;"CARTE 31/05 CARREFOUR CITY PARIS";"Alimentation";"Vie quotidienne";-42,90;;001234;"BOURSORAMA BANQUE";1226.68 +2026-06-02;2026-06-02;"VIR SEPA LOYER JUIN";"Logement";"Logement";-750,00;;001234;"BOURSORAMA BANQUE";476.68 +2026-06-03;2026-06-03;"VIR INST SALAIRE MAI";"Salaire";"Revenus";2450,00;;001234;"BOURSORAMA BANQUE";2926.68 +2026-06-05;2026-06-05;"CARTE 04/06 BOULANGERIE DU COIN";"Alimentation";"Vie quotidienne";-2,50;;001234;"BOURSORAMA BANQUE";2924.18 +2026-06-05;2026-06-05;"CARTE 04/06 BOULANGERIE DU COIN";"Alimentation";"Vie quotidienne";-2,50;;001234;"BOURSORAMA BANQUE";2921.68 +2026-06-10;2026-06-10;"PRLV SEPA NETFLIX.COM";"Loisirs";"Loisirs";-13,49;;001234;"BOURSORAMA BANQUE";2908.19 +2026-06-12;2026-06-12;"VIR SEPA VERS PAYPAL EUROPE";"Virements";"Virements";-50,00;;001234;"BOURSORAMA BANQUE";2858.19 +date-invalide;2026-06-15;"LIGNE CASSEE";"";"";-1,00;;001234;"BOURSORAMA BANQUE";2857.19 diff --git a/apps/api/app/tests/fixtures/finance/caisse_epargne.csv b/apps/api/app/tests/fixtures/finance/caisse_epargne.csv new file mode 100644 index 0000000..66df0b6 --- /dev/null +++ b/apps/api/app/tests/fixtures/finance/caisse_epargne.csv @@ -0,0 +1,3 @@ +Date de comptabilisation;Libelle simplifie;Libelle operation;Reference;Informations complementaires;Type operation;Categorie;Sous categorie;Debit;Credit;Date operation;Date de valeur;Pointage operation +15/11/2026;SUPERMARCHE;CB SUPERMARCHE CENTRAL FACT 141126;;;Carte bancaire;Alimentation;Hyper/supermarche;-45,50;;14/11/2026;15/11/2026;0 +10/11/2026;EMPLOYEUR SA;VIR INST Employeur SA;REF123456;Salaire Novembre-;Virement recu;Revenus;Salaires;;+3500,00;09/11/2026;09/11/2026;0 diff --git a/apps/api/app/tests/fixtures/finance/credit_agricole.csv b/apps/api/app/tests/fixtures/finance/credit_agricole.csv new file mode 100644 index 0000000..7dcbbb7 --- /dev/null +++ b/apps/api/app/tests/fixtures/finance/credit_agricole.csv @@ -0,0 +1,13 @@ +Liste des oprations +Compte de dpt n 12345678901 +Priode du 01/06/2026 au 30/06/2026 +Solde au 30/06/2026;1 234,56 + +Date;Date valeur;Libell;Dbit euros;Crdit euros; +02/06/2026;02/06/2026;"PAIEMENT CB 0106 INTERMARCHE +FACT 010626";58,20;; +03/06/2026;04/06/2026;VIREMENT DE M DUPONT;;1 500,00; +05/06/2026;05/06/2026;PRLV SEPA EDF ENERGIE;89,00;; +06/06/2026;06/06/2026;CARTE 05/06 TOTAL ENERGIES;45,10;; + +Total des oprations;;;192,30;1 500,00; diff --git a/apps/api/app/tests/fixtures/finance/fortuneo.csv b/apps/api/app/tests/fixtures/finance/fortuneo.csv new file mode 100644 index 0000000..cd5764d --- /dev/null +++ b/apps/api/app/tests/fixtures/finance/fortuneo.csv @@ -0,0 +1,3 @@ +Date opration;Date valeur;libell;Dbit;Crdit; +13/12/2026;13/12/2026;CARTE 12/12 FNAC METZ;-6,4;; +20/12/2026;20/12/2026;VIREMENT SALAIRE DECEMBRE;;2 500,00; diff --git a/apps/api/app/tests/fixtures/finance/generic.csv b/apps/api/app/tests/fixtures/finance/generic.csv new file mode 100644 index 0000000..eee1f20 --- /dev/null +++ b/apps/api/app/tests/fixtures/finance/generic.csv @@ -0,0 +1,3 @@ +date,libelle,montant +2026-06-02,Achat supermarche,-25.40 +2026-06-03,Remboursement mutuelle,42.10 diff --git a/apps/api/app/tests/fixtures/finance/n26.csv b/apps/api/app/tests/fixtures/finance/n26.csv new file mode 100644 index 0000000..2d4ca87 --- /dev/null +++ b/apps/api/app/tests/fixtures/finance/n26.csv @@ -0,0 +1,3 @@ +"Booking Date","Value Date","Partner Name","Partner Iban",Type,"Payment Reference","Account Name","Amount (EUR)","Original Amount","Original Currency","Exchange Rate" +"2026-02-01","2026-02-01","Netflix","DE1234",Debit,"Abonnement fevrier","Main Account","-13.49","","","" +"2026-02-03","2026-02-03","Employeur SA","DE9999",Credit,"Salaire","Main Account","2500.00","","","" diff --git a/apps/api/app/tests/fixtures/finance/paypal_fr.csv b/apps/api/app/tests/fixtures/finance/paypal_fr.csv new file mode 100644 index 0000000..28d7919 --- /dev/null +++ b/apps/api/app/tests/fixtures/finance/paypal_fr.csv @@ -0,0 +1,8 @@ +Date,Heure,Fuseau horaire,Nom,Type,État,Devise,Brut,Frais,Net,Adresse email de l'expéditeur,Adresse email du destinataire,Numéro de transaction,Titre de l'objet,Numéro de la transaction de référence,Solde,Impact sur le solde +04/06/2026,10:12:00,CEST,Steam Games,"Paiement express",Effectué,EUR,"-12,99","0,00","-12,99",moi@example.com,pay@steam.com,5AB12345CD678901E,"Half-Life 3",,"37,01",Débit +05/06/2026,11:00:00,CEST,Boutique Vape,"Paiement",Autorisation,EUR,"-25,00","0,00","-25,00",moi@example.com,shop@vape.fr,5AB12345CD678902E,"Kit",,"37,01",Mémo +06/06/2026,09:30:00,CEST,Client Vinted,"Paiement",En attente,EUR,"18,00","-0,50","17,50",client@example.com,moi@example.com,5AB12345CD678903E,"Veste",,"54,51",Crédit +07/06/2026,09:30:00,CEST,Client Vinted,"Paiement",Effectué,EUR,"18,00","-0,50","17,50",client@example.com,moi@example.com,5AB12345CD678904E,"Veste",,"54,51",Crédit +08/06/2026,08:00:00,CEST,Amazon US,"Paiement",Effectué,USD,"-30,00","0,00","-30,00",moi@example.com,pay@amazon.com,5AB12345CD678905E,"Livre",,"12,00",Débit +09/06/2026,08:05:00,CEST,PayPal,"Conversion de devise générale",Effectué,EUR,"-27,10","0,00","-27,10",moi@example.com,,5AB12345CD678906E,,5AB12345CD678905E,"9,91",Débit +12/06/2026,07:00:00,CEST,Ma Banque,"Virement depuis un compte bancaire",Effectué,EUR,"50,00","0,00","50,00",moi@example.com,,5AB12345CD678907E,,,"59,91",Crédit diff --git a/apps/api/app/tests/fixtures/finance/revolut.csv b/apps/api/app/tests/fixtures/finance/revolut.csv new file mode 100644 index 0000000..3c49f36 --- /dev/null +++ b/apps/api/app/tests/fixtures/finance/revolut.csv @@ -0,0 +1,4 @@ +Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance +CARD_PAYMENT,Current,2026-01-05 14:00:40,2026-01-05 14:00:41,Uber Eats,-15.00,0.50,EUR,COMPLETED,74.43 +TOPUP,Current,2026-01-06 09:00:00,2026-01-06 09:00:01,Payment from M Dupont,10.00,0.00,EUR,COMPLETED,84.43 +CARD_PAYMENT,Current,2026-01-07 10:00:00,,Boutique en attente,-20.00,0.00,EUR,PENDING,64.43 diff --git a/apps/api/app/tests/fixtures/finance/sample.ofx b/apps/api/app/tests/fixtures/finance/sample.ofx new file mode 100644 index 0000000..3e5ebea --- /dev/null +++ b/apps/api/app/tests/fixtures/finance/sample.ofx @@ -0,0 +1,25 @@ +OFXHEADER:100 +DATA:OFXSGML +VERSION:102 +SECURITY:NONE +ENCODING:USASCII +CHARSET:1252 +COMPRESSION:NONE +OLDFILEUID:NONE +NEWFILEUID:NONE + + +0INFO +20260630120000FRA +10INFO +EUR +30002005500000123456XCHECKING +2026060120260630 +DEBIT20260604-12.75948 040626 -1275CB FNAC METZ +DEBIT20260604-12.75948 040626 -1275CB FNAC METZ +DEBIT20260608-59.90948 080626 -5990PRLV SEPA ORANGEFACTURE JUIN +CREDIT202606101500.00948 100626 150000VIREMENT RECUSALAIRE MAI + +1414.6020260630 + + diff --git a/apps/api/app/tests/fixtures/finance/societe_generale.csv b/apps/api/app/tests/fixtures/finance/societe_generale.csv new file mode 100644 index 0000000..dabfab2 --- /dev/null +++ b/apps/api/app/tests/fixtures/finance/societe_generale.csv @@ -0,0 +1,6 @@ +="0201900016400270";01/06/2026;30/06/2026; +date_comptabilisation;libell_complet_operation;montant_operation;devise; +15/06/2026;CARTE X7527 15/06 METRO ;-14,90;EUR; +16/06/2026;PRLV SEPA FREE MOBILE ;-19,99;EUR; +17/06/2026;VIR SEPA SALAIRE JUIN ;2450,00;EUR; +18/06/2026;CARTE X7527 18/06 AMAZON EU ;-1 234,56;EUR; diff --git a/apps/api/app/tests/fixtures/health/foodvisor_export.csv b/apps/api/app/tests/fixtures/health/foodvisor_export.csv new file mode 100644 index 0000000..f4fbeb9 --- /dev/null +++ b/apps/api/app/tests/fixtures/health/foodvisor_export.csv @@ -0,0 +1,5 @@ +Date;Repas;Aliment;Marque;Quantité;Unité;Calories (kcal);Protéines (g);Glucides (g);Lipides (g);Fibres (g);Sucres (g) +2026-08-10 08:15;Petit-déjeuner;Flocons d'avoine;Quaker;60;g;228,0;8,1;39,0;4,2;6,0;0,8 +2026-08-10 12:45;Déjeuner;Poulet rôti;;150;g;248,5;46,2;0,0;5,4;0,0;0,0 +2026-08-10 20:10;Dîner;Pâtes complètes;Barilla;100;g;350,0;13,0;66,0;2,5;8,0;3,0 +2026-08-11 16:00;Collation;Pomme;;120;g;62,0;0,4;16,0;0,2;2,9;12,0 diff --git a/apps/api/app/tests/fixtures/health/health_sync.csv b/apps/api/app/tests/fixtures/health/health_sync.csv new file mode 100644 index 0000000..a58294b --- /dev/null +++ b/apps/api/app/tests/fixtures/health/health_sync.csv @@ -0,0 +1,4 @@ +Date;Pas;Distance (km);Calories actives;Poids (kg) +2026-08-10;9421;6,80;520;92,40 +2026-08-11;12034;8,10;640; +2026-08-12;7800;5,20;410;92,10 diff --git a/apps/api/app/tests/fixtures/health/weight_history.csv b/apps/api/app/tests/fixtures/health/weight_history.csv new file mode 100644 index 0000000..a31e723 --- /dev/null +++ b/apps/api/app/tests/fixtures/health/weight_history.csv @@ -0,0 +1,4 @@ +date;poids +2026-07-01;95,20 +2026-07-08;94,10 +2026-07-15;93,40 diff --git a/apps/api/app/tests/modules/__init__.py b/apps/api/app/tests/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/tests/modules/test_finance_api.py b/apps/api/app/tests/modules/test_finance_api.py new file mode 100644 index 0000000..4e875cc --- /dev/null +++ b/apps/api/app/tests/modules/test_finance_api.py @@ -0,0 +1,787 @@ +"""HTTP contract of the finance module (datamodel-finance.md §9). + +Covers the endpoints an agent/UI can call: seeding, CRUD, import (preview + +run + rollback), rules, budgets, transfers and the chart-ready stats. +""" + +from io import BytesIO +from pathlib import Path +from typing import Any + +from fastapi.testclient import TestClient + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "finance" +API = "/api/finance" + + +def upload(name: str) -> tuple[str, BytesIO, str]: + return (name, BytesIO((FIXTURES / name).read_bytes()), "text/csv") + + +def create_account( + client: TestClient, headers: dict[str, str], name: str = "BoursoBank" +) -> dict[str, Any]: + response = client.post( + f"{API}/accounts", + json={"name": name, "kind": "checking", "initial_balance": 100}, + headers=headers, + ) + assert response.status_code == 201, response.text + return response.json() + + +def profile_id(client: TestClient, headers: dict[str, str], name: str) -> str: + profiles = client.get(f"{API}/source-profiles", headers=headers).json() + return next(p["id"] for p in profiles if p["name"] == name) + + +def import_boursobank( + client: TestClient, headers: dict[str, str], account_id: str +) -> dict[str, Any]: + response = client.post( + f"{API}/imports", + files={"file": upload("boursobank.csv")}, + data={ + "account_id": account_id, + "source_profile_id": profile_id( + client, headers, "BoursoBank / Boursorama (CSV)" + ), + }, + headers=headers, + ) + assert response.status_code == 201, response.text + return response.json() + + +# --------------------------------------------------------------------------- +# Auth + seeding +# --------------------------------------------------------------------------- + + +def test_endpoints_require_authentication(client: TestClient) -> None: + response = client.get(f"{API}/accounts") + assert response.status_code == 401 + assert response.json()["error"]["code"] == "unauthorized" + + +def test_categories_are_seeded_on_first_access( + client: TestClient, auth_headers: dict[str, str] +) -> None: + response = client.get(f"{API}/categories", headers=auth_headers) + assert response.status_code == 200 + tree = response.json() + names = {node["name"] for node in tree} + assert {"Alimentation", "Logement", "Transports", "Salaire"} <= names + alimentation = next(n for n in tree if n["name"] == "Alimentation") + assert {c["name"] for c in alimentation["children"]} >= {"Courses", "Livraison"} + transfer = next(n for n in tree if n["kind"] == "transfer") + assert transfer["name"] == "Virements internes" + assert transfer["is_system"] is True + + +def test_builtin_source_profiles_are_seeded_and_read_only( + client: TestClient, auth_headers: dict[str, str] +) -> None: + profiles = client.get(f"{API}/source-profiles", headers=auth_headers).json() + builtin = [p for p in profiles if p["is_builtin"]] + assert len(builtin) >= 12 + assert {"csv", "ofx", "paypal_csv"} == {p["kind"] for p in builtin} + + target = next(p for p in builtin if p["name"] == "Fortuneo (CSV)") + forbidden = client.patch( + f"{API}/source-profiles/{target['id']}", + json={"name": "Bidouille"}, + headers=auth_headers, + ) + assert forbidden.status_code == 403 + + clone = client.post( + f"{API}/source-profiles/{target['id']}/clone", headers=auth_headers + ) + assert clone.status_code == 201 + assert clone.json()["is_builtin"] is False + assert clone.json()["name"] == "Fortuneo (CSV) (copie)" + patched = client.patch( + f"{API}/source-profiles/{clone.json()['id']}", + json={"name": "Fortuneo perso"}, + headers=auth_headers, + ) + assert patched.status_code == 200 + + +def test_deleting_a_category_cleans_transactions_budgets_and_rules( + client: TestClient, auth_headers: dict[str, str] +) -> None: + account = create_account(client, auth_headers) + import_boursobank(client, auth_headers, account["id"]) + tree = client.get(f"{API}/categories", headers=auth_headers).json() + alimentation = next(n for n in tree if n["name"] == "Alimentation") + courses = next(c for c in alimentation["children"] if c["name"] == "Courses") + + rule = client.post( + f"{API}/rules", + json={ + "name": "Courses Carrefour", + "matchers": {"label_contains": ["CARREFOUR"]}, + "actions": {"set_category_id": courses["id"]}, + }, + headers=auth_headers, + ).json() + client.post( + f"{API}/rules/apply", json={"scope": "uncategorized"}, headers=auth_headers + ) + client.post( + f"{API}/budgets", + json={ + "category_id": alimentation["id"], + "monthly_amount": 400, + "start_month": "2026-01-01", + }, + headers=auth_headers, + ) + + system = next(n for n in tree if n["is_system"]) + refused = client.delete(f"{API}/categories/{system['id']}", headers=auth_headers) + assert refused.status_code == 422 + + deleted = client.delete( + f"{API}/categories/{alimentation['id']}", headers=auth_headers + ) + assert deleted.status_code == 204 + + tree_after = client.get(f"{API}/categories", headers=auth_headers).json() + names = {node["name"] for node in tree_after} + assert "Alimentation" not in names + tx = client.get( + f"{API}/transactions", params={"q": "carrefour"}, headers=auth_headers + ).json()["items"][0] + assert tx["category_id"] is None + assert tx["category_source"] is None + assert client.get(f"{API}/budgets", headers=auth_headers).json() == [] + rules = client.get(f"{API}/rules", headers=auth_headers).json() + orphan = next(r for r in rules if r["id"] == rule["id"]) + assert orphan["actions"].get("set_category_id") is None + assert orphan["enabled"] is False + + +def test_category_depth_is_limited_to_two_levels( + client: TestClient, auth_headers: dict[str, str] +) -> None: + tree = client.get(f"{API}/categories", headers=auth_headers).json() + child = next(n for n in tree if n["name"] == "Alimentation")["children"][0] + response = client.post( + f"{API}/categories", + json={"name": "Trop profond", "parent_id": child["id"]}, + headers=auth_headers, + ) + assert response.status_code == 422 + assert "deux niveaux" in response.json()["error"]["message"] + + +# --------------------------------------------------------------------------- +# Accounts +# --------------------------------------------------------------------------- + + +def test_accounts_crud(client: TestClient, auth_headers: dict[str, str]) -> None: + account = create_account(client, auth_headers) + assert account["balance"] == 100.0 + assert account["transaction_count"] == 0 + + duplicate = client.post( + f"{API}/accounts", json={"name": "BoursoBank"}, headers=auth_headers + ) + assert duplicate.status_code == 409 + + patched = client.patch( + f"{API}/accounts/{account['id']}", + json={"institution": "BoursoBank", "is_archived": True}, + headers=auth_headers, + ) + assert patched.status_code == 200 + assert patched.json()["is_archived"] is True + assert client.get(f"{API}/accounts", headers=auth_headers).json() == [] + listed = client.get( + f"{API}/accounts", params={"include_archived": True}, headers=auth_headers + ).json() + assert len(listed) == 1 + + assert ( + client.delete( + f"{API}/accounts/{account['id']}", headers=auth_headers + ).status_code + == 204 + ) + + +def test_account_with_transactions_cannot_be_deleted( + client: TestClient, auth_headers: dict[str, str] +) -> None: + account = create_account(client, auth_headers) + import_boursobank(client, auth_headers, account["id"]) + response = client.delete(f"{API}/accounts/{account['id']}", headers=auth_headers) + assert response.status_code == 409 + assert "Archivez" in response.json()["error"]["message"] + + +# --------------------------------------------------------------------------- +# Imports +# --------------------------------------------------------------------------- + + +def test_import_preview_writes_nothing( + client: TestClient, auth_headers: dict[str, str] +) -> None: + account = create_account(client, auth_headers) + response = client.post( + f"{API}/imports/preview", + files={"file": upload("boursobank.csv")}, + data={ + "account_id": account["id"], + "source_profile_id": profile_id( + client, auth_headers, "BoursoBank / Boursorama (CSV)" + ), + }, + headers=auth_headers, + ) + assert response.status_code == 200 + body = response.json() + assert body["rows_total"] == 8 + assert body["rows_error"] == 1 + assert body["would_skip_duplicates"] == 0 + assert body["date_min"] == "2026-06-01" + assert len(body["rows_preview"]) == 7 + assert body["rows_preview"][0]["amount"] == -42.9 + listed = client.get(f"{API}/transactions", headers=auth_headers).json() + assert listed["total"] == 0 + + +def test_import_then_rollback(client: TestClient, auth_headers: dict[str, str]) -> None: + account = create_account(client, auth_headers) + run = import_boursobank(client, auth_headers, account["id"]) + assert run["status"] == "completed" + assert run["stats"]["rows_imported"] == 7 + + runs = client.get(f"{API}/imports", headers=auth_headers).json() + assert runs["total"] == 1 + detail = client.get(f"{API}/imports/{run['id']}", headers=auth_headers) + assert detail.status_code == 200 + + # A duplicate re-upload is reported, not inserted twice. + again = import_boursobank(client, auth_headers, account["id"]) + assert again["stats"]["rows_imported"] == 0 + assert again["stats"]["duplicate_file_of"] == run["import_run_id"] + + deleted = client.delete(f"{API}/imports/{run['id']}", headers=auth_headers) + assert deleted.status_code == 204 + remaining = client.get(f"{API}/transactions", headers=auth_headers).json() + assert remaining["total"] == 0 + + +def test_rollback_refuses_manually_edited_rows_without_force( + client: TestClient, auth_headers: dict[str, str] +) -> None: + account = create_account(client, auth_headers) + run = import_boursobank(client, auth_headers, account["id"]) + tx = client.get(f"{API}/transactions", headers=auth_headers).json()["items"][0] + categories = client.get(f"{API}/categories", headers=auth_headers).json() + courses = next( + c for node in categories for c in node["children"] if c["name"] == "Courses" + ) + client.patch( + f"{API}/transactions/{tx['id']}", + json={"category_id": courses["id"]}, + headers=auth_headers, + ) + blocked = client.delete(f"{API}/imports/{run['id']}", headers=auth_headers) + assert blocked.status_code == 409 + forced = client.delete( + f"{API}/imports/{run['id']}", params={"force": True}, headers=auth_headers + ) + assert forced.status_code == 204 + + +def test_generic_imports_endpoint_serves_the_finance_importers( + client: TestClient, auth_headers: dict[str, str] +) -> None: + """The registered importers (C3) also work through POST /api/imports.""" + sources = client.get("/api/imports/sources", headers=auth_headers).json() + finance_ids = {s["id"] for s in sources if s["domain"] == "finance"} + assert {"bank_generic_csv", "bank_ofx", "paypal_csv"} <= finance_ids + + first = client.post( + "/api/imports", + files={"file": upload("sample.ofx")}, + data={"source": "bank_ofx"}, + headers=auth_headers, + ) + assert first.status_code == 201, first.text + assert first.json()["rows_inserted"] == 4 + assert first.json()["status"] == "completed" + + # A default account was created for that format… + accounts = client.get(f"{API}/accounts", headers=auth_headers).json() + assert [a["name"] for a in accounts] == ["Compte importé (OFX)"] + assert accounts[0]["transaction_count"] == 4 + + # …and re-importing the same file stays idempotent. + again = client.post( + "/api/imports", + files={"file": upload("sample.ofx")}, + data={"source": "bank_ofx"}, + headers=auth_headers, + ) + assert again.json()["rows_inserted"] == 0 + assert again.json()["rows_duplicates"] == 4 + + # Central rollback removes the finance rows (FK ON DELETE CASCADE). + assert ( + client.delete( + f"/api/imports/{first.json()['id']}", headers=auth_headers + ).status_code + == 204 + ) + assert client.get(f"{API}/transactions", headers=auth_headers).json()["total"] == 0 + + +# --------------------------------------------------------------------------- +# Transactions +# --------------------------------------------------------------------------- + + +def test_transactions_filters_and_manual_flag( + client: TestClient, auth_headers: dict[str, str] +) -> None: + account = create_account(client, auth_headers) + import_boursobank(client, auth_headers, account["id"]) + + page = client.get( + f"{API}/transactions", + params={"page_size": 3, "sort": "booked_date"}, + headers=auth_headers, + ).json() + assert page["total"] == 7 + assert len(page["items"]) == 3 + assert page["items"][0]["booked_date"] == "2026-06-01" + assert page["items"][0]["account_name"] == "BoursoBank" + + credits = client.get( + f"{API}/transactions", params={"direction": "credit"}, headers=auth_headers + ).json() + assert credits["total"] == 1 + assert credits["items"][0]["amount"] == 2450.0 + + searched = client.get( + f"{API}/transactions", params={"q": "netflix"}, headers=auth_headers + ).json() + assert searched["total"] == 1 + + window = client.get( + f"{API}/transactions", + params={"date_from": "2026-06-05", "date_to": "2026-06-10"}, + headers=auth_headers, + ).json() + assert window["total"] == 3 + + uncategorized = client.get( + f"{API}/transactions", params={"category_id": "none"}, headers=auth_headers + ).json() + assert uncategorized["total"] == 7 + + bad_sort = client.get( + f"{API}/transactions", params={"sort": "hacker"}, headers=auth_headers + ) + assert bad_sort.status_code == 422 + + +def test_patch_and_bulk_categorize_set_user_source( + client: TestClient, auth_headers: dict[str, str] +) -> None: + account = create_account(client, auth_headers) + import_boursobank(client, auth_headers, account["id"]) + items = client.get(f"{API}/transactions", headers=auth_headers).json()["items"] + categories = client.get(f"{API}/categories", headers=auth_headers).json() + courses = next( + c for node in categories for c in node["children"] if c["name"] == "Courses" + ) + + patched = client.patch( + f"{API}/transactions/{items[0]['id']}", + json={"category_id": courses["id"], "notes": "Vérifié"}, + headers=auth_headers, + ).json() + assert patched["category_source"] == "user" + assert patched["category_name"] == "Courses" + assert patched["notes"] == "Vérifié" + + # Date/amount of an imported row stay read-only. + refused = client.patch( + f"{API}/transactions/{items[0]['id']}", + json={"amount": -1}, + headers=auth_headers, + ) + assert refused.status_code == 422 + + bulk = client.post( + f"{API}/transactions/bulk-categorize", + json={ + "transaction_ids": [item["id"] for item in items[:3]], + "category_id": courses["id"], + }, + headers=auth_headers, + ).json() + assert bulk["updated"] == 3 + + # Deleting an imported row is refused (roll the import back instead). + assert ( + client.delete( + f"{API}/transactions/{items[0]['id']}", headers=auth_headers + ).status_code + == 422 + ) + + +def test_manual_transaction_lifecycle( + client: TestClient, auth_headers: dict[str, str] +) -> None: + account = create_account(client, auth_headers) + created = client.post( + f"{API}/transactions", + json={ + "account_id": account["id"], + "booked_date": "2026-08-02", + "amount": -12.5, + "label_clean": "Café du coin", + }, + headers=auth_headers, + ) + assert created.status_code == 201 + body = created.json() + assert body["amount"] == -12.5 + assert body["import_run_id"] is None + + twin = client.post( + f"{API}/transactions", + json={ + "account_id": account["id"], + "booked_date": "2026-08-02", + "amount": -12.5, + "label_clean": "Café du coin", + }, + headers=auth_headers, + ) + assert twin.status_code == 201 # occurrence 1, not a conflict + + assert ( + client.delete( + f"{API}/transactions/{body['id']}", headers=auth_headers + ).status_code + == 204 + ) + + +# --------------------------------------------------------------------------- +# Rules +# --------------------------------------------------------------------------- + + +def test_rules_crud_preview_and_apply( + client: TestClient, auth_headers: dict[str, str] +) -> None: + account = create_account(client, auth_headers) + import_boursobank(client, auth_headers, account["id"]) + categories = client.get(f"{API}/categories", headers=auth_headers).json() + courses = next( + c for node in categories for c in node["children"] if c["name"] == "Courses" + ) + + invalid = client.post( + f"{API}/rules", + json={ + "name": "Regex cassée", + "matchers": {"label_regex": "["}, + "actions": {"set_category_id": courses["id"]}, + }, + headers=auth_headers, + ) + assert invalid.status_code == 422 + + empty = client.post( + f"{API}/rules", + json={"name": "Vide", "matchers": {}, "actions": {}}, + headers=auth_headers, + ) + assert empty.status_code == 422 + + preview = client.post( + f"{API}/rules/preview", + json={"matchers": {"label_contains": ["CARREFOUR"]}}, + headers=auth_headers, + ).json() + assert preview["total_matched"] == 1 + + created = client.post( + f"{API}/rules", + json={ + "name": "Courses Carrefour", + "priority": 10, + "matchers": {"label_contains": ["CARREFOUR"], "direction": "debit"}, + "actions": { + "set_category_id": courses["id"], + "set_counterparty": "Carrefour", + }, + }, + headers=auth_headers, + ) + assert created.status_code == 201 + rule = created.json() + + second = client.post( + f"{API}/rules", + json={ + "name": "Loyer", + "matchers": {"label_contains": ["LOYER"]}, + "actions": {"set_label_clean": "Loyer"}, + }, + headers=auth_headers, + ).json() + + reordered = client.post( + f"{API}/rules/reorder", + json={"ordered_ids": [second["id"], rule["id"]]}, + headers=auth_headers, + ).json() + assert [r["priority"] for r in reordered] == [0, 10] + + dry = client.post( + f"{API}/rules/apply", + json={"scope": "uncategorized", "dry_run": True}, + headers=auth_headers, + ).json() + assert dry["matched"] == 2 + assert dry["dry_run"] is True + + applied = client.post( + f"{API}/rules/apply", json={"scope": "uncategorized"}, headers=auth_headers + ).json() + assert applied["updated"] == 2 + assert {entry["name"] for entry in applied["by_rule"]} == { + "Courses Carrefour", + "Loyer", + } + + tx = client.get( + f"{API}/transactions", params={"q": "carrefour"}, headers=auth_headers + ).json()["items"][0] + assert tx["category_name"] == "Courses" + assert tx["category_source"] == "rule" + assert tx["counterparty"] == "Carrefour" + + forced = client.post( + f"{API}/rules/apply", json={"scope": "all"}, headers=auth_headers + ) + assert forced.status_code == 422 # force=true required + + assert ( + client.delete(f"{API}/rules/{rule['id']}", headers=auth_headers).status_code + == 204 + ) + + +# --------------------------------------------------------------------------- +# Budgets +# --------------------------------------------------------------------------- + + +def test_budgets_overlap_and_effective_from( + client: TestClient, auth_headers: dict[str, str] +) -> None: + categories = client.get(f"{API}/categories", headers=auth_headers).json() + alimentation = next(c for c in categories if c["name"] == "Alimentation") + salaire = next(c for c in categories if c["name"] == "Salaire") + + created = client.post( + f"{API}/budgets", + json={ + "category_id": alimentation["id"], + "monthly_amount": 450, + "start_month": "2026-01-01", + }, + headers=auth_headers, + ) + assert created.status_code == 201 + + overlap = client.post( + f"{API}/budgets", + json={ + "category_id": alimentation["id"], + "monthly_amount": 500, + "start_month": "2026-06-01", + }, + headers=auth_headers, + ) + assert overlap.status_code == 409 + + income_budget = client.post( + f"{API}/budgets", + json={ + "category_id": salaire["id"], + "monthly_amount": 100, + "start_month": "2026-01-01", + }, + headers=auth_headers, + ) + assert income_budget.status_code == 422 + + split = client.patch( + f"{API}/budgets/{created.json()['id']}", + json={"monthly_amount": 520, "effective_from": "2026-08-01"}, + headers=auth_headers, + ).json() + assert len(split) == 2 + assert split[0]["end_month"] == "2026-07-01" + assert split[1]["start_month"] == "2026-08-01" + assert split[1]["monthly_amount"] == 520.0 + + listed = client.get( + f"{API}/budgets", params={"month": "2026-08"}, headers=auth_headers + ).json() + assert len(listed) == 1 + assert listed[0]["monthly_amount"] == 520.0 + assert listed[0]["category_name"] == "Alimentation" + + assert ( + client.delete( + f"{API}/budgets/{split[1]['id']}", headers=auth_headers + ).status_code + == 204 + ) + + +# --------------------------------------------------------------------------- +# Transfers + stats +# --------------------------------------------------------------------------- + + +def test_transfer_endpoints(client: TestClient, auth_headers: dict[str, str]) -> None: + bank = create_account(client, auth_headers, "BoursoBank") + paypal = create_account(client, auth_headers, "PayPal") + import_boursobank(client, auth_headers, bank["id"]) + response = client.post( + f"{API}/imports", + files={"file": upload("paypal_fr.csv")}, + data={ + "account_id": paypal["id"], + "source_profile_id": profile_id( + client, auth_headers, "PayPal — rapport d'activité (CSV)" + ), + }, + headers=auth_headers, + ) + assert response.status_code == 201 + assert response.json()["stats"]["transfers_detected"] == 1 + + transfers = client.get( + f"{API}/transactions", params={"is_transfer": True}, headers=auth_headers + ).json() + assert transfers["total"] == 2 + group_id = transfers["items"][0]["transfer_group_id"] + assert transfers["items"][0]["category_name"] == "Virements internes" + + unlinked = client.delete(f"{API}/transfers/{group_id}", headers=auth_headers) + assert unlinked.status_code == 204 + assert ( + client.get( + f"{API}/transactions", params={"is_transfer": True}, headers=auth_headers + ).json()["total"] + == 0 + ) + + detected = client.post( + f"{API}/transfers/detect", json={}, headers=auth_headers + ).json() + assert detected["pairs_created"] == 1 + + legs = client.get( + f"{API}/transactions", params={"is_transfer": True}, headers=auth_headers + ).json()["items"] + client.delete( + f"{API}/transfers/{legs[0]['transfer_group_id']}", headers=auth_headers + ) + linked = client.post( + f"{API}/transfers/link", + json={ + "transaction_id_a": legs[0]["id"], + "transaction_id_b": legs[1]["id"], + }, + headers=auth_headers, + ) + assert linked.status_code == 200 + assert linked.json()["transfer_group_id"] + + +def test_stats_endpoints_are_chart_ready( + client: TestClient, auth_headers: dict[str, str] +) -> None: + account = create_account(client, auth_headers) + import_boursobank(client, auth_headers, account["id"]) + + monthly = client.get( + f"{API}/stats/monthly-by-category", + params={"months": 6}, + headers=auth_headers, + ).json() + assert len(monthly["months"]) == 6 + assert all(len(series["data"]) == 6 for series in monthly["series"]) + assert monthly["series"][0]["color"].startswith("#") + + cashflow = client.get( + f"{API}/stats/cashflow", params={"months": 4}, headers=auth_headers + ).json() + assert set(cashflow) == { + "months", + "income", + "expenses", + "net", + "cumulative_net", + } + assert len(cashflow["cumulative_net"]) == 4 + + merchants = client.get( + f"{API}/stats/top-merchants", params={"months": 24}, headers=auth_headers + ).json() + assert merchants["period"]["from"] <= merchants["period"]["to"] + assert merchants["items"] + + recurring = client.get(f"{API}/stats/recurring", headers=auth_headers).json() + assert recurring["items"] == [] + assert recurring["monthly_total_estimate"] == 0.0 + + budgets = client.get( + f"{API}/stats/budget-progress", + params={"month": "2026-06"}, + headers=auth_headers, + ).json() + assert budgets["month"] == "2026-06" + + sankey = client.get( + f"{API}/stats/sankey", params={"month": "2026-06"}, headers=auth_headers + ).json() + names = [node["name"] for node in sankey["nodes"]] + assert "Revenus" in names + assert len(names) == len(set(names)) + assert all(link["value"] > 0 for link in sankey["links"]) + + dashboard = client.get(f"{API}/dashboard", headers=auth_headers).json() + assert dashboard["accounts_count"] == 1 + assert dashboard["uncategorized_count"] == 7 + # 100 € initial balance + the net of the 7 imported rows. + assert dashboard["total_balance"] == 1688.61 + assert dashboard["month_expenses"] == 0.0 # the file covers June 2026 + + bad_month = client.get( + f"{API}/stats/budget-progress", + params={"month": "2026-13"}, + headers=auth_headers, + ) + assert bad_month.status_code == 422 diff --git a/apps/api/app/tests/modules/test_finance_calculations.py b/apps/api/app/tests/modules/test_finance_calculations.py new file mode 100644 index 0000000..d10c71a --- /dev/null +++ b/apps/api/app/tests/modules/test_finance_calculations.py @@ -0,0 +1,400 @@ +"""Calculation tests: recurring detection, budget rollup and chart aggregates. + +Reference: datamodel-finance.md §7 (recurring), §8 (aggregates), §9.8 (stats). +Every function takes an explicit `today`, so the expectations are deterministic. +""" + +import uuid +from datetime import date, timedelta +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.modules.auth.models import User +from app.modules.finance import service, stats +from app.modules.finance.categorize import ( + detect_recurring, + monthly_total_estimate, +) +from app.modules.finance.enums import AccountKind, CategorySource +from app.modules.finance.models import ( + FinAccount, + FinBudget, + FinCategory, + FinTransaction, +) +from app.modules.finance.normalize import add_months, compute_dedup_hash + +TODAY = date(2026, 8, 13) +THIS_MONTH = date(2026, 8, 1) + + +def account(db: Session, user: User, name: str = "Compte courant") -> FinAccount: + row = FinAccount( + id=uuid.uuid4(), + user_id=user.id, + name=name, + kind=AccountKind.CHECKING, + currency="EUR", + initial_balance=Decimal("1000.00"), + ) + db.add(row) + db.commit() + return row + + +def category(db: Session, user: User, name: str) -> FinCategory: + service.ensure_seed(db, user.id) + found = db.scalars( + select(FinCategory).where( + FinCategory.user_id == user.id, FinCategory.name == name + ) + ).first() + assert found is not None, name + return found + + +def add_tx( + db: Session, + user: User, + acc: FinAccount, + day: date, + amount: str, + label: str, + cat: FinCategory | None = None, + counterparty: str | None = None, +) -> FinTransaction: + value = Decimal(amount) + tx = FinTransaction( + id=uuid.uuid4(), + user_id=user.id, + account_id=acc.id, + booked_date=day, + amount=value, + currency="EUR", + label_raw=label, + label_clean=label, + counterparty=counterparty, + category_id=cat.id if cat else None, + category_source=CategorySource.RULE if cat else None, + dedup_hash=compute_dedup_hash(acc.id, day, value, label, 0) + + uuid.uuid4().hex[:4], + ) + db.add(tx) + db.commit() + return tx + + +# --------------------------------------------------------------------------- +# §7 — recurring detection +# --------------------------------------------------------------------------- + + +def test_recurring_detects_a_monthly_subscription(db: Session, user: User) -> None: + acc = account(db, user) + abo = category(db, user, "Abonnements & streaming") + for months_ago in range(6, 0, -1): + day = add_months(THIS_MONTH, -months_ago) + timedelta(days=27) + amount = "-13.49" if months_ago != 3 else "-13.99" + add_tx(db, user, acc, day, amount, "PRLV SEPA NETFLIX.COM", abo) + # noise: a one-off purchase must not create a series + add_tx(db, user, acc, date(2026, 7, 4), "-89.00", "ACHAT CB DARTY 4444") + + series = detect_recurring(db, user.id, today=TODAY) + assert len(series) == 1 + netflix = series[0] + assert netflix.merchant_key == "NETFLIX COM" + assert netflix.periodicity == "monthly" + assert netflix.occurrences == 6 + assert netflix.expected_amount == Decimal("13.49") + assert netflix.category_id == abo.id + assert netflix.is_active is True + assert netflix.next_date_predicted > netflix.last_date + assert monthly_total_estimate(series) == Decimal("13.49") + + +def test_recurring_ignores_irregular_and_unstable_series( + db: Session, user: User +) -> None: + acc = account(db, user) + # irregular intervals + for day in (date(2026, 3, 2), date(2026, 3, 20), date(2026, 7, 15)): + add_tx(db, user, acc, day, "-30.00", "CARTE BOUTIQUE ALPHA") + # regular but wildly unstable amounts + for index, amount in enumerate(("-10.00", "-90.00", "-200.00")): + add_tx( + db, + user, + acc, + add_months(THIS_MONTH, -(3 - index)) + timedelta(days=4), + amount, + "CARTE BOUTIQUE BETA", + ) + assert detect_recurring(db, user.id, today=TODAY) == [] + + +def test_recurring_on_income_direction(db: Session, user: User) -> None: + acc = account(db, user) + salaire = category(db, user, "Salaire") + for months_ago in range(5, 0, -1): + add_tx( + db, + user, + acc, + add_months(THIS_MONTH, -months_ago) + timedelta(days=1), + "2450.00", + "VIR SEPA SALAIRE EMPLOYEUR SA", + salaire, + ) + series = detect_recurring(db, user.id, direction="credit", today=TODAY) + assert len(series) == 1 + assert series[0].periodicity == "monthly" + assert series[0].expected_amount == Decimal("2450.00") + + +def test_recurring_marks_inactive_series(db: Session, user: User) -> None: + acc = account(db, user) + for months_ago in range(12, 8, -1): + add_tx( + db, + user, + acc, + add_months(THIS_MONTH, -months_ago) + timedelta(days=9), + "-9.99", + "PRLV SEPA VIEUX SERVICE", + ) + assert detect_recurring(db, user.id, today=TODAY) == [] + inactive = detect_recurring(db, user.id, include_inactive=True, today=TODAY) + assert len(inactive) == 1 + assert inactive[0].is_active is False + + +# --------------------------------------------------------------------------- +# §8.3 — budgets +# --------------------------------------------------------------------------- + + +def test_budget_rollup_root_covers_children(db: Session, user: User) -> None: + acc = account(db, user) + alimentation = category(db, user, "Alimentation") + courses = category(db, user, "Courses") + restaurants = category(db, user, "Restaurants & bars") + db.add( + FinBudget( + id=uuid.uuid4(), + user_id=user.id, + category_id=alimentation.id, + monthly_amount=Decimal("450.00"), + start_month=date(2026, 1, 1), + ) + ) + db.add( + FinBudget( + id=uuid.uuid4(), + user_id=user.id, + category_id=courses.id, + monthly_amount=Decimal("200.00"), + start_month=date(2026, 1, 1), + ) + ) + db.commit() + add_tx(db, user, acc, date(2026, 8, 3), "-300.00", "COURSES", courses) + add_tx(db, user, acc, date(2026, 8, 6), "-100.00", "RESTO", restaurants) + add_tx(db, user, acc, date(2026, 7, 6), "-500.00", "MOIS PRECEDENT", courses) + + progress = stats.budget_progress(db, user.id, THIS_MONTH, today=TODAY) + by_category = {item["category_name"]: item for item in progress["items"]} + root = by_category["Alimentation"] + assert root["actual"] == Decimal("400.00") # child + sibling child + assert root["remaining"] == Decimal("50.00") + assert root["progress_pct"] == 88.9 + assert root["status"] == "warning" + assert root["projected_eom"] is not None # current month -> linear projection + child = by_category["Courses"] + assert child["actual"] == Decimal("300.00") + assert child["status"] == "over" + assert progress["totals"]["budget"] == Decimal("650.00") + + +def test_budget_progress_of_a_past_month_has_no_projection( + db: Session, user: User +) -> None: + acc = account(db, user) + courses = category(db, user, "Courses") + db.add( + FinBudget( + id=uuid.uuid4(), + user_id=user.id, + category_id=courses.id, + monthly_amount=Decimal("200.00"), + start_month=date(2026, 1, 1), + end_month=date(2026, 7, 1), + ) + ) + db.commit() + add_tx(db, user, acc, date(2026, 7, 6), "-120.00", "COURSES", courses) + progress = stats.budget_progress(db, user.id, date(2026, 7, 1), today=TODAY) + assert progress["items"][0]["projected_eom"] is None + assert progress["items"][0]["status"] == "ok" + # the budget ended in July: it no longer applies in August + assert stats.budget_progress(db, user.id, THIS_MONTH, today=TODAY)["items"] == [] + + +# --------------------------------------------------------------------------- +# §9.8 — chart-ready aggregates +# --------------------------------------------------------------------------- + + +def _sample_month(db: Session, user: User) -> FinAccount: + acc = account(db, user) + courses = category(db, user, "Courses") + restaurants = category(db, user, "Restaurants & bars") + salaire = category(db, user, "Salaire") + add_tx( + db, user, acc, date(2026, 8, 2), "-120.00", "CARREFOUR", courses, "Carrefour" + ) + add_tx(db, user, acc, date(2026, 8, 9), "-80.00", "CARREFOUR", courses, "Carrefour") + add_tx(db, user, acc, date(2026, 8, 10), "-60.00", "BRASSERIE", restaurants) + add_tx(db, user, acc, date(2026, 8, 11), "-25.00", "INCONNU SANS CATEGORIE") + add_tx(db, user, acc, date(2026, 8, 1), "2450.00", "SALAIRE", salaire) + return acc + + +def test_monthly_by_category_rolls_children_into_roots(db: Session, user: User) -> None: + _sample_month(db, user) + data = stats.monthly_by_category(db, user.id, months=3, today=TODAY) + assert data["months"][-1] == "2026-08" + series = {item["name"]: item for item in data["series"]} + assert series["Alimentation"]["data"][-1] == Decimal("260.00") + assert series["Non catégorisé"]["data"][-1] == Decimal("25.00") + assert series["Non catégorisé"]["color"] == "#9ca3af" + assert data["totals"][-1] == Decimal("285.00") + + child_level = stats.monthly_by_category( + db, user.id, months=1, level="child", today=TODAY + ) + names = {item["name"] for item in child_level["series"]} + assert {"Courses", "Restaurants & bars"} <= names + + +def test_cashflow_series(db: Session, user: User) -> None: + _sample_month(db, user) + data = stats.cashflow(db, user.id, months=2, today=TODAY) + assert data["months"] == ["2026-07", "2026-08"] + assert data["income"] == [Decimal("0.00"), Decimal("2450.00")] + assert data["expenses"] == [Decimal("0.00"), Decimal("285.00")] + assert data["net"][-1] == Decimal("2165.00") + assert data["cumulative_net"][-1] == Decimal("2165.00") + + +def test_transfers_are_excluded_from_stats(db: Session, user: User) -> None: + acc = _sample_month(db, user) + other = account(db, user, "Livret A") + group = uuid.uuid4() + leg_a = add_tx(db, user, acc, date(2026, 8, 12), "-500.00", "VIR VERS LIVRET") + leg_b = add_tx(db, user, other, date(2026, 8, 12), "500.00", "VIR DEPUIS COURANT") + for leg in (leg_a, leg_b): + leg.transfer_group_id = group + db.commit() + data = stats.cashflow(db, user.id, months=1, today=TODAY) + assert data["expenses"] == [Decimal("285.00")] + assert data["income"] == [Decimal("2450.00")] + + +def test_top_merchants_groups_by_counterparty(db: Session, user: User) -> None: + _sample_month(db, user) + data = stats.top_merchants(db, user.id, months=1, today=TODAY) + top = data["items"][0] + assert top["merchant"] == "Carrefour" + assert top["total"] == Decimal("200.00") + assert top["count"] == 2 + assert top["average"] == Decimal("100.00") + assert top["category_name"] == "Courses" + + +def test_sankey_structure(db: Session, user: User) -> None: + _sample_month(db, user) + data = stats.sankey(db, user.id, month=THIS_MONTH, today=TODAY) + names = [node["name"] for node in data["nodes"]] + assert "Revenus" in names + assert "Salaire" in names + assert "Alimentation" in names + assert "Épargne du mois" in names + links = {(link["source"], link["target"]): link["value"] for link in data["links"]} + assert links[("Salaire", "Revenus")] == Decimal("2450.00") + assert links[("Revenus", "Alimentation")] == Decimal("260.00") + assert links[("Alimentation", "Courses")] == Decimal("200.00") + assert links[("Revenus", "Épargne du mois")] == Decimal("2165.00") + assert len(names) == len(set(names)) # ECharts requires unique node names + + +def test_sankey_never_nets_uncategorized_credits_against_debits( + db: Session, user: User +) -> None: + """§9.8: uncategorized credits feed « Autres revenus », uncategorized debits + feed « Non catégorisé » — netting the two buckets would drop the expense + AND shrink the income side by the same amount.""" + acc = _sample_month(db, user) # already holds a -25.00 uncategorized debit + add_tx(db, user, acc, date(2026, 8, 6), "300.00", "VIR RECU SANS CATEGORIE") + + data = stats.sankey(db, user.id, month=THIS_MONTH, today=TODAY) + names = [node["name"] for node in data["nodes"]] + assert "Non catégorisé" in names + assert "Autres revenus" in names + links = {(link["source"], link["target"]): link["value"] for link in data["links"]} + assert links[("Autres revenus", "Revenus")] == Decimal("300.00") + assert links[("Revenus", "Non catégorisé")] == Decimal("25.00") + + income = sum(v for (_s, t), v in links.items() if t == "Revenus") + expenses = sum( + v for (s, t), v in links.items() if s == "Revenus" and t != "Épargne du mois" + ) + assert income == Decimal("2750.00") # 2450 salary + 300 uncategorized credit + assert expenses == Decimal("285.00") # 120 + 80 + 60 + 25 + assert links[("Revenus", "Épargne du mois")] == Decimal("2465.00") + assert len(names) == len(set(names)) + + +def test_sankey_nets_a_refund_inside_its_own_category(db: Session, user: User) -> None: + """A credit on an EXPENSE category is a refund: it lowers that category's + expense instead of opening an income node with the same name (which would + make the ECharts sankey cyclic).""" + acc = _sample_month(db, user) + courses = category(db, user, "Courses") + add_tx( + db, user, acc, date(2026, 8, 12), "50.00", "REMBOURSEMENT CARREFOUR", courses + ) + + data = stats.sankey(db, user.id, month=THIS_MONTH, today=TODAY) + links = {(link["source"], link["target"]): link["value"] for link in data["links"]} + assert ("Alimentation", "Revenus") not in links # no cycle through the hub + assert links[("Revenus", "Alimentation")] == Decimal("210.00") # 260 - 50 + assert links[("Alimentation", "Courses")] == Decimal("150.00") # 200 - 50 + names = [node["name"] for node in data["nodes"]] + assert len(names) == len(set(names)) + + +def test_dashboard_kpis(db: Session, user: User) -> None: + _sample_month(db, user) + courses = category(db, user, "Courses") + db.add( + FinBudget( + id=uuid.uuid4(), + user_id=user.id, + category_id=courses.id, + monthly_amount=Decimal("400.00"), + start_month=date(2026, 1, 1), + ) + ) + db.commit() + kpi = stats.dashboard(db, user.id, today=TODAY) + assert kpi["month"] == "2026-08" + assert kpi["accounts_count"] == 1 + assert kpi["total_balance"] == Decimal("3165.00") # 1000 initial + net + assert kpi["month_expenses"] == Decimal("285.00") + assert kpi["month_income"] == Decimal("2450.00") + assert kpi["month_net"] == Decimal("2165.00") + assert kpi["uncategorized_count"] == 1 + assert kpi["budget_total"] == Decimal("400.00") + assert kpi["budget_actual"] == Decimal("200.00") diff --git a/apps/api/app/tests/modules/test_finance_parsers.py b/apps/api/app/tests/modules/test_finance_parsers.py new file mode 100644 index 0000000..e7d56bc --- /dev/null +++ b/apps/api/app/tests/modules/test_finance_parsers.py @@ -0,0 +1,252 @@ +"""Parser/normalisation tests against synthetic files of the real bank layouts. + +Fixtures live in app/tests/fixtures/finance and reproduce the header lines, +encodings and quirks documented in docs/research/finance-sources.md §2-4. +""" + +from datetime import date +from decimal import Decimal +from pathlib import Path + +import pytest + +from app.modules.finance.normalize import ( + compute_dedup_hash, + light_clean, + merchant_key, + normalize_label_for_hash, + parse_amount, +) +from app.modules.finance.parsers import decode_bytes +from app.modules.finance.pipeline import parse_file +from app.modules.finance.presets import BUILTIN_PROFILES + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "finance" +ACCOUNT_UUID = __import__("uuid").UUID("00000000-0000-0000-0000-0000000000aa") + + +def preset(slug: str) -> tuple[str, dict]: + for item in BUILTIN_PROFILES: + if item["slug"] == slug: + return item["kind"], item["config"] + raise AssertionError(f"preset {slug} missing") + + +def parse_fixture(slug: str, filename: str): + kind, config = preset(slug) + return parse_file(kind, config, (FIXTURES / filename).read_bytes(), "EUR") + + +# --------------------------------------------------------------------------- +# Amounts / labels +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("-6,4", "-6.4"), + ("+3500,00", "3500.00"), + ("-123 456,78", "-123456.78"), + ("1 234,56", "1234.56"), + ("1.234,56", "1234.56"), + ("226.68", "226.68"), + ("12,50 €", "12.50"), + ("−12,50", "-12.50"), + ("1 234,56", "1234.56"), + ], +) +def test_parse_amount_french_variants(raw: str, expected: str) -> None: + assert parse_amount(raw) == Decimal(expected) + + +def test_parse_amount_dot_decimal_profile() -> None: + assert parse_amount("1,234.56", decimal_separator=".") == Decimal("1234.56") + + +def test_parse_amount_rejects_garbage() -> None: + with pytest.raises(ValueError, match="montant"): + parse_amount("abc") + + +def test_normalize_label_is_conservative() -> None: + assert normalize_label_for_hash(" Café Crème\n2 ") == "CAFE CREME 2" + + +def test_light_clean_strips_technical_prefixes() -> None: + assert light_clean("CARTE 04/06 BOULANGERIE") == "BOULANGERIE" + assert light_clean("PRLV SEPA NETFLIX.COM") == "NETFLIX.COM" + assert light_clean("VIR INST SALAIRE MAI") == "SALAIRE MAI" + + +def test_merchant_key_drops_dates_and_numbers() -> None: + key = merchant_key("CARTE 04/06 CARREFOUR CITY 123456", None) + assert key == "CARREFOUR CITY" + assert merchant_key("n'importe quoi", "Netflix") == "NETFLIX" + + +def test_dedup_hash_depends_on_occurrence() -> None: + args = (ACCOUNT_UUID, date(2026, 6, 5), Decimal("-2.50"), "CAFE") + assert compute_dedup_hash(*args, 0) != compute_dedup_hash(*args, 1) + assert compute_dedup_hash(*args, 0) == compute_dedup_hash(*args, 0) + + +def test_dedup_hash_ignores_accents_and_case() -> None: + args = (ACCOUNT_UUID, date(2026, 6, 5), Decimal("-2.50")) + assert compute_dedup_hash(*args, "Café Crème", 0) == compute_dedup_hash( + *args, "CAFE CREME", 0 + ) + + +def test_decode_bytes_bom_and_cp1252() -> None: + sample = ( + "Date;Libellé;Débit euros;Crédit euros;\n" + "02/06/2026;PRLV SEPA EDF ÉNERGIE;89,00;;\n" + "03/06/2026;VIREMENT DE Mme DÉSIRÉE;;1 500,00;\n" + ) + # BOM wins over the configured encoding. + assert decode_bytes(b"\xef\xbb\xbfdate;label", "cp1252") == "date;label" + # "auto" never fails on a legacy French export… + assert decode_bytes(sample.encode("cp1252"), "auto") == sample + # …nor on UTF-8, and honours an explicit encoding. + assert decode_bytes(sample.encode(), "auto") == sample + assert decode_bytes(sample.encode("cp1252"), "cp1252") == sample + + +# --------------------------------------------------------------------------- +# Bank presets +# --------------------------------------------------------------------------- + + +def test_boursobank_csv() -> None: + result = parse_fixture("boursobank", "boursobank.csv") + assert len(result.rows) == 7 + assert result.rows_total == 8 + assert result.rows_error == 1 + assert result.errors[0]["message"].startswith("Date invalide") + first = result.rows[0] + assert first.booked_date == date(2026, 6, 1) + assert first.value_date == date(2026, 6, 1) + assert first.amount == Decimal("-42.90") + assert first.label_raw == "CARTE 31/05 CARREFOUR CITY PARIS" + # Two identical purchases the same day survive as two rows. + twins = [r for r in result.rows if r.amount == Decimal("-2.50")] + assert len(twins) == 2 + + +def test_credit_agricole_split_columns_multiline_and_footer() -> None: + result = parse_fixture("credit-agricole", "credit_agricole.csv") + assert len(result.rows) == 4 # footer row stopped the parsing + assert result.rows[0].amount == Decimal("-58.20") + # multi-line quoted label collapsed into a single line + assert result.rows[0].label_raw == "PAIEMENT CB 0106 INTERMARCHE FACT 010626" + assert result.rows[1].amount == Decimal("1500.00") # thousands space + assert result.rows[1].value_date == date(2026, 6, 4) + + +def test_societe_generale_preamble_and_padding() -> None: + result = parse_fixture("societe-generale", "societe_generale.csv") + assert len(result.rows) == 4 + assert result.rows[0].label_raw == "CARTE X7527 15/06 METRO" + assert result.rows[0].currency == "EUR" + assert result.rows[3].amount == Decimal("-1234.56") + + +def test_banque_postale_skip_until_header() -> None: + result = parse_fixture("banque-postale", "banque_postale.csv") + assert [r.amount for r in result.rows] == [ + Decimal("-32.15"), + Decimal("185.00"), + ] + + +def test_paypal_filters_and_net_amount() -> None: + result = parse_fixture("paypal", "paypal_fr.csv") + assert len(result.rows) == 3 + # Authorization + pending + USD + currency conversion are filtered out. + assert result.rows_filtered == 4 + steam = result.rows[0] + assert steam.amount == Decimal("-12.99") + assert steam.external_id == "5AB12345CD678901E" + assert steam.counterparty == "Steam Games" + vinted = result.rows[1] + assert vinted.amount == Decimal("17.50") # Net (gross 18,00 - fee 0,50) + + +def test_ofx_sgml_fitid_collision_gets_occurrence_suffix() -> None: + result = parse_fixture("ofx", "sample.ofx") + assert len(result.rows) == 4 + ids = [row.external_id for row in result.rows] + assert ids[0] == "948 040626 -1275" + assert ids[1] == "948 040626 -1275#1" + assert len(set(ids)) == 4 + assert result.rows[2].label_raw == "PRLV SEPA ORANGE — FACTURE JUIN" + assert result.rows[3].amount == Decimal("1500.00") + + +def test_bnp_positional_mapping_without_column_header() -> None: + result = parse_fixture("bnp-paribas", "bnp_paribas.csv") + # the balance line is skipped, the 3 operations are mapped by position + assert len(result.rows) == 3 + assert result.rows[0].label_raw == "AMORTISSEMENT PRET 1234" + assert result.rows[0].amount == Decimal("-70.93") + assert result.rows[2].amount == Decimal("2450.00") # thousands space + + +def test_caisse_epargne_signed_debit_and_plus_prefixed_credit() -> None: + result = parse_fixture("caisse-epargne", "caisse_epargne.csv") + assert [r.amount for r in result.rows] == [ + Decimal("-45.50"), + Decimal("3500.00"), + ] + assert result.rows[0].counterparty == "SUPERMARCHE" # "Libelle simplifie" + assert result.rows[1].value_date == date(2026, 11, 9) + + +def test_fortuneo_split_columns_and_short_decimals() -> None: + result = parse_fixture("fortuneo", "fortuneo.csv") + assert result.rows[0].amount == Decimal("-6.40") + assert result.rows[1].amount == Decimal("2500.00") + + +def test_revolut_net_of_fee_and_completed_only() -> None: + result = parse_fixture("revolut", "revolut.csv") + assert len(result.rows) == 2 + assert result.rows_filtered == 1 # the PENDING row is ignored + assert result.rows[0].amount == Decimal("-15.50") # amount 15,00 + fee 0,50 + assert result.rows[0].currency == "EUR" + + +def test_n26_label_join_and_counterparty() -> None: + result = parse_fixture("n26", "n26.csv") + assert result.rows[0].label_raw == "Netflix — Abonnement fevrier" + assert result.rows[0].counterparty == "Netflix" + assert result.rows[0].amount == Decimal("-13.49") + + +def test_generic_preset_autodetects_delimiter_and_columns() -> None: + result = parse_fixture("generic", "generic.csv") + assert [r.amount for r in result.rows] == [ + Decimal("-25.40"), + Decimal("42.10"), + ] + + +def test_every_builtin_preset_is_declared_once() -> None: + slugs = [item["slug"] for item in BUILTIN_PROFILES] + assert len(slugs) == len(set(slugs)) + expected = { + "generic", + "boursobank", + "credit-agricole", + "bnp-paribas", + "societe-generale", + "banque-postale", + "caisse-epargne", + "fortuneo", + "revolut", + "n26", + "ofx", + "paypal", + } + assert expected.issubset(set(slugs)) diff --git a/apps/api/app/tests/modules/test_finance_pipeline.py b/apps/api/app/tests/modules/test_finance_pipeline.py new file mode 100644 index 0000000..3ac8155 --- /dev/null +++ b/apps/api/app/tests/modules/test_finance_pipeline.py @@ -0,0 +1,481 @@ +"""Import pipeline: dedup/idempotence, occurrence, rules, transfers, rollback. + +Reference: datamodel-finance.md §4 (pipeline), §5 (rules), §6 (transfers), +§10.3 (same-day twins) and §10.6 (manual categorisation is sacred). +""" + +import uuid +from decimal import Decimal +from pathlib import Path + +import pytest +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.errors import DomainValidationError +from app.modules.auth.models import User +from app.modules.finance import categorize, pipeline, service +from app.modules.finance.enums import AccountKind, CategorySource, ImportStatus +from app.modules.finance.models import ( + FinAccount, + FinCategory, + FinRule, + FinSourceProfile, + FinTransaction, +) + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "finance" + + +def make_account( + db: Session, user: User, name: str, kind: AccountKind = AccountKind.CHECKING +) -> FinAccount: + account = FinAccount( + id=uuid.uuid4(), + user_id=user.id, + name=name, + kind=kind, + currency="EUR", + initial_balance=Decimal("0.00"), + ) + db.add(account) + db.commit() + return account + + +def builtin(db: Session, name: str) -> FinSourceProfile: + profile = service.find_profile_by_name(db, name) + assert profile is not None + return profile + + +def import_fixture( + db: Session, user: User, account: FinAccount, profile_name: str, filename: str +): + service.ensure_seed(db, user.id) + return pipeline.run_import( + db, + user.id, + account, + builtin(db, profile_name), + filename, + (FIXTURES / filename).read_bytes(), + ) + + +def transactions(db: Session, user: User) -> list[FinTransaction]: + return list( + db.scalars( + select(FinTransaction) + .where(FinTransaction.user_id == user.id) + .order_by(FinTransaction.booked_date, FinTransaction.amount) + ).all() + ) + + +# --------------------------------------------------------------------------- +# Import + idempotence +# --------------------------------------------------------------------------- + + +def test_import_boursobank_counts(db: Session, user: User) -> None: + account = make_account(db, user, "BoursoBank") + run = import_fixture( + db, user, account, "BoursoBank / Boursorama (CSV)", "boursobank.csv" + ) + assert run.status == ImportStatus.COMPLETED + assert run.stats["rows_total"] == 8 + assert run.stats["rows_imported"] == 7 + assert run.stats["rows_error"] == 1 + assert run.stats["rows_skipped_duplicate"] == 0 + assert run.stats["date_min"] == "2026-06-01" + assert run.stats["date_max"] == "2026-06-12" + assert len(run.stats["errors"]) == 1 + + rows = transactions(db, user) + assert len(rows) == 7 + assert all(tx.import_run_id == run.import_run_id for tx in rows) + assert all(tx.currency == "EUR" for tx in rows) + carrefour = next(tx for tx in rows if "CARREFOUR" in tx.label_raw) + assert carrefour.amount == Decimal("-42.90") + assert carrefour.label_clean == "CARREFOUR CITY PARIS" # technical prefix dropped + + +def test_reimporting_the_same_file_inserts_nothing(db: Session, user: User) -> None: + account = make_account(db, user, "BoursoBank") + first = import_fixture( + db, user, account, "BoursoBank / Boursorama (CSV)", "boursobank.csv" + ) + second = import_fixture( + db, user, account, "BoursoBank / Boursorama (CSV)", "boursobank.csv" + ) + assert second.stats["rows_imported"] == 0 + assert second.stats["rows_skipped_duplicate"] == 7 + assert second.stats["duplicate_file_of"] == first.import_run_id + assert len(transactions(db, user)) == 7 + + +def test_same_day_identical_purchases_are_kept(db: Session, user: User) -> None: + account = make_account(db, user, "BoursoBank") + import_fixture(db, user, account, "BoursoBank / Boursorama (CSV)", "boursobank.csv") + twins = [tx for tx in transactions(db, user) if tx.amount == Decimal("-2.50")] + assert len(twins) == 2 + assert twins[0].dedup_hash != twins[1].dedup_hash + + +def test_ofx_duplicate_fitid_is_not_a_conflict(db: Session, user: User) -> None: + account = make_account(db, user, "LCL") + run = import_fixture(db, user, account, "OFX (toutes banques)", "sample.ofx") + assert run.stats["rows_imported"] == 4 + again = import_fixture(db, user, account, "OFX (toutes banques)", "sample.ofx") + assert again.stats["rows_imported"] == 0 + assert again.stats["rows_skipped_duplicate"] == 4 + + +def test_paypal_import_uses_transaction_id(db: Session, user: User) -> None: + account = make_account(db, user, "PayPal", AccountKind.PAYPAL) + run = import_fixture( + db, user, account, "PayPal — rapport d'activité (CSV)", "paypal_fr.csv" + ) + assert run.stats["rows_imported"] == 3 + assert run.stats["rows_skipped_filtered"] == 4 + external = {tx.external_id for tx in transactions(db, user)} + assert "5AB12345CD678901E" in external + + +def test_stats_counters_add_up_to_rows_total(db: Session, user: User) -> None: + """§2.4: rows_total == imported + skipped_duplicate + skipped_filtered + + error. Rows dropped on purpose must stay visible in the total, otherwise + the import summary silently hides them.""" + account = make_account(db, user, "PayPal", AccountKind.PAYPAL) + for _ in range(2): # second pass turns the 3 kept rows into duplicates + run = import_fixture( + db, user, account, "PayPal — rapport d'activité (CSV)", "paypal_fr.csv" + ) + stats = run.stats + assert stats["rows_skipped_filtered"] == 4 + assert stats["rows_total"] == ( + stats["rows_imported"] + + stats["rows_skipped_duplicate"] + + stats["rows_skipped_filtered"] + + stats["rows_error"] + ) + assert run.stats["rows_skipped_duplicate"] == 3 + + +def test_a_fully_filtered_file_is_an_empty_import_not_a_failure( + db: Session, user: User +) -> None: + """A PayPal export made only of authorisations/pending lines is legitimate: + nothing to import, but the run must not be marked `failed` (§4.2 fails only + when every row ERRORED).""" + service.ensure_seed(db, user.id) + account = make_account(db, user, "PayPal", AccountKind.PAYPAL) + header = ( + (FIXTURES / "paypal_fr.csv").read_text(encoding="utf-8-sig").splitlines()[0] + ) + only_filtered = ( + header + "\n" + "01/06/2026,10:00:00,CEST,Fnac,Autorisation,Autorisation,EUR," + '"-120,00","0,00","-120,00",moi@example.com,pay@fnac.com,' + '9ZZ11111AA222222B,"Casque",,"0,00",Mémo\n' + ) + run = pipeline.run_import( + db, + user.id, + account, + builtin(db, "PayPal — rapport d'activité (CSV)"), + "paypal_vide.csv", + only_filtered.encode("utf-8-sig"), + ) + assert run.status == ImportStatus.COMPLETED + assert run.stats["rows_total"] == 1 + assert run.stats["rows_skipped_filtered"] == 1 + assert run.stats["rows_imported"] == 0 + assert run.stats["rows_error"] == 0 + + +def test_credit_agricole_import(db: Session, user: User) -> None: + account = make_account(db, user, "Crédit Agricole") + run = import_fixture( + db, user, account, "Crédit Agricole (CSV)", "credit_agricole.csv" + ) + assert run.stats["rows_imported"] == 4 + amounts = sorted(tx.amount for tx in transactions(db, user)) + assert amounts[0] == Decimal("-89.00") + assert amounts[-1] == Decimal("1500.00") + + +def test_rollback_removes_the_imported_rows(db: Session, user: User) -> None: + account = make_account(db, user, "BoursoBank") + run = import_fixture( + db, user, account, "BoursoBank / Boursorama (CSV)", "boursobank.csv" + ) + service.rollback_import(db, user.id, run.id) + assert transactions(db, user) == [] + assert db.scalar(select(func.count()).select_from(FinTransaction)) == 0 + + +def test_wrong_profile_marks_the_run_as_failed(db: Session, user: User) -> None: + account = make_account(db, user, "BoursoBank") + service.ensure_seed(db, user.id) + run = pipeline.run_import( + db, + user.id, + account, + builtin(db, "PayPal — rapport d'activité (CSV)"), + "boursobank.csv", + (FIXTURES / "boursobank.csv").read_bytes(), + ) + assert run.status == ImportStatus.FAILED + assert "PayPal" in run.error_message + assert transactions(db, user) == [] + + +def test_empty_file_is_rejected(db: Session, user: User) -> None: + account = make_account(db, user, "BoursoBank") + service.ensure_seed(db, user.id) + with pytest.raises(DomainValidationError): + pipeline.run_import( + db, user.id, account, builtin(db, "CSV générique"), "vide.csv", b"" + ) + + +# --------------------------------------------------------------------------- +# Rules engine +# --------------------------------------------------------------------------- + + +def make_rule( + db: Session, + user: User, + name: str, + matchers: dict, + actions: dict, + priority: int = 100, + stop: bool = True, +) -> FinRule: + rule = FinRule( + id=uuid.uuid4(), + user_id=user.id, + name=name, + priority=priority, + enabled=True, + stop=stop, + matchers=matchers, + actions=actions, + ) + db.add(rule) + db.commit() + return rule + + +def category_named(db: Session, user: User, name: str) -> FinCategory: + service.ensure_seed(db, user.id) + category = db.scalars( + select(FinCategory).where( + FinCategory.user_id == user.id, FinCategory.name == name + ) + ).first() + assert category is not None, name + return category + + +def test_rules_are_applied_before_insert(db: Session, user: User) -> None: + account = make_account(db, user, "BoursoBank") + courses = category_named(db, user, "Courses") + rule = make_rule( + db, + user, + "Courses Carrefour", + {"label_contains": ["CARREFOUR"], "direction": "debit"}, + {"set_category_id": str(courses.id), "set_counterparty": "Carrefour"}, + ) + run = import_fixture( + db, user, account, "BoursoBank / Boursorama (CSV)", "boursobank.csv" + ) + assert run.stats["rules_applied"] == 1 + tx = next(tx for tx in transactions(db, user) if "CARREFOUR" in tx.label_raw) + assert tx.category_id == courses.id + assert tx.category_source == CategorySource.RULE + assert tx.applied_rule_id == rule.id + assert tx.counterparty == "Carrefour" + db.refresh(rule) + assert rule.hit_count == 1 + assert rule.last_applied_at is not None + + +def test_rule_priority_and_stop_flag(db: Session, user: User) -> None: + account = make_account(db, user, "BoursoBank") + loisirs = category_named(db, user, "Abonnements & streaming") + autres = category_named(db, user, "Autres dépenses") + make_rule( + db, + user, + "Netflix", + {"label_contains": ["NETFLIX"]}, + {"set_category_id": str(loisirs.id), "set_label_clean": "Netflix"}, + priority=10, + ) + make_rule( + db, + user, + "Tout le reste", + {"direction": "debit"}, + {"set_category_id": str(autres.id)}, + priority=90, + ) + import_fixture(db, user, account, "BoursoBank / Boursorama (CSV)", "boursobank.csv") + netflix = next(tx for tx in transactions(db, user) if "NETFLIX" in tx.label_raw) + assert netflix.category_id == loisirs.id # priority 10 stopped the chain + assert netflix.label_clean == "Netflix" + other = next(tx for tx in transactions(db, user) if "LOYER" in tx.label_raw) + assert other.category_id == autres.id + + +def test_amount_bounds_and_regex_matchers(db: Session, user: User) -> None: + account = make_account(db, user, "BoursoBank") + loyer = category_named(db, user, "Loyer / Crédit") + make_rule( + db, + user, + "Loyer", + {"label_regex": r"^vir\s+sepa\s+loyer", "amount_max": -100}, + {"set_category_id": str(loyer.id)}, + ) + import_fixture(db, user, account, "BoursoBank / Boursorama (CSV)", "boursobank.csv") + rows = transactions(db, user) + tagged = [tx for tx in rows if tx.category_id == loyer.id] + assert len(tagged) == 1 + assert tagged[0].amount == Decimal("-750.00") + + +def test_manual_categorisation_is_never_overwritten(db: Session, user: User) -> None: + account = make_account(db, user, "BoursoBank") + courses = category_named(db, user, "Courses") + restaurants = category_named(db, user, "Restaurants & bars") + import_fixture(db, user, account, "BoursoBank / Boursorama (CSV)", "boursobank.csv") + tx = next(tx for tx in transactions(db, user) if "CARREFOUR" in tx.label_raw) + tx.category_id = restaurants.id + tx.category_source = CategorySource.USER + db.commit() + + make_rule( + db, + user, + "Courses Carrefour", + {"label_contains": ["CARREFOUR"]}, + {"set_category_id": str(courses.id)}, + ) + result = categorize.apply_rules(db, user.id, scope="all_non_manual") + db.refresh(tx) + assert tx.category_id == restaurants.id + assert result.updated == 0 or tx.category_source == CategorySource.USER + + with pytest.raises(DomainValidationError): + categorize.apply_rules(db, user.id, scope="all") + + categorize.apply_rules(db, user.id, scope="all", force=True) + db.refresh(tx) + assert tx.category_id == courses.id + + +def test_apply_rules_dry_run_writes_nothing(db: Session, user: User) -> None: + account = make_account(db, user, "BoursoBank") + courses = category_named(db, user, "Courses") + import_fixture(db, user, account, "BoursoBank / Boursorama (CSV)", "boursobank.csv") + make_rule( + db, + user, + "Courses Carrefour", + {"label_contains": ["CARREFOUR"]}, + {"set_category_id": str(courses.id)}, + ) + result = categorize.apply_rules(db, user.id, scope="uncategorized", dry_run=True) + assert result.matched == 1 + assert result.dry_run is True + assert result.by_rule[0]["matched"] == 1 + tx = next(tx for tx in transactions(db, user) if "CARREFOUR" in tx.label_raw) + assert tx.category_id is None + + +# --------------------------------------------------------------------------- +# Internal transfers +# --------------------------------------------------------------------------- + + +def test_transfer_pairing_between_two_accounts(db: Session, user: User) -> None: + bank = make_account(db, user, "BoursoBank") + paypal = make_account(db, user, "PayPal", AccountKind.PAYPAL) + import_fixture(db, user, bank, "BoursoBank / Boursorama (CSV)", "boursobank.csv") + run = import_fixture( + db, user, paypal, "PayPal — rapport d'activité (CSV)", "paypal_fr.csv" + ) + assert run.stats["transfers_detected"] == 1 + + legs = [tx for tx in transactions(db, user) if tx.transfer_group_id is not None] + assert len(legs) == 2 + assert legs[0].transfer_group_id == legs[1].transfer_group_id + assert {leg.account_id for leg in legs} == {bank.id, paypal.id} + assert {leg.amount for leg in legs} == {Decimal("-50.00"), Decimal("50.00")} + transfer_cat = service.transfer_category(db, user.id) + assert all(leg.category_id == transfer_cat.id for leg in legs) + + +def test_manual_link_and_unlink(db: Session, user: User) -> None: + bank = make_account(db, user, "Compte A") + other = make_account(db, user, "Compte B") + service.ensure_seed(db, user.id) + a = pipeline.build_transaction( + user.id, + bank, + _row("2026-07-01", "-120.00", "VIREMENT VERS LIVRET"), + "hash-a", + None, + ) + b = pipeline.build_transaction( + user.id, + other, + _row("2026-07-02", "120.00", "VIREMENT RECU"), + "hash-b", + None, + ) + db.add_all([a, b]) + db.commit() + + group_id = categorize.link_transfer(db, user.id, a.id, b.id) + db.refresh(a) + db.refresh(b) + assert a.transfer_group_id == group_id == b.transfer_group_id + + categorize.unlink_transfer(db, user.id, group_id) + db.refresh(a) + assert a.transfer_group_id is None + assert a.category_id is None + + +def test_link_rejects_non_opposite_amounts(db: Session, user: User) -> None: + bank = make_account(db, user, "Compte A") + other = make_account(db, user, "Compte B") + a = pipeline.build_transaction( + user.id, bank, _row("2026-07-01", "-120.00", "A"), "h1", None + ) + b = pipeline.build_transaction( + user.id, other, _row("2026-07-01", "119.00", "B"), "h2", None + ) + db.add_all([a, b]) + db.commit() + with pytest.raises(DomainValidationError): + categorize.link_transfer(db, user.id, a.id, b.id) + + +def _row(day: str, amount: str, label: str): + from datetime import date + + from app.modules.finance.parsers import NormalizedRow + + return NormalizedRow( + booked_date=date.fromisoformat(day), + value_date=None, + amount=Decimal(amount), + \ No newline at end of file diff --git a/apps/api/app/tests/modules/test_health_calculations.py b/apps/api/app/tests/modules/test_health_calculations.py new file mode 100644 index 0000000..dda4a3b --- /dev/null +++ b/apps/api/app/tests/modules/test_health_calculations.py @@ -0,0 +1,364 @@ +"""Unit tests for the pure health calculations (datamodel-health-vape.md §5 +and the planning addendum). Exact formula values, no database.""" + +import datetime as dt +from dataclasses import dataclass + +import pytest + +from app.modules.health import calculations as calc + +D = dt.date + + +# --- BMR / TDEE / budget ------------------------------------------------------ + + +def test_bmr_mifflin_exact_values_per_sex() -> None: + # 10*80 + 6.25*180 - 5*30 = 1775 + assert calc.bmr_mifflin(80, 180, 30, "male") == pytest.approx(1780.0) + assert calc.bmr_mifflin(80, 180, 30, "female") == pytest.approx(1614.0) + assert calc.bmr_mifflin(80, 180, 30, "other") == pytest.approx(1697.0) + + +def test_age_on_handles_birthday_not_reached() -> None: + assert calc.age_on(D(2026, 8, 13), D(1990, 8, 13)) == 36 + assert calc.age_on(D(2026, 8, 12), D(1990, 8, 13)) == 35 + assert calc.age_on(D(2026, 8, 14), D(1990, 8, 13)) == 36 + + +def test_tdee_three_tier_preference() -> None: + bmr = 1780.0 + measured = calc.tdee_effective(bmr, "sedentary", total_kcal=2600, active_kcal=400) + assert (measured.kcal, measured.method) == (2600.0, "measured_total") + + # 1000 kcal is below the 0.8 x BMR plausibility guard -> fall through + partial = calc.tdee_effective(bmr, "sedentary", total_kcal=1000, active_kcal=400) + assert partial.method == "bmr_plus_active" + assert partial.kcal == pytest.approx(2180.0) + + estimated = calc.tdee_effective(bmr, "moderate") + assert estimated.method == "estimated" + assert estimated.kcal == pytest.approx(1780.0 * 1.55) + + +def test_activity_factors_table() -> None: + assert calc.ACTIVITY_FACTORS == { + "sedentary": 1.2, + "light": 1.375, + "moderate": 1.55, + "active": 1.725, + "very_active": 1.9, + } + + +def test_moving_average_trailing_window_skips_none() -> None: + values = [None, 100.0, 200.0, None, 300.0] + assert calc.moving_average(values, 3) == [ + None, + 100.0, + 150.0, + 150.0, + 250.0, + ] + + +def test_daily_budget_deficit_and_floor() -> None: + rate = calc.GoalRate(0.5) + budget = calc.daily_budget(2400.0, rate, "male") + assert budget.deficit_target == pytest.approx(550.0) # 0.5 * 7700 / 7 + assert budget.kcal == pytest.approx(1850.0) + assert budget.floor_applied is False + + floored = calc.daily_budget(1800.0, calc.GoalRate(1.0), "male") + assert floored.kcal == pytest.approx(1500.0) + assert floored.floor_applied is True + + female = calc.daily_budget(1500.0, calc.GoalRate(1.0), "female") + assert female.kcal == pytest.approx(1200.0) + + custom = calc.daily_budget(1500.0, calc.GoalRate(1.0), "male", 1400) + assert custom.kcal == pytest.approx(1400.0) + + +def test_daily_budget_surplus_when_rate_is_negative() -> None: + budget = calc.daily_budget(2400.0, calc.GoalRate(-0.25), "male") + assert budget.kcal == pytest.approx(2675.0) + + +def test_resolve_goal_rate_target_date_is_clamped() -> None: + rate = calc.resolve_goal_rate( + "target_date", D(2026, 8, 13), 92.0, 78.0, None, D(2026, 9, 13) + ) + assert rate.weekly_rate_kg == pytest.approx(1.0) + assert rate.rate_clamped is True + + steady = calc.resolve_goal_rate( + "target_date", D(2026, 8, 13), 92.0, 88.0, None, D(2026, 10, 22) + ) + assert steady.rate_clamped is False + assert steady.weekly_rate_kg == pytest.approx(4.0 / 10.0) + + assert ( + calc.resolve_goal_rate( + "maintain", D(2026, 8, 13), 92.0, 78.0, 0.5, None + ).weekly_rate_kg + == 0.0 + ) + assert calc.resolve_goal_rate( + "weekly_rate", D(2026, 8, 13), 92.0, 78.0, 0.75, None + ).weekly_rate_kg == pytest.approx(0.75) + + +# --- Weight trend / slope / projection ---------------------------------------- + + +def test_weight_trend_ema_alpha_and_gap_correction() -> None: + trend = calc.weight_trend([(D(2026, 8, 1), 92.0), (D(2026, 8, 2), 91.0)]) + assert trend[0] == (D(2026, 8, 1), 92.0) + assert trend[1][1] == pytest.approx(91.9) # 92 + 0.1 * (91 - 92) + + gapped = calc.weight_trend([(D(2026, 8, 1), 92.0), (D(2026, 8, 6), 90.0)]) + # alpha_eff = 1 - 0.9**5 = 0.40951 -> 92 - 0.40951 * 2 = 91.18098 + assert gapped[1][1] == pytest.approx(91.18, abs=1e-2) + + +def test_weight_trend_empty_and_single_point() -> None: + assert calc.weight_trend([]) == [] + assert calc.weight_trend([(D(2026, 8, 1), 88.5)]) == [(D(2026, 8, 1), 88.5)] + + +def test_regression_slope_exact_and_guards() -> None: + points = [(D(2026, 8, 1), 90.0), (D(2026, 8, 2), 89.0), (D(2026, 8, 3), 88.0)] + assert calc.regression_slope(points) == pytest.approx(-1.0) + assert calc.regression_slope(points[:2]) is None + flat = [(D(2026, 8, 1), 90.0)] * 3 + assert calc.regression_slope(flat) is None # denom == 0 + + +def test_trend_at_day_carries_forward() -> None: + trend = [(D(2026, 8, 1), 92.0), (D(2026, 8, 5), 91.0)] + assert calc.trend_at_day(trend, D(2026, 7, 31)) is None + assert calc.trend_at_day(trend, D(2026, 8, 3)) == 92.0 + assert calc.trend_at_day(trend, D(2026, 8, 9)) == 91.0 + + +def test_projection_ok_reached_and_not_converging() -> None: + today = D(2026, 8, 13) + ok = calc.project_target_date(90.0, 85.0, -0.05, today) + assert ok.status == "ok" + assert ok.date == today + dt.timedelta(days=100) + + assert calc.project_target_date(85.05, 85.0, -0.05, today).status == "reached" + # slope below MIN_SLOPE_KG_PER_DAY + assert ( + calc.project_target_date(90.0, 85.0, -0.001, today).status == "not_converging" + ) + # gaining while a loss is needed + assert calc.project_target_date(90.0, 85.0, 0.05, today).status == "not_converging" + assert calc.project_target_date(90.0, 85.0, None, today).status == "not_converging" + # more than 10 years away + assert ( + calc.project_target_date(90.0, 85.0, -0.001_2, today).status == "not_converging" + ) + + +def test_projection_upwards_when_gaining_is_the_goal() -> None: + today = D(2026, 8, 13) + result = calc.project_target_date(70.0, 75.0, 0.05, today) + assert result.status == "ok" + assert result.date == today + dt.timedelta(days=100) + + +# --- Energy balance & calibration --------------------------------------------- + + +def test_energy_balance_is_none_on_untracked_days() -> None: + assert calc.energy_balance(1800.0, 2300.0) == pytest.approx(-500.0) + assert calc.energy_balance(None, 2300.0) is None + assert calc.energy_balance(1800.0, None) is None + + +def test_tdee_calibration_requires_21_tracked_days() -> None: + short = calc.tdee_calibration([(2000.0, 2500.0)] * 20, 92.0, 91.0) + assert short.status == "insufficient_data" + assert short.tracked_days == 20 + + +def test_tdee_calibration_exact_values() -> None: + result = calc.tdee_calibration([(2000.0, 2500.0)] * 21, 92.0, 91.0) + assert result.status == "ok" + assert result.tracked_days == 21 + assert result.expected_change_kg == pytest.approx(21 * -500 / 7700) + assert result.actual_change_kg == pytest.approx(-1.0) + assert result.gap_kg == pytest.approx(-1.0 - (21 * -500 / 7700)) + assert result.tdee_adaptive_kcal == pytest.approx(2000 + 7700 / 21) + assert result.tdee_correction_kcal == pytest.approx(2000 + 7700 / 21 - 2500) + + +def test_constants_match_the_spec() -> None: + assert calc.KCAL_PER_KG_FAT == 7700 + assert calc.EMA_ALPHA == 0.1 + assert calc.MIN_SLOPE_KG_PER_DAY == 0.005 + assert calc.CALORIE_FLOOR_MALE == 1500 + assert calc.CALORIE_FLOOR_FEMALE == 1200 + assert calc.WORKOUT_OVERLAP_THRESHOLD == 0.8 + assert calc.ADAPTIVE_TDEE_MIN_DAYS == 21 + + +# --- Activity merge & workout overlap ----------------------------------------- + + +@dataclass +class Row: + date: dt.date + source: str + steps: int | None = None + active_kcal: float | None = None + total_kcal: float | None = None + distance_m: int | None = None + active_minutes: int | None = None + floors: int | None = None + + +def test_merge_activity_is_field_by_field_by_priority() -> None: + day = D(2026, 8, 12) + merged = calc.merge_activity_day( + [ + Row(day, "csv_import", steps=5000, distance_m=3000), + Row(day, "health_connect", steps=9421, active_kcal=520.0), + Row(day, "manual", steps=10000), + ] + ) + assert merged.steps == 10000 # manual wins + assert merged.active_kcal == pytest.approx(520.0) # only health_connect has it + assert merged.distance_m == 3000 # only csv_import has it + assert merged.field_sources == { + "steps": "manual", + "active_kcal": "health_connect", + "distance_m": "csv_import", + } + + +def test_merge_puts_unknown_sources_last() -> None: + day = D(2026, 8, 12) + merged = calc.merge_activity_day( + [Row(day, "some_bridge", steps=1), Row(day, "api", steps=2)] + ) + assert merged.steps == 2 + assert calc.source_priority("manual") < calc.source_priority("health_connect") + assert calc.source_priority("api") < calc.source_priority("unknown") + + +def test_overlap_ratio_uses_the_shorter_session() -> None: + base = dt.datetime(2026, 8, 12, 18, 0, tzinfo=dt.UTC) + a_end = base + dt.timedelta(minutes=60) + b_start = base + dt.timedelta(minutes=5) + b_end = base + dt.timedelta(minutes=55) + assert calc.overlap_ratio(base, a_end, b_start, b_end) == pytest.approx(1.0) + far = base + dt.timedelta(hours=5) + assert calc.overlap_ratio(base, a_end, far, far + dt.timedelta(hours=1)) == 0.0 + + +# --- Body composition --------------------------------------------------------- + + +def test_bmi_and_navy_body_fat() -> None: + assert calc.bmi(80.0, 180.0) == pytest.approx(24.69, abs=1e-2) + assert calc.bmi(80.0, 0) is None + male = calc.navy_body_fat_pct("male", 180.0, 90.0, 38.0) + assert male == pytest.approx(19.8, abs=0.2) + assert calc.navy_body_fat_pct("female", 165.0, 75.0, 32.0) is None # hips missing + assert calc.navy_body_fat_pct("female", 165.0, 75.0, 32.0, 98.0) is not None + assert calc.navy_body_fat_pct("male", 180.0, None, 38.0) is None + + +# --- Planning: schedules, adherence, streaks ---------------------------------- + + +def test_schedule_kinds_are_the_three_habits() -> None: + assert calc.SCHEDULE_KINDS == ("weigh_in", "workout", "food_log") + + +def test_is_planned_uses_monday_zero_convention() -> None: + monday = D(2026, 7, 6) + assert monday.weekday() == 0 + assert calc.is_planned([0, 2, 4], monday) is True + assert calc.is_planned([1, 3], monday) is False + assert calc.is_planned([0], monday, enabled=False) is False + assert calc.is_planned([], monday) is False + assert calc.is_planned(None, monday) is False + + +def test_build_adherence_marks_planned_done_and_missed() -> None: + start, end = D(2026, 7, 6), D(2026, 7, 12) # a full Monday-Sunday week + days = calc.build_adherence(start, end, [0, 2], True, {D(2026, 7, 6)}) + assert len(days) == 7 + statuses = {day.day: day.status for day in days} + assert statuses[D(2026, 7, 6)] == "done" # Monday planned + done + assert statuses[D(2026, 7, 8)] == "missed" # Wednesday planned, not done + assert statuses[D(2026, 7, 7)] == "rest" + assert calc.adherence_pct(days) == pytest.approx(50.0) + + +def test_adherence_pct_is_none_without_planned_days() -> None: + days = calc.build_adherence(D(2026, 7, 6), D(2026, 7, 12), [], True, set()) + assert calc.adherence_pct(days) is None + + +def test_streaks_count_planned_days_only_across_weeks() -> None: + start, end = D(2026, 7, 6), D(2026, 8, 3) # 5 Mondays + mondays = [ + D(2026, 7, 6), + D(2026, 7, 13), + D(2026, 7, 20), + D(2026, 7, 27), + D(2026, 8, 3), + ] + assert all(day.weekday() == 0 for day in mondays) + done = {mondays[0], mondays[1], mondays[3], mondays[4]} # 07-20 missed + days = calc.build_adherence(start, end, [0], True, done) + streak = calc.compute_streaks(days, today=end) + assert streak.best == 2 + assert streak.current == 2 + + +def test_streak_grace_period_on_today() -> None: + mondays = [ + D(2026, 7, 6), + D(2026, 7, 13), + D(2026, 7, 20), + D(2026, 7, 27), + D(2026, 8, 3), + ] + days = calc.build_adherence( + D(2026, 7, 6), D(2026, 8, 3), [0], True, set(mondays[:-1]) + ) + # Today is a planned day that is not done yet: the streak is not broken. + streak = calc.compute_streaks(days, today=D(2026, 8, 3)) + assert streak.current == 4 + assert streak.best == 4 + # One day later the missed Monday does break it. + assert calc.compute_streaks(days, today=D(2026, 8, 4)).current == 0 + + +def test_streaks_ignore_unplanned_done_days() -> None: + days = calc.build_adherence( + D(2026, 7, 6), + D(2026, 7, 12), + [0], + True, + {D(2026, 7, 7), D(2026, 7, 8)}, # done on two unplanned days + ) + streak = calc.compute_streaks(days, today=D(2026, 7, 12)) + assert streak.best == 0 + assert streak.current == 0 + + +def test_date_range_is_inclusive_and_safe() -> None: + assert calc.date_range(D(2026, 8, 1), D(2026, 8, 3)) == [ + D(2026, 8, 1), + D(2026, 8, 2), + D(2026, 8, 3), + ] + assert calc.date_range(D(2026, 8, 3), D(2026, 8, 1)) == [] diff --git a/apps/api/app/tests/modules/test_health_foods.py b/apps/api/app/tests/modules/test_health_foods.py new file mode 100644 index 0000000..4553984 --- /dev/null +++ b/apps/api/app/tests/modules/test_health_foods.py @@ -0,0 +1,228 @@ +"""Food search: local `food_items` cache first, Open Food Facts proxy next. + +Every OFF call is served by an httpx.MockTransport — the suite never reaches +the network. +""" + +from collections.abc import Iterator + +import httpx +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.modules.health import foods +from app.modules.health.models import FoodItem + +URL = "/api/health/foods/search" + +OFF_HIT = { + "code": "3175680011480", + "product_name": "Petit Beurre", + "product_name_fr": "Véritable Petit Beurre", + "brands": "LU, Mondelez", + "serving_quantity": 8.3, + "nutriments": { + "energy-kcal_100g": 442, + "proteins_100g": 7.2, + "carbohydrates_100g": 74.5, + "sugars_100g": 22.1, + "fat_100g": 12.3, + "saturated-fat_100g": 6.4, + "fiber_100g": 2.6, + "salt_100g": 0.9, + }, +} + + +@pytest.fixture(autouse=True) +def _reset_transport() -> Iterator[None]: + yield + foods.set_http_transport(None) + + +def use_transport(handler) -> list[httpx.Request]: + """Install a MockTransport and return the list of captured requests.""" + seen: list[httpx.Request] = [] + + def wrapped(request: httpx.Request) -> httpx.Response: + seen.append(request) + return handler(request) + + foods.set_http_transport(httpx.MockTransport(wrapped)) + return seen + + +def ok_search(hits: list[dict]): + return lambda request: httpx.Response(200, json={"hits": hits, "count": len(hits)}) + + +# --- Normalisation ------------------------------------------------------------- + + +def test_normalize_prefers_the_french_name_and_first_brand() -> None: + data = foods.normalize_off_product(OFF_HIT) + assert data["name"] == "Véritable Petit Beurre" + assert data["brand"] == "LU" + assert float(data["energy_kcal_100g"]) == 442.0 + assert float(data["salt_g_100g"]) == 0.9 + assert float(data["serving_size_g"]) == 8.3 + + +def test_normalize_recomputes_kcal_from_kilojoules() -> None: + data = foods.normalize_off_product( + {"code": "1", "product_name": "Truc", "nutriments": {"energy_100g": 1000}} + ) + assert float(data["energy_kcal_100g"]) == pytest.approx(239.0, abs=0.1) + + +def test_normalize_skips_nameless_products() -> None: + assert foods.normalize_off_product({"code": "1", "nutriments": {}}) is None + + +# --- Search -------------------------------------------------------------------- + + +def test_search_queries_off_and_caches_the_hit( + client: TestClient, db: Session, auth_headers: dict[str, str] +) -> None: + seen = use_transport(ok_search([OFF_HIT])) + body = client.get(URL, params={"q": "petit beurre"}, headers=auth_headers).json() + assert body["origin"] == "cache+off" + assert body["items"][0]["name"] == "Véritable Petit Beurre" + assert body["items"][0]["source"] == "off" + assert body["items"][0]["source_id"] == "3175680011480" + + request = seen[0] + assert request.url.host == "search.openfoodfacts.org" + assert request.url.params["q"] == "petit beurre" + assert request.url.params["langs"] == "fr" + assert request.headers["User-Agent"] == "LifeTrack/1.0 (meejayproduction@gmail.com)" + + cached = db.scalars(select(FoodItem)).all() + assert len(cached) == 1 + assert cached[0].source_id == "3175680011480" + + +def test_second_search_is_served_from_the_cache( + client: TestClient, db: Session, auth_headers: dict[str, str] +) -> None: + db.add( + FoodItem( + source="off", + source_id="3175680011480", + name="Véritable Petit Beurre", + brand="LU", + ) + ) + db.commit() + seen = use_transport(ok_search([OFF_HIT])) + body = client.get( + URL, params={"q": "beurre", "limit": 1}, headers=auth_headers + ).json() + assert body["origin"] == "cache" + assert len(body["items"]) == 1 + assert seen == [] # OFF was never called + + +def test_cached_products_are_not_duplicated( + client: TestClient, db: Session, auth_headers: dict[str, str] +) -> None: + use_transport(ok_search([OFF_HIT])) + client.get(URL, params={"q": "petit beurre"}, headers=auth_headers) + client.get(URL, params={"q": "petit beurre"}, headers=auth_headers) + assert len(db.scalars(select(FoodItem)).all()) == 1 + + +def test_search_matches_the_brand_locally( + client: TestClient, db: Session, auth_headers: dict[str, str] +) -> None: + db.add(FoodItem(source="ciqual", source_id="1234", name="Pomme, crue", brand=None)) + db.commit() + use_transport(ok_search([])) + body = client.get(URL, params={"q": "pomme"}, headers=auth_headers).json() + assert [item["name"] for item in body["items"]] == ["Pomme, crue"] + + +def test_search_requires_two_characters( + client: TestClient, auth_headers: dict[str, str] +) -> None: + response = client.get(URL, params={"q": "a"}, headers=auth_headers) + assert response.status_code == 400 + assert "2 caractères" in response.json()["error"]["message"] + + +def test_search_requires_authentication(client: TestClient) -> None: + assert client.get(URL, params={"q": "pomme"}).status_code == 401 + + +# --- Degraded mode ------------------------------------------------------------- + + +def test_unreachable_off_returns_a_french_503( + client: TestClient, auth_headers: dict[str, str] +) -> None: + def boom(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("boom", request=request) + + use_transport(boom) + response = client.get(URL, params={"q": "petit beurre"}, headers=auth_headers) + assert response.status_code == 503 + error = response.json()["error"] + assert error["code"] == "service_unavailable" + assert "Open Food Facts" in error["message"] + assert "injoignable" in error["message"] + + +def test_unreachable_off_still_serves_local_results( + client: TestClient, db: Session, auth_headers: dict[str, str] +) -> None: + db.add(FoodItem(source="ciqual", source_id="1234", name="Pomme, crue")) + db.commit() + + def boom(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("boom", request=request) + + use_transport(boom) + body = client.get(URL, params={"q": "pomme"}, headers=auth_headers).json() + assert body["origin"] == "cache" + assert len(body["items"]) == 1 + + +def test_off_http_error_is_degraded_too( + client: TestClient, auth_headers: dict[str, str] +) -> None: + use_transport(lambda request: httpx.Response(429, json={"error": "rate limited"})) + response = client.get(URL, params={"q": "petit beurre"}, headers=auth_headers) + assert response.status_code == 503 + + +# --- Barcode ------------------------------------------------------------------- + + +def test_barcode_lookup_caches_and_then_hits_the_cache( + client: TestClient, db: Session, auth_headers: dict[str, str] +) -> None: + seen = use_transport( + lambda request: httpx.Response(200, json={"status": 1, "product": OFF_HIT}) + ) + body = client.get( + "/api/health/foods/barcode/3175680011480", headers=auth_headers + ).json() + assert body["name"] == "Véritable Petit Beurre" + assert len(seen) == 1 + assert seen[0].url.host == "world.openfoodfacts.org" + + client.get("/api/health/foods/barcode/3175680011480", headers=auth_headers) + assert len(seen) == 1 # served from food_items + assert len(db.scalars(select(FoodItem)).all()) == 1 + + +def test_unknown_barcode_is_a_french_404( + client: TestClient, auth_headers: dict[str, str] +) -> None: + use_transport(lambda request: httpx.Response(200, json={"status": 0})) + response = client.get("/api/health/foods/barcode/0000", headers=auth_headers) + assert response.status_code == 404 + assert "code-barres" in response.json()["error"]["message"] diff --git a/apps/api/app/tests/modules/test_health_importers.py b/apps/api/app/tests/modules/test_health_importers.py new file mode 100644 index 0000000..e351811 --- /dev/null +++ b/apps/api/app/tests/modules/test_health_importers.py @@ -0,0 +1,236 @@ +"""File importers of the health module: sniffing, parsing tolerance, and +idempotent deduplication (re-importing the same file changes nothing).""" + +import datetime as dt +from pathlib import Path + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.importing.registry import IMPORTER_REGISTRY, detect_importer +from app.modules.auth.models import User +from app.modules.health import importers as health_importers +from app.modules.health.models import DailyActivity, FoodEntry, WeightEntry +from app.modules.imports import service as imports_service +from app.modules.imports.models import ImportRun + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "health" + + +def fixture_bytes(name: str) -> bytes: + return (FIXTURES / name).read_bytes() + + +def run(db: Session, user: User, importer_id: str, name: str) -> ImportRun: + return imports_service.run_import(db, user, importer_id, name, fixture_bytes(name)) + + +# --- Registration & sniffing --------------------------------------------------- + + +def test_the_three_importers_are_registered() -> None: + for importer_id in ("foodvisor_csv", "health_sync_csv", "weight_generic_csv"): + assert importer_id in IMPORTER_REGISTRY + assert IMPORTER_REGISTRY[importer_id].domain == "health" + assert IMPORTER_REGISTRY[importer_id].accepted_extensions == (".csv",) + # French label, shown as-is in the source picker + assert IMPORTER_REGISTRY[importer_id].label + + +@pytest.mark.parametrize( + ("filename", "expected"), + [ + ("foodvisor_export.csv", "foodvisor_csv"), + ("health_sync.csv", "health_sync_csv"), + ("weight_history.csv", "weight_generic_csv"), + ], +) +def test_sniffing_is_unambiguous(filename: str, expected: str) -> None: + detected = detect_importer(filename, fixture_bytes(filename)[:4096]) + assert detected is not None, f"{filename} not detected" + assert detected.id == expected + + +def test_sniff_never_raises_on_garbage() -> None: + for cls in ( + health_importers.FoodvisorCsvImporter, + health_importers.HealthSyncCsvImporter, + health_importers.WeightGenericCsvImporter, + ): + assert cls.sniff("x.pdf", b"%PDF-1.7") is False + assert cls.sniff("x.csv", b"\xff\xfe\x00binary") is False + assert cls.sniff("x.csv", b"") is False + + +# --- Parsing helpers ----------------------------------------------------------- + + +def test_decoding_falls_back_to_cp1252() -> None: + assert health_importers.decode_bytes("Protéines".encode()) == "Protéines" + assert health_importers.decode_bytes("Protéines".encode("cp1252")) == "Protéines" + + +def test_number_parsing_is_tolerant() -> None: + to_decimal = health_importers.to_decimal + assert float(to_decimal("1 234,5")) == 1234.5 + assert float(to_decimal("< 0,5")) == 0.5 + assert float(to_decimal("traces")) == 0.0 + assert to_decimal("-") is None + assert to_decimal("") is None + assert float(to_decimal("228.0")) == 228.0 + + +def test_naive_timestamps_are_read_as_paris_time() -> None: + parsed = health_importers.parse_datetime("2026-08-10 08:15") + assert parsed == dt.datetime(2026, 8, 10, 6, 15, tzinfo=dt.UTC) # CEST = UTC+2 + assert health_importers.parse_datetime("10/08/2026") is not None + assert health_importers.parse_datetime("2026-08-10T08:15:00Z") == dt.datetime( + 2026, 8, 10, 8, 15, tzinfo=dt.UTC + ) + assert health_importers.parse_datetime("n'importe quoi") is None + + +def test_meal_mapping_fr_en() -> None: + assert health_importers.map_meal("Petit-déjeuner").value == "breakfast" + assert health_importers.map_meal("Déjeuner").value == "lunch" + assert health_importers.map_meal("Dîner").value == "dinner" + assert health_importers.map_meal("Collation").value == "snack" + assert health_importers.map_meal("Breakfast").value == "breakfast" + assert health_importers.map_meal("inconnu").value == "snack" + + +# --- Foodvisor ----------------------------------------------------------------- + + +def test_foodvisor_import_maps_meals_and_macros(db: Session, user: User) -> None: + result = run(db, user, "foodvisor_csv", "foodvisor_export.csv") + assert result.status == "completed" + assert (result.rows_total, result.rows_inserted, result.rows_errors) == (4, 4, 0) + + entries = db.scalars(select(FoodEntry).order_by(FoodEntry.eaten_at.asc())).all() + assert [entry.meal.value for entry in entries] == [ + "breakfast", + "lunch", + "dinner", + "snack", + ] + first = entries[0] + assert first.name == "Flocons d'avoine" + assert first.brand == "Quaker" + assert float(first.kcal) == 228.0 + assert float(first.protein_g) == 8.1 + assert float(first.quantity) == 60.0 + assert first.source == "foodvisor" + assert first.external_id is not None + assert first.import_run_id == result.id + assert first.raw["aliment"] == "Flocons d'avoine" + # 2026-08-10 08:15 Paris -> 06:15 UTC + assert first.eaten_at == dt.datetime(2026, 8, 10, 6, 15, tzinfo=dt.UTC) + assert entries[1].brand is None + + +def test_foodvisor_reimport_is_fully_duplicate(db: Session, user: User) -> None: + run(db, user, "foodvisor_csv", "foodvisor_export.csv") + second = run(db, user, "foodvisor_csv", "foodvisor_export.csv") + assert second.rows_total == 4 + assert second.rows_duplicates == 4 + assert second.rows_inserted == 0 + assert db.scalar(select(FoodEntry).where(FoodEntry.user_id == user.id)) is not None + assert len(db.scalars(select(FoodEntry)).all()) == 4 + + +def test_foodvisor_rejects_incomplete_rows(db: Session, user: User) -> None: + data = b"Date;Repas;Aliment;Calories (kcal)\n;Dejeuner;;\n" + result = imports_service.run_import(db, user, "foodvisor_csv", "x.csv", data) + assert result.rows_errors == 1 + assert "requis" in result.error_details[0]["message"] + + +# --- Health Sync --------------------------------------------------------------- + + +def test_health_sync_import_splits_weights_and_activity( + db: Session, user: User +) -> None: + result = run(db, user, "health_sync_csv", "health_sync.csv") + assert result.status == "completed" + # 3 rows -> 3 activity records + 2 weights (one row has no weight) + assert result.rows_total == 5 + assert result.rows_inserted == 5 + + activities = db.scalars( + select(DailyActivity).order_by(DailyActivity.date.asc()) + ).all() + assert [row.date for row in activities] == [ + dt.date(2026, 8, 10), + dt.date(2026, 8, 11), + dt.date(2026, 8, 12), + ] + assert activities[0].steps == 9421 + assert activities[0].distance_m == 6800 # 6,80 km -> metres + assert float(activities[0].active_kcal) == 520.0 + assert activities[0].source == "csv_import" + assert activities[0].import_run_id == result.id + + weights = db.scalars( + select(WeightEntry).order_by(WeightEntry.measured_at.asc()) + ).all() + assert [float(row.weight_kg) for row in weights] == [92.4, 92.1] + + +def test_health_sync_reimport_is_fully_duplicate(db: Session, user: User) -> None: + run(db, user, "health_sync_csv", "health_sync.csv") + second = run(db, user, "health_sync_csv", "health_sync.csv") + assert second.rows_total == 5 + assert second.rows_duplicates == 5 + assert second.rows_inserted == 0 + assert second.rows_updated == 0 + + +def test_health_sync_updated_values_replace_the_day(db: Session, user: User) -> None: + run(db, user, "health_sync_csv", "health_sync.csv") + later = b"Date;Pas;Distance (km);Calories actives\n2026-08-10;11000;7,50;600\n" + result = imports_service.run_import(db, user, "health_sync_csv", "hs.csv", later) + assert result.rows_updated == 1 + assert result.rows_inserted == 0 + row = db.scalar( + select(DailyActivity).where(DailyActivity.date == dt.date(2026, 8, 10)) + ) + assert row.steps == 11000 + assert row.distance_m == 7500 + + +# --- Generic weight history ---------------------------------------------------- + + +def test_weight_generic_import_and_reimport(db: Session, user: User) -> None: + result = run(db, user, "weight_generic_csv", "weight_history.csv") + assert (result.rows_total, result.rows_inserted) == (3, 3) + weights = db.scalars( + select(WeightEntry).order_by(WeightEntry.measured_at.asc()) + ).all() + assert [float(row.weight_kg) for row in weights] == [95.2, 94.1, 93.4] + assert weights[0].source == "csv_import" + assert weights[0].measured_at == dt.datetime(2026, 6, 30, 22, 0, tzinfo=dt.UTC) + + second = run(db, user, "weight_generic_csv", "weight_history.csv") + assert second.rows_duplicates == 3 + assert len(db.scalars(select(WeightEntry)).all()) == 3 + + +def test_weight_generic_rejects_out_of_range(db: Session, user: User) -> None: + data = b"date;poids\n2026-07-01;950,0\n" + result = imports_service.run_import(db, user, "weight_generic_csv", "w.csv", data) + assert result.rows_errors == 1 + assert "bornes" in result.error_details[0]["message"] + + +# --- Central rollback ---------------------------------------------------------- + + +def test_deleting_an_import_run_removes_its_rows(db: Session, user: User) -> None: + result = run(db, user, "foodvisor_csv", "foodvisor_export.csv") + assert len(db.scalars(select(FoodEntry)).all()) == 4 + imports_service.rollback_run(db, user.id, result.id) + assert db.scalars(select(FoodEntry)).all() == [] diff --git a/apps/api/app/tests/modules/test_health_ingest.py b/apps/api/app/tests/modules/test_health_ingest.py new file mode 100644 index 0000000..70af1f2 --- /dev/null +++ b/apps/api/app/tests/modules/test_health_ingest.py @@ -0,0 +1,412 @@ +"""JSON ingestion for the health domain: canonical shape, health-connect-webhook +bridge shape, idempotence and per-record error reporting.""" + +import datetime as dt + +from fastapi.testclient import TestClient +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.ingest.registry import INGEST_REGISTRY +from app.modules.health import ( + ingest as health_ingest, # noqa: F401 — registers the handler +) +from app.modules.health.models import ( + DailyActivity, + FoodEntry, + WaterEntry, + WeightEntry, + Workout, +) + +URL = "/api/ingest/health" + + +def post( + client: TestClient, headers: dict[str, str], *records, source="android_bridge" +): + response = client.post( + URL, json={"source": source, "records": list(records)}, headers=headers + ) + assert response.status_code == 200, response.text + return response.json() + + +# --- Registration & auth ------------------------------------------------------- + + +def test_handler_is_registered_with_every_record_type() -> None: + handler = INGEST_REGISTRY["health"] + assert set(handler.record_types) == { + "weight", + "steps", + "distance", + "active_calories", + "total_calories", + "exercise_session", + "nutrition", + "hydration", + } + + +def test_ingest_requires_a_credential(client: TestClient) -> None: + response = client.post( + URL, json={"source": "x", "records": [{"type": "steps", "data": {}}]} + ) + assert response.status_code == 401 + + +def test_ingest_accepts_the_device_key(client: TestClient, device_key) -> None: + body = post( + client, + device_key.headers, + { + "type": "steps", + "external_id": "hc:2026-08-12", + "data": {"day": "2026-08-12", "steps": 9421}, + }, + ) + assert body == { + "domain": "health", + "received": 1, + "inserted": 1, + "updated": 0, + "duplicates": 0, + "errors": [], + } + + +# --- Canonical shape (architecture §5.5) --------------------------------------- + + +def test_canonical_steps_record_fills_the_daily_row( + client: TestClient, db: Session, device_key +) -> None: + post( + client, + device_key.headers, + { + "type": "steps", + "external_id": "hc:2026-08-12", + "data": { + "day": "2026-08-12", + "steps": 9421, + "calories_kcal": 2350, + "distance_m": 6800, + }, + }, + ) + row = db.scalar(select(DailyActivity)) + assert row.date == dt.date(2026, 8, 12) + assert row.steps == 9421 + assert float(row.active_kcal) == 2350.0 # bridge convention: active calories + assert row.distance_m == 6800 + assert row.source == "health_connect" # android_bridge is a known alias + assert row.external_id == "hc:2026-08-12" + assert row.raw["day"] == "2026-08-12" + + +def test_reposting_the_same_day_is_idempotent_then_updates( + client: TestClient, db: Session, device_key +) -> None: + record = { + "type": "steps", + "external_id": "hc:2026-08-12", + "data": {"day": "2026-08-12", "steps": 9421}, + } + assert post(client, device_key.headers, record)["inserted"] == 1 + assert post(client, device_key.headers, record)["duplicates"] == 1 + + grown = {**record, "data": {**record["data"], "steps": 12500}} + assert post(client, device_key.headers, grown)["updated"] == 1 + rows = db.scalars(select(DailyActivity)).all() + assert len(rows) == 1 + assert rows[0].steps == 12500 + + +def test_each_activity_type_targets_its_own_field( + client: TestClient, db: Session, device_key +) -> None: + post( + client, + device_key.headers, + {"type": "steps", "data": {"day": "2026-08-12", "steps": 100}}, + {"type": "distance", "data": {"day": "2026-08-12", "value": 4200}}, + {"type": "active_calories", "data": {"day": "2026-08-12", "value": 480}}, + {"type": "total_calories", "data": {"day": "2026-08-12", "value": 2600}}, + ) + rows = db.scalars(select(DailyActivity)).all() + assert len(rows) == 1 # same (user, day, source) + row = rows[0] + assert (row.steps, row.distance_m) == (100, 4200) + assert float(row.active_kcal) == 480.0 + assert float(row.total_kcal) == 2600.0 + + +def test_canonical_weight_record(client: TestClient, db: Session, device_key) -> None: + post( + client, + device_key.headers, + { + "type": "weight", + "external_id": "hc:w:1755012345", + "data": {"measured_at": "2026-08-13T06:31:00Z", "weight_kg": 91.4}, + }, + ) + row = db.scalar(select(WeightEntry)) + assert float(row.weight_kg) == 91.4 + assert row.measured_at == dt.datetime(2026, 8, 13, 6, 31, tzinfo=dt.UTC) + assert row.source == "health_connect" + + +# --- health-connect-webhook bridge shape --------------------------------------- + + +def test_bridge_weight_record_with_nested_value_and_metadata( + client: TestClient, db: Session, device_key +) -> None: + post( + client, + device_key.headers, + { + "type": "weight", + "data": { + "time": "2026-08-13T06:31:00Z", + "value": {"weight": 91.4, "unit": "kg"}, + "metadata": { + "id": "hc-uuid-1", + "data_origin": "com.sec.android.app.shealth", + }, + }, + }, + source="health-connect-webhook", + ) + row = db.scalar(select(WeightEntry)) + assert float(row.weight_kg) == 91.4 + assert row.external_id == "hc-uuid-1" + assert row.source == "health_connect" + assert row.raw["metadata"]["id"] == "hc-uuid-1" + + # Re-pushing the same record (48 h sliding window) must not duplicate it. + body = post( + client, + device_key.headers, + { + "type": "weight", + "data": { + "time": "2026-08-13T06:31:00Z", + "value": {"weight": 91.4}, + "metadata": {"id": "hc-uuid-1"}, + }, + }, + source="health-connect-webhook", + ) + assert body["duplicates"] == 1 + + +def test_bridge_steps_record_uses_count_and_start_time( + client: TestClient, db: Session, device_key +) -> None: + post( + client, + device_key.headers, + { + "type": "steps", + "data": { + "start_time": "2026-08-12T05:00:00Z", + "end_time": "2026-08-12T20:00:00Z", + "count": 9421, + "metadata": {"id": "hc-steps-1"}, + }, + }, + source="health-connect-webhook", + ) + row = db.scalar(select(DailyActivity)) + assert row.date == dt.date(2026, 8, 12) # local day in Europe/Paris + assert row.steps == 9421 + + +def test_bridge_exercise_session_becomes_a_workout( + client: TestClient, db: Session, device_key +) -> None: + post( + client, + device_key.headers, + { + "type": "exercise_session", + "data": { + "start_time": "2026-08-12T16:00:00Z", + "end_time": "2026-08-12T17:00:00Z", + "exercise_type": "RUNNING_TREADMILL", + "energy_kcal": 620, + "distance_m": 9500, + "metadata": {"id": "hc-ex-1"}, + }, + }, + source="health-connect-webhook", + ) + row = db.scalar(select(Workout)) + assert row.sport_type.value == "treadmill_run" + assert row.duration_s == 3600 + assert float(row.kcal) == 620.0 + assert row.distance_m == 9500 + assert row.external_id == "hc-ex-1" + assert row.is_hidden is False + + +def test_overlapping_sessions_from_two_sources_hide_the_weaker_one( + client: TestClient, db: Session, device_key +) -> None: + session = { + "type": "exercise_session", + "data": { + "start_time": "2026-08-12T16:00:00Z", + "end_time": "2026-08-12T17:00:00Z", + "exercise_type": "RUNNING_TREADMILL", + "metadata": {"id": "fs-1"}, + }, + } + post(client, device_key.headers, session, source="fitshow") + post( + client, + device_key.headers, + {**session, "data": {**session["data"], "metadata": {"id": "hc-1"}}}, + source="health-connect-webhook", + ) + rows = {row.source: row for row in db.scalars(select(Workout)).all()} + assert len(rows) == 2 + assert rows["health_connect"].is_hidden is False # higher priority source + assert rows["fitshow"].is_hidden is True + + +def test_bridge_nutrition_record_maps_meal_and_grams( + client: TestClient, db: Session, device_key +) -> None: + post( + client, + device_key.headers, + { + "type": "nutrition", + "data": { + "start_time": "2026-08-13T11:45:00Z", + "end_time": "2026-08-13T12:15:00Z", + "meal_type": 2, + "name": "Salade poulet", + "energy_kcal": 620.0, + "protein_g": 42.1, + "total_carbohydrate_g": 51.0, + "total_fat_g": 24.3, + "dietary_fiber_g": 7.0, + "sodium_g": 1.1, + "metadata": {"id": "hc-nut-1"}, + }, + }, + source="health-connect-webhook", + ) + row = db.scalar(select(FoodEntry)) + assert row.meal.value == "lunch" + assert row.name == "Salade poulet" + assert float(row.kcal) == 620.0 + assert float(row.carbs_g) == 51.0 + assert float(row.sodium_mg) == 1100.0 # Health Connect sends grams + + +def test_nutrition_meal_falls_back_on_the_local_hour( + client: TestClient, db: Session, device_key +) -> None: + post( + client, + device_key.headers, + { + "type": "nutrition", + "data": { + "start_time": "2026-08-13T06:00:00Z", # 08:00 Paris + "name": "Café", + "energy_kcal": 5, + "meal_type": 0, + }, + }, + ) + assert db.scalar(select(FoodEntry)).meal.value == "breakfast" + + +def test_bridge_hydration_record(client: TestClient, db: Session, device_key) -> None: + post( + client, + device_key.headers, + { + "type": "hydration", + "data": { + "start_time": "2026-08-13T09:00:00Z", + "volume_liters": 0.5, + "metadata": {"id": "hc-hyd-1"}, + }, + }, + source="health-connect-webhook", + ) + row = db.scalar(select(WaterEntry)) + assert row.volume_ml == 500 + assert row.external_id == "hc-hyd-1" + + +def test_records_without_external_id_dedupe_on_content( + client: TestClient, db: Session, device_key +) -> None: + record = { + "type": "hydration", + "data": {"start_time": "2026-08-13T09:00:00Z", "volume_ml": 250}, + } + assert post(client, device_key.headers, record)["inserted"] == 1 + assert post(client, device_key.headers, record)["duplicates"] == 1 + assert len(db.scalars(select(WaterEntry)).all()) == 1 + + +# --- Error handling ------------------------------------------------------------ + + +def test_unsupported_type_is_reported_per_record( + client: TestClient, device_key +) -> None: + body = post( + client, + device_key.headers, + {"type": "sleep", "data": {"day": "2026-08-12"}}, + {"type": "steps", "data": {"day": "2026-08-12", "steps": 10}}, + ) + assert body["inserted"] == 1 + assert body["errors"][0]["index"] == 0 + assert body["errors"][0]["type"] == "sleep" + + +def test_invalid_records_do_not_fail_the_batch( + client: TestClient, db: Session, device_key +) -> None: + body = post( + client, + device_key.headers, + {"type": "weight", "data": {"measured_at": "2026-08-13T06:31:00Z"}}, + { + "type": "weight", + "data": {"measured_at": "2026-08-13T06:31:00Z", "weight_kg": 900}, + }, + {"type": "steps", "data": {"steps": 10}}, + { + "type": "weight", + "data": {"measured_at": "2026-08-13T07:00:00Z", "weight_kg": 91.4}, + }, + ) + assert body["inserted"] == 1 + assert len(body["errors"]) == 3 + messages = " ".join(error["message"] for error in body["errors"]) + assert "requis" in messages + assert "bornes" in messages + assert len(db.scalars(select(WeightEntry)).all()) == 1 + + +def test_unknown_domain_is_a_404(client: TestClient, device_key) -> None: + response = client.post( + "/api/ingest/sleep", + json={"source": "x", "records": [{"type": "steps", "data": {}}]}, + headers=device_key.headers, + ) + assert response.status_code == 404 diff --git a/apps/api/app/tests/modules/test_health_planning.py b/apps/api/app/tests/modules/test_health_planning.py new file mode 100644 index 0000000..9016987 --- /dev/null +++ b/apps/api/app/tests/modules/test_health_planning.py @@ -0,0 +1,280 @@ +"""Planning (addendum-planning.md) at service level: derived "done" days, +adherence over crafted weeks, streaks across week boundaries, timezone edges.""" + +import datetime as dt +from decimal import Decimal +from zoneinfo import ZoneInfo + +from sqlalchemy.orm import Session + +from app.modules.auth.models import User +from app.modules.health import service, stats +from app.modules.health.models import ( + FoodEntry, + ScheduleKind, + TrackingSchedule, + WeightEntry, + Workout, +) + +PARIS = ZoneInfo("Europe/Paris") + + +def utc_at(day: dt.date, hour: int, minute: int = 0) -> dt.datetime: + """UTC instant for a Paris wall-clock time.""" + return dt.datetime( + day.year, day.month, day.day, hour, minute, tzinfo=PARIS + ).astimezone(dt.UTC) + + +def schedule(db: Session, user: User, kind: str, weekdays: list[int], enabled=True): + db.add( + TrackingSchedule( + user_id=user.id, + kind=ScheduleKind(kind), + weekdays=weekdays, + enabled=enabled, + ) + ) + db.commit() + + +def add_weight(db: Session, user: User, day: dt.date, kg: float, hour: int = 7) -> None: + db.add( + WeightEntry( + user_id=user.id, + source="manual", + measured_at=utc_at(day, hour), + weight_kg=Decimal(str(kg)), + ) + ) + db.commit() + + +def add_workout(db: Session, user: User, day: dt.date, hidden: bool = False) -> None: + start = utc_at(day, 18) + db.add( + Workout( + user_id=user.id, + source="manual", + started_at=start, + ended_at=start + dt.timedelta(hours=1), + sport_type="running", + is_hidden=hidden, + ) + ) + db.commit() + + +def add_food( + db: Session, user: User, day: dt.date, kcal: float, hour: int = 13 +) -> None: + db.add( + FoodEntry( + user_id=user.id, + source="manual", + eaten_at=utc_at(day, hour), + meal="lunch", + name="Repas", + quantity=Decimal(100), + unit="g", + kcal=Decimal(str(kcal)), + ) + ) + db.commit() + + +# --- Derived "done" days ------------------------------------------------------- + + +def test_done_days_are_derived_from_the_three_tables(db: Session, user: User) -> None: + day = dt.date(2026, 8, 12) + add_weight(db, user, day, 91.6) + add_weight(db, user, day, 92.4, hour=20) # second weigh-in of the day + add_workout(db, user, day) + add_food(db, user, day, 620.0) + add_food(db, user, day, 380.0) + + done = service.done_days_by_kind(db, user.id, day, day, PARIS) + assert done["weigh_in"] == {day: 91.6} # FIRST weigh-in of the local day + assert done["workout"] == {day: 1.0} + assert done["food_log"] == {day: 1000.0} + + +def test_hidden_workouts_do_not_count_as_done(db: Session, user: User) -> None: + day = dt.date(2026, 8, 12) + add_workout(db, user, day, hidden=True) + done = service.done_days_by_kind(db, user.id, day, day, PARIS) + assert done["workout"] == {} + + +def test_local_day_boundary_uses_the_profile_timezone(db: Session, user: User) -> None: + # 22:30 UTC on 12/08 is 00:30 on 13/08 in Paris (CEST = UTC+2). + db.add( + WeightEntry( + user_id=user.id, + source="manual", + measured_at=dt.datetime(2026, 8, 12, 22, 30, tzinfo=dt.UTC), + weight_kg=Decimal("91.6"), + ) + ) + db.commit() + paris = service.done_days_by_kind( + db, user.id, dt.date(2026, 8, 12), dt.date(2026, 8, 13), PARIS + ) + assert dt.date(2026, 8, 13) in paris["weigh_in"] + assert dt.date(2026, 8, 12) not in paris["weigh_in"] + + utc = service.done_days_by_kind( + db, user.id, dt.date(2026, 8, 12), dt.date(2026, 8, 13), ZoneInfo("UTC") + ) + assert dt.date(2026, 8, 12) in utc["weigh_in"] + + +# --- Schedules ----------------------------------------------------------------- + + +def test_missing_schedules_are_returned_disabled(db: Session, user: User) -> None: + rows = service.list_schedules(db, user.id) + assert [row.kind.value for row in rows] == ["weigh_in", "workout", "food_log"] + assert all(row.enabled is False and row.weekdays == [] for row in rows) + + +def test_upsert_schedule_is_idempotent_per_kind(db: Session, user: User) -> None: + from app.modules.health.schemas import ScheduleUpdate + + first = service.upsert_schedule( + db, + user.id, + ScheduleKind.WEIGH_IN, + ScheduleUpdate(weekdays=[4, 0], enabled=True), + ) + assert first.weekdays == [0, 4] + second = service.upsert_schedule( + db, user.id, ScheduleKind.WEIGH_IN, ScheduleUpdate(weekdays=[2], enabled=False) + ) + assert first.id == second.id + assert second.weekdays == [2] + assert second.enabled is False + + +# --- Adherence over crafted weeks --------------------------------------------- + + +def test_adherence_over_four_weeks_of_mondays(db: Session, user: User) -> None: + today = service.today_local(PARIS) + weekday = today.weekday() + schedule(db, user, "weigh_in", [weekday]) + planned = [today - dt.timedelta(days=7 * n) for n in range(4)] + for day in planned[:3]: # the oldest planned day is missed + add_weight(db, user, day, 92.0) + + body = stats.adherence_stats(db, user.id, planned[-1], today, PARIS, "weigh_in") + assert [kind.kind.value for kind in body.kinds] == ["weigh_in"] + weigh_in = body.kinds[0] + assert weigh_in.weekdays == [weekday] + assert weigh_in.planned_days == 4 + assert weigh_in.done_days == 3 + assert weigh_in.missed_days == 1 + assert weigh_in.adherence_pct == 75.0 + assert weigh_in.streak.current == 3 + assert weigh_in.streak.best == 3 + assert len(weigh_in.days) == 22 # inclusive range + + statuses = {row.date: row.status for row in weigh_in.days} + assert statuses[today] == "done" + assert statuses[planned[-1]] == "missed" + assert statuses[today - dt.timedelta(days=1)] == "rest" + + +def test_streak_spans_week_boundaries_with_two_days_a_week( + db: Session, user: User +) -> None: + today = service.today_local(PARIS) + # Two consecutive weekdays: for a Sunday anchor the pair straddles two ISO weeks. + first_day, second_day = today.weekday(), (today.weekday() + 1) % 7 + schedule(db, user, "workout", sorted({first_day, second_day})) + start = today - dt.timedelta(days=21) + for offset in range((today - start).days + 1): + day = start + dt.timedelta(days=offset) + if day.weekday() in {first_day, second_day}: + add_workout(db, user, day) + + body = stats.adherence_stats(db, user.id, start, today, PARIS, "workout") + workout = body.kinds[0] + assert workout.planned_days == workout.done_days + assert workout.missed_days == 0 + assert workout.adherence_pct == 100.0 + assert workout.streak.current == workout.planned_days + assert workout.streak.best >= workout.planned_days + + +def test_adherence_without_schedule_reports_no_planned_day( + db: Session, user: User +) -> None: + today = service.today_local(PARIS) + add_weight(db, user, today, 92.0) + body = stats.adherence_stats( + db, user.id, today - dt.timedelta(days=6), today, PARIS + ) + kinds = {kind.kind.value: kind for kind in body.kinds} + assert set(kinds) == {"weigh_in", "workout", "food_log"} + weigh_in = kinds["weigh_in"] + assert weigh_in.planned_days == 0 + assert weigh_in.adherence_pct is None + assert weigh_in.streak.current == 0 + # An unplanned weigh-in is still reported for the heatmap. + statuses = {row.date: row.status for row in weigh_in.days} + assert statuses[today] == "done_unplanned" + + +def test_disabled_schedule_plans_nothing(db: Session, user: User) -> None: + today = service.today_local(PARIS) + schedule(db, user, "weigh_in", [today.weekday()], enabled=False) + body = stats.adherence_stats( + db, user.id, today - dt.timedelta(days=6), today, PARIS, "weigh_in" + ) + assert body.kinds[0].planned_days == 0 + assert body.kinds[0].enabled is False + + +# --- Today --------------------------------------------------------------------- + + +def test_today_reports_planned_done_and_values(db: Session, user: User) -> None: + today = service.today_local(PARIS) + weekday = today.weekday() + schedule(db, user, "weigh_in", [weekday]) + schedule(db, user, "workout", [(weekday + 3) % 7]) # not today + schedule(db, user, "food_log", [weekday]) + add_weight(db, user, today, 91.6) + add_food(db, user, today, 620.0) + + body = stats.today_view(db, user.id, PARIS) + assert body.date == today + items = {item.kind.value: item for item in body.items} + assert items["weigh_in"].planned is True + assert items["weigh_in"].done is True + assert items["weigh_in"].value == 91.6 + assert items["food_log"].value == 620.0 + assert items["workout"].planned is False + assert items["workout"].done is False + assert items["workout"].value is None + assert body.streaks["weigh_in"].current == 1 + assert body.streaks["workout"].current == 0 + + +def test_today_streak_survives_a_planned_but_unfinished_day( + db: Session, user: User +) -> None: + today = service.today_local(PARIS) + schedule(db, user, "weigh_in", [today.weekday()]) + for offset in (7, 14): + add_weight(db, user, today - dt.timedelta(days=offset), 92.0) + body = stats.today_view(db, user.id, PARIS) + items = {item.kind.value: item for item in body.items} + assert items["weigh_in"].planned is True + assert items["weigh_in"].done is False + # Today is not over yet: the two previous planned days still count. + assert body.streaks["weigh_in"].current == 2 + assert body.streaks["weigh_in"].best == 2 diff --git a/apps/api/app/tests/modules/test_health_regressions.py b/apps/api/app/tests/modules/test_health_regressions.py new file mode 100644 index 0000000..34e65e9 --- /dev/null +++ b/apps/api/app/tests/modules/test_health_regressions.py @@ -0,0 +1,275 @@ +"""Regression tests for defects found while walking the live health journey. + +Each test below fails on the code as it was before the matching fix: + +1. an unknown value on an enum-backed query filter (`sport_type`, `meal`, + `status`) reached SQLAlchemy and raised `LookupError` -> HTTP 500 with no + French error envelope; +2. `/health/weights/stats` drew `plan_line` from the goal start to the LAST DAY + OF THE REQUESTED WINDOW, so the plan reference line of the weight chart + moved with the chart period and was ~7x too steep for a `weekly_rate` goal + (datamodel-health-vape.md §5.6b: `days = delta / (weekly_rate_kg / 7)`); +3. `/health/goals/active` reported `deficit_target_kcal` as + `tdee_smoothed - budget`, i.e. the deficit left AFTER the calorie floor + clamped the budget, instead of the deficit AIMED AT by the goal + (§5.3 `BudgetResult.deficit_target = rate x 7700 / 7`). +""" + +import datetime as dt +from zoneinfo import ZoneInfo + +from fastapi.testclient import TestClient + +BASE = "/api/health" +PARIS = ZoneInfo("Europe/Paris") + +PROFILE = { + "height_cm": 178.0, + "sex": "male", + "birthdate": "1990-05-12", + "activity_level": "moderate", + "timezone": "Europe/Paris", +} + + +def _today() -> dt.date: + return dt.datetime.now(dt.UTC).astimezone(PARIS).date() + + +def _at(day: dt.date, hour: int) -> str: + return ( + dt.datetime(day.year, day.month, day.day, hour, tzinfo=PARIS) + .astimezone(dt.UTC) + .isoformat() + .replace("+00:00", "Z") + ) + + +def _seed(client: TestClient, headers: dict[str, str]) -> None: + assert ( + client.put(f"{BASE}/profile", json=PROFILE, headers=headers).status_code == 200 + ) + today = _today() + for offset, weight in ((6, 95.0), (4, 94.6), (2, 94.3), (0, 94.0)): + response = client.post( + f"{BASE}/weights", + json={ + "measured_at": _at(today - dt.timedelta(days=offset), 7), + "weight_kg": weight, + }, + headers=headers, + ) + assert response.status_code == 201, response.text + + +# --- 1. Unknown enum filter values must be rejected, never crash -------------- + + +def test_unknown_sport_type_filter_is_rejected( + client: TestClient, auth_headers: dict[str, str] +) -> None: + response = client.get( + f"{BASE}/workouts", params={"sport_type": "quidditch"}, headers=auth_headers + ) + assert response.status_code == 422, response.text + assert response.json()["error"]["code"] == "validation_error" + + +def test_unknown_meal_filter_is_rejected( + client: TestClient, auth_headers: dict[str, str] +) -> None: + response = client.get( + f"{BASE}/nutrition/entries", params={"meal": "brunch"}, headers=auth_headers + ) + assert response.status_code == 422, response.text + assert response.json()["error"]["code"] == "validation_error" + + +def test_unknown_goal_status_filter_is_rejected( + client: TestClient, auth_headers: dict[str, str] +) -> None: + response = client.get( + f"{BASE}/goals", params={"status": "zzz"}, headers=auth_headers + ) + assert response.status_code == 422, response.text + assert response.json()["error"]["code"] == "validation_error" + + +def test_known_enum_filters_still_work( + client: TestClient, auth_headers: dict[str, str] +) -> None: + assert ( + client.get( + f"{BASE}/workouts", params={"sport_type": "cycling"}, headers=auth_headers + ).status_code + == 200 + ) + assert ( + client.get( + f"{BASE}/nutrition/entries", params={"meal": "lunch"}, headers=auth_headers + ).status_code + == 200 + ) + assert ( + client.get( + f"{BASE}/goals", params={"status": "active"}, headers=auth_headers + ).status_code + == 200 + ) + + +# --- 2. plan_line must follow the goal rate, not the chart window ------------- + + +def test_plan_line_follows_the_weekly_rate_not_the_window( + client: TestClient, auth_headers: dict[str, str] +) -> None: + _seed(client, auth_headers) + today = _today() + start = today - dt.timedelta(days=6) + created = client.post( + f"{BASE}/goals", + json={ + "mode": "weekly_rate", + "start_date": start.isoformat(), + "start_weight_kg": 95.0, + "target_weight_kg": 85.0, + "weekly_rate_kg": 0.5, + }, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + + # 10 kg at 0.5 kg/week = 20 weeks = 140 days after the goal start date. + expected_end = (start + dt.timedelta(days=140)).isoformat() + + def plan_line(params: dict) -> list: + response = client.get( + f"{BASE}/weights/stats", params=params, headers=auth_headers + ) + assert response.status_code == 200, response.text + body = response.json() + found = [s for s in body["series"] if s["name"] == "plan_line"] + assert found, "plan_line series missing" + assert body["meta"]["plan_end_date"] == expected_end + return found[0]["points"] + + wide = plan_line({"from": start.isoformat(), "to": today.isoformat()}) + narrow = plan_line( + {"from": (today - dt.timedelta(days=2)).isoformat(), "to": today.isoformat()} + ) + assert wide == narrow, "plan_line must not depend on the requested window" + assert wide[0] == [start.isoformat(), 95.0] + assert wide[1] == [expected_end, 85.0] + + +def test_plan_line_uses_target_date_when_the_goal_has_one( + client: TestClient, auth_headers: dict[str, str] +) -> None: + _seed(client, auth_headers) + today = _today() + start = today - dt.timedelta(days=6) + target_date = today + dt.timedelta(days=200) + assert ( + client.post( + f"{BASE}/goals", + json={ + "mode": "target_date", + "start_date": start.isoformat(), + "start_weight_kg": 95.0, + "target_weight_kg": 85.0, + "target_date": target_date.isoformat(), + }, + headers=auth_headers, + ).status_code + == 201 + ) + body = client.get( + f"{BASE}/weights/stats", + params={"from": start.isoformat(), "to": today.isoformat()}, + headers=auth_headers, + ).json() + points = next(s for s in body["series"] if s["name"] == "plan_line")["points"] + assert points[1] == [target_date.isoformat(), 85.0] + + +def test_maintain_goal_has_no_plan_line( + client: TestClient, auth_headers: dict[str, str] +) -> None: + _seed(client, auth_headers) + today = _today() + assert ( + client.post( + f"{BASE}/goals", + json={ + "mode": "maintain", + "start_date": (today - dt.timedelta(days=6)).isoformat(), + "start_weight_kg": 95.0, + "target_weight_kg": 95.0, + }, + headers=auth_headers, + ).status_code + == 201 + ) + body = client.get(f"{BASE}/weights/stats", headers=auth_headers).json() + assert [s for s in body["series"] if s["name"] == "plan_line"] == [] + assert body["meta"]["plan_end_date"] is None + + +# --- 3. deficit_target_kcal is the AIMED deficit, floor or not ---------------- + + +def test_deficit_target_is_the_goal_target_even_when_the_floor_applies( + client: TestClient, auth_headers: dict[str, str] +) -> None: + _seed(client, auth_headers) + today = _today() + assert ( + client.post( + f"{BASE}/goals", + json={ + "mode": "weekly_rate", + "start_date": (today - dt.timedelta(days=6)).isoformat(), + "start_weight_kg": 95.0, + "target_weight_kg": 85.0, + "weekly_rate_kg": 1.5, + }, + headers=auth_headers, + ).status_code + == 201 + ) + budget = client.get(f"{BASE}/goals/active", headers=auth_headers).json()["budget"] + # 1.5 kg/week -> 1.5 * 7700 / 7 = 1650 kcal/day, whatever the floor does. + assert budget["deficit_target_kcal"] == 1650.0 + assert budget["floor_applied"] is True + assert budget["kcal"] == 1500.0 + + +def test_energy_balance_meta_exposes_the_target_deficit( + client: TestClient, auth_headers: dict[str, str] +) -> None: + _seed(client, auth_headers) + today = _today() + assert ( + client.post( + f"{BASE}/goals", + json={ + "mode": "weekly_rate", + "start_date": (today - dt.timedelta(days=6)).isoformat(), + "start_weight_kg": 95.0, + "target_weight_kg": 85.0, + "weekly_rate_kg": 0.5, + }, + headers=auth_headers, + ).status_code + == 201 + ) + meta = client.get( + f"{BASE}/energy-balance", + params={ + "from": (today - dt.timedelta(days=6)).isoformat(), + "to": today.isoformat(), + }, + headers=auth_headers, + ).json()["meta"] + assert meta["deficit_target_kcal"] == 550.0 # 0.5 * 7700 / 7 diff --git a/apps/api/app/tests/modules/test_health_router.py b/apps/api/app/tests/modules/test_health_router.py new file mode 100644 index 0000000..395753c --- /dev/null +++ b/apps/api/app/tests/modules/test_health_router.py @@ -0,0 +1,1025 @@ +"""Contract tests for the health router (/api/health).""" + +import datetime as dt +from collections.abc import Callable +from decimal import Decimal +from zoneinfo import ZoneInfo + +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.modules.auth.models import User +from app.modules.health import calculations as calc +from app.modules.health.models import DailyActivity + +PARIS = ZoneInfo("Europe/Paris") +BASE = "/api/health" + + +def today_paris() -> dt.date: + return dt.datetime.now(dt.UTC).astimezone(PARIS).date() + + +def at(day: dt.date, hour: int, minute: int = 0) -> str: + """ISO-8601 UTC instant for a Paris wall-clock time on `day`.""" + local = dt.datetime(day.year, day.month, day.day, hour, minute, tzinfo=PARIS) + return local.astimezone(dt.UTC).isoformat().replace("+00:00", "Z") + + +PROFILE = { + "height_cm": 180.0, + "sex": "male", + "birthdate": "1990-08-13", + "activity_level": "moderate", + "timezone": "Europe/Paris", + "water_goal_ml": 2000, +} + + +def setup_profile(client: TestClient, headers: dict[str, str]) -> dict: + response = client.put(f"{BASE}/profile", json=PROFILE, headers=headers) + assert response.status_code == 200, response.text + return response.json() + + +# --- Auth & profile ----------------------------------------------------------- + + +def test_endpoints_require_authentication(client: TestClient) -> None: + assert client.get(f"{BASE}/profile").status_code == 401 + assert client.get(f"{BASE}/weights").status_code == 401 + assert client.get(f"{BASE}/today").status_code == 401 + + +def test_profile_missing_returns_french_404( + client: TestClient, auth_headers: dict[str, str] +) -> None: + response = client.get(f"{BASE}/profile", headers=auth_headers) + assert response.status_code == 404 + error = response.json()["error"] + assert error["code"] == "not_found" + assert "Profil santé" in error["message"] + + +def test_profile_put_then_get_computes_age_bmr_tdee( + client: TestClient, auth_headers: dict[str, str] +) -> None: + body = setup_profile(client, auth_headers) + assert body["height_cm"] == 180.0 + assert body["sex"] == "male" + # No weigh-in yet -> no BMR + assert body["bmr_kcal"] is None + + client.post( + f"{BASE}/weights", + json={"measured_at": at(today_paris(), 7), "weight_kg": 80.0}, + headers=auth_headers, + ) + body = client.get(f"{BASE}/profile", headers=auth_headers).json() + age = calc.age_on(today_paris(), dt.date(1990, 8, 13)) + assert body["age"] == age + expected_bmr = calc.bmr_mifflin(80.0, 180.0, age, "male") + assert body["bmr_kcal"] == round(expected_bmr, 1) + assert body["tdee_estimated_kcal"] == round(expected_bmr * 1.55, 1) + assert body["bmi"] == 24.7 + + +def test_profile_rejects_unknown_timezone( + client: TestClient, auth_headers: dict[str, str] +) -> None: + payload = {**PROFILE, "timezone": "Mars/Olympus"} + response = client.put(f"{BASE}/profile", json=payload, headers=auth_headers) + assert response.status_code == 422 + assert "Fuseau horaire" in response.json()["error"]["message"] + + +# --- Weights ------------------------------------------------------------------ + + +def test_weight_crud_and_pagination( + client: TestClient, auth_headers: dict[str, str] +) -> None: + setup_profile(client, auth_headers) + today = today_paris() + for offset, weight in enumerate([92.4, 92.0, 91.6]): + response = client.post( + f"{BASE}/weights", + json={ + "measured_at": at(today - dt.timedelta(days=offset), 7), + "weight_kg": weight, + "note": "après le sport", + }, + headers=auth_headers, + ) + assert response.status_code == 201, response.text + + listing = client.get( + f"{BASE}/weights", params={"page_size": 2}, headers=auth_headers + ).json() + assert listing["total"] == 3 + assert len(listing["items"]) == 2 + assert listing["items"][0]["weight_kg"] == 92.4 # -measured_at by default + assert listing["items"][0]["source"] == "manual" + + entry_id = listing["items"][0]["id"] + patched = client.patch( + f"{BASE}/weights/{entry_id}", json={"weight_kg": 92.5}, headers=auth_headers + ) + assert patched.status_code == 200 + assert patched.json()["weight_kg"] == 92.5 + + assert ( + client.delete(f"{BASE}/weights/{entry_id}", headers=auth_headers).status_code + == 204 + ) + assert client.get(f"{BASE}/weights", headers=auth_headers).json()["total"] == 2 + assert ( + client.delete(f"{BASE}/weights/{entry_id}", headers=auth_headers).status_code + == 404 + ) + + +def test_weight_duplicate_timestamp_is_a_conflict( + client: TestClient, auth_headers: dict[str, str] +) -> None: + payload = {"measured_at": at(today_paris(), 7), "weight_kg": 92.4} + assert ( + client.post(f"{BASE}/weights", json=payload, headers=auth_headers).status_code + == 201 + ) + conflict = client.post(f"{BASE}/weights", json=payload, headers=auth_headers) + assert conflict.status_code == 409 + assert "pesée" in conflict.json()["error"]["message"].lower() + + +def test_weight_out_of_range_is_rejected( + client: TestClient, auth_headers: dict[str, str] +) -> None: + response = client.post( + f"{BASE}/weights", + json={"measured_at": at(today_paris(), 7), "weight_kg": 5}, + headers=auth_headers, + ) + assert response.status_code == 422 + + +def test_weight_stats_series_contract( + client: TestClient, auth_headers: dict[str, str] +) -> None: + setup_profile(client, auth_headers) + today = today_paris() + for offset in range(6): + client.post( + f"{BASE}/weights", + json={ + "measured_at": at(today - dt.timedelta(days=offset * 3), 7), + "weight_kg": 92.0 - 0.3 * (5 - offset), + }, + headers=auth_headers, + ) + client.post( + f"{BASE}/goals", + json={"mode": "weekly_rate", "target_weight_kg": 85.0, "weekly_rate_kg": 0.5}, + headers=auth_headers, + ) + body = client.get(f"{BASE}/weights/stats", headers=auth_headers).json() + assert set(body) >= {"from", "to", "unit", "series", "meta"} + assert body["unit"] == "kg" + names = {series["name"] for series in body["series"]} + assert {"weight_raw", "weight_trend", "plan_line"} <= names + raw = next(s for s in body["series"] if s["name"] == "weight_raw") + assert raw["type"] == "scatter" + assert all(len(point) == 2 for point in raw["points"]) + assert body["meta"]["trend_now_kg"] is not None + assert body["meta"]["projection"]["status"] in {"ok", "reached", "not_converging"} + assert body["meta"]["target_weight_kg"] == 85.0 + + +# --- Measurements ------------------------------------------------------------- + + +def test_measurements_crud_and_navy_series( + client: TestClient, auth_headers: dict[str, str] +) -> None: + setup_profile(client, auth_headers) + response = client.post( + f"{BASE}/measurements", + json={ + "measured_at": at(today_paris(), 8), + "waist_cm": 90.0, + "neck_cm": 38.0, + "chest_cm": 102.0, + }, + headers=auth_headers, + ) + assert response.status_code == 201, response.text + assert response.json()["waist_cm"] == 90.0 + + stats = client.get(f"{BASE}/measurements/stats", headers=auth_headers).json() + names = {series["name"] for series in stats["series"]} + assert {"waist_cm", "neck_cm", "chest_cm", "body_fat_navy_pct"} <= names + assert stats["unit"] == "cm" + + +# --- Activity ----------------------------------------------------------------- + + +def test_activity_merge_priority_per_field( + client: TestClient, + db: Session, + user: User, + auth_headers: dict[str, str], +) -> None: + day = today_paris() + db.add_all( + [ + DailyActivity( + user_id=user.id, + date=day, + source="csv_import", + steps=5000, + distance_m=3000, + active_minutes=42, + ), + DailyActivity( + user_id=user.id, + date=day, + source="health_connect", + steps=9421, + active_kcal=Decimal("520.0"), + ), + ] + ) + db.commit() + assert ( + client.post( + f"{BASE}/activity", + json={"date": day.isoformat(), "steps": 10000}, + headers=auth_headers, + ).status_code + == 201 + ) + + merged = client.get( + f"{BASE}/activity", + params={"from": day.isoformat(), "to": day.isoformat()}, + headers=auth_headers, + ).json() + assert len(merged) == 1 + row = merged[0] + assert row["steps"] == 10000 + assert row["active_kcal"] == 520.0 + assert row["distance_m"] == 3000 + assert row["active_minutes"] == 42 + assert row["field_sources"] == { + "steps": "manual", + "active_kcal": "health_connect", + "distance_m": "csv_import", + "active_minutes": "csv_import", + } + + raw = client.get( + f"{BASE}/activity", + params={"from": day.isoformat(), "to": day.isoformat(), "raw": True}, + headers=auth_headers, + ).json() + assert len(raw) == 3 + assert {item["source"] for item in raw} == { + "manual", + "health_connect", + "csv_import", + } + + deleted = client.delete(f"{BASE}/activity/{raw[0]['id']}", headers=auth_headers) + assert deleted.status_code == 204 + + +def test_activity_manual_upsert_replaces_the_same_day( + client: TestClient, auth_headers: dict[str, str] +) -> None: + day = today_paris().isoformat() + first = client.post( + f"{BASE}/activity", json={"date": day, "steps": 1000}, headers=auth_headers + ).json() + second = client.post( + f"{BASE}/activity", json={"date": day, "steps": 2000}, headers=auth_headers + ).json() + assert first["id"] == second["id"] + assert second["steps"] == 2000 + + +def test_activity_stats_has_moving_averages( + client: TestClient, auth_headers: dict[str, str] +) -> None: + day = today_paris() + client.post( + f"{BASE}/activity", + json={"date": day.isoformat(), "steps": 12000, "active_kcal": 500.0}, + headers=auth_headers, + ) + body = client.get(f"{BASE}/activity/stats", headers=auth_headers).json() + names = {series["name"] for series in body["series"]} + assert {"steps", "steps_ma7", "active_kcal", "distance_m"} <= names + assert body["meta"]["avg_steps"] == 12000.0 + assert body["meta"]["tracked_days"] == 1 + + +# --- Workouts ----------------------------------------------------------------- + + +def test_workout_crud_and_stats( + client: TestClient, auth_headers: dict[str, str] +) -> None: + day = today_paris() + response = client.post( + f"{BASE}/workouts", + json={ + "started_at": at(day, 18), + "ended_at": at(day, 19), + "sport_type": "treadmill_run", + "kcal": 620.0, + "distance_m": 9500, + }, + headers=auth_headers, + ) + assert response.status_code == 201, response.text + body = response.json() + assert body["duration_s"] == 3600 + assert body["is_hidden"] is False + + listing = client.get(f"{BASE}/workouts", headers=auth_headers).json() + assert listing["total"] == 1 + + stats = client.get(f"{BASE}/workouts/stats", headers=auth_headers).json() + assert stats["meta"]["sessions"] == 1 + assert stats["meta"]["total_duration_min"] == 60.0 + names = {series["name"] for series in stats["series"]} + assert {"sessions_count", "total_kcal", "by_sport_duration_min"} <= names + + bad = client.post( + f"{BASE}/workouts", + json={ + "started_at": at(day, 19), + "ended_at": at(day, 18), + "sport_type": "running", + }, + headers=auth_headers, + ) + assert bad.status_code == 422 + assert "fin de séance" in bad.json()["error"]["message"] + + +def test_workout_overlap_hides_lower_priority_source( + client: TestClient, + db: Session, + user: User, + auth_headers: dict[str, str], +) -> None: + from app.modules.health.models import Workout + + day = today_paris() + start = dt.datetime(day.year, day.month, day.day, 18, tzinfo=PARIS).astimezone( + dt.UTC + ) + db.add( + Workout( + user_id=user.id, + source="fitshow", + started_at=start, + ended_at=start + dt.timedelta(minutes=60), + sport_type="treadmill_run", + ) + ) + db.commit() + # A manual session covering the same slot wins: fitshow gets hidden. + client.post( + f"{BASE}/workouts", + json={ + "started_at": at(day, 18), + "ended_at": at(day, 19), + "sport_type": "treadmill_run", + }, + headers=auth_headers, + ) + visible = client.get(f"{BASE}/workouts", headers=auth_headers).json() + assert visible["total"] == 1 + assert visible["items"][0]["source"] == "manual" + everything = client.get( + f"{BASE}/workouts", params={"include_hidden": True}, headers=auth_headers + ).json() + assert everything["total"] == 2 + + +# --- Goals -------------------------------------------------------------------- + + +def test_goal_lifecycle_single_active( + client: TestClient, auth_headers: dict[str, str] +) -> None: + setup_profile(client, auth_headers) + client.post( + f"{BASE}/weights", + json={"measured_at": at(today_paris(), 7), "weight_kg": 92.0}, + headers=auth_headers, + ) + payload = { + "mode": "weekly_rate", + "target_weight_kg": 85.0, + "weekly_rate_kg": 0.5, + } + first = client.post(f"{BASE}/goals", json=payload, headers=auth_headers) + assert first.status_code == 201, first.text + assert first.json()["start_weight_kg"] == 92.0 + assert first.json()["status"] == "active" + + conflict = client.post(f"{BASE}/goals", json=payload, headers=auth_headers) + assert conflict.status_code == 409 + assert "objectif" in conflict.json()["error"]["message"].lower() + + replaced = client.post( + f"{BASE}/goals", + json=payload, + params={"replace_active": True}, + headers=auth_headers, + ) + assert replaced.status_code == 201 + history = client.get(f"{BASE}/goals", headers=auth_headers).json() + assert history["total"] == 2 + assert {item["status"] for item in history["items"]} == {"active", "abandoned"} + + reactivated = client.post( + f"{BASE}/goals/{first.json()['id']}/activate", headers=auth_headers + ) + assert reactivated.status_code == 200 + assert reactivated.json()["status"] == "active" + active = client.get(f"{BASE}/goals/active", headers=auth_headers).json() + assert active["goal"]["id"] == first.json()["id"] + assert active["budget"]["kcal"] > 0 + assert active["budget"]["deficit_target_kcal"] == 550.0 + assert active["progress_pct"] is not None + + +def test_goal_target_date_mode_requires_a_date( + client: TestClient, auth_headers: dict[str, str] +) -> None: + setup_profile(client, auth_headers) + client.post( + f"{BASE}/weights", + json={"measured_at": at(today_paris(), 7), "weight_kg": 92.0}, + headers=auth_headers, + ) + response = client.post( + f"{BASE}/goals", + json={"mode": "target_date", "target_weight_kg": 85.0}, + headers=auth_headers, + ) + assert response.status_code == 422 + assert "date cible" in response.json()["error"]["message"] + + +def test_active_goal_is_empty_without_goal( + client: TestClient, auth_headers: dict[str, str] +) -> None: + body = client.get(f"{BASE}/goals/active", headers=auth_headers).json() + assert body["goal"] is None + assert body["budget"] is None + + +# --- Nutrition ---------------------------------------------------------------- + + +def add_food( + client: TestClient, + headers: dict[str, str], + day: dt.date, + hour: int, + meal: str, + name: str, + kcal: float, + **macros: float, +) -> dict: + payload = { + "eaten_at": at(day, hour), + "meal": meal, + "name": name, + "quantity": 100, + "unit": "g", + "kcal": kcal, + **macros, + } + response = client.post(f"{BASE}/nutrition/entries", json=payload, headers=headers) + assert response.status_code == 201, response.text + return response.json() + + +def test_food_entries_by_day_and_meal( + client: TestClient, auth_headers: dict[str, str] +) -> None: + day = today_paris() + add_food( + client, + auth_headers, + day, + 8, + "breakfast", + "Flocons d'avoine", + 228.0, + protein_g=8.1, + carbs_g=39.0, + fat_g=4.2, + ) + add_food( + client, + auth_headers, + day, + 13, + "lunch", + "Poulet rôti", + 248.5, + protein_g=46.2, + carbs_g=0.0, + fat_g=5.4, + ) + add_food( + client, auth_headers, day - dt.timedelta(days=1), 20, "dinner", "Pâtes", 350.0 + ) + + same_day = client.get( + f"{BASE}/nutrition/entries", + params={"day": day.isoformat()}, + headers=auth_headers, + ).json() + assert same_day["total"] == 2 + + lunch = client.get( + f"{BASE}/nutrition/entries", + params={"day": day.isoformat(), "meal": "lunch"}, + headers=auth_headers, + ).json() + assert lunch["total"] == 1 + assert lunch["items"][0]["name"] == "Poulet rôti" + + searched = client.get( + f"{BASE}/nutrition/entries", params={"q": "poulet"}, headers=auth_headers + ).json() + assert searched["total"] == 1 + + detail = client.get( + f"{BASE}/nutrition/days/{day.isoformat()}", headers=auth_headers + ).json() + assert detail["totals"]["kcal"] == 476.5 + meals = {meal["meal"]: meal for meal in detail["meals"]} + assert meals["breakfast"]["kcal"] == 228.0 + assert len(meals["lunch"]["entries"]) == 1 + assert meals["dinner"]["kcal"] == 0.0 + + +def test_food_entry_update_and_delete( + client: TestClient, auth_headers: dict[str, str] +) -> None: + entry = add_food(client, auth_headers, today_paris(), 8, "breakfast", "Pain", 250.0) + patched = client.patch( + f"{BASE}/nutrition/entries/{entry['id']}", + json={"kcal": 300.0, "meal": "snack"}, + headers=auth_headers, + ).json() + assert patched["kcal"] == 300.0 + assert patched["meal"] == "snack" + assert ( + client.delete( + f"{BASE}/nutrition/entries/{entry['id']}", headers=auth_headers + ).status_code + == 204 + ) + + +def test_favorites_crud_and_prorated_entry( + client: TestClient, auth_headers: dict[str, str] +) -> None: + favorite = client.post( + f"{BASE}/nutrition/favorites", + json={ + "name": "Flocons d'avoine", + "brand": "Quaker", + "default_quantity": 100, + "unit": "g", + "kcal": 380.0, + "protein_g": 13.5, + "default_meal": "breakfast", + }, + headers=auth_headers, + ) + assert favorite.status_code == 201, favorite.text + favorite_id = favorite.json()["id"] + + duplicate = client.post( + f"{BASE}/nutrition/favorites", + json={ + "name": "Flocons d'avoine", + "brand": "Quaker", + "default_quantity": 100, + "kcal": 380.0, + }, + headers=auth_headers, + ) + assert duplicate.status_code == 409 + + entry = client.post( + f"{BASE}/nutrition/entries", + json={ + "eaten_at": at(today_paris(), 8), + "meal": "breakfast", + "name": "ignoré", + "quantity": 60, + "kcal": 0, + "favorite_id": favorite_id, + }, + headers=auth_headers, + ) + assert entry.status_code == 201 + assert entry.json()["kcal"] == 228.0 # 380 * 60 / 100 + assert entry.json()["protein_g"] == 8.1 + assert entry.json()["name"] == "Flocons d'avoine" + + listing = client.get(f"{BASE}/nutrition/favorites", headers=auth_headers).json() + assert listing["items"][0]["use_count"] == 1 + assert listing["items"][0]["last_used_at"] is not None + + assert ( + client.delete( + f"{BASE}/nutrition/favorites/{favorite_id}", headers=auth_headers + ).status_code + == 204 + ) + + +def test_recent_foods_are_deduplicated( + client: TestClient, auth_headers: dict[str, str] +) -> None: + day = today_paris() + add_food(client, auth_headers, day, 8, "breakfast", "Pain complet", 250.0) + add_food(client, auth_headers, day, 9, "snack", "Pain complet", 130.0) + add_food(client, auth_headers, day, 13, "lunch", "Riz", 300.0) + recent = client.get(f"{BASE}/nutrition/recent", headers=auth_headers).json() + assert [item["name"] for item in recent] == ["Riz", "Pain complet"] + + +def test_water_crud_and_stats(client: TestClient, auth_headers: dict[str, str]) -> None: + setup_profile(client, auth_headers) + day = today_paris() + created = client.post( + f"{BASE}/nutrition/water", + json={"drunk_at": at(day, 10), "volume_ml": 500}, + headers=auth_headers, + ) + assert created.status_code == 201 + client.post( + f"{BASE}/nutrition/water", + json={"drunk_at": at(day, 15), "volume_ml": 750}, + headers=auth_headers, + ) + stats = client.get(f"{BASE}/nutrition/water/stats", headers=auth_headers).json() + volume = next(s for s in stats["series"] if s["name"] == "volume_ml") + today_point = next(p for p in volume["points"] if p[0] == day.isoformat()) + assert today_point[1] == 1250 + assert stats["meta"]["goal_ml"] == 2000 + assert ( + client.delete( + f"{BASE}/nutrition/water/{created.json()['id']}", headers=auth_headers + ).status_code + == 204 + ) + + +def test_nutrition_stats_contract( + client: TestClient, auth_headers: dict[str, str] +) -> None: + setup_profile(client, auth_headers) + day = today_paris() + client.post( + f"{BASE}/weights", + json={"measured_at": at(day, 7), "weight_kg": 92.0}, + headers=auth_headers, + ) + add_food( + client, + auth_headers, + day, + 8, + "breakfast", + "Avoine", + 400.0, + protein_g=10.0, + carbs_g=60.0, + fat_g=8.0, + ) + add_food( + client, + auth_headers, + day, + 13, + "lunch", + "Poulet", + 600.0, + protein_g=50.0, + carbs_g=20.0, + fat_g=25.0, + ) + + body = client.get(f"{BASE}/nutrition/stats", headers=auth_headers).json() + names = {series["name"] for series in body["series"]} + assert { + "kcal", + "budget_kcal", + "protein_g", + "protein_g_kcal", + "meal_breakfast_kcal", + "meal_lunch_kcal", + "top_foods_kcal", + } <= names + top = next(s for s in body["series"] if s["name"] == "top_foods_kcal") + assert top["points"][0] == ["Poulet", 600.0] + assert body["meta"]["tracked_days"] == 1 + assert body["meta"]["avg_kcal"] == 1000.0 + assert sum(body["meta"]["macro_split_pct"].values()) == 100.0 + + days = client.get(f"{BASE}/nutrition/days", headers=auth_headers).json() + current = next(row for row in days if row["date"] == day.isoformat()) + assert current["kcal"] == 1000.0 + assert current["budget_kcal"] is not None + assert current["vs_budget_kcal"] == round( + current["kcal"] - current["budget_kcal"], 1 + ) + + +# --- Energy balance & dashboard ------------------------------------------------ + + +def test_energy_balance_contract( + client: TestClient, auth_headers: dict[str, str] +) -> None: + setup_profile(client, auth_headers) + day = today_paris() + client.post( + f"{BASE}/weights", + json={"measured_at": at(day, 7), "weight_kg": 92.0}, + headers=auth_headers, + ) + client.post( + f"{BASE}/activity", + json={"date": day.isoformat(), "active_kcal": 500.0}, + headers=auth_headers, + ) + add_food(client, auth_headers, day, 13, "lunch", "Poulet", 1800.0) + + body = client.get(f"{BASE}/energy-balance", headers=auth_headers).json() + names = {series["name"] for series in body["series"]} + assert { + "intake_kcal", + "tdee_kcal", + "balance_kcal", + "budget_kcal", + "cumulative_balance_kcal", + } <= names + assert body["unit"] == "kcal" + assert body["meta"]["profile_missing"] is False + assert body["meta"]["calibration_status"] == "insufficient_data" + assert body["meta"]["tdee_methods"][day.isoformat()] == "bmr_plus_active" + + age = calc.age_on(day, dt.date(1990, 8, 13)) + expected_tdee = calc.bmr_mifflin(92.0, 180.0, age, "male") + 500.0 + tdee_series = next(s for s in body["series"] if s["name"] == "tdee_kcal") + point = next(p for p in tdee_series["points"] if p[0] == day.isoformat()) + assert point[1] == round(expected_tdee, 1) + + +def test_energy_balance_without_profile_degrades( + client: TestClient, auth_headers: dict[str, str] +) -> None: + body = client.get(f"{BASE}/energy-balance", headers=auth_headers).json() + assert body["meta"]["profile_missing"] is True + tdee = next(s for s in body["series"] if s["name"] == "tdee_kcal") + assert all(point[1] is None for point in tdee["points"]) + + +def test_dashboard_aggregates_the_home_screen( + client: TestClient, auth_headers: dict[str, str] +) -> None: + setup_profile(client, auth_headers) + day = today_paris() + client.post( + f"{BASE}/weights", + json={"measured_at": at(day, 7), "weight_kg": 92.0}, + headers=auth_headers, + ) + client.post( + f"{BASE}/activity", + json={"date": day.isoformat(), "steps": 9421, "active_kcal": 520.0}, + headers=auth_headers, + ) + add_food(client, auth_headers, day, 13, "lunch", "Poulet", 700.0) + client.post( + f"{BASE}/nutrition/water", + json={"drunk_at": at(day, 10), "volume_ml": 500}, + headers=auth_headers, + ) + client.post( + f"{BASE}/workouts", + json={ + "started_at": at(day, 18), + "ended_at": at(day, 19), + "sport_type": "running", + }, + headers=auth_headers, + ) + + body = client.get(f"{BASE}/dashboard", headers=auth_headers).json() + assert body["date"] == day.isoformat() + assert body["weight_kg"] == 92.0 + assert body["trend_weight_kg"] == 92.0 + assert body["steps"] == 9421 + assert body["intake_kcal"] == 700.0 + assert body["budget_kcal"] is not None + assert body["remaining_kcal"] == round(body["budget_kcal"] - 700.0, 1) + assert body["water_ml"] == 500 + assert body["water_goal_ml"] == 2000 + assert body["workouts_this_week"] == 1 + assert body["today"]["date"] == day.isoformat() + assert len(body["weight_series"]) == 1 + + +# --- Planning ------------------------------------------------------------------ + + +def test_schedules_default_to_disabled_then_upsert( + client: TestClient, auth_headers: dict[str, str] +) -> None: + defaults = client.get(f"{BASE}/schedules", headers=auth_headers).json() + assert [item["kind"] for item in defaults] == ["weigh_in", "workout", "food_log"] + assert all(item["enabled"] is False and item["weekdays"] == [] for item in defaults) + + updated = client.put( + f"{BASE}/schedules/weigh_in", + json={"weekdays": [2, 0, 0, 4], "enabled": True}, + headers=auth_headers, + ) + assert updated.status_code == 200 + assert updated.json() == { + "kind": "weigh_in", + "weekdays": [0, 2, 4], + "enabled": True, + } + + again = client.put( + f"{BASE}/schedules/weigh_in", + json={"weekdays": [1], "enabled": False}, + headers=auth_headers, + ).json() + assert again["weekdays"] == [1] + assert again["enabled"] is False + + invalid = client.put( + f"{BASE}/schedules/weigh_in", + json={"weekdays": [9], "enabled": True}, + headers=auth_headers, + ) + assert invalid.status_code == 422 + assert "lundi" in invalid.json()["error"]["message"] + + unknown = client.put( + f"{BASE}/schedules/sleep", json={"weekdays": [1]}, headers=auth_headers + ) + assert unknown.status_code == 422 + + +def test_today_derives_done_from_existing_tables( + client: TestClient, auth_headers: dict[str, str] +) -> None: + day = today_paris() + weekday = day.weekday() + for kind in ("weigh_in", "workout", "food_log"): + client.put( + f"{BASE}/schedules/{kind}", + json={"weekdays": [weekday], "enabled": True}, + headers=auth_headers, + ) + client.post( + f"{BASE}/weights", + json={"measured_at": at(day, 7), "weight_kg": 91.6}, + headers=auth_headers, + ) + add_food(client, auth_headers, day, 13, "lunch", "Poulet", 620.0) + + body = client.get(f"{BASE}/today", headers=auth_headers).json() + assert body["date"] == day.isoformat() + items = {item["kind"]: item for item in body["items"]} + assert items["weigh_in"] == { + "kind": "weigh_in", + "planned": True, + "done": True, + "value": 91.6, + } + assert items["food_log"]["done"] is True + assert items["food_log"]["value"] == 620.0 + assert items["workout"]["planned"] is True + assert items["workout"]["done"] is False + assert items["workout"]["value"] is None + assert body["streaks"]["weigh_in"]["current"] == 1 + + +def test_today_rest_day_when_nothing_is_planned( + client: TestClient, auth_headers: dict[str, str] +) -> None: + body = client.get(f"{BASE}/today", headers=auth_headers).json() + assert all(item["planned"] is False for item in body["items"]) + assert body["streaks"]["weigh_in"]["current"] == 0 + + +def test_adherence_heatmap_contract( + client: TestClient, auth_headers: dict[str, str] +) -> None: + day = today_paris() + weekday = day.weekday() + client.put( + f"{BASE}/schedules/weigh_in", + json={"weekdays": [weekday], "enabled": True}, + headers=auth_headers, + ) + client.post( + f"{BASE}/weights", + json={"measured_at": at(day, 7), "weight_kg": 91.6}, + headers=auth_headers, + ) + # Same weekday two weeks ago: planned but never weighed -> missed. + start = day - dt.timedelta(days=14) + body = client.get( + f"{BASE}/stats/adherence", + params={"from": start.isoformat(), "to": day.isoformat()}, + headers=auth_headers, + ).json() + assert body["from"] == start.isoformat() + assert body["to"] == day.isoformat() + kinds = {item["kind"]: item for item in body["kinds"]} + weigh_in = kinds["weigh_in"] + assert weigh_in["planned_days"] == 3 + assert weigh_in["done_days"] == 1 + assert weigh_in["missed_days"] == 2 + assert weigh_in["adherence_pct"] == 33.3 + assert len(weigh_in["days"]) == 15 + statuses = {row["date"]: row["status"] for row in weigh_in["days"]} + assert statuses[day.isoformat()] == "done" + assert statuses[start.isoformat()] == "missed" + assert statuses[(day - dt.timedelta(days=1)).isoformat()] == "rest" + + filtered = client.get( + f"{BASE}/stats/adherence", + params={"kind": "workout", "from": start.isoformat(), "to": day.isoformat()}, + headers=auth_headers, + ).json() + assert [item["kind"] for item in filtered["kinds"]] == ["workout"] + + +# --- Isolation ------------------------------------------------------------------ + + +def test_data_is_scoped_to_the_owner( + client: TestClient, + db: Session, + auth_headers: dict[str, str], + make_auth_headers: Callable[[int], dict[str, str]], +) -> None: + from app.core.security import hash_password + from app.modules.auth.models import User as UserModel + + other = UserModel( + email="other@lifetrack.local", + password_hash=hash_password("another-strong-password"), + display_name="Autre", + ) + db.add(other) + db.commit() + created = client.post( + f"{BASE}/weights", + json={"measured_at": at(today_paris(), 7), "weight_kg": 92.0}, + headers=auth_headers, + ).json() + other_headers = make_auth_headers(other.id) + assert client.get(f"{BASE}/weights", headers=other_headers).json()["total"] == 0 + assert ( + client.patch( + f"{BASE}/weights/{created['id']}", + json={"weight_kg": 50.0}, + headers=other_headers, + ).status_code + == 404 + ) + + +def test_invalid_sort_field_is_rejected( + client: TestClient, auth_headers: dict[str, str] +) -> None: + response = client.get( + f"{BASE}/weights", params={"sort": "-secret"}, headers=auth_headers + ) + assert response.status_code == 422 + assert "tri" in response.json()["error"]["message"] diff --git a/apps/api/app/tests/modules/test_vape_api.py b/apps/api/app/tests/modules/test_vape_api.py new file mode 100644 index 0000000..b8a8649 --- /dev/null +++ b/apps/api/app/tests/modules/test_vape_api.py @@ -0,0 +1,914 @@ +"""Contract tests of the vape module API (datamodel-health-vape.md §8.4).""" + +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from typing import Any +from zoneinfo import ZoneInfo + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.modules.auth.models import User + +PARIS = ZoneInfo("Europe/Paris") +BASE = "/api/vape" + + +def _today() -> Any: + return datetime.now(PARIS).date() + + +def _day(offset: int) -> str: + return (_today() + timedelta(days=offset)).isoformat() + + +def _settings_payload(**overrides: Any) -> dict[str, Any]: + payload = { + "quit_date": _day(-10), + "cigs_per_day_before": 15, + "cig_pack_price_cents": 1250, + "cigs_per_pack": 20, + "default_nicotine_mg_ml": 6, + } + payload.update(overrides) + return payload + + +def _put_settings(client: TestClient, headers: dict[str, str], **overrides: Any) -> Any: + response = client.put( + f"{BASE}/settings", json=_settings_payload(**overrides), headers=headers + ) + assert response.status_code == 200, response.text + return response.json() + + +def _create_product( + client: TestClient, headers: dict[str, str], **payload: Any +) -> dict[str, Any]: + response = client.post(f"{BASE}/products", json=payload, headers=headers) + assert response.status_code == 201, response.text + return response.json() + + +def _catalog(client: TestClient, headers: dict[str, str]) -> dict[str, dict[str, Any]]: + """Base 1 L / booster 10 ml 20 mg / aroma 30 ml / coil box of 5.""" + return { + "base": _create_product( + client, + headers, + kind="base", + name="Base 50/50 1 L", + price_cents=1200, + size_value=1000, + size_unit="ml", + vg_pct=50, + ), + "booster": _create_product( + client, + headers, + kind="booster", + name="Booster 20 mg", + price_cents=90, + size_value=10, + size_unit="ml", + nicotine_mg_ml=20, + vg_pct=50, + ), + "aroma": _create_product( + client, + headers, + kind="aroma", + name="Arôme fraise", + price_cents=600, + size_value=30, + size_unit="ml", + vg_pct=0, + ), + "coil": _create_product( + client, + headers, + kind="coil", + name="GT Mesh 0,6 Ω", + price_cents=1950, + size_value=5, + size_unit="unit", + ohm="0.6", + ), + } + + +def _create_mix( + client: TestClient, headers: dict[str, str], catalog: dict[str, dict[str, Any]] +) -> dict[str, Any]: + response = client.post( + f"{BASE}/mixes", + json={ + "name": "Fraise 6 mg 50/50", + "total_ml": 260, + "target_nicotine_mg_ml": 6, + "components": [ + {"product_id": catalog["base"]["id"], "quantity": 156}, + {"product_id": catalog["booster"]["id"], "quantity": 78}, + {"product_id": catalog["aroma"]["id"], "quantity": 26}, + ], + }, + headers=headers, + ) + assert response.status_code == 201, response.text + return response.json() + + +# --- Settings --------------------------------------------------------------- + + +def test_settings_404_then_upsert( + client: TestClient, auth_headers: dict[str, str] +) -> None: + missing = client.get(f"{BASE}/settings", headers=auth_headers) + assert missing.status_code == 404 + error = missing.json()["error"] + assert error["code"] == "not_found" + assert error["message"] == "Paramètres vape non configurés." + + created = _put_settings(client, auth_headers) + assert created["cig_pack_price_cents"] == 1250 + assert created["cigs_per_pack"] == 20 + + updated = _put_settings(client, auth_headers, cig_pack_price_cents=1300) + assert updated["id"] == created["id"] # upsert, not a second row + assert updated["cig_pack_price_cents"] == 1300 + + read = client.get(f"{BASE}/settings", headers=auth_headers) + assert read.status_code == 200 + assert read.json()["cig_pack_price_cents"] == 1300 + + +def test_endpoints_require_authentication(client: TestClient) -> None: + for path in ("/settings", "/products", "/dashboard", "/milestones"): + assert client.get(f"{BASE}{path}").status_code == 401 + + +# --- Products --------------------------------------------------------------- + + +def test_products_crud_pagination_and_conflicts( + client: TestClient, auth_headers: dict[str, str] +) -> None: + catalog = _catalog(client, auth_headers) + assert catalog["coil"]["unit_price_cents"] == 390.0 # 19,50 € / 5 + + listing = client.get(f"{BASE}/products", headers=auth_headers).json() + assert listing["total"] == 4 + assert {"items", "total", "page", "page_size"} <= set(listing) + + filtered = client.get(f"{BASE}/products?kind=coil", headers=auth_headers).json() + assert [item["kind"] for item in filtered["items"]] == ["coil"] + + duplicate = client.post( + f"{BASE}/products", + json={ + "kind": "base", + "name": "Base 50/50 1 L", + "price_cents": 1200, + "size_value": 1000, + "size_unit": "ml", + }, + headers=auth_headers, + ) + assert duplicate.status_code == 409 + + booster_without_rate = client.post( + f"{BASE}/products", + json={ + "kind": "booster", + "name": "Booster sans taux", + "price_cents": 90, + "size_value": 10, + "size_unit": "ml", + }, + headers=auth_headers, + ) + assert booster_without_rate.status_code == 422 + + patched = client.patch( + f"{BASE}/products/{catalog['coil']['id']}", + json={"price_cents": 2000, "is_archived": True}, + headers=auth_headers, + ) + assert patched.status_code == 200 + assert patched.json()["unit_price_cents"] == 400.0 + + hidden = client.get(f"{BASE}/products", headers=auth_headers).json() + assert hidden["total"] == 3 + shown = client.get( + f"{BASE}/products?include_archived=true", headers=auth_headers + ).json() + assert shown["total"] == 4 + + unknown_sort = client.get(f"{BASE}/products?sort=secret", headers=auth_headers) + assert unknown_sort.status_code == 422 + + assert ( + client.delete( + f"{BASE}/products/{catalog['aroma']['id']}", headers=auth_headers + ).status_code + == 204 + ) + + +def test_delete_referenced_product_conflicts( + client: TestClient, auth_headers: dict[str, str] +) -> None: + catalog = _catalog(client, auth_headers) + _create_mix(client, auth_headers, catalog) + response = client.delete( + f"{BASE}/products/{catalog['base']['id']}", headers=auth_headers + ) + assert response.status_code == 409 + assert "archivez" in response.json()["error"]["message"] + + +# --- Mixes ------------------------------------------------------------------ + + +def test_mix_cost_nicotine_check_and_activation( + client: TestClient, auth_headers: dict[str, str] +) -> None: + catalog = _catalog(client, auth_headers) + mix = _create_mix(client, auth_headers, catalog) + # 156×1,2 + 78×9 + 26×20 = 1409,2 cents over 260 ml + assert mix["cost_total_cents"] == 1409.2 + assert mix["cost_per_ml_cents"] == 5.42 + assert mix["nicotine_check_mg_ml"] == 6.0 + assert mix["warning"] is None + assert mix["is_active"] is False + assert len(mix["components"]) == 3 + assert mix["components"][1]["cost_cents"] == 702.0 + + activated = client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) + assert activated.status_code == 200 + assert activated.json()["is_active"] is True + + second = _create_mix(client, auth_headers, catalog) + client.patch( + f"{BASE}/mixes/{second['id']}", + json={"name": "Fraise 3 mg", "target_nicotine_mg_ml": 3}, + headers=auth_headers, + ) + activated_second = client.post( + f"{BASE}/mixes/{second['id']}/activate", headers=auth_headers + ).json() + assert activated_second["is_active"] is True + assert activated_second["warning"] == "nicotine_mismatch" # 6 mg/ml for a 3 target + + listing = client.get(f"{BASE}/mixes", headers=auth_headers).json() + assert [item["is_active"] for item in listing["items"]].count(True) == 1 + + +def test_archiving_the_active_mix_deactivates_it( + client: TestClient, auth_headers: dict[str, str] +) -> None: + """An archived recipe is hidden from the lists: it must stop being active, + otherwise it keeps driving cost_per_ml while the UI shows no active mix.""" + _put_settings(client, auth_headers) + catalog = _catalog(client, auth_headers) + mix = _create_mix(client, auth_headers, catalog) + activated = client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) + assert activated.json()["is_active"] is True + + archived = client.patch( + f"{BASE}/mixes/{mix['id']}", json={"is_archived": True}, headers=auth_headers + ) + assert archived.status_code == 200, archived.text + assert archived.json()["is_archived"] is True + assert archived.json()["is_active"] is False + + listing = client.get(f"{BASE}/mixes?include_archived=true", headers=auth_headers) + assert [item["is_active"] for item in listing.json()["items"]].count(True) == 0 + dashboard = client.get(f"{BASE}/dashboard", headers=auth_headers).json() + assert dashboard["cost_per_ml_cents"] is None # no active mix, no purchase + + +def test_mix_component_replacement_and_validation( + client: TestClient, auth_headers: dict[str, str] +) -> None: + catalog = _catalog(client, auth_headers) + mix = _create_mix(client, auth_headers, catalog) + replaced = client.patch( + f"{BASE}/mixes/{mix['id']}", + json={ + "components": [ + {"product_id": catalog["base"]["id"], "quantity": 100}, + {"product_id": catalog["booster"]["id"], "quantity": 30}, + ] + }, + headers=auth_headers, + ) + assert replaced.status_code == 200 + assert len(replaced.json()["components"]) == 2 + + hardware_component = client.patch( + f"{BASE}/mixes/{mix['id']}", + json={"components": [{"product_id": catalog["coil"]["id"], "quantity": 1}]}, + headers=auth_headers, + ) + assert hardware_component.status_code == 422 + + twice = client.patch( + f"{BASE}/mixes/{mix['id']}", + json={ + "components": [ + {"product_id": catalog["base"]["id"], "quantity": 10}, + {"product_id": catalog["base"]["id"], "quantity": 20}, + ] + }, + headers=auth_headers, + ) + assert twice.status_code == 422 + + assert ( + client.delete(f"{BASE}/mixes/{mix['id']}", headers=auth_headers).status_code + == 204 + ) + assert client.get(f"{BASE}/mixes", headers=auth_headers).json()["total"] == 0 + + +def test_mix_calculator_is_stateless( + client: TestClient, auth_headers: dict[str, str] +) -> None: + catalog = _catalog(client, auth_headers) + response = client.post( + f"{BASE}/mixes/calculator", + json={ + "total_ml": 260, + "target_nicotine_mg_ml": 6, + "booster_product_id": catalog["booster"]["id"], + "base_product_id": catalog["base"]["id"], + "aroma_pct": 10, + "aroma_product_id": catalog["aroma"]["id"], + }, + headers=auth_headers, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["booster_ml"] == 78.0 + assert body["aroma_ml"] == 26.0 + assert body["base_ml"] == 156.0 + assert body["nicotine_check_mg_ml"] == 6.0 + assert body["cost_per_ml_cents"] == 5.42 + assert client.get(f"{BASE}/mixes", headers=auth_headers).json()["total"] == 0 + + impossible = client.post( + f"{BASE}/mixes/calculator", + json={ + "total_ml": 100, + "target_nicotine_mg_ml": 18, + "booster_product_id": catalog["booster"]["id"], + "base_product_id": catalog["base"]["id"], + "aroma_pct": 20, + }, + headers=auth_headers, + ) + assert impossible.status_code == 422 + + +# --- Liquid entries --------------------------------------------------------- + + +def test_daily_total_overrides_refills_and_is_unique( + client: TestClient, auth_headers: dict[str, str] +) -> None: + _put_settings(client, auth_headers) + for ml in (4, 3): + created = client.post( + f"{BASE}/liquids", + json={"entry_date": _day(-1), "ml": ml, "kind": "refill"}, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + assert created.json()["nicotine_effective_mg_ml"] == 6.0 + + stats = client.get( + f"{BASE}/stats/consumption?from={_day(-1)}&to={_day(-1)}", headers=auth_headers + ).json() + assert stats["series"][0]["points"] == [[_day(-1), 7.0]] + + total = client.post( + f"{BASE}/liquids", + json={"entry_date": _day(-1), "ml": 9, "kind": "daily_total"}, + headers=auth_headers, + ) + assert total.status_code == 201 + + overridden = client.get( + f"{BASE}/stats/consumption?from={_day(-1)}&to={_day(-1)}", headers=auth_headers + ).json() + assert overridden["series"][0]["points"] == [[_day(-1), 9.0]] + assert overridden["meta"]["tracked_days_ratio"] == 1.0 + + duplicate = client.post( + f"{BASE}/liquids", + json={"entry_date": _day(-1), "ml": 5, "kind": "daily_total"}, + headers=auth_headers, + ) + assert duplicate.status_code == 409 + assert "total quotidien" in duplicate.json()["error"]["message"] + + # the same day of another date is fine + other_day = client.post( + f"{BASE}/liquids", + json={"entry_date": _day(-2), "ml": 5, "kind": "daily_total"}, + headers=auth_headers, + ) + assert other_day.status_code == 201 + + listing = client.get( + f"{BASE}/liquids?kind=daily_total", headers=auth_headers + ).json() + assert listing["total"] == 2 + + patched = client.patch( + f"{BASE}/liquids/{total.json()['id']}", + json={"ml": 11, "nicotine_mg_ml": 3}, + headers=auth_headers, + ) + assert patched.status_code == 200 + assert patched.json()["nicotine_mg"] == 33.0 + + assert ( + client.delete( + f"{BASE}/liquids/{total.json()['id']}", headers=auth_headers + ).status_code + == 204 + ) + assert ( + client.get( + f"{BASE}/liquids/{total.json()['id']}", headers=auth_headers + ).status_code + == 405 + ) + + +def test_liquid_entry_inherits_the_active_mix( + client: TestClient, auth_headers: dict[str, str] +) -> None: + _put_settings(client, auth_headers, default_nicotine_mg_ml=12) + catalog = _catalog(client, auth_headers) + mix = _create_mix(client, auth_headers, catalog) + client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) + entry = client.post( + f"{BASE}/liquids", + json={"entry_date": _day(0), "ml": 4}, + headers=auth_headers, + ).json() + assert entry["mix_id"] == mix["id"] + assert entry["nicotine_effective_mg_ml"] == 6.0 # mix target beats the default + assert entry["nicotine_mg"] == 24.0 + + +# --- Coils ------------------------------------------------------------------ + + +def test_one_click_coil_change_reports_previous_lifespan( + client: TestClient, auth_headers: dict[str, str] +) -> None: + catalog = _catalog(client, auth_headers) + first = client.post( + f"{BASE}/coils", + json={ + "changed_at": (datetime.now(UTC) - timedelta(days=20)).isoformat(), + "product_id": catalog["coil"]["id"], + }, + headers=auth_headers, + ) + assert first.status_code == 201, first.text + assert first.json()["previous_lifespan_days"] is None + assert first.json()["is_current"] is True + + quick = client.post(f"{BASE}/coils/change", headers=auth_headers) + assert quick.status_code == 201, quick.text + body = quick.json() + assert body["previous_lifespan_days"] == pytest.approx(20.0, abs=0.01) + assert body["is_current"] is True + + listing = client.get(f"{BASE}/coils", headers=auth_headers).json() + assert listing["total"] == 2 + finished = next(item for item in listing["items"] if not item["is_current"]) + assert finished["lifespan_days"] == pytest.approx(20.0, abs=0.01) + assert finished["product_name"] == "GT Mesh 0,6 Ω" + + +def test_coil_stats_average_and_amortization( + client: TestClient, auth_headers: dict[str, str] +) -> None: + catalog = _catalog(client, auth_headers) + for offset in (30, 20, 10): + client.post( + f"{BASE}/coils", + json={ + "changed_at": (datetime.now(UTC) - timedelta(days=offset)).isoformat(), + "product_id": catalog["coil"]["id"], + }, + headers=auth_headers, + ) + client.post( + f"{BASE}/liquids", + json={"entry_date": _day(-15), "ml": 6}, + headers=auth_headers, + ) + stats = client.get(f"{BASE}/coils/stats", headers=auth_headers).json() + assert stats["unit"] == "days" + assert stats["meta"]["avg_lifespan_days"] == pytest.approx(10.0, abs=0.01) + assert stats["meta"]["avg_lifespan_is_default"] is False + assert stats["meta"]["coil_unit_price_cents"] == 390.0 + assert stats["meta"]["coil_cost_per_day_cents"] == pytest.approx(39.0, abs=0.05) + assert stats["meta"]["current_coil_age_days"] == pytest.approx(10.0, abs=0.01) + lifespans = stats["series"][0]["points"] + assert len(lifespans) == 3 + # the 6 ml logged 15 days ago belong to the coil installed 20 days ago + ml_through = dict(stats["series"][1]["points"]) + assert ( + ml_through[(datetime.now(UTC) - timedelta(days=20)).date().isoformat()] == 6.0 + ) + + +def test_avg_ml_through_coil_uses_completed_cycles_only( + client: TestClient, auth_headers: dict[str, str] +) -> None: + """§7.3: the running coil (partially used) must not drag the average down, + and only the last COIL_AVG_LAST_N finished cycles are averaged.""" + catalog = _catalog(client, auth_headers) + for offset in (20, 10): + client.post( + f"{BASE}/coils", + json={ + "changed_at": (datetime.now(UTC) - timedelta(days=offset)).isoformat(), + "product_id": catalog["coil"]["id"], + }, + headers=auth_headers, + ) + # 8 ml on the finished cycle, 2 ml on the coil currently in use + for day, ml in ((-15, 8), (-5, 2)): + created = client.post( + f"{BASE}/liquids", + json={"entry_date": _day(day), "ml": ml}, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + + stats = client.get(f"{BASE}/coils/stats", headers=auth_headers).json() + ml_points = dict(stats["series"][1]["points"]) + assert ml_points[(datetime.now(UTC) - timedelta(days=20)).date().isoformat()] == 8.0 + assert ml_points[(datetime.now(UTC) - timedelta(days=10)).date().isoformat()] == 2.0 + assert stats["meta"]["avg_ml_through_coil"] == 8.0 # not (8 + 2) / 2 = 5.0 + + +def test_coil_changed_at_is_stored_in_utc_and_filterable( + client: TestClient, auth_headers: dict[str, str] +) -> None: + local_instant = datetime.now(PARIS) - timedelta(days=2) + created = client.post( + f"{BASE}/coils", + json={"changed_at": local_instant.isoformat()}, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + returned = datetime.fromisoformat(created.json()["changed_at"]) + assert returned.utcoffset() == timedelta(0) # always UTC on the wire + assert returned == local_instant + + day = local_instant.date().isoformat() + listing = client.get(f"{BASE}/coils?from={day}&to={day}", headers=auth_headers) + assert listing.json()["total"] == 1 + empty = client.get( + f"{BASE}/coils?from={_day(-1)}&to={_day(0)}", headers=auth_headers + ) + assert empty.json()["total"] == 0 + + +def test_coil_stats_without_any_change_uses_the_default_lifespan( + client: TestClient, auth_headers: dict[str, str] +) -> None: + stats = client.get(f"{BASE}/coils/stats", headers=auth_headers).json() + assert stats["meta"]["avg_lifespan_days"] == 14.0 + assert stats["meta"]["avg_lifespan_is_default"] is True + assert stats["meta"]["current_coil_age_days"] is None + assert stats["series"][0]["points"] == [] + + +# --- Purchases & savings ---------------------------------------------------- + + +def test_purchases_crud_and_totals( + client: TestClient, auth_headers: dict[str, str] +) -> None: + catalog = _catalog(client, auth_headers) + created = client.post( + f"{BASE}/purchases", + json={ + "purchased_on": _day(-5), + "product_id": catalog["coil"]["id"], + "qty": 2, + }, + headers=auth_headers, + ) + assert created.status_code == 201, created.text + assert created.json()["total_cents"] == 3900.0 # 2 × 19,50 € catalog price + + overridden = client.patch( + f"{BASE}/purchases/{created.json()['id']}", + json={"unit_price_cents": 1500}, + headers=auth_headers, + ) + assert overridden.json()["total_cents"] == 3000.0 + + listing = client.get( + f"{BASE}/purchases?from={_day(-6)}&to={_day(-4)}", headers=auth_headers + ).json() + assert listing["total"] == 1 + assert listing["items"][0]["product_name"] == "GT Mesh 0,6 Ω" + + assert ( + client.delete( + f"{BASE}/purchases/{created.json()['id']}", headers=auth_headers + ).status_code + == 204 + ) + + +def test_savings_theoretical_and_real( + client: TestClient, auth_headers: dict[str, str] +) -> None: + _put_settings(client, auth_headers, quit_date=_day(-4)) + catalog = _catalog(client, auth_headers) + mix = _create_mix(client, auth_headers, catalog) + client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) + for offset in (-4, -3, -2, -1, 0): + client.post( + f"{BASE}/liquids", + json={"entry_date": _day(offset), "ml": 4, "kind": "daily_total"}, + headers=auth_headers, + ) + client.post( + f"{BASE}/purchases", + json={"purchased_on": _day(-4), "product_id": catalog["base"]["id"], "qty": 1}, + headers=auth_headers, + ) + + stats = client.get(f"{BASE}/stats/savings", headers=auth_headers).json() + assert stats["unit"] == "cents" + assert stats["from"] == _day(-4) + assert stats["to"] == _day(0) + names = [series["name"] for series in stats["series"]] + assert names == ["savings_theoretical", "savings_real"] + assert len(stats["series"][0]["points"]) == 5 + + meta = stats["meta"] + # 15 cig/day ÷ 20 × 12,50 € = 937,5 cents/day, frozen since the quit date + assert meta["cig_cost_per_day_cents"] == 937.5 + assert meta["days_since_quit"] == 4 + assert meta["cigarettes_avoided"] == 60 + assert meta["packs_avoided"] == 3.0 + assert meta["time_regained_minutes"] == 660 + assert meta["has_purchases"] is True + + # daily vape cost = 4 ml × 5,42 c + coil amortization (no coil -> 0) + theoretical_points = stats["series"][0]["points"] + assert theoretical_points[0][1] == pytest.approx(-21.68, abs=0.01) + assert theoretical_points[4][1] == pytest.approx(937.5 * 4 - 5 * 21.68, abs=0.05) + real_points = stats["series"][1]["points"] + assert real_points[0][1] == pytest.approx(-1200.0, abs=0.01) + assert real_points[4][1] == pytest.approx(937.5 * 4 - 1200.0, abs=0.05) + + +def test_savings_requires_settings( + client: TestClient, auth_headers: dict[str, str] +) -> None: + for path in ("/stats/savings", "/milestones", "/dashboard"): + response = client.get(f"{BASE}{path}", headers=auth_headers) + assert response.status_code == 404 + assert response.json()["error"]["message"] == "Paramètres vape non configurés." + + +# --- Stats contract --------------------------------------------------------- + + +def test_stats_endpoints_follow_the_series_contract( + client: TestClient, auth_headers: dict[str, str] +) -> None: + _put_settings(client, auth_headers) + catalog = _catalog(client, auth_headers) + mix = _create_mix(client, auth_headers, catalog) + client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) + client.post( + f"{BASE}/liquids", + json={"entry_date": _day(-1), "ml": 4, "kind": "daily_total"}, + headers=auth_headers, + ) + for path in ( + "/stats/consumption", + "/stats/nicotine", + "/stats/costs", + "/stats/savings", + "/coils/stats", + ): + body = client.get(f"{BASE}{path}", headers=auth_headers).json() + assert set(body) == {"from", "to", "unit", "series", "meta"}, path + for series in body["series"]: + assert set(series) == {"name", "type", "points"} + for point in series["points"]: + assert len(point) == 2 + assert isinstance(point[0], str) + assert point[1] is None or isinstance(point[1], (int, float)) + + consumption = client.get( + f"{BASE}/stats/consumption?from={_day(-6)}&to={_day(0)}", headers=auth_headers + ).json() + assert len(consumption["series"][0]["points"]) == 7 + assert consumption["meta"]["ml_per_day_7"] == 4.0 + assert consumption["meta"]["tracked_days_ratio"] == pytest.approx(1 / 7, abs=0.001) + assert consumption["series"][1]["name"] == "ml_ma7" + + nicotine = client.get( + f"{BASE}/stats/nicotine?from={_day(-1)}&to={_day(-1)}", headers=auth_headers + ).json() + assert nicotine["unit"] == "mg" + assert nicotine["series"][0]["points"] == [[_day(-1), 24.0]] # 4 ml × 6 mg/ml + assert nicotine["meta"]["cig_equivalent_per_day"] == 2.0 + assert nicotine["meta"]["trend_status"] in {"down", "stable", "up"} + + costs = client.get(f"{BASE}/stats/costs", headers=auth_headers).json() + assert costs["unit"] == "cents" + assert costs["meta"]["cost_per_ml_cents"] == 5.42 + assert costs["meta"]["cost_per_ml_source"] == "active_mix" + assert costs["meta"]["coil_cost_per_day_cents"] == 0.0 + assert [series["name"] for series in costs["series"]] == [ + "theoretical_cost", + "real_spend", + ] + + invalid = client.get( + f"{BASE}/stats/consumption?from={_day(0)}&to={_day(-5)}", headers=auth_headers + ) + assert invalid.status_code == 422 + + +def test_theoretical_cost_is_not_imputed_before_the_quit_date( + client: TestClient, auth_headers: dict[str, str] +) -> None: + """Mean-imputation fills tracking gaps — it must not invent a vape cost for + the months the user was still smoking (nor for the future).""" + _put_settings(client, auth_headers, quit_date=_day(-3)) + catalog = _catalog(client, auth_headers) + mix = _create_mix(client, auth_headers, catalog) + client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) + client.post( + f"{BASE}/liquids", + json={"entry_date": _day(-1), "ml": 4, "kind": "daily_total"}, + headers=auth_headers, + ) + + # A window that starts well before the quit date and ends in the future + costs = client.get( + f"{BASE}/stats/costs?from={_day(-40)}&to={_day(30)}", headers=auth_headers + ).json() + theoretical = dict(costs["series"][0]["points"]) + total = sum(value or 0.0 for value in theoretical.values()) + # 4 days from the quit date to today: 1 tracked (4 ml) + 3 imputed at 4 ml + assert total == pytest.approx(4 * 4 * 5.42, abs=0.05) + + +def test_savings_do_not_project_into_the_future( + client: TestClient, auth_headers: dict[str, str] +) -> None: + _put_settings(client, auth_headers, quit_date=_day(-2)) + body = client.get( + f"{BASE}/stats/savings?to={_day(60)}", headers=auth_headers + ).json() + assert body["to"] == _day(0) + assert body["series"][0]["points"][-1][0] == _day(0) + # 15 cig/day ÷ 20 × 12,50 € × 2 days, nothing vaped yet + assert body["meta"]["savings_theoretical_cents"] == pytest.approx(937.5 * 2) + + +def test_cost_per_ml_falls_back_to_purchases( + client: TestClient, auth_headers: dict[str, str] +) -> None: + _put_settings(client, auth_headers) + catalog = _catalog(client, auth_headers) + client.post( + f"{BASE}/purchases", + json={"purchased_on": _day(-3), "product_id": catalog["base"]["id"], "qty": 1}, + headers=auth_headers, + ) + costs = client.get(f"{BASE}/stats/costs", headers=auth_headers).json() + # 12,00 € for 1 000 ml -> 1,2 cent/ml, no active mix + assert costs["meta"]["cost_per_ml_cents"] == 1.2 + assert costs["meta"]["cost_per_ml_source"] == "purchases" + + +# --- Milestones & dashboard ------------------------------------------------- + + +def test_milestones_timeline(client: TestClient, auth_headers: dict[str, str]) -> None: + _put_settings(client, auth_headers, quit_date=_day(-20)) + body = client.get(f"{BASE}/milestones", headers=auth_headers).json() + assert len(body) == 12 + assert set(body[0]) == { + "code", + "label_fr", + "reached_at", + "achieved", + "progress_pct", + } + by_code = {item["code"]: item for item in body} + assert by_code["hr_bp_normal"]["achieved"] is True + assert by_code["circulation"]["achieved"] is True + assert by_code["lung_function"]["achieved"] is False + assert by_code["chd_risk_normal"]["label_fr"].startswith("Risque coronarien") + assert 0 < by_code["lung_function"]["progress_pct"] < 100 + + +def test_dashboard_aggregates_the_home_kpis( + client: TestClient, auth_headers: dict[str, str] +) -> None: + _put_settings(client, auth_headers, quit_date=_day(-10)) + catalog = _catalog(client, auth_headers) + mix = _create_mix(client, auth_headers, catalog) + client.post(f"{BASE}/mixes/{mix['id']}/activate", headers=auth_headers) + client.post( + f"{BASE}/liquids", + json={"entry_date": _day(0), "ml": 4, "kind": "daily_total"}, + headers=auth_headers, + ) + client.post( + f"{BASE}/coils", + json={ + "changed_at": (datetime.now(UTC) - timedelta(days=6)).isoformat(), + "product_id": catalog["coil"]["id"], + }, + headers=auth_headers, + ) + + body = client.get(f"{BASE}/dashboard", headers=auth_headers).json() + assert body["quit_date"] == _day(-10) + assert body["days_since_quit"] == 10 + assert body["ml_today"] == 4.0 + assert body["nicotine_today_mg"] == 24.0 + assert body["cost_per_ml_cents"] == 5.42 + assert body["cigarettes_avoided"] == 150 + assert body["packs_avoided"] == 7.5 + assert body["cig_cost_per_day_cents"] == 937.5 + assert body["coil_avg_lifespan_days"] == 14.0 # default: a single change so far + assert body["coil_lifespan_is_default"] is True + assert body["coil_cost_per_day_cents"] == pytest.approx(390 / 14, abs=0.01) + assert body["current_coil_age_days"] == pytest.approx(6.0, abs=0.01) + assert body["has_purchases"] is False + assert body["savings_display_cents"] == body["savings_theoretical_cents"] + assert body["savings_theoretical_cents"] > 0 + assert body["next_milestone"]["code"] == "circulation" # 14 days, quit 10 days ago + + +# --- Multi-user isolation --------------------------------------------------- + + +def test_user_isolation( + client: TestClient, + db: Session, + auth_headers: dict[str, str], + make_auth_headers: Callable[[int], dict[str, str]], +) -> None: + _put_settings(client, auth_headers) + catalog = _catalog(client, auth_headers) + intruder = User( + email="autre@lifetrack.local", + password_hash="x" * 20, + display_name="Autre", + ) + db.add(intruder) + db.commit() + other_headers = make_auth_headers(intruder.id) + + assert client.get(f"{BASE}/settings", headers=other_headers).status_code == 404 + assert client.get(f"{BASE}/products", headers=other_headers).json()["total"] == 0 + assert ( + client.get( + f"{BASE}/products/{catalog['base']['id']}", headers=other_headers + ).status_code + == 405 # no item GET route: the module never exposes another user's row + ) + assert ( + client.patch( + f"{BASE}/products/{catalog['base']['id']}", + json={"name": "Volé"}, + headers=other_headers, + ).status_code + == 404 + ) + assert ( + client.delete( + f"{BASE}/products/{catalog['base']['id']}", headers=other_headers + ).status_code + == 404 + ) diff --git a/apps/api/app/tests/modules/test_vape_calculations.py b/apps/api/app/tests/modules/test_vape_calculations.py new file mode 100644 index 0000000..46aaac5 --- /dev/null +++ b/apps/api/app/tests/modules/test_vape_calculations.py @@ -0,0 +1,316 @@ +"""Unit tests of the pure vape calculations (datamodel-health-vape.md §7).""" + +from datetime import UTC, date, datetime, timedelta +from decimal import Decimal +from zoneinfo import ZoneInfo + +import pytest + +from app.modules.vape import calculations as calc + +PARIS = ZoneInfo("Europe/Paris") + + +def _component( + quantity: str, price_cents: int, size_value: str, **kwargs: object +) -> calc.ComponentInput: + return calc.ComponentInput( + quantity=Decimal(quantity), + price_cents=price_cents, + size_value=Decimal(size_value), + **kwargs, # type: ignore[arg-type] + ) + + +# --- Recipe costing (§6.3, §7.1) ------------------------------------------- + + +def _recipe() -> list[calc.ComponentInput]: + """260 ml at 6 mg/ml: 156 ml base + 78 ml booster (20 mg/ml) + 26 ml aroma.""" + return [ + # 1 L base bottle at 12,00 € -> 1.2 cents/ml + _component("156", 1200, "1000", vg_pct=Decimal(50)), + # 10 ml booster at 0,90 € -> 9 cents/ml + _component("78", 90, "10", nicotine_mg_ml=Decimal(20), vg_pct=Decimal(50)), + # 30 ml aroma at 6,00 € -> 20 cents/ml + _component("26", 600, "30", vg_pct=Decimal(0)), + ] + + +def test_mix_cost_total_and_per_ml_are_exact() -> None: + components = _recipe() + # 156×1.2 + 78×9 + 26×20 = 187.2 + 702 + 520 = 1409.2 cents + assert calc.mix_cost_total_cents(components) == Decimal("1409.2") + per_ml = calc.mix_cost_per_ml_cents(components, Decimal(260)) + assert per_ml == Decimal("1409.2") / Decimal(260) + assert per_ml is not None + assert round(float(per_ml), 4) == 5.4200 + + +def test_mix_cost_per_ml_is_none_without_volume() -> None: + assert calc.mix_cost_per_ml_cents(_recipe(), Decimal(0)) is None + + +def test_nicotine_check_matches_target_and_warns_when_off() -> None: + components = _recipe() + check = calc.mix_nicotine_check_mg_ml(components, Decimal(260)) + assert check == Decimal(6) # 78 ml × 20 mg/ml / 260 ml + assert calc.nicotine_mismatch_warning(Decimal(6), check) is None + assert calc.nicotine_mismatch_warning(Decimal(3), check) == "nicotine_mismatch" + # 10 % tolerance boundary: 6.6 for a 6 mg/ml target is still accepted + assert calc.nicotine_mismatch_warning(Decimal(6), Decimal("6.6")) is None + assert ( + calc.nicotine_mismatch_warning(Decimal(6), Decimal("6.7")) + == "nicotine_mismatch" + ) + + +def test_mix_vg_pct_needs_every_component() -> None: + components = _recipe() + vg = calc.mix_vg_pct(components, Decimal(260)) + assert vg == (Decimal(156) * 50 + Decimal(78) * 50) / Decimal(260) + partial = [*components[:2], _component("26", 600, "30")] + assert calc.mix_vg_pct(partial, Decimal(260)) is None + + +def test_recipe_quantities_assistant() -> None: + result = calc.recipe_quantities(Decimal(260), Decimal(6), Decimal(20), Decimal(10)) + assert result.booster_ml == Decimal(78) + assert result.aroma_ml == Decimal(26) + assert result.base_ml == Decimal(156) + + +def test_recipe_quantities_rejects_impossible_recipes() -> None: + with pytest.raises(ValueError, match="base volume"): + calc.recipe_quantities(Decimal(100), Decimal(18), Decimal(20), Decimal(20)) + with pytest.raises(ValueError, match="booster nicotine"): + calc.recipe_quantities(Decimal(100), Decimal(6), Decimal(0), Decimal(10)) + + +def test_fallback_cost_per_ml_is_volume_weighted() -> None: + pairs = [(Decimal(1200), Decimal(1000)), (Decimal(540), Decimal(60))] + assert calc.fallback_cost_per_ml_cents(pairs) == Decimal(1740) / Decimal(1060) + assert calc.fallback_cost_per_ml_cents([]) is None + + +# --- Daily consumption (§6.4, §7.2) ---------------------------------------- + + +def test_daily_total_overrides_refills() -> None: + entries = [ + ("refill", Decimal(4)), + ("refill", Decimal(3)), + ("daily_total", Decimal(9)), + ] + assert calc.effective_daily_ml(entries) == Decimal(9) + + +def test_refills_are_summed_and_untracked_day_is_none() -> None: + entries = [("refill", Decimal(4)), ("refill", Decimal("3.5"))] + assert calc.effective_daily_ml(entries) == Decimal("7.5") + assert calc.effective_daily_ml([]) is None + + +def test_mean_and_tracked_ratio_ignore_untracked_days() -> None: + values = [Decimal(4), None, Decimal(6), None] + assert calc.mean_ml_per_day(values) == Decimal(5) + assert calc.tracked_days_ratio(values) == 0.5 + assert calc.mean_ml_per_day([None, None]) is None + + +def test_sum_ml_between_excludes_the_end_day() -> None: + daily = { + date(2026, 8, 1): Decimal(4), + date(2026, 8, 2): Decimal(5), + date(2026, 8, 3): Decimal(6), + } + assert calc.sum_ml_between(daily, date(2026, 8, 1), date(2026, 8, 3)) == Decimal(9) + assert calc.sum_ml_between(daily, date(2026, 9, 1), date(2026, 9, 5)) is None + + +# --- Coils (§7.3) ---------------------------------------------------------- + + +def _dt(day: int) -> datetime: + return datetime(2026, 1, day, 12, 0, tzinfo=UTC) + + +def test_coil_intervals_and_average_of_last_five_cycles() -> None: + # changes every 10, 12, 14, 16, 18 and 20 days + starts = [0, 10, 22, 36, 52, 70, 90] + changed = [_dt(1) + timedelta(days=offset) for offset in starts] + intervals = calc.coil_intervals_days(changed) + assert intervals == [10.0, 12.0, 14.0, 16.0, 18.0, 20.0] + avg, is_default = calc.avg_coil_lifespan_days(intervals) + assert avg == 16.0 # mean of the last five: 12, 14, 16, 18, 20 + assert is_default is False + + +def test_coil_average_falls_back_to_default_without_two_changes() -> None: + avg, is_default = calc.avg_coil_lifespan_days([]) + assert avg == calc.DEFAULT_COIL_LIFESPAN_DAYS == 14.0 + assert is_default is True + + +def test_coil_cost_per_day_amortization() -> None: + # 19,50 € box of 5 coils -> 390 cents each, 16-day average lifespan + assert calc.coil_cost_per_day_cents(Decimal(390), 16.0) == 24.375 + assert calc.coil_cost_per_day_cents(None, 16.0) == 0.0 + assert calc.coil_cost_per_day_cents(390, 0.0) == 0.0 + + +# --- Daily cost and cigarette baseline (§7.4, §7.5) ------------------------ + + +def test_vape_cost_per_day_combines_liquid_and_coil() -> None: + assert calc.vape_cost_per_day_cents(Decimal(4), Decimal(5), 24.375) == 44.375 + assert calc.vape_cost_per_day_cents(Decimal(4), None, 24.375) is None + assert calc.vape_cost_per_day_cents(None, Decimal(5), 24.375) is None + + +def test_cig_baseline_per_day() -> None: + # 15 cig/day, 20 per pack, 12,50 € pack -> 9,375 €/day + assert calc.cig_cost_per_day_cents(Decimal(15), 20, 1250) == 937.5 + assert calc.savings_per_day_cents(937.5, 44.375) == 893.125 + assert calc.savings_per_day_cents(937.5, None) is None + + +def test_theoretical_cost_imputes_untracked_days() -> None: + tracked = calc.theoretical_vape_cost_cents(Decimal(4), Decimal(5), Decimal(5), 10.0) + assert tracked == 30.0 + imputed = calc.theoretical_vape_cost_cents(None, Decimal(5), Decimal(5), 10.0) + assert imputed == 35.0 # mean-imputed 5 ml × 5 cents + 10 cents of coil + + +def test_cumulative_theoretical_savings_with_imputation() -> None: + quit_date = date(2026, 1, 1) + today = date(2026, 1, 5) + daily = {date(2026, 1, 1): Decimal(4), date(2026, 1, 3): Decimal(6)} + series = calc.cumulative_savings_theoretical( + quit_date, + today, + daily, + imputed_ml_per_day=Decimal(5), + cost_per_ml_cents=Decimal(5), + coil_cpd_cents=0.0, + cig_cpd_cents=100.0, + ) + # vape costs: 20, 25, 30, 25, 25 (untracked days imputed at 5 ml) + assert [round(value, 3) for _, value in series] == [ + -20.0, + 55.0, + 125.0, + 200.0, + 275.0, + ] + assert [day for day, _ in series] == [date(2026, 1, d) for d in range(1, 6)] + + +def test_cumulative_real_savings_uses_purchases() -> None: + quit_date = date(2026, 1, 1) + spend = {date(2026, 1, 2): 300.0} + series = calc.cumulative_savings_real( + quit_date, date(2026, 1, 4), spend, cig_cpd_cents=100.0 + ) + assert [round(value, 3) for _, value in series] == [0.0, -200.0, -100.0, 0.0] + + +def test_savings_series_is_empty_before_the_quit_date() -> None: + assert ( + calc.cumulative_savings_real(date(2026, 2, 1), date(2026, 1, 1), {}, 100.0) + == [] + ) + + +def test_real_cost_per_day() -> None: + assert calc.real_cost_per_day_cents(3000.0, 30) == 100.0 + assert calc.real_cost_per_day_cents(3000.0, 0) is None + + +# --- Nicotine and avoided cigarettes (§7.6, §7.7) -------------------------- + + +def test_nicotine_of_a_day() -> None: + entries = [(Decimal(4), Decimal(6)), (Decimal(2), Decimal(3))] + assert calc.nicotine_mg_for_day(entries) == 30.0 + assert calc.nicotine_mg_for_day([(Decimal(4), None)]) == 0.0 + assert calc.nicotine_mg_for_day([]) is None + + +def test_cigarette_equivalent_and_counters() -> None: + assert calc.cig_equivalent(24.0) == 2.0 + assert calc.days_since_quit(date(2026, 1, 1), date(2026, 1, 11)) == 10 + assert calc.days_since_quit(date(2026, 2, 1), date(2026, 1, 11)) == 0 + avoided = calc.cigarettes_avoided(10, Decimal("15.5")) + assert avoided == 155 + assert calc.cigarettes_avoided(3, Decimal("15.5")) == 46 # floor(46.5) + assert calc.packs_avoided(155, 20) == 7.75 + assert calc.time_regained_minutes(155) == 155 * 11 + + +# --- Series helpers -------------------------------------------------------- + + +def test_moving_average_skips_gaps() -> None: + values: list[float | None] = [3.0, None, 6.0, 9.0] + assert calc.moving_average(values, 3) == [3.0, 3.0, 4.5, 7.5] + assert calc.moving_average([None, None], 3) == [None, None] + + +def test_regression_slope_and_trend_status() -> None: + points = [(date(2026, 1, 1) + timedelta(days=i), 10.0 - i) for i in range(5)] + slope = calc.regression_slope(points) + assert slope == pytest.approx(-1.0) + assert calc.trend_status(slope) == "down" + assert calc.trend_status(0.0) == "stable" + assert calc.trend_status(None) == "stable" + assert calc.trend_status(0.2) == "up" + assert calc.regression_slope(points[:2]) is None + + +# --- Health milestones (§7.8) --------------------------------------------- + + +def test_twelve_milestones_from_twenty_minutes_to_fifteen_years() -> None: + assert len(calc.MILESTONES) == 12 + assert calc.MILESTONES[0].offset == timedelta(minutes=20) + assert calc.MILESTONES[-1].offset == timedelta(days=15 * 365) + assert calc.MILESTONES[0].code == "hr_bp_normal" + assert calc.MILESTONES[-1].code == "chd_risk_normal" + assert all(m.label_fr and m.label_fr[0].isupper() for m in calc.MILESTONES) + + +def test_milestone_statuses_reached_and_pending() -> None: + quit_date = date(2026, 1, 1) + now = datetime(2026, 1, 20, 12, 0, tzinfo=UTC) # 19 days later + statuses = calc.milestone_statuses(quit_date, PARIS, now) + by_code = {status.code: status for status in statuses} + assert by_code["hr_bp_normal"].achieved is True + assert by_code["circulation"].achieved is True # 14 days + assert by_code["lung_function"].achieved is False # 90 days + assert by_code["lung_function"].progress_pct == pytest.approx( + 100 * (19 + 23 / 24) / 90, abs=0.5 + ) + assert by_code["hr_bp_normal"].progress_pct == 100.0 + # local midnight of the quit day, expressed in UTC (Paris = UTC+1 in January) + assert by_code["hr_bp_normal"].reached_at == datetime( + 2025, 12, 31, 23, 20, tzinfo=UTC + ) + + +def test_next_milestone_is_the_first_pending_one() -> None: + statuses = calc.milestone_statuses( + date(2026, 1, 1), PARIS, datetime(2026, 1, 20, 12, 0, tzinfo=UTC) + ) + upcoming = calc.next_milestone(statuses) + assert upcoming is not None + assert upcoming.code == "lung_function" + assert calc.next_milestone([]) is None + + +def test_progress_is_zero_before_the_quit_date() -> None: + statuses = calc.milestone_statuses( + date(2026, 6, 1), PARIS, datetime(2026, 1, 1, tzinfo=UTC) + ) + assert all(status.progress_pct == 0.0 for status in statuses) + assert all(status.achieved is False for status in statuses) diff --git a/apps/api/app/tests/test_api_contract.py b/apps/api/app/tests/test_api_contract.py new file mode 100644 index 0000000..933f715 --- /dev/null +++ b/apps/api/app/tests/test_api_contract.py @@ -0,0 +1,424 @@ +"""Cross-module API contract tests (architecture §8, CONVENTIONS C6). + +These assert the rules every module must honour, so a regression in one module +is caught even if that module's own suite still passes: the `Z` suffix on every +datetime, the single error envelope, the `Page[T]` envelope on unbounded lists, +the chart-ready `stats/*` shape, and the presence of the dashboards the home +page calls. +""" + +import re +from collections.abc import Callable +from typing import Any + +from fastapi.testclient import TestClient + +from app.core.module_loader import iter_mounted_routes +from app.main import app + +# An ISO datetime whose time part carries no offset at all. +NAIVE_ISO = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$") + +# A JSON string that is really a number — the tell-tale of an unserialised +# `Decimal` (Pydantic renders it as `"6.0"`, not `6.0`). +STRINGIFIED_NUMBER = re.compile(r"^-?\d+\.\d+$") + +STATS_CONTRACT_KEYS = {"from", "to", "unit", "series", "meta"} + + +def _find_strings(node: Any, pattern: re.Pattern[str], path: str = "") -> list[str]: + """Every `path = value` in a JSON tree whose string value matches `pattern`.""" + if isinstance(node, dict): + return [ + f for k, v in node.items() for f in _find_strings(v, pattern, f"{path}.{k}") + ] + if isinstance(node, list): + return [ + f + for i, v in enumerate(node) + for f in _find_strings(v, pattern, f"{path}[{i}]") + ] + if isinstance(node, str) and pattern.match(node): + return [f"{path} = {node}"] + return [] + + +def _naive_datetimes(node: Any, path: str = "") -> list[str]: + return _find_strings(node, NAIVE_ISO, path) + + +# --- Datetimes: UTC ISO 8601 with the `Z` suffix (§8.4) ------------------------ + + +def _seed_every_module(client: TestClient, headers: dict[str, str]) -> None: + """One row in each datetime-bearing table, so read paths return data.""" + response = client.put( + "/api/health/profile", + headers=headers, + json={ + "height_cm": 180.0, + "sex": "male", + "birthdate": "1990-08-13", + "activity_level": "moderate", + "timezone": "Europe/Paris", + }, + ) + assert response.status_code == 200, response.text + + for path, payload in ( + ( + "/api/health/weights", + {"measured_at": "2026-08-13T06:31:00Z", "weight_kg": 90.0}, + ), + ( + "/api/health/measurements", + {"measured_at": "2026-08-13T07:00:00Z", "waist_cm": 90}, + ), + ( + "/api/health/workouts", + { + "started_at": "2026-08-13T18:00:00Z", + "ended_at": "2026-08-13T19:00:00Z", + "sport_type": "running", + }, + ), + ( + "/api/health/nutrition/entries", + { + "eaten_at": "2026-08-13T12:00:00Z", + "name": "Poulet", + "meal": "lunch", + "kcal": 500, + }, + ), + ( + "/api/health/nutrition/water", + {"drunk_at": "2026-08-13T09:00:00Z", "volume_ml": 500}, + ), + ("/api/health/nutrition/favorites", {"name": "Favori", "kcal": 100}), + ("/api/health/activity", {"date": "2026-08-13", "steps": 9000}), + ( + "/api/health/goals", + { + "mode": "weekly_rate", + "start_date": "2026-01-01", + "start_weight_kg": 95, + "target_weight_kg": 80, + "weekly_rate_kg": -0.5, + }, + ), + ("/api/auth/device-keys", {"name": "Pont", "scopes": ["ingest:health"]}), + ("/api/vape/coils", {"changed_at": "2026-08-13T07:00:00Z"}), + ( + "/api/vape/liquids", + {"entry_date": "2026-08-13", "kind": "daily_total", "ml": 4.0}, + ), + ("/api/finance/accounts", {"name": "Compte courant", "kind": "checking"}), + ): + response = client.post(path, headers=headers, json=payload) + assert response.status_code in (200, 201), f"{path}: {response.text}" + + csv = b"date;poids\n2026-08-01;91,2\n" + run = client.post( + "/api/imports", + headers=headers, + files={"file": ("poids.csv", csv, "text/csv")}, + data={"source": "weight_generic_csv"}, + ) + assert run.status_code == 201, run.text + + +def test_no_endpoint_returns_a_naive_datetime( + client: TestClient, auth_headers: dict[str, str] +) -> None: + """Sweeps every parameterless GET for datetimes missing their offset. + + SQLite hands back naive datetimes for `DateTime(timezone=True)` columns, so + an unguarded read path emits `2026-08-13T18:58:59` — which `new Date(...)` + parses as *local* time in the browser, a silent 2 h shift in Paris. + """ + _configure_vape(client, auth_headers) + _seed_every_module(client, auth_headers) + + offenders: list[str] = [] + checked = 0 + for path, operations in app.openapi()["paths"].items(): + if "get" not in operations or "{" in path: + continue + response = client.get(path, headers=auth_headers) + if response.status_code != 200: + continue + checked += 1 + offenders += [f"{path}{field}" for field in _naive_datetimes(response.json())] + + assert offenders == [] + assert checked > 30, checked + + +def test_no_endpoint_returns_a_number_as_a_string( + client: TestClient, auth_headers: dict[str, str] +) -> None: + """`Numeric` columns must reach the frontend as JSON numbers. + + Pydantic serialises a bare `Decimal` as a string, so `4.25` ml would arrive + as `"4.25"` — ECharts plots nothing and `Intl.NumberFormat` throws. Modules + wrap them (`finance.Money`, `vape.Quantity`) or expose floats (`health`). + """ + _configure_vape(client, auth_headers) + _seed_every_module(client, auth_headers) + + offenders: list[str] = [] + for path, operations in app.openapi()["paths"].items(): + if "get" not in operations or "{" in path: + continue + response = client.get(path, headers=auth_headers) + if response.status_code != 200: + continue + offenders += [ + f"{path}{field}" + for field in _find_strings(response.json(), STRINGIFIED_NUMBER) + ] + assert offenders == [] + + +def test_naive_datetime_input_is_refused( + client: TestClient, auth_headers: dict[str, str] +) -> None: + """§8.4: naive input is refused (422) rather than silently read as UTC.""" + response = client.post( + "/api/health/weights", + headers=auth_headers, + json={"measured_at": "2026-08-13T06:31:00", "weight_kg": 90.2}, + ) + assert response.status_code == 422, response.text + assert response.json()["error"]["code"] == "validation_error" + + +def test_offset_datetime_input_is_converted_to_utc( + client: TestClient, auth_headers: dict[str, str] +) -> None: + response = client.post( + "/api/health/weights", + headers=auth_headers, + json={"measured_at": "2026-08-13T08:31:00+02:00", "weight_kg": 90.2}, + ) + assert response.status_code == 201, response.text + assert response.json()["measured_at"] == "2026-08-13T06:31:00Z" + + +# --- Error envelope (§8.3) ---------------------------------------------------- + + +def test_every_module_uses_the_single_error_envelope(client: TestClient) -> None: + for path in ( + "/api/health/weights", + "/api/vape/liquids", + "/api/finance/transactions", + "/api/imports", + ): + response = client.get(path) + assert response.status_code == 401, path + error = response.json()["error"] + assert set(error) == {"code", "message", "details"}, path + assert error["code"] == "unauthorized", path + # French, user-facing message (C1). + assert error["message"].endswith("."), path + + +# --- Home page: one dashboard per module (§7.1 of ux-pages) ------------------- + + +def test_dashboard_endpoints_exist_for_every_business_module() -> None: + paths = {path for path, _ in iter_mounted_routes(app)} + for module in ("health", "vape", "finance"): + assert f"/api/{module}/dashboard" in paths, module + + +def test_health_and_finance_dashboards_work_on_a_blank_account( + client: TestClient, auth_headers: dict[str, str] +) -> None: + """A brand-new user must not get a 500 on the home page.""" + for path in ("/api/health/dashboard", "/api/finance/dashboard"): + response = client.get(path, headers=auth_headers) + assert response.status_code == 200, f"{path}: {response.text}" + + +def test_vape_dashboard_signals_setup_instead_of_failing( + client: TestClient, auth_headers: dict[str, str] +) -> None: + """Vape needs a baseline before any figure means anything (§8.4 of the + datamodel): it answers 404 + `setup_required` so the home page can show the + « Module vape non configuré » empty state of ux-pages §16.""" + response = client.get("/api/vape/dashboard", headers=auth_headers) + assert response.status_code == 404 + error = response.json()["error"] + assert error["code"] == "not_found" + assert error["details"] == {"setup_required": True} + + +# --- Chart-ready stats contract (datamodel-health-vape §8.1) ------------------ + + +def _configure_vape(client: TestClient, headers: dict[str, str]) -> None: + response = client.put( + "/api/vape/settings", + headers=headers, + json={ + "quit_date": "2026-01-01", + "cigs_per_day_before": 20, + "cig_pack_price_cents": 1200, + "cigs_per_pack": 20, + "default_nicotine_mg_ml": 6.0, + "currency": "EUR", + }, + ) + assert response.status_code == 200, response.text + + +def test_health_and_vape_stats_follow_the_series_contract( + client: TestClient, auth_headers: dict[str, str] +) -> None: + _configure_vape(client, auth_headers) + endpoints = ( + "/api/health/weights/stats", + "/api/health/measurements/stats", + "/api/health/activity/stats", + "/api/health/workouts/stats", + "/api/health/nutrition/stats", + "/api/health/nutrition/water/stats", + "/api/health/stats/adherence", + "/api/health/energy-balance", + "/api/vape/stats/consumption", + "/api/vape/stats/nicotine", + "/api/vape/stats/costs", + "/api/vape/stats/savings", + ) + for path in endpoints: + response = client.get(path, headers=auth_headers) + assert response.status_code == 200, f"{path}: {response.text}" + body = response.json() + assert STATS_CONTRACT_KEYS <= set(body), path + assert isinstance(body["meta"], dict), path + for serie in body["series"]: + assert {"name", "type", "points"} <= set(serie), path + for point in serie["points"]: + # [date_iso, value|null] — ready for ECharts `dataset.source`. + assert len(point) == 2, f"{path}/{serie['name']}" + assert isinstance(point[0], str), f"{path}/{serie['name']}" + + +def test_finance_stats_follow_their_documented_shapes( + client: TestClient, auth_headers: dict[str, str] +) -> None: + """datamodel-finance §9.8 defines per-endpoint shapes, not the §8.1 envelope.""" + expected = { + "/api/finance/stats/monthly-by-category": {"months", "series", "totals"}, + "/api/finance/stats/cashflow": { + "months", + "income", + "expenses", + "net", + "cumulative_net", + }, + "/api/finance/stats/top-merchants": {"period", "items"}, + "/api/finance/stats/recurring": {"items", "monthly_total_estimate"}, + "/api/finance/stats/budget-progress": {"month", "items", "totals"}, + "/api/finance/stats/sankey": {"period", "nodes", "links"}, + } + for path, keys in expected.items(): + response = client.get(path, headers=auth_headers) + assert response.status_code == 200, f"{path}: {response.text}" + assert keys <= set(response.json()), path + + +# --- Pagination envelope (C2.8) ---------------------------------------------- + + +def test_unbounded_lists_use_the_page_envelope( + client: TestClient, auth_headers: dict[str, str] +) -> None: + """Growing collections are paginated. Bounded config/selector lists + (`/finance/categories` tree, `/imports/sources`, `/vape/milestones`, + `/health/schedules`, `/finance/rules` — whose `reorder` takes the whole + ordered set) stay plain arrays on purpose.""" + for path in ( + "/api/health/weights", + "/api/health/measurements", + "/api/health/workouts", + "/api/health/nutrition/entries", + "/api/health/nutrition/water", + "/api/health/goals", + "/api/vape/liquids", + "/api/vape/products", + "/api/vape/coils", + "/api/vape/purchases", + "/api/finance/transactions", + "/api/imports", + ): + response = client.get(path, headers=auth_headers) + assert response.status_code == 200, f"{path}: {response.text}" + body = response.json() + assert {"items", "total", "page", "page_size"} <= set(body), path + + +# --- Ingest (§5.5) ------------------------------------------------------------ + + +def test_unknown_ingest_domain_is_404( + client: TestClient, auth_headers: dict[str, str] +) -> None: + response = client.post( + "/api/ingest/unknown-domain", + headers=auth_headers, + json={ + "source": "probe", + "records": [ + { + "type": "weight", + "data": {"measured_at": "2026-08-13T06:31:00Z", "weight_kg": 90.0}, + } + ], + }, + ) + assert response.status_code == 404, response.text + assert response.json()["error"]["code"] == "not_found" + + +# --- Auth boundary (C2.7) ----------------------------------------------------- + + +def test_business_endpoints_reject_anonymous_calls(client: TestClient) -> None: + """Every documented route except the public auth ones and the probes.""" + public = { + "/api/auth/status", + "/api/auth/setup", + "/api/auth/login", + "/api/healthz", + "/healthz", + "/openapi.json", + "/api/openapi.json", + "/api/docs", + } + schema = app.openapi() + unprotected: list[str] = [] + checked = 0 + caller: dict[str, Callable[..., Any]] = { + "get": client.get, + "post": client.post, + "patch": client.patch, + "put": client.put, + "delete": client.delete, + } + for path, operations in schema["paths"].items(): + if path in public or "{" in path: + continue + for verb, call in caller.items(): + if verb not in operations: + continue + response = call(path) + checked += 1 + if response.status_code != 401: + unprotected.append(f"{verb.upper()} {path} -> {response.status_code}") + assert unprotected == [] + # Guard against the sweep silently becoming vacuous. + assert checked > 50, checked diff --git a/apps/api/app/tests/test_auth.py b/apps/api/app/tests/test_auth.py new file mode 100644 index 0000000..a1bafb1 --- /dev/null +++ b/apps/api/app/tests/test_auth.py @@ -0,0 +1,149 @@ +from fastapi.testclient import TestClient + +from app.tests.conftest import TEST_USER_EMAIL, TEST_USER_PASSWORD + + +def test_status_reports_setup_required_then_not(client: TestClient) -> None: + res = client.get("/api/auth/status") + assert res.status_code == 200 + body = res.json() + assert body["setup_required"] is True + assert body["needs_setup"] is True + + res = client.post( + "/api/auth/setup", + json={ + "email": "first@lifetrack.local", + "password": "long-enough-pw", + "display_name": "Premier", + }, + ) + assert res.status_code == 201 + body = res.json() + assert body["token_type"] == "bearer" + assert body["access_token"] + assert body["user"]["email"] == "first@lifetrack.local" + + res = client.get("/api/auth/status") + assert res.json()["setup_required"] is False + + +def test_setup_forbidden_once_a_user_exists(client: TestClient, user) -> None: + res = client.post( + "/api/auth/setup", + json={ + "email": "other@lifetrack.local", + "password": "long-enough-pw", + "display_name": "Intrus", + }, + ) + assert res.status_code == 403 + body = res.json() + assert body["error"]["code"] == "forbidden" + assert body["error"]["message"] + + +def test_login_ok_and_wrong_password(client: TestClient, user) -> None: + res = client.post( + "/api/auth/login", + json={"email": TEST_USER_EMAIL, "password": TEST_USER_PASSWORD}, + ) + assert res.status_code == 200 + assert res.json()["access_token"] + + res = client.post( + "/api/auth/login", + json={"email": TEST_USER_EMAIL, "password": "wrong-password"}, + ) + assert res.status_code == 401 + assert res.json()["error"]["code"] == "unauthorized" + + +def test_me_requires_auth(client: TestClient, user, auth_headers) -> None: + res = client.get("/api/auth/me") + assert res.status_code == 401 + assert res.json()["error"]["code"] == "unauthorized" + + res = client.get("/api/auth/me", headers=auth_headers) + assert res.status_code == 200 + body = res.json() + assert body["email"] == TEST_USER_EMAIL + assert "password_hash" not in body + + +def test_patch_me_password_change_requires_current( + client: TestClient, user, auth_headers +) -> None: + res = client.patch( + "/api/auth/me", + headers=auth_headers, + json={"password": "new-password-123"}, + ) + assert res.status_code == 422 + + res = client.patch( + "/api/auth/me", + headers=auth_headers, + json={ + "password": "new-password-123", + "current_password": TEST_USER_PASSWORD, + "display_name": "Renommé", + }, + ) + assert res.status_code == 200 + assert res.json()["display_name"] == "Renommé" + + res = client.post( + "/api/auth/login", + json={"email": TEST_USER_EMAIL, "password": "new-password-123"}, + ) + assert res.status_code == 200 + + +def test_device_key_lifecycle(client: TestClient, user, auth_headers) -> None: + # Create: plaintext key returned exactly once, shaped ltk__. + res = client.post( + "/api/auth/device-keys", + headers=auth_headers, + json={"name": "Pixel 8 – pont Health Connect", "scopes": ["ingest:health"]}, + ) + assert res.status_code == 201 + created = res.json() + key = created["key"] + assert key.startswith("ltk_") + prefix = key.split("_", 2)[1] + assert len(prefix) == 8 + assert created["key_prefix"] == prefix + assert created["scopes"] == ["ingest:health"] + + # List never exposes the plaintext key. + res = client.get("/api/auth/device-keys", headers=auth_headers) + assert res.status_code == 200 + keys = res.json() + assert len(keys) == 1 + assert "key" not in keys[0] + assert keys[0]["key_prefix"] == prefix + assert keys[0]["revoked_at"] is None + + # Revoke (DELETE keeps the row, sets revoked_at). + res = client.delete(f"/api/auth/device-keys/{created['id']}", headers=auth_headers) + assert res.status_code == 204 + res = client.get("/api/auth/device-keys", headers=auth_headers) + assert res.json()[0]["revoked_at"] is not None + + +def test_device_key_invalid_scope_rejected( + client: TestClient, user, auth_headers +) -> None: + res = client.post( + "/api/auth/device-keys", + headers=auth_headers, + json={"name": "Mauvaise clé", "scopes": ["admin:*"]}, + ) + assert res.status_code == 422 + + +def test_error_shape_on_unknown_route(client: TestClient) -> None: + res = client.get("/api/definitely-not-a-route") + assert res.status_code == 404 + assert set(res.json()["error"].keys()) == {"code", "message", "details"} diff --git a/apps/api/app/tests/test_imports.py b/apps/api/app/tests/test_imports.py new file mode 100644 index 0000000..c694e72 --- /dev/null +++ b/apps/api/app/tests/test_imports.py @@ -0,0 +1,368 @@ +"""Contract tests for the imports/ingest framework. + +They use a self-contained sample domain (model + importer + ingest handler +defined below) so they do not depend on any business module. +""" + +from collections.abc import Iterator +from io import BytesIO + +from fastapi.testclient import TestClient +from sqlalchemy import ForeignKey, String, UniqueConstraint, select +from sqlalchemy.orm import Mapped, Session, mapped_column + +from app.core.database import Base +from app.core.importing.base import ( + BaseImporter, + ImporterParseError, + NormalizedRecord, + RowError, + UpsertOutcome, +) +from app.core.importing.hashing import content_hash +from app.core.importing.registry import IMPORTER_REGISTRY, register_importer +from app.core.ingest.base import BaseIngestHandler, IngestRecord +from app.core.ingest.registry import INGEST_REGISTRY, register_ingest_handler +from app.core.mixins import SourceMixin, TimestampMixin +from app.modules.auth import service as auth_service +from app.modules.auth.schemas import DeviceKeyCreate + + +class SampleItem(TimestampMixin, SourceMixin, Base): + __tablename__ = "test_sample_items" + __table_args__ = ( + UniqueConstraint( + "user_id", "source", "external_id", name="uq_test_sample_items_external" + ), + UniqueConstraint("user_id", "content_hash", name="uq_test_sample_items_hash"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + import_run_id: Mapped[int | None] = mapped_column( + ForeignKey("import_runs.id", ondelete="CASCADE"), default=None + ) + label: Mapped[str] = mapped_column(String(100)) + value: Mapped[float] = mapped_column() + + +def _upsert_sample( + db: Session, + user_id: int, + record: NormalizedRecord, + source: str, + import_run_id: int | None, +) -> UpsertOutcome: + if record.data["value"] < 0: + raise RowError("Valeur négative interdite.") + if record.external_id is not None: + digest = None + existing = db.scalar( + select(SampleItem).where( + SampleItem.user_id == user_id, + SampleItem.source == source, + SampleItem.external_id == record.external_id, + ) + ) + else: + digest = content_hash(record) + existing = db.scalar( + select(SampleItem).where( + SampleItem.user_id == user_id, + SampleItem.content_hash == digest, + ) + ) + if existing is not None: + return UpsertOutcome.DUPLICATE + db.add( + SampleItem( + user_id=user_id, + source=source, + external_id=record.external_id, + content_hash=digest, + import_run_id=import_run_id, + label=record.data["label"], + value=record.data["value"], + ) + ) + db.flush() + return UpsertOutcome.INSERTED + + +if "test_sample_csv" not in IMPORTER_REGISTRY: + + @register_importer + class SampleCsvImporter(BaseImporter): + id = "test_sample_csv" + label = "Échantillon de test (CSV)" + domain = "sample" + accepted_extensions = (".csv",) + + @classmethod + def sniff(cls, filename: str, head: bytes) -> bool: + return filename.lower().endswith(".csv") and head.startswith(b"label;value") + + def parse(self, data: bytes, filename: str) -> Iterator[NormalizedRecord]: + lines = [ln for ln in data.decode("utf-8").splitlines() if ln.strip()] + if not lines or not lines[0].startswith("label;value"): + raise ImporterParseError("En-têtes introuvables dans le fichier.") + for line in lines[1:]: + label, raw_value, external_id = line.split(";") + yield NormalizedRecord( + kind="sample", + data={"label": label, "value": float(raw_value.replace(",", "."))}, + external_id=external_id or None, + dedupe_fields=("label", "value"), + ) + + def upsert( + self, db: Session, user_id: int, record: NormalizedRecord + ) -> UpsertOutcome: + return _upsert_sample(db, user_id, record, self.id, self.import_run_id) + + +if "sample" not in INGEST_REGISTRY: + + @register_ingest_handler + class SampleIngestHandler(BaseIngestHandler): + domain = "sample" + record_types = ("sample",) + + def apply( + self, db: Session, user_id: int, record: IngestRecord + ) -> UpsertOutcome: + normalized = NormalizedRecord( + kind=record.type, + data=record.data, + external_id=record.external_id, + dedupe_fields=("label", "value"), + ) + return _upsert_sample(db, user_id, normalized, record.source, None) + + +CSV_OK = "label;value;ext\nCafé;1,5;a1\nThé;2;a2\nJus;3;\n" + + +def _upload(client: TestClient, headers: dict[str, str], content: str, source: str): + return client.post( + "/api/imports", + headers=headers, + files={"file": ("sample.csv", BytesIO(content.encode("utf-8")), "text/csv")}, + data={"source": source}, + ) + + +def test_sources_endpoint_lists_importers( + client: TestClient, user, auth_headers +) -> None: + res = client.get("/api/imports/sources", headers=auth_headers) + assert res.status_code == 200 + by_id = {src["id"]: src for src in res.json()} + assert by_id["test_sample_csv"]["label"] == "Échantillon de test (CSV)" + assert by_id["test_sample_csv"]["domain"] == "sample" + assert by_id["test_sample_csv"]["accepted_extensions"] == [".csv"] + + +def test_import_then_reimport_is_idempotent( + client: TestClient, db: Session, user, auth_headers +) -> None: + res = _upload(client, auth_headers, CSV_OK, "test_sample_csv") + assert res.status_code == 201 + run = res.json() + assert run["status"] == "completed" + assert run["rows_total"] == 3 + assert run["rows_inserted"] == 3 + assert run["rows_duplicates"] == 0 + + # Same file again: everything deduped (external_id + content_hash paths). + res = _upload(client, auth_headers, CSV_OK, "test_sample_csv") + assert res.status_code == 201 + rerun = res.json() + assert rerun["rows_inserted"] == 0 + assert rerun["rows_duplicates"] == 3 + + assert db.scalar(select(SampleItem.id).limit(1)) is not None + rows = db.scalars(select(SampleItem)).all() + assert len(rows) == 3 + assert all(row.import_run_id == run["id"] for row in rows) + + +def test_auto_sniff_detects_and_rejects(client: TestClient, user, auth_headers) -> None: + res = _upload(client, auth_headers, CSV_OK, "auto") + assert res.status_code == 201 + assert res.json()["importer_id"] == "test_sample_csv" + + res = _upload(client, auth_headers, "something;else\n1;2\n", "auto") + assert res.status_code == 422 + assert ( + res.json()["error"]["message"] + == "Format non reconnu, choisissez un profil de source." + ) + + +def test_unknown_source_rejected(client: TestClient, user, auth_headers) -> None: + res = _upload(client, auth_headers, CSV_OK, "nope_csv") + assert res.status_code == 422 + assert res.json()["error"]["code"] == "validation_error" + + +def test_row_errors_do_not_stop_the_run(client: TestClient, user, auth_headers) -> None: + csv = "label;value;ext\nCafé;1,5;b1\nMauvais;-4;b2\nThé;2;b3\n" + res = _upload(client, auth_headers, csv, "test_sample_csv") + assert res.status_code == 201 + run = res.json() + assert run["status"] == "completed" + assert run["rows_total"] == 3 + assert run["rows_inserted"] == 2 + assert run["rows_errors"] == 1 + assert run["error_details"][0]["row"] == 2 + assert "négative" in run["error_details"][0]["message"] + + +def test_fatal_parse_error_marks_run_failed( + client: TestClient, db: Session, user, auth_headers +) -> None: + res = _upload(client, auth_headers, "garbage without headers\n", "test_sample_csv") + assert res.status_code == 201 + run = res.json() + assert run["status"] == "failed" + assert run["error_details"] + # No partial domain rows are kept for a failed run. + assert db.scalars(select(SampleItem)).all() == [] + + +def test_history_pagination_and_filters(client: TestClient, user, auth_headers) -> None: + _upload(client, auth_headers, CSV_OK, "test_sample_csv") + _upload(client, auth_headers, CSV_OK, "test_sample_csv") + + res = client.get("/api/imports", headers=auth_headers) + assert res.status_code == 200 + page = res.json() + assert set(page.keys()) == {"items", "total", "page", "page_size"} + assert page["total"] == 2 + + res = client.get("/api/imports?domain=finance", headers=auth_headers) + assert res.json()["total"] == 0 + + res = client.get("/api/imports?sort=bogus", headers=auth_headers) + assert res.status_code == 422 + + run_id = page["items"][0]["id"] + res = client.get(f"/api/imports/{run_id}", headers=auth_headers) + assert res.status_code == 200 + assert res.json()["id"] == run_id + + +def test_rollback_deletes_run_and_cascades( + client: TestClient, db: Session, user, auth_headers +) -> None: + run_id = _upload(client, auth_headers, CSV_OK, "test_sample_csv").json()["id"] + assert len(db.scalars(select(SampleItem)).all()) == 3 + + res = client.delete(f"/api/imports/{run_id}", headers=auth_headers) + assert res.status_code == 204 + + db.expire_all() + assert db.scalars(select(SampleItem)).all() == [] + res = client.get(f"/api/imports/{run_id}", headers=auth_headers) + assert res.status_code == 404 + assert res.json()["error"]["code"] == "not_found" + + +def test_upload_too_large_rejected( + client: TestClient, user, auth_headers, monkeypatch +) -> None: + from app.core.config import get_settings + + monkeypatch.setattr(get_settings(), "max_upload_bytes", 10) + res = _upload(client, auth_headers, CSV_OK, "test_sample_csv") + assert res.status_code == 413 + assert res.json()["error"]["code"] == "payload_too_large" + + +def test_ingest_with_device_key_and_dedup(client: TestClient, user, device_key) -> None: + payload = { + "source": "android_bridge", + "records": [ + {"type": "sample", "external_id": "x1", "data": {"label": "A", "value": 1}}, + {"type": "sample", "data": {"label": "B", "value": 2}}, + ], + } + res = client.post("/api/ingest/sample", headers=device_key.headers, json=payload) + assert res.status_code == 200 + body = res.json() + assert body == { + "domain": "sample", + "received": 2, + "inserted": 2, + "updated": 0, + "duplicates": 0, + "errors": [], + } + + res = client.post("/api/ingest/sample", headers=device_key.headers, json=payload) + assert res.json()["duplicates"] == 2 + + +def test_ingest_works_with_jwt_too(client: TestClient, user, auth_headers) -> None: + payload = {"records": [{"type": "sample", "data": {"label": "J", "value": 9}}]} + res = client.post("/api/ingest/sample", headers=auth_headers, json=payload) + assert res.status_code == 200 + assert res.json()["inserted"] == 1 + + +def test_ingest_auth_and_scope_rules( + client: TestClient, db: Session, user, device_key +) -> None: + payload = {"records": [{"type": "sample", "data": {"label": "S", "value": 4}}]} + + # No credentials at all -> 401. + res = client.post("/api/ingest/sample", json=payload) + assert res.status_code == 401 + + # Key scoped to another domain -> 403. + _, other_plaintext = auth_service.create_device_key( + db, user.id, DeviceKeyCreate(name="Autre", scopes=["ingest:health"]) + ) + res = client.post( + "/api/ingest/sample", headers={"X-API-Key": other_plaintext}, json=payload + ) + assert res.status_code == 403 + + # Revoked wildcard key -> 401. + auth_service.revoke_device_key(db, user.id, device_key.id) + res = client.post("/api/ingest/sample", headers=device_key.headers, json=payload) + assert res.status_code == 401 + + +def test_ingest_unknown_domain_and_bad_type( + client: TestClient, user, device_key +) -> None: + payload = {"records": [{"type": "sample", "data": {"label": "D", "value": 1}}]} + res = client.post("/api/ingest/nope", headers=device_key.headers, json=payload) + assert res.status_code == 404 + + payload = { + "records": [ + {"type": "sample", "data": {"label": "OK", "value": 1}}, + {"type": "unknown_type", "data": {}}, + {"type": "sample", "data": {"label": "KO", "value": -1}}, + ] + } + res = client.post("/api/ingest/sample", headers=device_key.headers, json=payload) + assert res.status_code == 200 + body = res.json() + assert body["received"] == 3 + assert body["inserted"] == 1 + assert {err["index"] for err in body["errors"]} == {1, 2} + + +def test_ingest_record_limit(client: TestClient, user, device_key) -> None: + records = [ + {"type": "sample", "data": {"label": f"r{i}", "value": i}} for i in range(1001) + ] + res = client.post( + "/api/ingest/sample", headers=device_key.headers, json={"records": records} + ) + assert res.status_code == 422 + assert res.json()["error"]["code"] == "validation_error" diff --git a/apps/api/app/tests/test_module_loader.py b/apps/api/app/tests/test_module_loader.py new file mode 100644 index 0000000..276bddc --- /dev/null +++ b/apps/api/app/tests/test_module_loader.py @@ -0,0 +1,84 @@ +import pytest +from fastapi.testclient import TestClient + +from app.core.module_loader import iter_module_names, iter_mounted_routes +from app.main import app + + +def test_discovers_auth_and_imports(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LIFETRACK_MODULES", raising=False) + names = iter_module_names() + assert "auth" in names + assert "imports" in names + assert names == sorted(names) + + +def test_whitelist_env_var_filters_modules(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LIFETRACK_MODULES", "auth") + assert iter_module_names() == ["auth"] + + monkeypatch.setenv("LIFETRACK_MODULES", " auth , imports ,") + assert iter_module_names() == ["auth", "imports"] + + # Unknown names are simply ignored (the folder does not exist). + monkeypatch.setenv("LIFETRACK_MODULES", "auth,nonexistent") + assert iter_module_names() == ["auth"] + + +def test_empty_whitelist_means_discover_all(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LIFETRACK_MODULES", " ") + names = iter_module_names() + assert "auth" in names + assert "imports" in names + + +def test_routers_mounted_under_api_prefix() -> None: + paths = set(app.openapi()["paths"]) + assert "/api/auth/login" in paths + assert "/api/imports" in paths + # extra_routers support: the imports module also serves /api/ingest/{domain}. + assert "/api/ingest/{domain}" in paths + + +def test_every_module_router_is_mounted_on_app_routes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Route inventory read from `app.routes`, not from the OpenAPI schema. + + Guards the `_IncludedRouter` unwrapping of `iter_mounted_routes`: a plain + `app.routes` scan sees only the three healthz/openapi aliases. + """ + monkeypatch.delenv("LIFETRACK_MODULES", raising=False) + paths = {path for path, _ in iter_mounted_routes(app)} + + assert "/healthz" in paths + for module in ("auth", "health", "vape", "finance", "imports"): + assert any(p.startswith(f"/api/{module}") for p in paths), module + assert "/api/ingest/{domain}" in paths + + # Methods survive the walk (used by contract tooling). + methods = dict(iter_mounted_routes(app)) + assert "POST" in methods["/api/auth/login"] + + # Same inventory as the OpenAPI schema (minus the hidden probe aliases). + documented = set(app.openapi()["paths"]) + assert documented <= paths + + +def test_openapi_lists_module_routes(client: TestClient) -> None: + res = client.get("/api/openapi.json") + assert res.status_code == 200 + paths = res.json()["paths"] + assert "/api/auth/login" in paths + assert "/api/imports" in paths + + # Bare alias for orchestrator probes. + res = client.get("/openapi.json") + assert res.status_code == 200 + assert "/api/auth/login" in res.json()["paths"] + + +def test_healthz(client: TestClient) -> None: + assert client.get("/api/healthz").status_code == 200 + assert client.get("/healthz").status_code == 200 + assert client.get("/healthz").json() == {"status": "ok"} diff --git a/apps/api/app/tests/test_postgres_ddl.py b/apps/api/app/tests/test_postgres_ddl.py new file mode 100644 index 0000000..65c06aa --- /dev/null +++ b/apps/api/app/tests/test_postgres_ddl.py @@ -0,0 +1,348 @@ +"""Offline PostgreSQL DDL validation. + +The pytest suite runs on SQLite (CONVENTIONS C8) and the production engine is +PostgreSQL 16: without this file the PostgreSQL DDL of the whole schema would +never be exercised at all until the first `docker compose up`. + +Nothing here needs a server: every table, index and constraint of the *complete* +metadata (all modules loaded, `LIFETRACK_MODULES` ignored) is compiled with the +`postgresql` dialect, and the resulting statements are inspected for the traps +that SQLite silently forgives: + +* native `ENUM` types (C8.1) — must stay VARCHAR + CHECK; +* `sa.Uuid` / JSON variants (C8.2, C8.3) — must become UUID / JSONB; +* partial unique indexes (C8.4) — must carry their `postgresql_where`; +* FK to `import_runs.id` (C8.5) — must cascade, the import rollback depends on it; +* identifiers over 63 characters, which PostgreSQL truncates *silently*, and + index/constraint names that collide once truncated or once put in the single + per-schema namespace PostgreSQL uses for them; +* reserved words used as table or column names. +""" + +import os +import re + +import pytest +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy.engine import Dialect +from sqlalchemy.schema import AddConstraint, CreateIndex, CreateTable + +from app.core.database import Base +from app.core.module_loader import import_side_modules + + +def _load_every_module() -> None: + """Populate the metadata with every module, whatever LIFETRACK_MODULES says.""" + previous = os.environ.pop("LIFETRACK_MODULES", None) + try: + import_side_modules() + finally: + if previous is not None: + os.environ["LIFETRACK_MODULES"] = previous + + +_load_every_module() + +METADATA = Base.metadata +PG: Dialect = postgresql.dialect() + +#: PostgreSQL truncates any identifier longer than NAMEDATALEN-1 without a word. +MAX_IDENTIFIER = 63 + +#: Reserved key words of PostgreSQL 16 (appendix C, "reserved" column only): +#: those cannot name a table or a column without double quotes. +_PG_RESERVED_SOURCE = """ + all analyse analyze and any array as asc asymmetric both case cast check + collate column constraint create current_catalog current_date current_role + current_time current_timestamp current_user default deferrable desc distinct + do else end except false fetch for foreign from grant group having in + initially intersect into lateral leading limit localtime localtimestamp not + null offset on only or order placing primary references returning select + session_user some symmetric table then to trailing true union unique user + using variadic when where window with +""" +PG_RESERVED_WORDS = frozenset(_PG_RESERVED_SOURCE.split()) + + +def _tables() -> list[sa.Table]: + return list(METADATA.sorted_tables) + + +def _columns() -> list[tuple[sa.Table, sa.Column]]: + return [(table, column) for table in _tables() for column in table.columns] + + +def _implicit_constraint_name(table: sa.Table, constraint: sa.Constraint) -> str: + """Name PostgreSQL generates for a constraint the models left unnamed.""" + columns = "_".join(column.name for column in constraint.columns) + if isinstance(constraint, sa.PrimaryKeyConstraint): + return f"{table.name}_pkey" + if isinstance(constraint, sa.ForeignKeyConstraint): + return f"{table.name}_{columns}_fkey" + if isinstance(constraint, sa.UniqueConstraint): + return f"{table.name}_{columns}_key" + return f"{table.name}_{columns}_check" + + +# --------------------------------------------------------------------------- +# 1. Everything compiles +# --------------------------------------------------------------------------- + + +def test_the_metadata_holds_every_module_table() -> None: + names = set(METADATA.tables) + assert {"users", "import_runs"} <= names # core + assert {"weight_entries", "workouts", "food_entries"} <= names # health + assert {"products", "mixes", "liquid_entries"} <= names # vape + assert {"fin_accounts", "fin_transactions", "fin_rules"} <= names # finance + assert len(names) >= 28 + + +def test_every_table_compiles_to_postgresql_ddl() -> None: + for table in _tables(): + statement = str(CreateTable(table).compile(dialect=PG)) + assert statement.lstrip().startswith("CREATE TABLE"), table.name + assert table.name in statement + + +def test_every_index_compiles_to_postgresql_ddl() -> None: + count = 0 + for table in _tables(): + for index in table.indexes: + statement = str(CreateIndex(index).compile(dialect=PG)) + assert "CREATE " in statement and "INDEX" in statement, index.name + count += 1 + assert count >= 50 + + +def test_every_constraint_compiles_to_postgresql_ddl() -> None: + for table in _tables(): + for constraint in table.constraints: + # isolate_from_table=False: the default detaches the constraint from + # its CREATE TABLE (it is meant for ALTER-based schemas) and would + # silently strip it from the DDL every later test compiles. + statement = str( + AddConstraint(constraint, isolate_from_table=False).compile(dialect=PG) + ) + assert statement.startswith(f"ALTER TABLE {table.name} ADD") + + +def test_create_all_emits_the_whole_schema_on_postgresql() -> None: + """`create_all` (the v1 migration strategy, §9) against a mock PG engine.""" + emitted: list[str] = [] + + def executor(sql: sa.ClauseElement, *args: object, **kwargs: object) -> None: + emitted.append(str(sql.compile(dialect=engine.dialect))) + + engine = sa.create_mock_engine("postgresql+psycopg://", executor) + METADATA.create_all(engine, checkfirst=False) + + tables = len(METADATA.tables) + indexes = sum(len(table.indexes) for table in _tables()) + # tables + indexes + the pg_trgm hook of datamodel-finance §2.5 + assert len(emitted) == tables + indexes + 1 + assert sum(s.lstrip().startswith("CREATE TABLE") for s in emitted) == tables + assert not any("CREATE TYPE" in s for s in emitted) # no native enum (C8.1) + + +# --------------------------------------------------------------------------- +# 2. Column types +# --------------------------------------------------------------------------- + + +def test_enum_columns_are_varchar_with_a_check_constraint() -> None: + """C8.1: `native_enum=False` + `create_constraint=True` -> VARCHAR + CHECK.""" + enum_columns = [ + (table, column) + for table, column in _columns() + if isinstance(column.type, sa.Enum) + ] + assert len(enum_columns) >= 16 + for table, column in enum_columns: + enum_type: sa.Enum = column.type + assert enum_type.native_enum is False, f"{table.name}.{column.name}" + assert enum_type.create_constraint is True, f"{table.name}.{column.name}" + assert column.type.compile(dialect=PG).startswith("VARCHAR") + ddl = str(CreateTable(table).compile(dialect=PG)) + assert f"CHECK ({column.name} IN (" in ddl, f"{table.name}.{column.name}" + + +def test_uuid_columns_use_the_native_postgresql_uuid_type() -> None: + uuid_columns = [ + (table, column) for table, column in _columns() if isinstance(column.type, sa.Uuid) + ] + assert len(uuid_columns) >= 15 + for table, column in uuid_columns: + assert column.type.compile(dialect=PG) == "UUID", f"{table.name}.{column.name}" + + +def test_json_columns_compile_to_jsonb() -> None: + """C8.3: JSON on SQLite, JSONB on PostgreSQL — always through JSONB_V.""" + json_columns = [ + (table, column) + for table, column in _columns() + if isinstance(column.type, sa.JSON) + ] + assert len(json_columns) >= 12 + for table, column in json_columns: + assert column.type.compile(dialect=PG) == "JSONB", f"{table.name}.{column.name}" + + +def test_every_datetime_column_is_timestamptz() -> None: + """C2.3: instants are stored in UTC in a `timestamptz`, never a naive one.""" + for table, column in _columns(): + compiled = column.type.compile(dialect=PG) + if not compiled.startswith("TIMESTAMP"): + continue + assert compiled == "TIMESTAMP WITH TIME ZONE", f"{table.name}.{column.name}" + + +def test_numeric_columns_declare_precision_and_scale() -> None: + """A bare NUMERIC on PostgreSQL stores any precision: money would drift.""" + for table, column in _columns(): + if not isinstance(column.type, sa.Numeric) or isinstance( + column.type, sa.Float + ): + continue + assert column.type.precision is not None, f"{table.name}.{column.name}" + assert column.type.scale is not None, f"{table.name}.{column.name}" + assert re.fullmatch( + r"NUMERIC\(\d+, \d+\)", column.type.compile(dialect=PG) + ), f"{table.name}.{column.name}" + + +# --------------------------------------------------------------------------- +# 3. Indexes and constraints +# --------------------------------------------------------------------------- + + +def test_partial_indexes_carry_both_dialect_where_clauses() -> None: + """C8.4: `sqlite_where` AND `postgresql_where`, same expression.""" + partial = [ + (table, index) + for table in _tables() + for index in table.indexes + if index.dialect_options["postgresql"]._non_defaults.get("where") is not None + or index.dialect_options["sqlite"]._non_defaults.get("where") is not None + ] + assert len(partial) >= 5 + for table, index in partial: + pg_where = index.dialect_options["postgresql"]._non_defaults.get("where") + sqlite_where = index.dialect_options["sqlite"]._non_defaults.get("where") + assert pg_where is not None, f"{index.name}: missing postgresql_where" + assert sqlite_where is not None, f"{index.name}: missing sqlite_where" + assert str(pg_where) == str(sqlite_where), index.name + statement = str(CreateIndex(index).compile(dialect=PG)) + assert " WHERE " in statement, index.name + assert "nulls not distinct" not in statement.lower() # C8.4 + + +def test_no_index_uses_nulls_not_distinct() -> None: + """C8.4: unsupported by SQLite, and only available since PostgreSQL 15.""" + for table in _tables(): + for index in table.indexes: + options = index.dialect_options["postgresql"]._non_defaults + assert "nulls_not_distinct" not in options, index.name + + +def test_every_fk_to_import_runs_cascades() -> None: + """C8.5: `DELETE /api/imports/{id}` relies on the database cascade.""" + seen = 0 + for table in _tables(): + for fk in table.foreign_keys: + if fk.column.table.name != "import_runs": + continue + seen += 1 + assert fk.ondelete == "CASCADE", f"{table.name}.{fk.parent.name}" + ddl = str(CreateTable(table).compile(dialect=PG)) + assert "REFERENCES import_runs (id) ON DELETE CASCADE" in ddl, table.name + assert seen >= 9 + + +# --------------------------------------------------------------------------- +# 4. Identifiers +# --------------------------------------------------------------------------- + + +def test_identifiers_fit_the_63_character_limit() -> None: + """PostgreSQL truncates longer identifiers silently (NAMEDATALEN = 64).""" + too_long: list[str] = [] + for table in _tables(): + candidates = [table.name, *(column.name for column in table.columns)] + candidates += [index.name or "" for index in table.indexes] + for constraint in table.constraints: + candidates.append( + str(constraint.name) + if constraint.name is not None + and not str(constraint.name).startswith("_unnamed_") + else _implicit_constraint_name(table, constraint) + ) + too_long += [name for name in candidates if len(name) > MAX_IDENTIFIER] + assert too_long == [] + + +def test_no_table_or_column_is_a_postgresql_reserved_word() -> None: + offenders = [ + f"{table.name}.{column.name}" + for table, column in _columns() + if column.name.lower() in PG_RESERVED_WORDS + ] + [table.name for table in _tables() if table.name.lower() in PG_RESERVED_WORDS] + assert offenders == [] + + +def test_index_and_unique_constraint_names_are_unique_schema_wide() -> None: + """PostgreSQL keeps indexes and their backing constraints in one namespace.""" + names: dict[str, str] = {} + for table in _tables(): + owners = [(index.name, f"index of {table.name}") for index in table.indexes] + owners += [ + (str(constraint.name), f"constraint of {table.name}") + for constraint in table.constraints + if constraint.name is not None + and not str(constraint.name).startswith("_unnamed_") + and isinstance( + constraint, sa.UniqueConstraint | sa.PrimaryKeyConstraint + ) + ] + for name, owner in owners: + assert name is not None + assert name not in names, f"{name}: {owner} clashes with {names.get(name)}" + names[name] = owner + + +def test_constraint_names_are_unique_within_a_table() -> None: + for table in _tables(): + named = [ + str(constraint.name) + for constraint in table.constraints + if constraint.name is not None + and not str(constraint.name).startswith("_unnamed_") + ] + assert len(named) == len(set(named)), table.name + + +# --------------------------------------------------------------------------- +# 5. pg_trgm (datamodel-finance §2.5) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("url", "expected"), + [("postgresql+psycopg://", True), ("sqlite://", False)], +) +def test_trigram_index_is_emitted_on_postgresql_only(url: str, expected: bool) -> None: + emitted: list[str] = [] + + def executor(sql: sa.ClauseElement, *args: object, **kwargs: object) -> None: + emitted.append(str(sql.compile(dialect=engine.dialect))) + + engine = sa.create_mock_engine(url, executor) + METADATA.create_all(engine, checkfirst=False) + + trigram = [statement for statement in emitted if "gin_trgm_ops" in statement] + assert bool(trigram) is expected + if expected: + assert "CREATE EXTENSION IF NOT EXISTS pg_trgm" in trigram[0] + assert "ix_fin_transactions_label_trgm" in trigram[0] + assert "label_clean" in trigram[0] diff --git a/apps/api/app/tests/test_timezone_portability.py b/apps/api/app/tests/test_timezone_portability.py new file mode 100644 index 0000000..dc22ac3 --- /dev/null +++ b/apps/api/app/tests/test_timezone_portability.py @@ -0,0 +1,168 @@ +"""Cross-engine timezone behaviour (SQLite suite vs PostgreSQL production). + +`DateTime(timezone=True)` behaves differently on the two engines: + +* PostgreSQL `timestamptz` gives back an **aware** datetime (UTC); +* SQLite has no timezone storage: SQLAlchemy writes the wall clock and gives + back a **naive** datetime — and, worse, silently drops the offset of an aware + value on the way in, so anything not already converted to UTC would be stored + shifted. + +`app.core.timeutils.as_utc` (exposed as the `StoredUtcDatetime` annotation) is +what absorbs the difference in read schemas, and `app.modules.health.models +.UTCDateTime` does it at the column level. The suite only ever runs on SQLite, +so the PostgreSQL branch (values that arrive **aware**) is simulated here +explicitly — otherwise it would never be executed before production. +""" + +import datetime as dt +from zoneinfo import ZoneInfo + +import pytest +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.core.timeutils import StoredUtcDatetime, as_utc, require_utc, utcnow +from app.modules.auth.models import User +from app.modules.health.models import UTCDateTime, WeightEntry +from app.modules.imports.models import ImportRun +from app.modules.imports.schemas import ImportRunRead + +PARIS = ZoneInfo("Europe/Paris") + +#: 2026-08-13 08:31 in Paris (CEST, +02:00) == 06:31 UTC. +PARIS_INSTANT = dt.datetime(2026, 8, 13, 8, 31, tzinfo=PARIS) +UTC_INSTANT = dt.datetime(2026, 8, 13, 6, 31, tzinfo=dt.UTC) + + +class _Response(BaseModel): + """Stand-in for any XxxRead field holding a stored instant.""" + + moment: StoredUtcDatetime + optional: StoredUtcDatetime | None = None + + +# --------------------------------------------------------------------------- +# as_utc — both directions +# --------------------------------------------------------------------------- + + +def test_as_utc_stamps_utc_on_a_naive_value() -> None: + """SQLite branch: the naive value read back is UTC by construction.""" + naive = UTC_INSTANT.replace(tzinfo=None) + result = as_utc(naive) + assert result.tzinfo is dt.UTC + assert result == UTC_INSTANT + + +def test_as_utc_keeps_an_aware_utc_value_untouched() -> None: + """PostgreSQL branch: `timestamptz` already yields an aware UTC value.""" + result = as_utc(UTC_INSTANT) + assert result == UTC_INSTANT + assert result.utcoffset() == dt.timedelta(0) + + +def test_as_utc_converts_an_aware_non_utc_value() -> None: + """PostgreSQL with a session `TimeZone` other than UTC: same instant, UTC.""" + result = as_utc(PARIS_INSTANT) + assert result.utcoffset() == dt.timedelta(0) + assert result == UTC_INSTANT + assert result.hour == 6 # not the 08:31 wall clock + + +def test_as_utc_is_idempotent() -> None: + assert as_utc(as_utc(PARIS_INSTANT)) == as_utc(PARIS_INSTANT) + + +def test_require_utc_refuses_naive_input_and_converts_aware_input() -> None: + """Inbound instants (architecture §8.4), the mirror of `as_utc`.""" + assert require_utc(PARIS_INSTANT) == UTC_INSTANT + with pytest.raises(ValueError, match="fuseau horaire"): + require_utc(UTC_INSTANT.replace(tzinfo=None)) + + +# --------------------------------------------------------------------------- +# StoredUtcDatetime in response schemas +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "stored", + [ + UTC_INSTANT.replace(tzinfo=None), # what SQLite returns + UTC_INSTANT, # what PostgreSQL returns + PARIS_INSTANT, # PostgreSQL with a non-UTC session timezone + ], +) +def test_stored_utc_datetime_serialises_to_the_same_z_string( + stored: dt.datetime, +) -> None: + payload = _Response(moment=stored).model_dump(mode="json") + assert payload["moment"] == "2026-08-13T06:31:00Z" + assert payload["optional"] is None + + +def test_import_run_read_normalises_aware_values_from_postgresql() -> None: + """Simulates the psycopg driver handing back aware datetimes.""" + run = ImportRun( + id=1, + user_id=1, + importer_id="foodvisor_csv", + domain="health", + filename="export.csv", + file_size=10, + status="completed", + rows_total=1, + rows_inserted=1, + rows_updated=0, + rows_duplicates=0, + rows_errors=0, + error_details=[], + started_at=PARIS_INSTANT, # aware, non-UTC: the PostgreSQL-ish case + finished_at=UTC_INSTANT, + created_at=UTC_INSTANT.replace(tzinfo=None), # naive: the SQLite case + ) + payload = ImportRunRead.model_validate(run).model_dump(mode="json") + assert payload["started_at"] == "2026-08-13T06:31:00Z" + assert payload["finished_at"] == "2026-08-13T06:31:00Z" + assert payload["created_at"] == "2026-08-13T06:31:00Z" + + +# --------------------------------------------------------------------------- +# Column level: UTCDateTime keeps the two engines aligned +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("measured_at", [PARIS_INSTANT, UTC_INSTANT]) +def test_utc_datetime_column_round_trips_aware_values( + db: Session, user: User, measured_at: dt.datetime +) -> None: + """`UTCDateTime` stores UTC and returns aware values on SQLite too.""" + entry = WeightEntry( + user_id=user.id, + measured_at=measured_at, + weight_kg=80, + source="manual", + ) + db.add(entry) + db.commit() + db.expire_all() + + stored = db.get(WeightEntry, entry.id) + assert stored is not None + assert stored.measured_at.tzinfo is not None # aware on both engines + assert stored.measured_at == UTC_INSTANT + + +def test_utc_datetime_bind_normalises_before_storage() -> None: + """The offset is applied *before* SQLite drops it (no wall-clock shift).""" + processor = UTCDateTime() + assert processor.process_bind_param(PARIS_INSTANT, None) == UTC_INSTANT + assert processor.process_bind_param(None, None) is None + assert processor.process_result_value(None, None) is None + naive = processor.process_result_value(UTC_INSTANT.replace(tzinfo=None), None) + assert naive == UTC_INSTANT + + +def test_utcnow_is_always_aware() -> None: + assert utcnow().tzinfo is dt.UTC diff --git a/apps/api/openapi.json b/apps/api/openapi.json new file mode 100644 index 0000000..954e9fb --- /dev/null +++ b/apps/api/openapi.json @@ -0,0 +1,18281 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "LifeTrack API", + "version": "1.0.0" + }, + "paths": { + "/api/auth/status": { + "get": { + "tags": [ + "auth" + ], + "summary": "Auth Status", + "operationId": "auth_status_api_auth_status_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthStatus" + } + } + } + } + } + } + }, + "/api/auth/setup": { + "post": { + "tags": [ + "auth" + ], + "summary": "Setup", + "operationId": "setup_api_auth_setup_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/auth/login": { + "post": { + "tags": [ + "auth" + ], + "summary": "Login", + "operationId": "login_api_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/auth/me": { + "get": { + "tags": [ + "auth" + ], + "summary": "Me", + "operationId": "me_api_auth_me_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserRead" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "patch": { + "tags": [ + "auth" + ], + "summary": "Update Me", + "operationId": "update_me_api_auth_me_patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/auth/device-keys": { + "get": { + "tags": [ + "auth" + ], + "summary": "List Device Keys", + "operationId": "list_device_keys_api_auth_device_keys_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/DeviceKeyRead" + }, + "type": "array", + "title": "Response List Device Keys Api Auth Device Keys Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "auth" + ], + "summary": "Create Device Key", + "operationId": "create_device_key_api_auth_device_keys_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceKeyCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceKeyCreated" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/auth/device-keys/{key_id}": { + "delete": { + "tags": [ + "auth" + ], + "summary": "Revoke Device Key", + "operationId": "revoke_device_key_api_auth_device_keys__key_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "key_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Key Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/accounts": { + "get": { + "tags": [ + "finance" + ], + "summary": "List Accounts", + "operationId": "list_accounts_api_finance_accounts_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "include_archived", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Archived" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AccountRead" + }, + "title": "Response List Accounts Api Finance Accounts Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "finance" + ], + "summary": "Create Account", + "operationId": "create_account_api_finance_accounts_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/accounts/{account_id}": { + "patch": { + "tags": [ + "finance" + ], + "summary": "Update Account", + "operationId": "update_account_api_finance_accounts__account_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "account_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Account Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "finance" + ], + "summary": "Delete Account", + "operationId": "delete_account_api_finance_accounts__account_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "account_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Account Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/categories": { + "get": { + "tags": [ + "finance" + ], + "summary": "List Categories", + "operationId": "list_categories_api_finance_categories_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/CategoryRead" + }, + "type": "array", + "title": "Response List Categories Api Finance Categories Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "finance" + ], + "summary": "Create Category", + "operationId": "create_category_api_finance_categories_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/finance/categories/{category_id}": { + "patch": { + "tags": [ + "finance" + ], + "summary": "Update Category", + "operationId": "update_category_api_finance_categories__category_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "category_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Category Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "finance" + ], + "summary": "Delete Category", + "operationId": "delete_category_api_finance_categories__category_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "category_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Category Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/source-profiles": { + "get": { + "tags": [ + "finance" + ], + "summary": "List Source Profiles", + "operationId": "list_source_profiles_api_finance_source_profiles_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SourceProfileRead" + }, + "type": "array", + "title": "Response List Source Profiles Api Finance Source Profiles Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "finance" + ], + "summary": "Create Source Profile", + "operationId": "create_source_profile_api_finance_source_profiles_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SourceProfileCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SourceProfileRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/finance/source-profiles/{profile_id}/clone": { + "post": { + "tags": [ + "finance" + ], + "summary": "Clone Source Profile", + "operationId": "clone_source_profile_api_finance_source_profiles__profile_id__clone_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "profile_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Profile Id" + } + } + ], + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SourceProfileRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/source-profiles/{profile_id}": { + "patch": { + "tags": [ + "finance" + ], + "summary": "Update Source Profile", + "operationId": "update_source_profile_api_finance_source_profiles__profile_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "profile_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Profile Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SourceProfileUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SourceProfileRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "finance" + ], + "summary": "Delete Source Profile", + "operationId": "delete_source_profile_api_finance_source_profiles__profile_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "profile_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Profile Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/transactions": { + "get": { + "tags": [ + "finance" + ], + "summary": "List Transactions", + "operationId": "list_transactions_api_finance_transactions_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "date_from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Date From" + } + }, + { + "name": "date_to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Date To" + } + }, + { + "name": "account_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + { + "type": "null" + } + ], + "title": "Account Id" + } + }, + { + "name": "category_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "title": "Category Id" + } + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Q" + } + }, + { + "name": "direction", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "debit", + "credit" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Direction" + } + }, + { + "name": "amount_min", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Amount Min" + } + }, + { + "name": "amount_max", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Amount Max" + } + }, + { + "name": "is_transfer", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Transfer" + } + }, + { + "name": "import_run_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Import Run Id" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_TransactionRead_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "finance" + ], + "summary": "Create Transaction", + "operationId": "create_transaction_api_finance_transactions_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransactionCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransactionRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/transactions/{transaction_id}": { + "patch": { + "tags": [ + "finance" + ], + "summary": "Update Transaction", + "operationId": "update_transaction_api_finance_transactions__transaction_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "transaction_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Transaction Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransactionUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransactionRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "finance" + ], + "summary": "Delete Transaction", + "operationId": "delete_transaction_api_finance_transactions__transaction_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "transaction_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Transaction Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/transactions/bulk-categorize": { + "post": { + "tags": [ + "finance" + ], + "summary": "Bulk Categorize", + "operationId": "bulk_categorize_api_finance_transactions_bulk_categorize_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkCategorizeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkCategorizeResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/finance/rules": { + "get": { + "tags": [ + "finance" + ], + "summary": "List Rules", + "operationId": "list_rules_api_finance_rules_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/RuleRead" + }, + "type": "array", + "title": "Response List Rules Api Finance Rules Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": [ + "finance" + ], + "summary": "Create Rule", + "operationId": "create_rule_api_finance_rules_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/finance/rules/{rule_id}": { + "patch": { + "tags": [ + "finance" + ], + "summary": "Update Rule", + "operationId": "update_rule_api_finance_rules__rule_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "rule_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Rule Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "finance" + ], + "summary": "Delete Rule", + "operationId": "delete_rule_api_finance_rules__rule_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "rule_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Rule Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/rules/reorder": { + "post": { + "tags": [ + "finance" + ], + "summary": "Reorder Rules", + "operationId": "reorder_rules_api_finance_rules_reorder_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleReorderRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/RuleRead" + }, + "type": "array", + "title": "Response Reorder Rules Api Finance Rules Reorder Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/finance/rules/apply": { + "post": { + "tags": [ + "finance" + ], + "summary": "Apply Rules", + "operationId": "apply_rules_api_finance_rules_apply_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleApplyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RuleApplyResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/finance/rules/preview": { + "post": { + "tags": [ + "finance" + ], + "summary": "Preview Rule", + "operationId": "preview_rule_api_finance_rules_preview_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RulePreviewRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RulePreviewResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/finance/budgets": { + "get": { + "tags": [ + "finance" + ], + "summary": "List Budgets", + "operationId": "list_budgets_api_finance_budgets_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "month", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Month" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/app__modules__finance__schemas__BudgetRead" + }, + "title": "Response List Budgets Api Finance Budgets Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "finance" + ], + "summary": "Create Budget", + "operationId": "create_budget_api_finance_budgets_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BudgetCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__finance__schemas__BudgetRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/budgets/{budget_id}": { + "patch": { + "tags": [ + "finance" + ], + "summary": "Update Budget", + "operationId": "update_budget_api_finance_budgets__budget_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "budget_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Budget Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BudgetUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/app__modules__finance__schemas__BudgetRead" + }, + "title": "Response Update Budget Api Finance Budgets Budget Id Patch" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "finance" + ], + "summary": "Delete Budget", + "operationId": "delete_budget_api_finance_budgets__budget_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "budget_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Budget Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/imports/preview": { + "post": { + "tags": [ + "finance" + ], + "summary": "Preview Import", + "operationId": "preview_import_api_finance_imports_preview_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_preview_import_api_finance_imports_preview_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportPreviewResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/finance/imports": { + "post": { + "tags": [ + "finance" + ], + "summary": "Run Import", + "operationId": "run_import_api_finance_imports_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_run_import_api_finance_imports_post" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__finance__schemas__ImportRunRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "tags": [ + "finance" + ], + "summary": "List Imports", + "operationId": "list_imports_api_finance_imports_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__core__pagination__Page_ImportRunRead___1" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/imports/{run_id}": { + "get": { + "tags": [ + "finance" + ], + "summary": "Get Import", + "operationId": "get_import_api_finance_imports__run_id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__finance__schemas__ImportRunRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "finance" + ], + "summary": "Rollback Import", + "operationId": "rollback_import_api_finance_imports__run_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Run Id" + } + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Force" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/transfers/detect": { + "post": { + "tags": [ + "finance" + ], + "summary": "Detect Transfers", + "operationId": "detect_transfers_api_finance_transfers_detect_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/TransferDetectRequest" + }, + { + "type": "null" + } + ], + "title": "Payload" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransferDetectResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/finance/transfers/link": { + "post": { + "tags": [ + "finance" + ], + "summary": "Link Transfer", + "operationId": "link_transfer_api_finance_transfers_link_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransferLinkRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransferLinkResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/finance/transfers/{transfer_group_id}": { + "delete": { + "tags": [ + "finance" + ], + "summary": "Unlink Transfer", + "operationId": "unlink_transfer_api_finance_transfers__transfer_group_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "transfer_group_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Transfer Group Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/stats/monthly-by-category": { + "get": { + "tags": [ + "finance" + ], + "summary": "Stats Monthly By Category", + "operationId": "stats_monthly_by_category_api_finance_stats_monthly_by_category_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "months", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 60, + "minimum": 1, + "default": 12, + "title": "Months" + } + }, + { + "name": "level", + "in": "query", + "required": false, + "schema": { + "enum": [ + "root", + "child" + ], + "type": "string", + "default": "root", + "title": "Level" + } + }, + { + "name": "direction", + "in": "query", + "required": false, + "schema": { + "enum": [ + "debit", + "credit" + ], + "type": "string", + "default": "debit", + "title": "Direction" + } + }, + { + "name": "account_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + { + "type": "null" + } + ], + "title": "Account Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MonthlyByCategoryResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/stats/cashflow": { + "get": { + "tags": [ + "finance" + ], + "summary": "Stats Cashflow", + "operationId": "stats_cashflow_api_finance_stats_cashflow_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "months", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 60, + "minimum": 1, + "default": 12, + "title": "Months" + } + }, + { + "name": "account_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + { + "type": "null" + } + ], + "title": "Account Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CashflowResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/stats/top-merchants": { + "get": { + "tags": [ + "finance" + ], + "summary": "Stats Top Merchants", + "operationId": "stats_top_merchants_api_finance_stats_top_merchants_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "months", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 60, + "minimum": 1, + "default": 3, + "title": "Months" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "default": 15, + "title": "Limit" + } + }, + { + "name": "direction", + "in": "query", + "required": false, + "schema": { + "enum": [ + "debit", + "credit" + ], + "type": "string", + "default": "debit", + "title": "Direction" + } + }, + { + "name": "account_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + { + "type": "null" + } + ], + "title": "Account Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TopMerchantsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/stats/recurring": { + "get": { + "tags": [ + "finance" + ], + "summary": "Stats Recurring", + "operationId": "stats_recurring_api_finance_stats_recurring_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "direction", + "in": "query", + "required": false, + "schema": { + "enum": [ + "debit", + "credit" + ], + "type": "string", + "default": "debit", + "title": "Direction" + } + }, + { + "name": "include_inactive", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Inactive" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecurringResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/stats/budget-progress": { + "get": { + "tags": [ + "finance" + ], + "summary": "Stats Budget Progress", + "operationId": "stats_budget_progress_api_finance_stats_budget_progress_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "month", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Month" + } + }, + { + "name": "account_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + { + "type": "null" + } + ], + "title": "Account Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BudgetProgressResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/stats/sankey": { + "get": { + "tags": [ + "finance" + ], + "summary": "Stats Sankey", + "operationId": "stats_sankey_api_finance_stats_sankey_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "month", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Month" + } + }, + { + "name": "months", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 60, + "minimum": 1, + "default": 1, + "title": "Months" + } + }, + { + "name": "account_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + { + "type": "null" + } + ], + "title": "Account Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SankeyResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/finance/dashboard": { + "get": { + "tags": [ + "finance" + ], + "summary": "Dashboard", + "operationId": "dashboard_api_finance_dashboard_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__finance__schemas__DashboardResponse" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/health/profile": { + "get": { + "tags": [ + "health" + ], + "summary": "Get Profile", + "operationId": "get_profile_api_health_profile_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthProfileRead" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "put": { + "tags": [ + "health" + ], + "summary": "Put Profile", + "operationId": "put_profile_api_health_profile_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthProfileUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthProfileRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/health/weights/stats": { + "get": { + "tags": [ + "health" + ], + "summary": "Weights Stats", + "operationId": "weights_stats_api_health_weights_stats_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__health__schemas__StatsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/weights": { + "get": { + "tags": [ + "health" + ], + "summary": "List Weights", + "operationId": "list_weights_api_health_weights_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_WeightEntryRead_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "health" + ], + "summary": "Create Weight", + "operationId": "create_weight_api_health_weights_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeightEntryCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeightEntryRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/weights/{entry_id}": { + "put": { + "tags": [ + "health" + ], + "summary": "Update Weight", + "operationId": "update_weight_api_health_weights__entry_id__put", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "entry_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Entry Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeightEntryUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeightEntryRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "health" + ], + "summary": "Update Weight", + "operationId": "update_weight_api_health_weights__entry_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "entry_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Entry Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeightEntryUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeightEntryRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "health" + ], + "summary": "Delete Weight", + "operationId": "delete_weight_api_health_weights__entry_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "entry_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Entry Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/measurements/stats": { + "get": { + "tags": [ + "health" + ], + "summary": "Measurements Stats", + "operationId": "measurements_stats_api_health_measurements_stats_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__health__schemas__StatsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/measurements": { + "get": { + "tags": [ + "health" + ], + "summary": "List Measurements", + "operationId": "list_measurements_api_health_measurements_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_BodyMeasurementRead_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "health" + ], + "summary": "Create Measurement", + "operationId": "create_measurement_api_health_measurements_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BodyMeasurementCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BodyMeasurementRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/measurements/{row_id}": { + "put": { + "tags": [ + "health" + ], + "summary": "Update Measurement", + "operationId": "update_measurement_api_health_measurements__row_id__put", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "row_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Row Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BodyMeasurementUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BodyMeasurementRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "health" + ], + "summary": "Update Measurement", + "operationId": "update_measurement_api_health_measurements__row_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "row_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Row Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BodyMeasurementUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BodyMeasurementRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "health" + ], + "summary": "Delete Measurement", + "operationId": "delete_measurement_api_health_measurements__row_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "row_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Row Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/activity/stats": { + "get": { + "tags": [ + "health" + ], + "summary": "Activity Stats", + "operationId": "activity_stats_api_health_activity_stats_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__health__schemas__StatsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/activity": { + "get": { + "tags": [ + "health" + ], + "summary": "List Activity", + "operationId": "list_activity_api_health_activity_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + }, + { + "name": "raw", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Raw" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "health" + ], + "summary": "Upsert Activity", + "operationId": "upsert_activity_api_health_activity_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DailyActivityUpsert" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DailyActivityRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/activity/{row_id}": { + "delete": { + "tags": [ + "health" + ], + "summary": "Delete Activity", + "operationId": "delete_activity_api_health_activity__row_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "row_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Row Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/workouts/stats": { + "get": { + "tags": [ + "health" + ], + "summary": "Workouts Stats", + "operationId": "workouts_stats_api_health_workouts_stats_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__health__schemas__StatsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/workouts": { + "get": { + "tags": [ + "health" + ], + "summary": "List Workouts", + "operationId": "list_workouts_api_health_workouts_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + }, + { + "name": "sport_type", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sport Type" + } + }, + { + "name": "include_hidden", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Hidden" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_WorkoutRead_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "health" + ], + "summary": "Create Workout", + "operationId": "create_workout_api_health_workouts_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkoutCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkoutRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/workouts/{workout_id}": { + "put": { + "tags": [ + "health" + ], + "summary": "Update Workout", + "operationId": "update_workout_api_health_workouts__workout_id__put", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "workout_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Workout Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkoutUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkoutRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "health" + ], + "summary": "Update Workout", + "operationId": "update_workout_api_health_workouts__workout_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "workout_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Workout Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkoutUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkoutRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "health" + ], + "summary": "Delete Workout", + "operationId": "delete_workout_api_health_workouts__workout_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "workout_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Workout Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/goals/active": { + "get": { + "tags": [ + "health" + ], + "summary": "Get Active Goal", + "operationId": "get_active_goal_api_health_goals_active_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActiveGoalRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/goals": { + "get": { + "tags": [ + "health" + ], + "summary": "List Goals", + "operationId": "list_goals_api_health_goals_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_GoalRead_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "health" + ], + "summary": "Create Goal", + "operationId": "create_goal_api_health_goals_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "replace_active", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Replace Active" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/goals/{goal_id}/activate": { + "post": { + "tags": [ + "health" + ], + "summary": "Activate Goal", + "operationId": "activate_goal_api_health_goals__goal_id__activate_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "goal_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Goal Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/goals/{goal_id}": { + "put": { + "tags": [ + "health" + ], + "summary": "Update Goal", + "operationId": "update_goal_api_health_goals__goal_id__put", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "goal_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Goal Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "health" + ], + "summary": "Update Goal", + "operationId": "update_goal_api_health_goals__goal_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "goal_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Goal Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoalRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "health" + ], + "summary": "Delete Goal", + "operationId": "delete_goal_api_health_goals__goal_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "goal_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Goal Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/energy-balance": { + "get": { + "tags": [ + "health" + ], + "summary": "Energy Balance", + "operationId": "energy_balance_api_health_energy_balance_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__health__schemas__StatsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/dashboard": { + "get": { + "tags": [ + "health" + ], + "summary": "Dashboard", + "operationId": "dashboard_api_health_dashboard_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__health__schemas__DashboardResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/nutrition/stats": { + "get": { + "tags": [ + "health" + ], + "summary": "Nutrition Stats", + "operationId": "nutrition_stats_api_health_nutrition_stats_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__health__schemas__StatsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/nutrition/days": { + "get": { + "tags": [ + "health" + ], + "summary": "Nutrition Days", + "operationId": "nutrition_days_api_health_nutrition_days_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NutritionDayRead" + }, + "title": "Response Nutrition Days Api Health Nutrition Days Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/nutrition/days/{day}": { + "get": { + "tags": [ + "health" + ], + "summary": "Nutrition Day", + "operationId": "nutrition_day_api_health_nutrition_days__day__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "day", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "date", + "title": "Day" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NutritionDayDetail" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/nutrition/recent": { + "get": { + "tags": [ + "health" + ], + "summary": "Recent Foods", + "operationId": "recent_foods_api_health_nutrition_recent_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 50, + "minimum": 1, + "default": 20, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecentFoodRead" + }, + "title": "Response Recent Foods Api Health Nutrition Recent Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/nutrition/favorites": { + "get": { + "tags": [ + "health" + ], + "summary": "List Favorites", + "operationId": "list_favorites_api_health_nutrition_favorites_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_FoodFavoriteRead_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "health" + ], + "summary": "Create Favorite", + "operationId": "create_favorite_api_health_nutrition_favorites_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodFavoriteCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodFavoriteRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/nutrition/favorites/{favorite_id}": { + "put": { + "tags": [ + "health" + ], + "summary": "Update Favorite", + "operationId": "update_favorite_api_health_nutrition_favorites__favorite_id__put", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "favorite_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Favorite Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodFavoriteUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodFavoriteRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "health" + ], + "summary": "Update Favorite", + "operationId": "update_favorite_api_health_nutrition_favorites__favorite_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "favorite_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Favorite Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodFavoriteUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodFavoriteRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "health" + ], + "summary": "Delete Favorite", + "operationId": "delete_favorite_api_health_nutrition_favorites__favorite_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "favorite_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Favorite Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/nutrition/water/stats": { + "get": { + "tags": [ + "health" + ], + "summary": "Water Stats", + "operationId": "water_stats_api_health_nutrition_water_stats_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__health__schemas__StatsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/nutrition/water": { + "get": { + "tags": [ + "health" + ], + "summary": "List Water", + "operationId": "list_water_api_health_nutrition_water_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_WaterEntryRead_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "health" + ], + "summary": "Create Water", + "operationId": "create_water_api_health_nutrition_water_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WaterEntryCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WaterEntryRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/nutrition/water/{entry_id}": { + "delete": { + "tags": [ + "health" + ], + "summary": "Delete Water", + "operationId": "delete_water_api_health_nutrition_water__entry_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "entry_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Entry Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/nutrition/entries": { + "get": { + "tags": [ + "health" + ], + "summary": "List Food Entries", + "operationId": "list_food_entries_api_health_nutrition_entries_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "day", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Day" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + }, + { + "name": "meal", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Meal" + } + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Q" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_FoodEntryRead_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "health" + ], + "summary": "Create Food Entry", + "operationId": "create_food_entry_api_health_nutrition_entries_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodEntryCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodEntryRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/nutrition/entries/{entry_id}": { + "put": { + "tags": [ + "health" + ], + "summary": "Update Food Entry", + "operationId": "update_food_entry_api_health_nutrition_entries__entry_id__put", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "entry_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Entry Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodEntryUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodEntryRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "health" + ], + "summary": "Update Food Entry", + "operationId": "update_food_entry_api_health_nutrition_entries__entry_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "entry_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Entry Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodEntryUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodEntryRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "health" + ], + "summary": "Delete Food Entry", + "operationId": "delete_food_entry_api_health_nutrition_entries__entry_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "entry_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Entry Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/foods/search": { + "get": { + "tags": [ + "health" + ], + "summary": "Search Foods", + "operationId": "search_foods_api_health_foods_search_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "title": "Q" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 50, + "minimum": 1, + "default": 20, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodSearchResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/foods/barcode/{barcode}": { + "get": { + "tags": [ + "health" + ], + "summary": "Food By Barcode", + "operationId": "food_by_barcode_api_health_foods_barcode__barcode__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "barcode", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Barcode" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FoodSearchItem" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/schedules": { + "get": { + "tags": [ + "health" + ], + "summary": "List Schedules", + "operationId": "list_schedules_api_health_schedules_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ScheduleRead" + }, + "type": "array", + "title": "Response List Schedules Api Health Schedules Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/health/schedules/{kind}": { + "put": { + "tags": [ + "health" + ], + "summary": "Put Schedule", + "operationId": "put_schedule_api_health_schedules__kind__put", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ScheduleKind" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/today": { + "get": { + "tags": [ + "health" + ], + "summary": "Today", + "operationId": "today_api_health_today_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TodayResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/health/stats/adherence": { + "get": { + "tags": [ + "health" + ], + "summary": "Adherence", + "operationId": "adherence_api_health_stats_adherence_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + }, + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScheduleKind" + }, + { + "type": "null" + } + ], + "title": "Kind" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdherenceResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/imports/sources": { + "get": { + "tags": [ + "imports" + ], + "summary": "List Sources", + "operationId": "list_sources_api_imports_sources_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SourceInfo" + }, + "type": "array", + "title": "Response List Sources Api Imports Sources Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/imports": { + "post": { + "tags": [ + "imports" + ], + "summary": "Upload Import", + "operationId": "upload_import_api_imports_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_import_api_imports_post" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__imports__schemas__ImportRunRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "tags": [ + "imports" + ], + "summary": "List Imports", + "operationId": "list_imports_api_imports_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__core__pagination__Page_ImportRunRead___2" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/imports/{run_id}": { + "get": { + "tags": [ + "imports" + ], + "summary": "Get Import", + "operationId": "get_import_api_imports__run_id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Run Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__imports__schemas__ImportRunRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "imports" + ], + "summary": "Rollback Import", + "operationId": "rollback_import_api_imports__run_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "run_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Run Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/ingest/{domain}": { + "post": { + "tags": [ + "ingest" + ], + "summary": "Ingest", + "operationId": "ingest_api_ingest__domain__post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "domain", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Domain" + } + }, + { + "name": "X-API-Key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Key" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IngestPayload" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IngestResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/settings": { + "get": { + "tags": [ + "vape" + ], + "summary": "Read Settings", + "operationId": "read_settings_api_vape_settings_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VapeSettingsRead" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "put": { + "tags": [ + "vape" + ], + "summary": "Write Settings", + "operationId": "write_settings_api_vape_settings_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VapeSettingsPut" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VapeSettingsRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/vape/products": { + "get": { + "tags": [ + "vape" + ], + "summary": "List Products", + "operationId": "list_products_api_vape_products_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProductKind" + }, + { + "type": "null" + } + ], + "title": "Kind" + } + }, + { + "name": "include_archived", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Archived" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_ProductRead_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "vape" + ], + "summary": "Create Product", + "operationId": "create_product_api_vape_products_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/products/{product_id}": { + "patch": { + "tags": [ + "vape" + ], + "summary": "Update Product", + "operationId": "update_product_api_vape_products__product_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "product_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Product Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "vape" + ], + "summary": "Delete Product", + "operationId": "delete_product_api_vape_products__product_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "product_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Product Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/mixes": { + "get": { + "tags": [ + "vape" + ], + "summary": "List Mixes", + "operationId": "list_mixes_api_vape_mixes_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "include_archived", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Archived" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_MixRead_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "vape" + ], + "summary": "Create Mix", + "operationId": "create_mix_api_vape_mixes_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MixCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MixRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/mixes/calculator": { + "post": { + "tags": [ + "vape" + ], + "summary": "Mix Calculator", + "description": "Stateless recipe assistant — persists nothing (§6.3).", + "operationId": "mix_calculator_api_vape_mixes_calculator_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MixCalculatorRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MixCalculatorResult" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/vape/mixes/{mix_id}/activate": { + "post": { + "tags": [ + "vape" + ], + "summary": "Activate Mix", + "operationId": "activate_mix_api_vape_mixes__mix_id__activate_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "mix_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Mix Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MixRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/mixes/{mix_id}": { + "patch": { + "tags": [ + "vape" + ], + "summary": "Update Mix", + "operationId": "update_mix_api_vape_mixes__mix_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "mix_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Mix Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MixUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MixRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "vape" + ], + "summary": "Delete Mix", + "operationId": "delete_mix_api_vape_mixes__mix_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "mix_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Mix Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/liquids": { + "get": { + "tags": [ + "vape" + ], + "summary": "List Liquids", + "operationId": "list_liquids_api_vape_liquids_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/LiquidEntryKind" + }, + { + "type": "null" + } + ], + "title": "Kind" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_LiquidEntryRead_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "vape" + ], + "summary": "Create Liquid", + "operationId": "create_liquid_api_vape_liquids_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiquidEntryCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiquidEntryRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/liquids/{entry_id}": { + "patch": { + "tags": [ + "vape" + ], + "summary": "Update Liquid", + "operationId": "update_liquid_api_vape_liquids__entry_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "entry_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Entry Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiquidEntryUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiquidEntryRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "vape" + ], + "summary": "Delete Liquid", + "operationId": "delete_liquid_api_vape_liquids__entry_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "entry_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Entry Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/coils": { + "get": { + "tags": [ + "vape" + ], + "summary": "List Coils", + "operationId": "list_coils_api_vape_coils_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_CoilChangeRead_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "vape" + ], + "summary": "Create Coil", + "operationId": "create_coil_api_vape_coils_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoilChangeCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoilChangeCreated" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/coils/stats": { + "get": { + "tags": [ + "vape" + ], + "summary": "Coil Stats", + "operationId": "coil_stats_api_vape_coils_stats_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__vape__schemas__StatsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/coils/change": { + "post": { + "tags": [ + "vape" + ], + "summary": "Quick Coil Change", + "description": "One-click « Résistance changée » : registers a change at `now` (§12.6).", + "operationId": "quick_coil_change_api_vape_coils_change_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CoilChangeCreate" + }, + { + "type": "null" + } + ], + "title": "Payload" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoilChangeCreated" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/coils/{coil_id}": { + "patch": { + "tags": [ + "vape" + ], + "summary": "Update Coil", + "operationId": "update_coil_api_vape_coils__coil_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "coil_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Coil Id" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoilChangeUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CoilChangeRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "vape" + ], + "summary": "Delete Coil", + "operationId": "delete_coil_api_vape_coils__coil_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "coil_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Coil Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/purchases": { + "get": { + "tags": [ + "vape" + ], + "summary": "List Purchases", + "operationId": "list_purchases_api_vape_purchases_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "product_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Product Id" + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page_PurchaseRead_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "vape" + ], + "summary": "Create Purchase", + "operationId": "create_purchase_api_vape_purchases_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchaseCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchaseRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/purchases/{purchase_id}": { + "patch": { + "tags": [ + "vape" + ], + "summary": "Update Purchase", + "operationId": "update_purchase_api_vape_purchases__purchase_id__patch", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "purchase_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Purchase Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchaseUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PurchaseRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "vape" + ], + "summary": "Delete Purchase", + "operationId": "delete_purchase_api_vape_purchases__purchase_id__delete", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "purchase_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Purchase Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/stats/consumption": { + "get": { + "tags": [ + "vape" + ], + "summary": "Consumption Stats", + "operationId": "consumption_stats_api_vape_stats_consumption_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__vape__schemas__StatsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/stats/nicotine": { + "get": { + "tags": [ + "vape" + ], + "summary": "Nicotine Stats", + "operationId": "nicotine_stats_api_vape_stats_nicotine_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__vape__schemas__StatsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/stats/costs": { + "get": { + "tags": [ + "vape" + ], + "summary": "Cost Stats", + "operationId": "cost_stats_api_vape_stats_costs_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__vape__schemas__StatsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/stats/savings": { + "get": { + "tags": [ + "vape" + ], + "summary": "Savings Stats", + "operationId": "savings_stats_api_vape_stats_savings_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/app__modules__vape__schemas__StatsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/milestones": { + "get": { + "tags": [ + "vape" + ], + "summary": "Milestones", + "operationId": "milestones_api_vape_milestones_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MilestoneRead" + }, + "title": "Response Milestones Api Vape Milestones Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/vape/dashboard": { + "get": { + "tags": [ + "vape" + ], + "summary": "Dashboard", + "operationId": "dashboard_api_vape_dashboard_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "tz", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tz" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VapeDashboard" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AccountCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 100, + "minLength": 1, + "title": "Name" + }, + "kind": { + "$ref": "#/components/schemas/AccountKind", + "default": "checking" + }, + "currency": { + "type": "string", + "maxLength": 3, + "minLength": 3, + "title": "Currency", + "default": "EUR" + }, + "institution": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ], + "title": "Institution" + }, + "iban_masked": { + "anyOf": [ + { + "type": "string", + "maxLength": 34 + }, + { + "type": "null" + } + ], + "title": "Iban Masked" + }, + "initial_balance": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Initial Balance", + "default": "0.00" + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "AccountCreate" + }, + "AccountKind": { + "type": "string", + "enum": [ + "checking", + "savings", + "paypal", + "cash", + "other" + ], + "title": "AccountKind" + }, + "AccountRead": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "kind": { + "$ref": "#/components/schemas/AccountKind" + }, + "currency": { + "type": "string", + "title": "Currency" + }, + "institution": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Institution" + }, + "iban_masked": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Iban Masked" + }, + "initial_balance": { + "type": "number", + "title": "Initial Balance" + }, + "is_archived": { + "type": "boolean", + "title": "Is Archived" + }, + "balance": { + "type": "number", + "title": "Balance" + }, + "transaction_count": { + "type": "integer", + "title": "Transaction Count" + }, + "last_transaction_date": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Last Transaction Date" + } + }, + "type": "object", + "required": [ + "id", + "name", + "kind", + "currency", + "institution", + "iban_masked", + "initial_balance", + "is_archived", + "balance", + "transaction_count" + ], + "title": "AccountRead" + }, + "AccountUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "kind": { + "anyOf": [ + { + "$ref": "#/components/schemas/AccountKind" + }, + { + "type": "null" + } + ] + }, + "currency": { + "anyOf": [ + { + "type": "string", + "maxLength": 3, + "minLength": 3 + }, + { + "type": "null" + } + ], + "title": "Currency" + }, + "institution": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ], + "title": "Institution" + }, + "iban_masked": { + "anyOf": [ + { + "type": "string", + "maxLength": 34 + }, + { + "type": "null" + } + ], + "title": "Iban Masked" + }, + "initial_balance": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Initial Balance" + }, + "is_archived": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Archived" + } + }, + "type": "object", + "title": "AccountUpdate" + }, + "ActiveGoalRead": { + "properties": { + "goal": { + "anyOf": [ + { + "$ref": "#/components/schemas/GoalRead" + }, + { + "type": "null" + } + ] + }, + "budget": { + "anyOf": [ + { + "$ref": "#/components/schemas/app__modules__health__schemas__BudgetRead" + }, + { + "type": "null" + } + ] + }, + "projection": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectionRead" + }, + { + "type": "null" + } + ] + }, + "trend_weight_kg": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Trend Weight Kg" + }, + "done_kg": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Done Kg" + }, + "remaining_kg": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Remaining Kg" + }, + "progress_pct": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Progress Pct" + } + }, + "type": "object", + "title": "ActiveGoalRead" + }, + "ActivityLevel": { + "type": "string", + "enum": [ + "sedentary", + "light", + "moderate", + "active", + "very_active" + ], + "title": "ActivityLevel" + }, + "AdherenceDay": { + "properties": { + "date": { + "type": "string", + "format": "date", + "title": "Date" + }, + "planned": { + "type": "boolean", + "title": "Planned" + }, + "done": { + "type": "boolean", + "title": "Done" + }, + "status": { + "type": "string", + "title": "Status" + } + }, + "type": "object", + "required": [ + "date", + "planned", + "done", + "status" + ], + "title": "AdherenceDay" + }, + "AdherenceKind": { + "properties": { + "kind": { + "$ref": "#/components/schemas/ScheduleKind" + }, + "weekdays": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Weekdays" + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": false + }, + "planned_days": { + "type": "integer", + "title": "Planned Days", + "default": 0 + }, + "done_days": { + "type": "integer", + "title": "Done Days", + "default": 0 + }, + "missed_days": { + "type": "integer", + "title": "Missed Days", + "default": 0 + }, + "adherence_pct": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Adherence Pct" + }, + "streak": { + "$ref": "#/components/schemas/StreakRead" + }, + "days": { + "items": { + "$ref": "#/components/schemas/AdherenceDay" + }, + "type": "array", + "title": "Days" + } + }, + "type": "object", + "required": [ + "kind" + ], + "title": "AdherenceKind" + }, + "AdherenceResponse": { + "properties": { + "from": { + "type": "string", + "format": "date", + "title": "From" + }, + "to": { + "type": "string", + "format": "date", + "title": "To" + }, + "unit": { + "type": "string", + "title": "Unit" + }, + "series": { + "items": { + "$ref": "#/components/schemas/Series" + }, + "type": "array", + "title": "Series" + }, + "meta": { + "additionalProperties": true, + "type": "object", + "title": "Meta" + }, + "kinds": { + "items": { + "$ref": "#/components/schemas/AdherenceKind" + }, + "type": "array", + "title": "Kinds" + } + }, + "type": "object", + "required": [ + "from", + "to", + "unit" + ], + "title": "AdherenceResponse", + "description": "`/health/stats/adherence` — a `stats/*` endpoint, so it carries the §8.1\nenvelope (one calendar-heatmap series per habit) plus the richer per-habit\nbreakdown of addendum-planning.md that the planning editor needs." + }, + "AuthStatus": { + "properties": { + "setup_required": { + "type": "boolean", + "title": "Setup Required" + }, + "needs_setup": { + "type": "boolean", + "title": "Needs Setup" + } + }, + "type": "object", + "required": [ + "setup_required", + "needs_setup" + ], + "title": "AuthStatus" + }, + "BodyMeasurementCreate": { + "properties": { + "measured_at": { + "type": "string", + "format": "date-time", + "title": "Measured At" + }, + "neck_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Neck Cm" + }, + "chest_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Chest Cm" + }, + "waist_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Waist Cm" + }, + "hips_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Hips Cm" + }, + "biceps_left_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Biceps Left Cm" + }, + "biceps_right_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Biceps Right Cm" + }, + "thigh_left_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Thigh Left Cm" + }, + "thigh_right_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Thigh Right Cm" + }, + "calf_left_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Calf Left Cm" + }, + "calf_right_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Calf Right Cm" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "required": [ + "measured_at" + ], + "title": "BodyMeasurementCreate" + }, + "BodyMeasurementRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "measured_at": { + "type": "string", + "format": "date-time", + "title": "Measured At" + }, + "neck_cm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Neck Cm" + }, + "chest_cm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Chest Cm" + }, + "waist_cm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Waist Cm" + }, + "hips_cm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Hips Cm" + }, + "biceps_left_cm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Biceps Left Cm" + }, + "biceps_right_cm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Biceps Right Cm" + }, + "thigh_left_cm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Thigh Left Cm" + }, + "thigh_right_cm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Thigh Right Cm" + }, + "calf_left_cm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Calf Left Cm" + }, + "calf_right_cm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Calf Right Cm" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "source": { + "type": "string", + "title": "Source" + } + }, + "type": "object", + "required": [ + "id", + "measured_at", + "neck_cm", + "chest_cm", + "waist_cm", + "hips_cm", + "biceps_left_cm", + "biceps_right_cm", + "thigh_left_cm", + "thigh_right_cm", + "calf_left_cm", + "calf_right_cm", + "note", + "source" + ], + "title": "BodyMeasurementRead" + }, + "BodyMeasurementUpdate": { + "properties": { + "measured_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Measured At" + }, + "neck_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Neck Cm" + }, + "chest_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Chest Cm" + }, + "waist_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Waist Cm" + }, + "hips_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Hips Cm" + }, + "biceps_left_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Biceps Left Cm" + }, + "biceps_right_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Biceps Right Cm" + }, + "thigh_left_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Thigh Left Cm" + }, + "thigh_right_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Thigh Right Cm" + }, + "calf_left_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Calf Left Cm" + }, + "calf_right_cm": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Calf Right Cm" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "title": "BodyMeasurementUpdate" + }, + "Body_preview_import_api_finance_imports_preview_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + }, + "account_id": { + "type": "string", + "format": "uuid", + "title": "Account Id" + }, + "source_profile_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Source Profile Id" + } + }, + "type": "object", + "required": [ + "file", + "account_id" + ], + "title": "Body_preview_import_api_finance_imports_preview_post" + }, + "Body_run_import_api_finance_imports_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + }, + "account_id": { + "type": "string", + "format": "uuid", + "title": "Account Id" + }, + "source_profile_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Source Profile Id" + } + }, + "type": "object", + "required": [ + "file", + "account_id" + ], + "title": "Body_run_import_api_finance_imports_post" + }, + "Body_upload_import_api_imports_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + }, + "source": { + "type": "string", + "title": "Source" + } + }, + "type": "object", + "required": [ + "file", + "source" + ], + "title": "Body_upload_import_api_imports_post" + }, + "BudgetCreate": { + "properties": { + "category_id": { + "type": "string", + "format": "uuid", + "title": "Category Id" + }, + "monthly_amount": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Monthly Amount" + }, + "start_month": { + "type": "string", + "format": "date", + "title": "Start Month" + }, + "end_month": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "End Month" + } + }, + "type": "object", + "required": [ + "category_id", + "monthly_amount", + "start_month" + ], + "title": "BudgetCreate" + }, + "BudgetProgressItem": { + "properties": { + "budget_id": { + "type": "string", + "format": "uuid", + "title": "Budget Id" + }, + "category_id": { + "type": "string", + "format": "uuid", + "title": "Category Id" + }, + "category_name": { + "type": "string", + "title": "Category Name" + }, + "category_color": { + "type": "string", + "title": "Category Color" + }, + "budget": { + "type": "number", + "title": "Budget" + }, + "actual": { + "type": "number", + "title": "Actual" + }, + "remaining": { + "type": "number", + "title": "Remaining" + }, + "progress_pct": { + "type": "number", + "title": "Progress Pct" + }, + "projected_eom": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Projected Eom" + }, + "status": { + "type": "string", + "title": "Status" + } + }, + "type": "object", + "required": [ + "budget_id", + "category_id", + "category_name", + "category_color", + "budget", + "actual", + "remaining", + "progress_pct", + "projected_eom", + "status" + ], + "title": "BudgetProgressItem" + }, + "BudgetProgressResponse": { + "properties": { + "month": { + "type": "string", + "title": "Month" + }, + "items": { + "items": { + "$ref": "#/components/schemas/BudgetProgressItem" + }, + "type": "array", + "title": "Items" + }, + "totals": { + "$ref": "#/components/schemas/BudgetProgressTotals" + } + }, + "type": "object", + "required": [ + "month", + "items", + "totals" + ], + "title": "BudgetProgressResponse" + }, + "BudgetProgressTotals": { + "properties": { + "budget": { + "type": "number", + "title": "Budget" + }, + "actual": { + "type": "number", + "title": "Actual" + }, + "progress_pct": { + "type": "number", + "title": "Progress Pct" + } + }, + "type": "object", + "required": [ + "budget", + "actual", + "progress_pct" + ], + "title": "BudgetProgressTotals" + }, + "BudgetUpdate": { + "properties": { + "monthly_amount": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Monthly Amount" + }, + "end_month": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "End Month" + }, + "effective_from": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Effective From" + } + }, + "type": "object", + "title": "BudgetUpdate" + }, + "BulkCategorizeRequest": { + "properties": { + "transaction_ids": { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array", + "maxItems": 500, + "minItems": 1, + "title": "Transaction Ids" + }, + "category_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Category Id" + } + }, + "type": "object", + "required": [ + "transaction_ids" + ], + "title": "BulkCategorizeRequest" + }, + "BulkCategorizeResponse": { + "properties": { + "updated": { + "type": "integer", + "title": "Updated" + } + }, + "type": "object", + "required": [ + "updated" + ], + "title": "BulkCategorizeResponse" + }, + "CashflowResponse": { + "properties": { + "months": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Months" + }, + "income": { + "items": { + "type": "number" + }, + "type": "array", + "title": "Income" + }, + "expenses": { + "items": { + "type": "number" + }, + "type": "array", + "title": "Expenses" + }, + "net": { + "items": { + "type": "number" + }, + "type": "array", + "title": "Net" + }, + "cumulative_net": { + "items": { + "type": "number" + }, + "type": "array", + "title": "Cumulative Net" + } + }, + "type": "object", + "required": [ + "months", + "income", + "expenses", + "net", + "cumulative_net" + ], + "title": "CashflowResponse" + }, + "CategoryCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 80, + "minLength": 1, + "title": "Name" + }, + "parent_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Parent Id" + }, + "icon": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "title": "Icon" + }, + "color": { + "anyOf": [ + { + "type": "string", + "maxLength": 7 + }, + { + "type": "null" + } + ], + "title": "Color" + }, + "kind": { + "$ref": "#/components/schemas/CategoryKind", + "default": "expense" + }, + "sort_order": { + "type": "integer", + "title": "Sort Order", + "default": 0 + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "CategoryCreate" + }, + "CategoryKind": { + "type": "string", + "enum": [ + "income", + "expense", + "transfer" + ], + "title": "CategoryKind" + }, + "CategoryRead": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "parent_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Parent Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "icon": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Icon" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Color" + }, + "kind": { + "$ref": "#/components/schemas/CategoryKind" + }, + "is_system": { + "type": "boolean", + "title": "Is System" + }, + "sort_order": { + "type": "integer", + "title": "Sort Order" + }, + "transaction_count": { + "type": "integer", + "title": "Transaction Count", + "default": 0 + }, + "children": { + "items": { + "$ref": "#/components/schemas/CategoryRead" + }, + "type": "array", + "title": "Children" + } + }, + "type": "object", + "required": [ + "id", + "parent_id", + "name", + "icon", + "color", + "kind", + "is_system", + "sort_order" + ], + "title": "CategoryRead" + }, + "CategorySource": { + "type": "string", + "enum": [ + "rule", + "user" + ], + "title": "CategorySource" + }, + "CategoryUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 80, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "parent_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Parent Id" + }, + "icon": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "title": "Icon" + }, + "color": { + "anyOf": [ + { + "type": "string", + "maxLength": 7 + }, + { + "type": "null" + } + ], + "title": "Color" + }, + "sort_order": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sort Order" + } + }, + "type": "object", + "title": "CategoryUpdate" + }, + "CoilChangeCreate": { + "properties": { + "changed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Changed At" + }, + "product_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Product Id" + }, + "reason": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "title": "Reason" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "title": "CoilChangeCreate" + }, + "CoilChangeCreated": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "changed_at": { + "type": "string", + "format": "date-time", + "title": "Changed At" + }, + "product_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Product Id" + }, + "product_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Product Name" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "is_current": { + "type": "boolean", + "title": "Is Current" + }, + "lifespan_days": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Lifespan Days" + }, + "ml_through": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Ml Through" + }, + "previous_lifespan_days": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Previous Lifespan Days" + } + }, + "type": "object", + "required": [ + "id", + "changed_at", + "product_id", + "product_name", + "reason", + "note", + "is_current", + "lifespan_days", + "ml_through", + "previous_lifespan_days" + ], + "title": "CoilChangeCreated" + }, + "CoilChangeRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "changed_at": { + "type": "string", + "format": "date-time", + "title": "Changed At" + }, + "product_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Product Id" + }, + "product_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Product Name" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "is_current": { + "type": "boolean", + "title": "Is Current" + }, + "lifespan_days": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Lifespan Days" + }, + "ml_through": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Ml Through" + } + }, + "type": "object", + "required": [ + "id", + "changed_at", + "product_id", + "product_name", + "reason", + "note", + "is_current", + "lifespan_days", + "ml_through" + ], + "title": "CoilChangeRead" + }, + "CoilChangeUpdate": { + "properties": { + "changed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Changed At" + }, + "product_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Product Id" + }, + "reason": { + "anyOf": [ + { + "type": "string", + "maxLength": 50 + }, + { + "type": "null" + } + ], + "title": "Reason" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "title": "CoilChangeUpdate" + }, + "DailyActivityRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "date": { + "type": "string", + "format": "date", + "title": "Date" + }, + "steps": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Steps" + }, + "active_kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Active Kcal" + }, + "total_kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Total Kcal" + }, + "distance_m": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Distance M" + }, + "active_minutes": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Active Minutes" + }, + "floors": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Floors" + }, + "source": { + "type": "string", + "title": "Source" + } + }, + "type": "object", + "required": [ + "id", + "date", + "steps", + "active_kcal", + "total_kcal", + "distance_m", + "active_minutes", + "floors", + "source" + ], + "title": "DailyActivityRead" + }, + "DailyActivityUpsert": { + "properties": { + "date": { + "type": "string", + "format": "date", + "title": "Date" + }, + "steps": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Steps" + }, + "active_kcal": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Active Kcal" + }, + "total_kcal": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Total Kcal" + }, + "distance_m": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Distance M" + }, + "active_minutes": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Active Minutes" + }, + "floors": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Floors" + } + }, + "type": "object", + "required": [ + "date" + ], + "title": "DailyActivityUpsert", + "description": "POST /health/activity — manual upsert of one local day." + }, + "DeviceKeyCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 100, + "minLength": 1, + "title": "Name" + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array", + "minItems": 1, + "title": "Scopes" + } + }, + "type": "object", + "required": [ + "name", + "scopes" + ], + "title": "DeviceKeyCreate" + }, + "DeviceKeyCreated": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "key_prefix": { + "type": "string", + "title": "Key Prefix" + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Scopes" + }, + "last_used_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Used At" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "revoked_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Revoked At" + }, + "key": { + "type": "string", + "title": "Key" + } + }, + "type": "object", + "required": [ + "id", + "name", + "key_prefix", + "scopes", + "last_used_at", + "created_at", + "revoked_at", + "key" + ], + "title": "DeviceKeyCreated", + "description": "Returned once at creation time: the only moment the plaintext key exists." + }, + "DeviceKeyRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "key_prefix": { + "type": "string", + "title": "Key Prefix" + }, + "scopes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Scopes" + }, + "last_used_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Used At" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "revoked_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Revoked At" + } + }, + "type": "object", + "required": [ + "id", + "name", + "key_prefix", + "scopes", + "last_used_at", + "created_at", + "revoked_at" + ], + "title": "DeviceKeyRead" + }, + "FoodEntryCreate": { + "properties": { + "eaten_at": { + "type": "string", + "format": "date-time", + "title": "Eaten At" + }, + "meal": { + "$ref": "#/components/schemas/MealType" + }, + "name": { + "type": "string", + "maxLength": 200, + "minLength": 1, + "title": "Name" + }, + "brand": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ], + "title": "Brand" + }, + "quantity": { + "type": "number", + "exclusiveMinimum": 0.0, + "title": "Quantity", + "default": 100.0 + }, + "unit": { + "type": "string", + "maxLength": 20, + "title": "Unit", + "default": "g" + }, + "kcal": { + "type": "number", + "minimum": 0.0, + "title": "Kcal" + }, + "protein_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Protein G" + }, + "carbs_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Carbs G" + }, + "fat_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Fat G" + }, + "fiber_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Fiber G" + }, + "sugar_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Sugar G" + }, + "sat_fat_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Sat Fat G" + }, + "sodium_mg": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Sodium Mg" + }, + "favorite_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Favorite Id" + } + }, + "type": "object", + "required": [ + "eaten_at", + "meal", + "name", + "kcal" + ], + "title": "FoodEntryCreate" + }, + "FoodEntryRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "eaten_at": { + "type": "string", + "format": "date-time", + "title": "Eaten At" + }, + "meal": { + "$ref": "#/components/schemas/MealType" + }, + "name": { + "type": "string", + "title": "Name" + }, + "brand": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Brand" + }, + "quantity": { + "type": "number", + "title": "Quantity" + }, + "unit": { + "type": "string", + "title": "Unit" + }, + "kcal": { + "type": "number", + "title": "Kcal" + }, + "protein_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Protein G" + }, + "carbs_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Carbs G" + }, + "fat_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Fat G" + }, + "fiber_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Fiber G" + }, + "sugar_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Sugar G" + }, + "sat_fat_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Sat Fat G" + }, + "sodium_mg": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Sodium Mg" + }, + "source": { + "type": "string", + "title": "Source" + } + }, + "type": "object", + "required": [ + "id", + "eaten_at", + "meal", + "name", + "brand", + "quantity", + "unit", + "kcal", + "protein_g", + "carbs_g", + "fat_g", + "fiber_g", + "sugar_g", + "sat_fat_g", + "sodium_mg", + "source" + ], + "title": "FoodEntryRead" + }, + "FoodEntryUpdate": { + "properties": { + "eaten_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Eaten At" + }, + "meal": { + "anyOf": [ + { + "$ref": "#/components/schemas/MealType" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 200, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "brand": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ], + "title": "Brand" + }, + "quantity": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Quantity" + }, + "unit": { + "anyOf": [ + { + "type": "string", + "maxLength": 20 + }, + { + "type": "null" + } + ], + "title": "Unit" + }, + "kcal": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Kcal" + }, + "protein_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Protein G" + }, + "carbs_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Carbs G" + }, + "fat_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Fat G" + }, + "fiber_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Fiber G" + }, + "sugar_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Sugar G" + }, + "sat_fat_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Sat Fat G" + }, + "sodium_mg": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Sodium Mg" + } + }, + "type": "object", + "title": "FoodEntryUpdate" + }, + "FoodFavoriteCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 200, + "minLength": 1, + "title": "Name" + }, + "brand": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ], + "title": "Brand" + }, + "default_quantity": { + "type": "number", + "exclusiveMinimum": 0.0, + "title": "Default Quantity", + "default": 100.0 + }, + "unit": { + "type": "string", + "maxLength": 20, + "title": "Unit", + "default": "g" + }, + "kcal": { + "type": "number", + "minimum": 0.0, + "title": "Kcal" + }, + "protein_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Protein G" + }, + "carbs_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Carbs G" + }, + "fat_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Fat G" + }, + "fiber_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Fiber G" + }, + "default_meal": { + "anyOf": [ + { + "$ref": "#/components/schemas/MealType" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "name", + "kcal" + ], + "title": "FoodFavoriteCreate" + }, + "FoodFavoriteRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "brand": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Brand" + }, + "default_quantity": { + "type": "number", + "title": "Default Quantity" + }, + "unit": { + "type": "string", + "title": "Unit" + }, + "kcal": { + "type": "number", + "title": "Kcal" + }, + "protein_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Protein G" + }, + "carbs_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Carbs G" + }, + "fat_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Fat G" + }, + "fiber_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Fiber G" + }, + "default_meal": { + "anyOf": [ + { + "$ref": "#/components/schemas/MealType" + }, + { + "type": "null" + } + ] + }, + "use_count": { + "type": "integer", + "title": "Use Count" + }, + "last_used_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Used At" + } + }, + "type": "object", + "required": [ + "id", + "name", + "brand", + "default_quantity", + "unit", + "kcal", + "protein_g", + "carbs_g", + "fat_g", + "fiber_g", + "default_meal", + "use_count", + "last_used_at" + ], + "title": "FoodFavoriteRead" + }, + "FoodFavoriteUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 200, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "brand": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ], + "title": "Brand" + }, + "default_quantity": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Default Quantity" + }, + "unit": { + "anyOf": [ + { + "type": "string", + "maxLength": 20 + }, + { + "type": "null" + } + ], + "title": "Unit" + }, + "kcal": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Kcal" + }, + "protein_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Protein G" + }, + "carbs_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Carbs G" + }, + "fat_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Fat G" + }, + "fiber_g": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Fiber G" + }, + "default_meal": { + "anyOf": [ + { + "$ref": "#/components/schemas/MealType" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "title": "FoodFavoriteUpdate" + }, + "FoodSearchItem": { + "properties": { + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "source": { + "type": "string", + "title": "Source" + }, + "source_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "brand": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Brand" + }, + "energy_kcal_100g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Energy Kcal 100G" + }, + "protein_g_100g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Protein G 100G" + }, + "carbs_g_100g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Carbs G 100G" + }, + "sugar_g_100g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Sugar G 100G" + }, + "fat_g_100g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Fat G 100G" + }, + "sat_fat_g_100g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Sat Fat G 100G" + }, + "fiber_g_100g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Fiber G 100G" + }, + "salt_g_100g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Salt G 100G" + }, + "serving_size_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Serving Size G" + } + }, + "type": "object", + "required": [ + "source", + "name" + ], + "title": "FoodSearchItem" + }, + "FoodSearchResponse": { + "properties": { + "query": { + "type": "string", + "title": "Query" + }, + "items": { + "items": { + "$ref": "#/components/schemas/FoodSearchItem" + }, + "type": "array", + "title": "Items" + }, + "origin": { + "type": "string", + "title": "Origin", + "default": "cache" + } + }, + "type": "object", + "required": [ + "query" + ], + "title": "FoodSearchResponse" + }, + "GoalCreate": { + "properties": { + "mode": { + "$ref": "#/components/schemas/GoalMode" + }, + "start_date": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Start Date" + }, + "start_weight_kg": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 400.0, + "exclusiveMinimum": 20.0 + }, + { + "type": "null" + } + ], + "title": "Start Weight Kg" + }, + "target_weight_kg": { + "type": "number", + "exclusiveMaximum": 400.0, + "exclusiveMinimum": 20.0, + "title": "Target Weight Kg" + }, + "target_date": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Target Date" + }, + "weekly_rate_kg": { + "anyOf": [ + { + "type": "number", + "maximum": 1.5, + "exclusiveMinimum": -1.01 + }, + { + "type": "null" + } + ], + "title": "Weekly Rate Kg" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "required": [ + "mode", + "target_weight_kg" + ], + "title": "GoalCreate" + }, + "GoalMode": { + "type": "string", + "enum": [ + "weekly_rate", + "target_date", + "maintain" + ], + "title": "GoalMode" + }, + "GoalRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "mode": { + "$ref": "#/components/schemas/GoalMode" + }, + "start_date": { + "type": "string", + "format": "date", + "title": "Start Date" + }, + "start_weight_kg": { + "type": "number", + "title": "Start Weight Kg" + }, + "target_weight_kg": { + "type": "number", + "title": "Target Weight Kg" + }, + "target_date": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Target Date" + }, + "weekly_rate_kg": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Weekly Rate Kg" + }, + "status": { + "$ref": "#/components/schemas/GoalStatus" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "required": [ + "id", + "mode", + "start_date", + "start_weight_kg", + "target_weight_kg", + "target_date", + "weekly_rate_kg", + "status", + "note" + ], + "title": "GoalRead" + }, + "GoalStatus": { + "type": "string", + "enum": [ + "active", + "completed", + "abandoned" + ], + "title": "GoalStatus" + }, + "GoalUpdate": { + "properties": { + "mode": { + "anyOf": [ + { + "$ref": "#/components/schemas/GoalMode" + }, + { + "type": "null" + } + ] + }, + "start_date": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Start Date" + }, + "start_weight_kg": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 400.0, + "exclusiveMinimum": 20.0 + }, + { + "type": "null" + } + ], + "title": "Start Weight Kg" + }, + "target_weight_kg": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 400.0, + "exclusiveMinimum": 20.0 + }, + { + "type": "null" + } + ], + "title": "Target Weight Kg" + }, + "target_date": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Target Date" + }, + "weekly_rate_kg": { + "anyOf": [ + { + "type": "number", + "maximum": 1.5, + "exclusiveMinimum": -1.01 + }, + { + "type": "null" + } + ], + "title": "Weekly Rate Kg" + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/GoalStatus" + }, + { + "type": "null" + } + ] + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "title": "GoalUpdate" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "HealthProfileRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "height_cm": { + "type": "number", + "title": "Height Cm" + }, + "sex": { + "$ref": "#/components/schemas/Sex" + }, + "birthdate": { + "type": "string", + "format": "date", + "title": "Birthdate" + }, + "activity_level": { + "$ref": "#/components/schemas/ActivityLevel" + }, + "timezone": { + "type": "string", + "title": "Timezone" + }, + "water_goal_ml": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Water Goal Ml" + }, + "calorie_floor_kcal": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Calorie Floor Kcal" + }, + "age": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Age" + }, + "bmr_kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Bmr Kcal" + }, + "tdee_estimated_kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Tdee Estimated Kcal" + }, + "current_weight_kg": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Current Weight Kg" + }, + "bmi": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Bmi" + } + }, + "type": "object", + "required": [ + "id", + "height_cm", + "sex", + "birthdate", + "activity_level", + "timezone", + "water_goal_ml", + "calorie_floor_kcal" + ], + "title": "HealthProfileRead" + }, + "HealthProfileUpdate": { + "properties": { + "height_cm": { + "type": "number", + "exclusiveMaximum": 300.0, + "exclusiveMinimum": 0.0, + "title": "Height Cm" + }, + "sex": { + "$ref": "#/components/schemas/Sex" + }, + "birthdate": { + "type": "string", + "format": "date", + "title": "Birthdate" + }, + "activity_level": { + "$ref": "#/components/schemas/ActivityLevel", + "default": "sedentary" + }, + "timezone": { + "type": "string", + "maxLength": 64, + "title": "Timezone", + "default": "Europe/Paris" + }, + "water_goal_ml": { + "anyOf": [ + { + "type": "integer", + "maximum": 20000.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Water Goal Ml", + "default": 2000 + }, + "calorie_floor_kcal": { + "anyOf": [ + { + "type": "integer", + "maximum": 5000.0, + "minimum": 800.0 + }, + { + "type": "null" + } + ], + "title": "Calorie Floor Kcal" + } + }, + "type": "object", + "required": [ + "height_cm", + "sex", + "birthdate" + ], + "title": "HealthProfileUpdate", + "description": "PUT /health/profile — full singleton upsert." + }, + "ImportPreviewResponse": { + "properties": { + "rows_preview": { + "items": { + "$ref": "#/components/schemas/NormalizedRowRead" + }, + "type": "array", + "title": "Rows Preview" + }, + "rows_total": { + "type": "integer", + "title": "Rows Total" + }, + "rows_error": { + "type": "integer", + "title": "Rows Error" + }, + "rows_skipped_filtered": { + "type": "integer", + "title": "Rows Skipped Filtered" + }, + "would_skip_duplicates": { + "type": "integer", + "title": "Would Skip Duplicates" + }, + "date_min": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Date Min" + }, + "date_max": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Date Max" + }, + "errors": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Errors" + }, + "duplicate_file_of": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Duplicate File Of" + } + }, + "type": "object", + "required": [ + "rows_preview", + "rows_total", + "rows_error", + "rows_skipped_filtered", + "would_skip_duplicates", + "date_min", + "date_max", + "errors" + ], + "title": "ImportPreviewResponse" + }, + "ImportStatus": { + "type": "string", + "enum": [ + "pending", + "running", + "completed", + "failed" + ], + "title": "ImportStatus" + }, + "IngestPayload": { + "properties": { + "source": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "title": "Source", + "default": "ingest" + }, + "records": { + "items": { + "$ref": "#/components/schemas/IngestRecordIn" + }, + "type": "array", + "maxItems": 1000, + "minItems": 1, + "title": "Records" + } + }, + "type": "object", + "required": [ + "records" + ], + "title": "IngestPayload" + }, + "IngestRecordError": { + "properties": { + "index": { + "type": "integer", + "title": "Index" + }, + "type": { + "type": "string", + "title": "Type" + }, + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "index", + "type", + "message" + ], + "title": "IngestRecordError" + }, + "IngestRecordIn": { + "properties": { + "type": { + "type": "string", + "title": "Type" + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data" + }, + "external_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Id" + } + }, + "type": "object", + "required": [ + "type", + "data" + ], + "title": "IngestRecordIn" + }, + "IngestResponse": { + "properties": { + "domain": { + "type": "string", + "title": "Domain" + }, + "received": { + "type": "integer", + "title": "Received" + }, + "inserted": { + "type": "integer", + "title": "Inserted" + }, + "updated": { + "type": "integer", + "title": "Updated" + }, + "duplicates": { + "type": "integer", + "title": "Duplicates" + }, + "errors": { + "items": { + "$ref": "#/components/schemas/IngestRecordError" + }, + "type": "array", + "title": "Errors" + } + }, + "type": "object", + "required": [ + "domain", + "received", + "inserted", + "updated", + "duplicates", + "errors" + ], + "title": "IngestResponse" + }, + "LiquidEntryCreate": { + "properties": { + "entry_date": { + "type": "string", + "format": "date", + "title": "Entry Date" + }, + "ml": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Ml" + }, + "kind": { + "$ref": "#/components/schemas/LiquidEntryKind", + "default": "refill" + }, + "nicotine_mg_ml": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Nicotine Mg Ml" + }, + "mix_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Mix Id" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "required": [ + "entry_date", + "ml" + ], + "title": "LiquidEntryCreate" + }, + "LiquidEntryKind": { + "type": "string", + "enum": [ + "refill", + "daily_total" + ], + "title": "LiquidEntryKind" + }, + "LiquidEntryRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "entry_date": { + "type": "string", + "format": "date", + "title": "Entry Date" + }, + "kind": { + "$ref": "#/components/schemas/LiquidEntryKind" + }, + "ml": { + "type": "number", + "title": "Ml" + }, + "nicotine_mg_ml": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Nicotine Mg Ml" + }, + "nicotine_effective_mg_ml": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Nicotine Effective Mg Ml" + }, + "nicotine_mg": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Nicotine Mg" + }, + "mix_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Mix Id" + }, + "source": { + "type": "string", + "title": "Source" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "required": [ + "id", + "entry_date", + "kind", + "ml", + "nicotine_mg_ml", + "nicotine_effective_mg_ml", + "nicotine_mg", + "mix_id", + "source", + "note" + ], + "title": "LiquidEntryRead" + }, + "LiquidEntryUpdate": { + "properties": { + "entry_date": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Entry Date" + }, + "ml": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Ml" + }, + "kind": { + "anyOf": [ + { + "$ref": "#/components/schemas/LiquidEntryKind" + }, + { + "type": "null" + } + ] + }, + "nicotine_mg_ml": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Nicotine Mg Ml" + }, + "mix_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Mix Id" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "title": "LiquidEntryUpdate" + }, + "LoginRequest": { + "properties": { + "email": { + "type": "string", + "title": "Email" + }, + "password": { + "type": "string", + "title": "Password" + } + }, + "type": "object", + "required": [ + "email", + "password" + ], + "title": "LoginRequest" + }, + "MealTotals": { + "properties": { + "meal": { + "$ref": "#/components/schemas/MealType" + }, + "kcal": { + "type": "number", + "title": "Kcal", + "default": 0.0 + }, + "protein_g": { + "type": "number", + "title": "Protein G", + "default": 0.0 + }, + "carbs_g": { + "type": "number", + "title": "Carbs G", + "default": 0.0 + }, + "fat_g": { + "type": "number", + "title": "Fat G", + "default": 0.0 + }, + "entries": { + "items": { + "$ref": "#/components/schemas/FoodEntryRead" + }, + "type": "array", + "title": "Entries" + } + }, + "type": "object", + "required": [ + "meal" + ], + "title": "MealTotals" + }, + "MealType": { + "type": "string", + "enum": [ + "breakfast", + "lunch", + "dinner", + "snack" + ], + "title": "MealType" + }, + "MerchantRead": { + "properties": { + "merchant": { + "type": "string", + "title": "Merchant" + }, + "total": { + "type": "number", + "title": "Total" + }, + "count": { + "type": "integer", + "title": "Count" + }, + "average": { + "type": "number", + "title": "Average" + }, + "category_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category Name" + }, + "category_color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category Color" + } + }, + "type": "object", + "required": [ + "merchant", + "total", + "count", + "average" + ], + "title": "MerchantRead" + }, + "MilestoneRead": { + "properties": { + "code": { + "type": "string", + "title": "Code" + }, + "label_fr": { + "type": "string", + "title": "Label Fr" + }, + "reached_at": { + "type": "string", + "format": "date-time", + "title": "Reached At" + }, + "achieved": { + "type": "boolean", + "title": "Achieved" + }, + "progress_pct": { + "type": "number", + "title": "Progress Pct" + } + }, + "type": "object", + "required": [ + "code", + "label_fr", + "reached_at", + "achieved", + "progress_pct" + ], + "title": "MilestoneRead" + }, + "MixCalculatorRequest": { + "properties": { + "total_ml": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Total Ml" + }, + "target_nicotine_mg_ml": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Target Nicotine Mg Ml" + }, + "booster_product_id": { + "type": "integer", + "title": "Booster Product Id" + }, + "base_product_id": { + "type": "integer", + "title": "Base Product Id" + }, + "aroma_pct": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Aroma Pct", + "default": "0" + }, + "aroma_product_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Aroma Product Id" + } + }, + "type": "object", + "required": [ + "total_ml", + "target_nicotine_mg_ml", + "booster_product_id", + "base_product_id" + ], + "title": "MixCalculatorRequest" + }, + "MixCalculatorResult": { + "properties": { + "total_ml": { + "type": "number", + "title": "Total Ml" + }, + "target_nicotine_mg_ml": { + "type": "number", + "title": "Target Nicotine Mg Ml" + }, + "booster_ml": { + "type": "number", + "title": "Booster Ml" + }, + "aroma_ml": { + "type": "number", + "title": "Aroma Ml" + }, + "base_ml": { + "type": "number", + "title": "Base Ml" + }, + "nicotine_check_mg_ml": { + "type": "number", + "title": "Nicotine Check Mg Ml" + }, + "cost_total_cents": { + "type": "number", + "title": "Cost Total Cents" + }, + "cost_per_ml_cents": { + "type": "number", + "title": "Cost Per Ml Cents" + } + }, + "type": "object", + "required": [ + "total_ml", + "target_nicotine_mg_ml", + "booster_ml", + "aroma_ml", + "base_ml", + "nicotine_check_mg_ml", + "cost_total_cents", + "cost_per_ml_cents" + ], + "title": "MixCalculatorResult" + }, + "MixComponentIn": { + "properties": { + "product_id": { + "type": "integer", + "title": "Product Id" + }, + "quantity": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Quantity" + } + }, + "type": "object", + "required": [ + "product_id", + "quantity" + ], + "title": "MixComponentIn" + }, + "MixComponentRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "product_id": { + "type": "integer", + "title": "Product Id" + }, + "product_name": { + "type": "string", + "title": "Product Name" + }, + "product_kind": { + "$ref": "#/components/schemas/ProductKind" + }, + "quantity": { + "type": "number", + "title": "Quantity" + }, + "cost_cents": { + "type": "number", + "title": "Cost Cents" + } + }, + "type": "object", + "required": [ + "id", + "product_id", + "product_name", + "product_kind", + "quantity", + "cost_cents" + ], + "title": "MixComponentRead" + }, + "MixCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 150, + "minLength": 1, + "title": "Name" + }, + "total_ml": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Total Ml" + }, + "target_nicotine_mg_ml": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Target Nicotine Mg Ml" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "components": { + "items": { + "$ref": "#/components/schemas/MixComponentIn" + }, + "type": "array", + "title": "Components" + } + }, + "type": "object", + "required": [ + "name", + "total_ml", + "target_nicotine_mg_ml" + ], + "title": "MixCreate" + }, + "MixRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "total_ml": { + "type": "number", + "title": "Total Ml" + }, + "target_nicotine_mg_ml": { + "type": "number", + "title": "Target Nicotine Mg Ml" + }, + "is_active": { + "type": "boolean", + "title": "Is Active" + }, + "is_archived": { + "type": "boolean", + "title": "Is Archived" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "components": { + "items": { + "$ref": "#/components/schemas/MixComponentRead" + }, + "type": "array", + "title": "Components" + }, + "cost_total_cents": { + "type": "number", + "title": "Cost Total Cents" + }, + "cost_per_ml_cents": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Cost Per Ml Cents" + }, + "nicotine_check_mg_ml": { + "type": "number", + "title": "Nicotine Check Mg Ml" + }, + "vg_pct_mix": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Vg Pct Mix" + }, + "warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Warning" + } + }, + "type": "object", + "required": [ + "id", + "name", + "total_ml", + "target_nicotine_mg_ml", + "is_active", + "is_archived", + "note", + "components", + "cost_total_cents", + "cost_per_ml_cents", + "nicotine_check_mg_ml", + "vg_pct_mix", + "warning" + ], + "title": "MixRead" + }, + "MixUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 150, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "total_ml": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Total Ml" + }, + "target_nicotine_mg_ml": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Target Nicotine Mg Ml" + }, + "is_archived": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Archived" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "components": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/MixComponentIn" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Components" + } + }, + "type": "object", + "title": "MixUpdate" + }, + "MonthlyByCategoryResponse": { + "properties": { + "months": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Months" + }, + "series": { + "items": { + "$ref": "#/components/schemas/MonthlySeries" + }, + "type": "array", + "title": "Series" + }, + "totals": { + "items": { + "type": "number" + }, + "type": "array", + "title": "Totals" + } + }, + "type": "object", + "required": [ + "months", + "series", + "totals" + ], + "title": "MonthlyByCategoryResponse" + }, + "MonthlySeries": { + "properties": { + "category_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Category Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "color": { + "type": "string", + "title": "Color" + }, + "data": { + "items": { + "type": "number" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "required": [ + "category_id", + "name", + "color", + "data" + ], + "title": "MonthlySeries" + }, + "NormalizedRowRead": { + "properties": { + "booked_date": { + "type": "string", + "format": "date", + "title": "Booked Date" + }, + "value_date": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Value Date" + }, + "amount": { + "type": "number", + "title": "Amount" + }, + "currency": { + "type": "string", + "title": "Currency" + }, + "label_raw": { + "type": "string", + "title": "Label Raw" + }, + "counterparty": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Counterparty" + }, + "external_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Id" + }, + "source_row_index": { + "type": "integer", + "title": "Source Row Index" + } + }, + "type": "object", + "required": [ + "booked_date", + "value_date", + "amount", + "currency", + "label_raw", + "counterparty", + "external_id", + "source_row_index" + ], + "title": "NormalizedRowRead" + }, + "NutritionDayDetail": { + "properties": { + "date": { + "type": "string", + "format": "date", + "title": "Date" + }, + "totals": { + "$ref": "#/components/schemas/NutritionDayRead" + }, + "water_ml": { + "type": "integer", + "title": "Water Ml", + "default": 0 + }, + "water_goal_ml": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Water Goal Ml" + }, + "meals": { + "items": { + "$ref": "#/components/schemas/MealTotals" + }, + "type": "array", + "title": "Meals" + } + }, + "type": "object", + "required": [ + "date", + "totals" + ], + "title": "NutritionDayDetail" + }, + "NutritionDayRead": { + "properties": { + "date": { + "type": "string", + "format": "date", + "title": "Date" + }, + "kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Kcal" + }, + "protein_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Protein G" + }, + "carbs_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Carbs G" + }, + "fat_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Fat G" + }, + "fiber_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Fiber G" + }, + "budget_kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Budget Kcal" + }, + "vs_budget_kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Vs Budget Kcal" + } + }, + "type": "object", + "required": [ + "date" + ], + "title": "NutritionDayRead" + }, + "Page_BodyMeasurementRead_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/BodyMeasurementRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[BodyMeasurementRead]" + }, + "Page_CoilChangeRead_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/CoilChangeRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[CoilChangeRead]" + }, + "Page_FoodEntryRead_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/FoodEntryRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[FoodEntryRead]" + }, + "Page_FoodFavoriteRead_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/FoodFavoriteRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[FoodFavoriteRead]" + }, + "Page_GoalRead_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/GoalRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[GoalRead]" + }, + "Page_LiquidEntryRead_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/LiquidEntryRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[LiquidEntryRead]" + }, + "Page_MixRead_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/MixRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[MixRead]" + }, + "Page_ProductRead_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ProductRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[ProductRead]" + }, + "Page_PurchaseRead_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/PurchaseRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[PurchaseRead]" + }, + "Page_TransactionRead_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/TransactionRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[TransactionRead]" + }, + "Page_WaterEntryRead_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/WaterEntryRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[WaterEntryRead]" + }, + "Page_WeightEntryRead_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/WeightEntryRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[WeightEntryRead]" + }, + "Page_WorkoutRead_": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/WorkoutRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[WorkoutRead]" + }, + "PeriodRead": { + "properties": { + "from": { + "type": "string", + "format": "date", + "title": "From" + }, + "to": { + "type": "string", + "format": "date", + "title": "To" + } + }, + "type": "object", + "required": [ + "from", + "to" + ], + "title": "PeriodRead" + }, + "ProductCreate": { + "properties": { + "kind": { + "$ref": "#/components/schemas/ProductKind" + }, + "name": { + "type": "string", + "maxLength": 150, + "minLength": 1, + "title": "Name" + }, + "brand": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ], + "title": "Brand" + }, + "price_cents": { + "type": "integer", + "minimum": 0.0, + "title": "Price Cents" + }, + "size_value": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Size Value" + }, + "size_unit": { + "$ref": "#/components/schemas/SizeUnit" + }, + "nicotine_mg_ml": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Nicotine Mg Ml" + }, + "vg_pct": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Vg Pct" + }, + "ohm": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Ohm" + }, + "is_archived": { + "type": "boolean", + "title": "Is Archived", + "default": false + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "required": [ + "kind", + "name", + "price_cents", + "size_value", + "size_unit" + ], + "title": "ProductCreate" + }, + "ProductKind": { + "type": "string", + "enum": [ + "coil", + "base", + "booster", + "aroma", + "hardware", + "pod" + ], + "title": "ProductKind" + }, + "ProductRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "kind": { + "$ref": "#/components/schemas/ProductKind" + }, + "name": { + "type": "string", + "title": "Name" + }, + "brand": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Brand" + }, + "price_cents": { + "type": "integer", + "title": "Price Cents" + }, + "size_value": { + "type": "number", + "title": "Size Value" + }, + "size_unit": { + "$ref": "#/components/schemas/SizeUnit" + }, + "nicotine_mg_ml": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Nicotine Mg Ml" + }, + "vg_pct": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Vg Pct" + }, + "ohm": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Ohm" + }, + "is_archived": { + "type": "boolean", + "title": "Is Archived" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "unit_price_cents": { + "type": "number", + "title": "Unit Price Cents" + } + }, + "type": "object", + "required": [ + "id", + "kind", + "name", + "brand", + "price_cents", + "size_value", + "size_unit", + "nicotine_mg_ml", + "vg_pct", + "ohm", + "is_archived", + "note", + "unit_price_cents" + ], + "title": "ProductRead" + }, + "ProductUpdate": { + "properties": { + "kind": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProductKind" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 150, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "brand": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ], + "title": "Brand" + }, + "price_cents": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Price Cents" + }, + "size_value": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Size Value" + }, + "size_unit": { + "anyOf": [ + { + "$ref": "#/components/schemas/SizeUnit" + }, + { + "type": "null" + } + ] + }, + "nicotine_mg_ml": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Nicotine Mg Ml" + }, + "vg_pct": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Vg Pct" + }, + "ohm": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Ohm" + }, + "is_archived": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Archived" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "title": "ProductUpdate" + }, + "ProjectionRead": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "date": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Date" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "ProjectionRead" + }, + "PurchaseCreate": { + "properties": { + "purchased_on": { + "type": "string", + "format": "date", + "title": "Purchased On" + }, + "product_id": { + "type": "integer", + "title": "Product Id" + }, + "qty": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Qty", + "default": "1" + }, + "unit_price_cents": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Unit Price Cents" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "required": [ + "purchased_on", + "product_id" + ], + "title": "PurchaseCreate" + }, + "PurchaseRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "purchased_on": { + "type": "string", + "format": "date", + "title": "Purchased On" + }, + "product_id": { + "type": "integer", + "title": "Product Id" + }, + "product_name": { + "type": "string", + "title": "Product Name" + }, + "qty": { + "type": "number", + "title": "Qty" + }, + "unit_price_cents": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Unit Price Cents" + }, + "total_cents": { + "type": "number", + "title": "Total Cents" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "required": [ + "id", + "purchased_on", + "product_id", + "product_name", + "qty", + "unit_price_cents", + "total_cents", + "note" + ], + "title": "PurchaseRead" + }, + "PurchaseUpdate": { + "properties": { + "purchased_on": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Purchased On" + }, + "product_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Product Id" + }, + "qty": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Qty" + }, + "unit_price_cents": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Unit Price Cents" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "title": "PurchaseUpdate" + }, + "RecentFoodRead": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "brand": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Brand" + }, + "unit": { + "type": "string", + "title": "Unit", + "default": "g" + }, + "quantity": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Quantity" + }, + "kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Kcal" + }, + "protein_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Protein G" + }, + "carbs_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Carbs G" + }, + "fat_g": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Fat G" + }, + "meal": { + "anyOf": [ + { + "$ref": "#/components/schemas/MealType" + }, + { + "type": "null" + } + ] + }, + "last_eaten_at": { + "type": "string", + "format": "date-time", + "title": "Last Eaten At" + } + }, + "type": "object", + "required": [ + "name", + "last_eaten_at" + ], + "title": "RecentFoodRead", + "description": "Distinct (name, brand) recently logged — computed from food_entries." + }, + "RecurringItem": { + "properties": { + "merchant_key": { + "type": "string", + "title": "Merchant Key" + }, + "label_display": { + "type": "string", + "title": "Label Display" + }, + "category_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Category Id" + }, + "category_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category Name" + }, + "periodicity": { + "type": "string", + "title": "Periodicity" + }, + "occurrences": { + "type": "integer", + "title": "Occurrences" + }, + "average_amount": { + "type": "number", + "title": "Average Amount" + }, + "expected_amount": { + "type": "number", + "title": "Expected Amount" + }, + "last_date": { + "type": "string", + "format": "date", + "title": "Last Date" + }, + "next_date_predicted": { + "type": "string", + "format": "date", + "title": "Next Date Predicted" + }, + "is_active": { + "type": "boolean", + "title": "Is Active" + } + }, + "type": "object", + "required": [ + "merchant_key", + "label_display", + "category_id", + "category_name", + "periodicity", + "occurrences", + "average_amount", + "expected_amount", + "last_date", + "next_date_predicted", + "is_active" + ], + "title": "RecurringItem" + }, + "RecurringResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/RecurringItem" + }, + "type": "array", + "title": "Items" + }, + "monthly_total_estimate": { + "type": "number", + "title": "Monthly Total Estimate" + } + }, + "type": "object", + "required": [ + "items", + "monthly_total_estimate" + ], + "title": "RecurringResponse" + }, + "RuleApplyByRule": { + "properties": { + "rule_id": { + "type": "string", + "format": "uuid", + "title": "Rule Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "matched": { + "type": "integer", + "title": "Matched" + } + }, + "type": "object", + "required": [ + "rule_id", + "name", + "matched" + ], + "title": "RuleApplyByRule" + }, + "RuleApplyRequest": { + "properties": { + "scope": { + "type": "string", + "enum": [ + "uncategorized", + "all_non_manual", + "all" + ], + "title": "Scope", + "default": "uncategorized" + }, + "date_from": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Date From" + }, + "date_to": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Date To" + }, + "account_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Account Id" + }, + "rule_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Rule Id" + }, + "dry_run": { + "type": "boolean", + "title": "Dry Run", + "default": false + }, + "force": { + "type": "boolean", + "title": "Force", + "default": false + } + }, + "type": "object", + "title": "RuleApplyRequest" + }, + "RuleApplyResponse": { + "properties": { + "scanned": { + "type": "integer", + "title": "Scanned" + }, + "matched": { + "type": "integer", + "title": "Matched" + }, + "updated": { + "type": "integer", + "title": "Updated" + }, + "dry_run": { + "type": "boolean", + "title": "Dry Run" + }, + "by_rule": { + "items": { + "$ref": "#/components/schemas/RuleApplyByRule" + }, + "type": "array", + "title": "By Rule" + } + }, + "type": "object", + "required": [ + "scanned", + "matched", + "updated", + "dry_run", + "by_rule" + ], + "title": "RuleApplyResponse" + }, + "RuleCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 100, + "minLength": 1, + "title": "Name" + }, + "priority": { + "type": "integer", + "title": "Priority", + "default": 100 + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + }, + "stop": { + "type": "boolean", + "title": "Stop", + "default": true + }, + "matchers": { + "additionalProperties": true, + "type": "object", + "title": "Matchers" + }, + "actions": { + "additionalProperties": true, + "type": "object", + "title": "Actions" + } + }, + "type": "object", + "required": [ + "name", + "matchers", + "actions" + ], + "title": "RuleCreate" + }, + "RulePreviewRequest": { + "properties": { + "matchers": { + "additionalProperties": true, + "type": "object", + "title": "Matchers" + } + }, + "type": "object", + "required": [ + "matchers" + ], + "title": "RulePreviewRequest" + }, + "RulePreviewResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/TransactionRead" + }, + "type": "array", + "title": "Items" + }, + "total_matched": { + "type": "integer", + "title": "Total Matched" + } + }, + "type": "object", + "required": [ + "items", + "total_matched" + ], + "title": "RulePreviewResponse" + }, + "RuleRead": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "priority": { + "type": "integer", + "title": "Priority" + }, + "enabled": { + "type": "boolean", + "title": "Enabled" + }, + "stop": { + "type": "boolean", + "title": "Stop" + }, + "matchers": { + "additionalProperties": true, + "type": "object", + "title": "Matchers" + }, + "actions": { + "additionalProperties": true, + "type": "object", + "title": "Actions" + }, + "hit_count": { + "type": "integer", + "title": "Hit Count" + }, + "last_applied_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Applied At" + } + }, + "type": "object", + "required": [ + "id", + "name", + "priority", + "enabled", + "stop", + "matchers", + "actions", + "hit_count", + "last_applied_at" + ], + "title": "RuleRead" + }, + "RuleReorderRequest": { + "properties": { + "ordered_ids": { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array", + "minItems": 1, + "title": "Ordered Ids" + } + }, + "type": "object", + "required": [ + "ordered_ids" + ], + "title": "RuleReorderRequest" + }, + "RuleUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "priority": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Priority" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled" + }, + "stop": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Stop" + }, + "matchers": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Matchers" + }, + "actions": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Actions" + } + }, + "type": "object", + "title": "RuleUpdate" + }, + "SankeyLink": { + "properties": { + "source": { + "type": "string", + "title": "Source" + }, + "target": { + "type": "string", + "title": "Target" + }, + "value": { + "type": "number", + "title": "Value" + } + }, + "type": "object", + "required": [ + "source", + "target", + "value" + ], + "title": "SankeyLink" + }, + "SankeyNode": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "color": { + "type": "string", + "title": "Color" + } + }, + "type": "object", + "required": [ + "name", + "color" + ], + "title": "SankeyNode" + }, + "SankeyResponse": { + "properties": { + "period": { + "$ref": "#/components/schemas/PeriodRead" + }, + "nodes": { + "items": { + "$ref": "#/components/schemas/SankeyNode" + }, + "type": "array", + "title": "Nodes" + }, + "links": { + "items": { + "$ref": "#/components/schemas/SankeyLink" + }, + "type": "array", + "title": "Links" + } + }, + "type": "object", + "required": [ + "period", + "nodes", + "links" + ], + "title": "SankeyResponse" + }, + "ScheduleKind": { + "type": "string", + "enum": [ + "weigh_in", + "workout", + "food_log" + ], + "title": "ScheduleKind" + }, + "ScheduleRead": { + "properties": { + "kind": { + "$ref": "#/components/schemas/ScheduleKind" + }, + "weekdays": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Weekdays" + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": false + } + }, + "type": "object", + "required": [ + "kind" + ], + "title": "ScheduleRead" + }, + "ScheduleUpdate": { + "properties": { + "weekdays": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Weekdays" + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + } + }, + "type": "object", + "title": "ScheduleUpdate" + }, + "Series": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "type": { + "type": "string", + "title": "Type" + }, + "points": { + "items": { + "items": {}, + "type": "array" + }, + "type": "array", + "title": "Points" + } + }, + "type": "object", + "required": [ + "name", + "type" + ], + "title": "Series" + }, + "SeriesModel": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "type": { + "type": "string", + "title": "Type" + }, + "points": { + "items": { + "prefixItems": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + ], + "type": "array", + "maxItems": 2, + "minItems": 2 + }, + "type": "array", + "title": "Points" + } + }, + "type": "object", + "required": [ + "name", + "type", + "points" + ], + "title": "SeriesModel" + }, + "SetupRequest": { + "properties": { + "email": { + "type": "string", + "maxLength": 255, + "minLength": 3, + "pattern": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", + "title": "Email" + }, + "password": { + "type": "string", + "maxLength": 128, + "minLength": 8, + "title": "Password" + }, + "display_name": { + "type": "string", + "maxLength": 100, + "minLength": 1, + "title": "Display Name" + } + }, + "type": "object", + "required": [ + "email", + "password", + "display_name" + ], + "title": "SetupRequest" + }, + "Sex": { + "type": "string", + "enum": [ + "male", + "female", + "other" + ], + "title": "Sex" + }, + "SizeUnit": { + "type": "string", + "enum": [ + "ml", + "unit", + "g" + ], + "title": "SizeUnit" + }, + "SourceInfo": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "label": { + "type": "string", + "title": "Label" + }, + "domain": { + "type": "string", + "title": "Domain" + }, + "accepted_extensions": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Accepted Extensions" + } + }, + "type": "object", + "required": [ + "id", + "label", + "domain", + "accepted_extensions" + ], + "title": "SourceInfo" + }, + "SourceKind": { + "type": "string", + "enum": [ + "csv", + "ofx", + "paypal_csv" + ], + "title": "SourceKind" + }, + "SourceProfileCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 100, + "minLength": 1, + "title": "Name" + }, + "kind": { + "$ref": "#/components/schemas/SourceKind" + }, + "config": { + "additionalProperties": true, + "type": "object", + "title": "Config" + } + }, + "type": "object", + "required": [ + "name", + "kind" + ], + "title": "SourceProfileCreate" + }, + "SourceProfileRead": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "kind": { + "$ref": "#/components/schemas/SourceKind" + }, + "config": { + "additionalProperties": true, + "type": "object", + "title": "Config" + }, + "is_builtin": { + "type": "boolean", + "title": "Is Builtin" + } + }, + "type": "object", + "required": [ + "id", + "name", + "kind", + "config", + "is_builtin" + ], + "title": "SourceProfileRead" + }, + "SourceProfileUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Config" + } + }, + "type": "object", + "title": "SourceProfileUpdate" + }, + "SportType": { + "type": "string", + "enum": [ + "treadmill_walk", + "treadmill_run", + "walking", + "running", + "cycling", + "swimming", + "strength", + "hiit", + "yoga", + "hiking", + "other" + ], + "title": "SportType" + }, + "StreakRead": { + "properties": { + "current": { + "type": "integer", + "title": "Current", + "default": 0 + }, + "best": { + "type": "integer", + "title": "Best", + "default": 0 + } + }, + "type": "object", + "title": "StreakRead" + }, + "TodayItem": { + "properties": { + "kind": { + "$ref": "#/components/schemas/ScheduleKind" + }, + "planned": { + "type": "boolean", + "title": "Planned" + }, + "done": { + "type": "boolean", + "title": "Done" + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Value" + } + }, + "type": "object", + "required": [ + "kind", + "planned", + "done" + ], + "title": "TodayItem" + }, + "TodayResponse": { + "properties": { + "date": { + "type": "string", + "format": "date", + "title": "Date" + }, + "items": { + "items": { + "$ref": "#/components/schemas/TodayItem" + }, + "type": "array", + "title": "Items" + }, + "streaks": { + "additionalProperties": { + "$ref": "#/components/schemas/StreakRead" + }, + "type": "object", + "title": "Streaks" + } + }, + "type": "object", + "required": [ + "date" + ], + "title": "TodayResponse" + }, + "TokenResponse": { + "properties": { + "access_token": { + "type": "string", + "title": "Access Token" + }, + "token_type": { + "type": "string", + "title": "Token Type", + "default": "bearer" + }, + "user": { + "$ref": "#/components/schemas/UserRead" + } + }, + "type": "object", + "required": [ + "access_token", + "user" + ], + "title": "TokenResponse" + }, + "TopMerchantsResponse": { + "properties": { + "period": { + "$ref": "#/components/schemas/PeriodRead" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MerchantRead" + }, + "type": "array", + "title": "Items" + } + }, + "type": "object", + "required": [ + "period", + "items" + ], + "title": "TopMerchantsResponse" + }, + "TransactionCreate": { + "properties": { + "account_id": { + "type": "string", + "format": "uuid", + "title": "Account Id" + }, + "booked_date": { + "type": "string", + "format": "date", + "title": "Booked Date" + }, + "amount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Amount" + }, + "label_clean": { + "type": "string", + "minLength": 1, + "title": "Label Clean" + }, + "value_date": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Value Date" + }, + "currency": { + "anyOf": [ + { + "type": "string", + "maxLength": 3, + "minLength": 3 + }, + { + "type": "null" + } + ], + "title": "Currency" + }, + "category_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Category Id" + }, + "counterparty": { + "anyOf": [ + { + "type": "string", + "maxLength": 150 + }, + { + "type": "null" + } + ], + "title": "Counterparty" + }, + "notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Notes" + } + }, + "type": "object", + "required": [ + "account_id", + "booked_date", + "amount", + "label_clean" + ], + "title": "TransactionCreate" + }, + "TransactionRead": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "account_id": { + "type": "string", + "format": "uuid", + "title": "Account Id" + }, + "account_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Account Name" + }, + "booked_date": { + "type": "string", + "format": "date", + "title": "Booked Date" + }, + "value_date": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Value Date" + }, + "amount": { + "type": "number", + "title": "Amount" + }, + "currency": { + "type": "string", + "title": "Currency" + }, + "label_raw": { + "type": "string", + "title": "Label Raw" + }, + "label_clean": { + "type": "string", + "title": "Label Clean" + }, + "counterparty": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Counterparty" + }, + "category_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Category Id" + }, + "category_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category Name" + }, + "category_color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category Color" + }, + "category_source": { + "anyOf": [ + { + "$ref": "#/components/schemas/CategorySource" + }, + { + "type": "null" + } + ] + }, + "notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Notes" + }, + "transfer_group_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Transfer Group Id" + }, + "external_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Id" + }, + "import_run_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Import Run Id" + } + }, + "type": "object", + "required": [ + "id", + "account_id", + "booked_date", + "amount", + "currency", + "label_raw", + "label_clean" + ], + "title": "TransactionRead" + }, + "TransactionUpdate": { + "properties": { + "label_clean": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Label Clean" + }, + "counterparty": { + "anyOf": [ + { + "type": "string", + "maxLength": 150 + }, + { + "type": "null" + } + ], + "title": "Counterparty" + }, + "category_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Category Id" + }, + "notes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Notes" + }, + "booked_date": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Booked Date" + }, + "amount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + }, + { + "type": "null" + } + ], + "title": "Amount" + } + }, + "additionalProperties": false, + "type": "object", + "title": "TransactionUpdate" + }, + "TransferDetectRequest": { + "properties": { + "date_from": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Date From" + }, + "date_to": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "Date To" + } + }, + "type": "object", + "title": "TransferDetectRequest" + }, + "TransferDetectResponse": { + "properties": { + "pairs_created": { + "type": "integer", + "title": "Pairs Created" + } + }, + "type": "object", + "required": [ + "pairs_created" + ], + "title": "TransferDetectResponse" + }, + "TransferLinkRequest": { + "properties": { + "transaction_id_a": { + "type": "string", + "format": "uuid", + "title": "Transaction Id A" + }, + "transaction_id_b": { + "type": "string", + "format": "uuid", + "title": "Transaction Id B" + } + }, + "type": "object", + "required": [ + "transaction_id_a", + "transaction_id_b" + ], + "title": "TransferLinkRequest" + }, + "TransferLinkResponse": { + "properties": { + "transfer_group_id": { + "type": "string", + "format": "uuid", + "title": "Transfer Group Id" + } + }, + "type": "object", + "required": [ + "transfer_group_id" + ], + "title": "TransferLinkResponse" + }, + "UserRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "email": { + "type": "string", + "title": "Email" + }, + "display_name": { + "type": "string", + "title": "Display Name" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "email", + "display_name", + "created_at" + ], + "title": "UserRead" + }, + "UserUpdate": { + "properties": { + "email": { + "anyOf": [ + { + "type": "string", + "maxLength": 255, + "minLength": 3, + "pattern": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$" + }, + { + "type": "null" + } + ], + "title": "Email" + }, + "display_name": { + "anyOf": [ + { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Display Name" + }, + "password": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "minLength": 8 + }, + { + "type": "null" + } + ], + "title": "Password" + }, + "current_password": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Current Password" + } + }, + "type": "object", + "title": "UserUpdate" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + }, + "VapeDashboard": { + "properties": { + "quit_date": { + "type": "string", + "format": "date", + "title": "Quit Date" + }, + "days_since_quit": { + "type": "integer", + "title": "Days Since Quit" + }, + "ml_today": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Ml Today" + }, + "ml_per_day_7": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Ml Per Day 7" + }, + "ml_per_day_30": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Ml Per Day 30" + }, + "nicotine_today_mg": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Nicotine Today Mg" + }, + "cost_per_ml_cents": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Cost Per Ml Cents" + }, + "coil_cost_per_day_cents": { + "type": "number", + "title": "Coil Cost Per Day Cents" + }, + "vape_cost_per_day_cents": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Vape Cost Per Day Cents" + }, + "cig_cost_per_day_cents": { + "type": "number", + "title": "Cig Cost Per Day Cents" + }, + "savings_theoretical_cents": { + "type": "number", + "title": "Savings Theoretical Cents" + }, + "savings_real_cents": { + "type": "number", + "title": "Savings Real Cents" + }, + "savings_display_cents": { + "type": "number", + "title": "Savings Display Cents" + }, + "has_purchases": { + "type": "boolean", + "title": "Has Purchases" + }, + "cigarettes_avoided": { + "type": "integer", + "title": "Cigarettes Avoided" + }, + "packs_avoided": { + "type": "number", + "title": "Packs Avoided" + }, + "current_coil_age_days": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Current Coil Age Days" + }, + "coil_avg_lifespan_days": { + "type": "number", + "title": "Coil Avg Lifespan Days" + }, + "coil_lifespan_is_default": { + "type": "boolean", + "title": "Coil Lifespan Is Default" + }, + "next_milestone": { + "anyOf": [ + { + "$ref": "#/components/schemas/MilestoneRead" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "quit_date", + "days_since_quit", + "ml_today", + "ml_per_day_7", + "ml_per_day_30", + "nicotine_today_mg", + "cost_per_ml_cents", + "coil_cost_per_day_cents", + "vape_cost_per_day_cents", + "cig_cost_per_day_cents", + "savings_theoretical_cents", + "savings_real_cents", + "savings_display_cents", + "has_purchases", + "cigarettes_avoided", + "packs_avoided", + "current_coil_age_days", + "coil_avg_lifespan_days", + "coil_lifespan_is_default", + "next_milestone" + ], + "title": "VapeDashboard" + }, + "VapeSettingsPut": { + "properties": { + "quit_date": { + "type": "string", + "format": "date", + "title": "Quit Date" + }, + "cigs_per_day_before": { + "anyOf": [ + { + "type": "number", + "maximum": 200.0, + "minimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Cigs Per Day Before" + }, + "cig_pack_price_cents": { + "type": "integer", + "minimum": 0.0, + "title": "Cig Pack Price Cents" + }, + "cigs_per_pack": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Cigs Per Pack", + "default": 20 + }, + "default_nicotine_mg_ml": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "string", + "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$" + } + ], + "title": "Default Nicotine Mg Ml" + }, + "currency": { + "type": "string", + "maxLength": 3, + "minLength": 3, + "title": "Currency", + "default": "EUR" + } + }, + "type": "object", + "required": [ + "quit_date", + "cigs_per_day_before", + "cig_pack_price_cents", + "default_nicotine_mg_ml" + ], + "title": "VapeSettingsPut" + }, + "VapeSettingsRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "quit_date": { + "type": "string", + "format": "date", + "title": "Quit Date" + }, + "cigs_per_day_before": { + "type": "number", + "title": "Cigs Per Day Before" + }, + "cig_pack_price_cents": { + "type": "integer", + "title": "Cig Pack Price Cents" + }, + "cigs_per_pack": { + "type": "integer", + "title": "Cigs Per Pack" + }, + "default_nicotine_mg_ml": { + "type": "number", + "title": "Default Nicotine Mg Ml" + }, + "currency": { + "type": "string", + "title": "Currency" + } + }, + "type": "object", + "required": [ + "id", + "quit_date", + "cigs_per_day_before", + "cig_pack_price_cents", + "cigs_per_pack", + "default_nicotine_mg_ml", + "currency" + ], + "title": "VapeSettingsRead" + }, + "WaterEntryCreate": { + "properties": { + "drunk_at": { + "type": "string", + "format": "date-time", + "title": "Drunk At" + }, + "volume_ml": { + "type": "integer", + "maximum": 5000.0, + "exclusiveMinimum": 0.0, + "title": "Volume Ml" + } + }, + "type": "object", + "required": [ + "drunk_at", + "volume_ml" + ], + "title": "WaterEntryCreate" + }, + "WaterEntryRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "drunk_at": { + "type": "string", + "format": "date-time", + "title": "Drunk At" + }, + "volume_ml": { + "type": "integer", + "title": "Volume Ml" + }, + "source": { + "type": "string", + "title": "Source" + } + }, + "type": "object", + "required": [ + "id", + "drunk_at", + "volume_ml", + "source" + ], + "title": "WaterEntryRead" + }, + "WeightEntryCreate": { + "properties": { + "measured_at": { + "type": "string", + "format": "date-time", + "title": "Measured At" + }, + "weight_kg": { + "type": "number", + "exclusiveMaximum": 400.0, + "exclusiveMinimum": 20.0, + "title": "Weight Kg" + }, + "body_fat_pct": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Body Fat Pct" + }, + "muscle_mass_kg": { + "anyOf": [ + { + "type": "number", + "maximum": 200.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Muscle Mass Kg" + }, + "water_pct": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Water Pct" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "required": [ + "measured_at", + "weight_kg" + ], + "title": "WeightEntryCreate" + }, + "WeightEntryRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "measured_at": { + "type": "string", + "format": "date-time", + "title": "Measured At" + }, + "weight_kg": { + "type": "number", + "title": "Weight Kg" + }, + "body_fat_pct": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Body Fat Pct" + }, + "muscle_mass_kg": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Muscle Mass Kg" + }, + "water_pct": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Water Pct" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "source": { + "type": "string", + "title": "Source" + } + }, + "type": "object", + "required": [ + "id", + "measured_at", + "weight_kg", + "body_fat_pct", + "muscle_mass_kg", + "water_pct", + "note", + "source" + ], + "title": "WeightEntryRead" + }, + "WeightEntryUpdate": { + "properties": { + "measured_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Measured At" + }, + "weight_kg": { + "anyOf": [ + { + "type": "number", + "exclusiveMaximum": 400.0, + "exclusiveMinimum": 20.0 + }, + { + "type": "null" + } + ], + "title": "Weight Kg" + }, + "body_fat_pct": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Body Fat Pct" + }, + "muscle_mass_kg": { + "anyOf": [ + { + "type": "number", + "maximum": 200.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Muscle Mass Kg" + }, + "water_pct": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Water Pct" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "title": "WeightEntryUpdate" + }, + "WorkoutCreate": { + "properties": { + "started_at": { + "type": "string", + "format": "date-time", + "title": "Started At" + }, + "ended_at": { + "type": "string", + "format": "date-time", + "title": "Ended At" + }, + "sport_type": { + "$ref": "#/components/schemas/SportType" + }, + "sport_label": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ], + "title": "Sport Label" + }, + "kcal": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Kcal" + }, + "distance_m": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Distance M" + }, + "steps": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Steps" + }, + "avg_hr": { + "anyOf": [ + { + "type": "integer", + "maximum": 300.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Avg Hr" + }, + "max_hr": { + "anyOf": [ + { + "type": "integer", + "maximum": 300.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Hr" + }, + "avg_speed_kmh": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Avg Speed Kmh" + }, + "elevation_m": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Elevation M" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + } + }, + "type": "object", + "required": [ + "started_at", + "ended_at", + "sport_type" + ], + "title": "WorkoutCreate" + }, + "WorkoutRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "started_at": { + "type": "string", + "format": "date-time", + "title": "Started At" + }, + "ended_at": { + "type": "string", + "format": "date-time", + "title": "Ended At" + }, + "duration_s": { + "type": "integer", + "title": "Duration S" + }, + "sport_type": { + "$ref": "#/components/schemas/SportType" + }, + "sport_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sport Label" + }, + "kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Kcal" + }, + "distance_m": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Distance M" + }, + "steps": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Steps" + }, + "avg_hr": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Avg Hr" + }, + "max_hr": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Hr" + }, + "avg_speed_kmh": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Avg Speed Kmh" + }, + "elevation_m": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Elevation M" + }, + "is_hidden": { + "type": "boolean", + "title": "Is Hidden" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "source": { + "type": "string", + "title": "Source" + } + }, + "type": "object", + "required": [ + "id", + "started_at", + "ended_at", + "duration_s", + "sport_type", + "sport_label", + "kcal", + "distance_m", + "steps", + "avg_hr", + "max_hr", + "avg_speed_kmh", + "elevation_m", + "is_hidden", + "note", + "source" + ], + "title": "WorkoutRead" + }, + "WorkoutUpdate": { + "properties": { + "started_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "ended_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Ended At" + }, + "sport_type": { + "anyOf": [ + { + "$ref": "#/components/schemas/SportType" + }, + { + "type": "null" + } + ] + }, + "sport_label": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ], + "title": "Sport Label" + }, + "kcal": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Kcal" + }, + "distance_m": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Distance M" + }, + "steps": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Steps" + }, + "avg_hr": { + "anyOf": [ + { + "type": "integer", + "maximum": 300.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Avg Hr" + }, + "max_hr": { + "anyOf": [ + { + "type": "integer", + "maximum": 300.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Hr" + }, + "avg_speed_kmh": { + "anyOf": [ + { + "type": "number", + "maximum": 100.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Avg Speed Kmh" + }, + "elevation_m": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Elevation M" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "is_hidden": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Hidden" + } + }, + "type": "object", + "title": "WorkoutUpdate" + }, + "app__core__pagination__Page_ImportRunRead___1": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/app__modules__finance__schemas__ImportRunRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[ImportRunRead]" + }, + "app__core__pagination__Page_ImportRunRead___2": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/app__modules__imports__schemas__ImportRunRead" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "page_size": { + "type": "integer", + "title": "Page Size" + } + }, + "type": "object", + "required": [ + "items", + "total", + "page", + "page_size" + ], + "title": "Page[ImportRunRead]" + }, + "app__modules__finance__schemas__BudgetRead": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "category_id": { + "type": "string", + "format": "uuid", + "title": "Category Id" + }, + "category_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category Name" + }, + "category_color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category Color" + }, + "monthly_amount": { + "type": "number", + "title": "Monthly Amount" + }, + "start_month": { + "type": "string", + "format": "date", + "title": "Start Month" + }, + "end_month": { + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "title": "End Month" + }, + "actual": { + "type": "number", + "title": "Actual", + "default": 0.0 + }, + "remaining": { + "type": "number", + "title": "Remaining", + "default": 0.0 + }, + "progress_pct": { + "type": "number", + "title": "Progress Pct", + "default": 0.0 + }, + "projected_eom": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Projected Eom" + }, + "status": { + "type": "string", + "title": "Status", + "default": "ok" + } + }, + "type": "object", + "required": [ + "id", + "category_id", + "monthly_amount", + "start_month", + "end_month" + ], + "title": "BudgetRead" + }, + "app__modules__finance__schemas__DashboardResponse": { + "properties": { + "month": { + "type": "string", + "title": "Month" + }, + "total_balance": { + "type": "number", + "title": "Total Balance" + }, + "accounts_count": { + "type": "integer", + "title": "Accounts Count" + }, + "month_expenses": { + "type": "number", + "title": "Month Expenses" + }, + "month_income": { + "type": "number", + "title": "Month Income" + }, + "month_net": { + "type": "number", + "title": "Month Net" + }, + "average_expenses_6m": { + "type": "number", + "title": "Average Expenses 6M" + }, + "budget_total": { + "type": "number", + "title": "Budget Total" + }, + "budget_actual": { + "type": "number", + "title": "Budget Actual" + }, + "budget_progress_pct": { + "type": "number", + "title": "Budget Progress Pct" + }, + "uncategorized_count": { + "type": "integer", + "title": "Uncategorized Count" + } + }, + "type": "object", + "required": [ + "month", + "total_balance", + "accounts_count", + "month_expenses", + "month_income", + "month_net", + "average_expenses_6m", + "budget_total", + "budget_actual", + "budget_progress_pct", + "uncategorized_count" + ], + "title": "DashboardResponse" + }, + "app__modules__finance__schemas__ImportRunRead": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "import_run_id": { + "type": "integer", + "title": "Import Run Id" + }, + "account_id": { + "type": "string", + "format": "uuid", + "title": "Account Id" + }, + "source_profile_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Source Profile Id" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "file_sha256": { + "type": "string", + "title": "File Sha256" + }, + "status": { + "$ref": "#/components/schemas/ImportStatus" + }, + "started_at": { + "type": "string", + "format": "date-time", + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Finished At" + }, + "stats": { + "additionalProperties": true, + "type": "object", + "title": "Stats" + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Message" + } + }, + "type": "object", + "required": [ + "id", + "import_run_id", + "account_id", + "source_profile_id", + "filename", + "file_sha256", + "status", + "started_at", + "finished_at", + "stats", + "error_message" + ], + "title": "ImportRunRead" + }, + "app__modules__health__schemas__BudgetRead": { + "properties": { + "kcal": { + "type": "number", + "title": "Kcal" + }, + "deficit_target_kcal": { + "type": "number", + "title": "Deficit Target Kcal" + }, + "floor_applied": { + "type": "boolean", + "title": "Floor Applied" + }, + "rate_clamped": { + "type": "boolean", + "title": "Rate Clamped", + "default": false + }, + "tdee_kcal": { + "type": "number", + "title": "Tdee Kcal" + }, + "tdee_method": { + "type": "string", + "title": "Tdee Method" + } + }, + "type": "object", + "required": [ + "kcal", + "deficit_target_kcal", + "floor_applied", + "tdee_kcal", + "tdee_method" + ], + "title": "BudgetRead" + }, + "app__modules__health__schemas__DashboardResponse": { + "properties": { + "date": { + "type": "string", + "format": "date", + "title": "Date" + }, + "weight_kg": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Weight Kg" + }, + "weight_measured_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Weight Measured At" + }, + "trend_weight_kg": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Trend Weight Kg" + }, + "trend_delta_7d_kg": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Trend Delta 7D Kg" + }, + "bmi": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Bmi" + }, + "intake_kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Intake Kcal" + }, + "budget_kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Budget Kcal" + }, + "remaining_kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Remaining Kcal" + }, + "tdee_kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Tdee Kcal" + }, + "tdee_method": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tdee Method" + }, + "balance_kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Balance Kcal" + }, + "cumulative_balance_30d_kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Cumulative Balance 30D Kcal" + }, + "steps": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Steps" + }, + "active_kcal": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Active Kcal" + }, + "distance_m": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Distance M" + }, + "water_ml": { + "type": "integer", + "title": "Water Ml", + "default": 0 + }, + "water_goal_ml": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Water Goal Ml" + }, + "workouts_this_week": { + "type": "integer", + "title": "Workouts This Week", + "default": 0 + }, + "goal": { + "anyOf": [ + { + "$ref": "#/components/schemas/GoalRead" + }, + { + "type": "null" + } + ] + }, + "projection": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectionRead" + }, + { + "type": "null" + } + ] + }, + "weight_series": { + "items": { + "items": {}, + "type": "array" + }, + "type": "array", + "title": "Weight Series" + }, + "today": { + "anyOf": [ + { + "$ref": "#/components/schemas/TodayResponse" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "date" + ], + "title": "DashboardResponse" + }, + "app__modules__health__schemas__StatsResponse": { + "properties": { + "from": { + "type": "string", + "format": "date", + "title": "From" + }, + "to": { + "type": "string", + "format": "date", + "title": "To" + }, + "unit": { + "type": "string", + "title": "Unit" + }, + "series": { + "items": { + "$ref": "#/components/schemas/Series" + }, + "type": "array", + "title": "Series" + }, + "meta": { + "additionalProperties": true, + "type": "object", + "title": "Meta" + } + }, + "type": "object", + "required": [ + "from", + "to", + "unit" + ], + "title": "StatsResponse" + }, + "app__modules__imports__schemas__ImportRunRead": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "importer_id": { + "type": "string", + "title": "Importer Id" + }, + "domain": { + "type": "string", + "title": "Domain" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "file_size": { + "type": "integer", + "title": "File Size" + }, + "status": { + "type": "string", + "title": "Status" + }, + "rows_total": { + "type": "integer", + "title": "Rows Total" + }, + "rows_inserted": { + "type": "integer", + "title": "Rows Inserted" + }, + "rows_updated": { + "type": "integer", + "title": "Rows Updated" + }, + "rows_duplicates": { + "type": "integer", + "title": "Rows Duplicates" + }, + "rows_errors": { + "type": "integer", + "title": "Rows Errors" + }, + "error_details": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Error Details" + }, + "started_at": { + "type": "string", + "format": "date-time", + "title": "Started At" + }, + "finished_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Finished At" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "importer_id", + "domain", + "filename", + "file_size", + "status", + "rows_total", + "rows_inserted", + "rows_updated", + "rows_duplicates", + "rows_errors", + "error_details", + "started_at", + "finished_at", + "created_at" + ], + "title": "ImportRunRead" + }, + "app__modules__vape__schemas__StatsResponse": { + "properties": { + "from": { + "type": "string", + "format": "date", + "title": "From" + }, + "to": { + "type": "string", + "format": "date", + "title": "To" + }, + "unit": { + "type": "string", + "title": "Unit" + }, + "series": { + "items": { + "$ref": "#/components/schemas/SeriesModel" + }, + "type": "array", + "title": "Series" + }, + "meta": { + "additionalProperties": true, + "type": "object", + "title": "Meta" + } + }, + "type": "object", + "required": [ + "from", + "to", + "unit", + "series", + "meta" + ], + "title": "StatsResponse" + } + }, + "securitySchemes": { + "HTTPBearer": { + "type": "http", + "scheme": "bearer" + } + } + } +} \ No newline at end of file diff --git a/apps/api/pytest.ini b/apps/api/pytest.ini new file mode 100644 index 0000000..71f5b1f --- /dev/null +++ b/apps/api/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = app/tests +pythonpath = . diff --git a/apps/api/requirements-dev.txt b/apps/api/requirements-dev.txt new file mode 100644 index 0000000..80c2941 --- /dev/null +++ b/apps/api/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest>=8.3 +ruff>=0.8 diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt new file mode 100644 index 0000000..e54eaec --- /dev/null +++ b/apps/api/requirements.txt @@ -0,0 +1,13 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +sqlalchemy>=2.0.36 +psycopg[binary]>=3.2 +pydantic>=2.9 +pydantic-settings>=2.6 +PyJWT>=2.9 +pwdlib[argon2]>=0.2 +python-multipart>=0.0.12 +httpx>=0.27 +ofxtools>=0.9.5 +charset-normalizer>=3.4 +tzdata>=2024.2 diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..e5537be --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.local diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..77685fa --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,17 @@ + + + + + + + + LifeTrack + + +
+ + + diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json new file mode 100644 index 0000000..965c658 --- /dev/null +++ b/apps/web/package-lock.json @@ -0,0 +1,2913 @@ +{ + "name": "lifetrack-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lifetrack-web", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.51.0", + "clsx": "^2.1.1", + "echarts": "^5.5.1", + "lucide-react": "latest", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.10", + "typescript": "^5.6.2", + "vite": "^6.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.405", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz", + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", + "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..ef6abc8 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "lifetrack-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.51.0", + "clsx": "^2.1.1", + "echarts": "^5.5.1", + "lucide-react": "latest", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.10", + "typescript": "^5.6.2", + "vite": "^6.0.0" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx new file mode 100644 index 0000000..d09af1c --- /dev/null +++ b/apps/web/src/app/App.tsx @@ -0,0 +1,16 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "react-router-dom"; + +import { queryClient } from "../lib/queryClient"; +import { AuthProvider } from "./auth/AuthContext"; +import { router } from "./router"; + +export function App() { + return ( + + + + + + ); +} diff --git a/apps/web/src/app/auth/AuthContext.tsx b/apps/web/src/app/auth/AuthContext.tsx new file mode 100644 index 0000000..5bc0b14 --- /dev/null +++ b/apps/web/src/app/auth/AuthContext.tsx @@ -0,0 +1,113 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; + +import { api, getToken, setToken } from "../../lib/api"; +import { queryClient } from "../../lib/queryClient"; + +export interface AuthUser { + id: number; + email: string; + display_name: string; +} + +interface LoginResponse { + access_token: string; + token_type?: string; + user?: AuthUser; +} + +export interface AuthContextValue { + token: string | null; + user: AuthUser | null; + isAuthenticated: boolean; + login: (email: string, password: string) => Promise; + /** First-run: create the admin account (POST /auth/setup) then authenticate. */ + setup: (email: string, password: string, displayName: string) => Promise; + logout: () => void; + refreshUser: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [token, setTokenState] = useState(() => getToken()); + const [user, setUser] = useState(null); + + const refreshUser = useCallback(async () => { + if (!getToken()) return; + const me = await api("/auth/me"); + setUser(me); + }, []); + + // Restore the session on mount when a token is present. + useEffect(() => { + if (!token) return; + refreshUser().catch(() => { + // 401 is handled globally by the api() wrapper (redirect to /login). + }); + }, [token, refreshUser]); + + const adoptSession = useCallback((accessToken: string, knownUser?: AuthUser) => { + setToken(accessToken); + setTokenState(accessToken); + queryClient.clear(); + if (knownUser) setUser(knownUser); + }, []); + + const login = useCallback( + async (email: string, password: string) => { + const res = await api("/auth/login", { + method: "POST", + body: JSON.stringify({ email, password }), + }); + adoptSession(res.access_token, res.user); + if (!res.user) await refreshUser(); + }, + [adoptSession, refreshUser], + ); + + const setup = useCallback( + async (email: string, password: string, displayName: string) => { + const res = await api>("/auth/setup", { + method: "POST", + body: JSON.stringify({ email, password, display_name: displayName }), + }); + if (res.access_token) { + adoptSession(res.access_token, res.user); + if (!res.user) await refreshUser(); + } else { + // Contract variant returning only 201: authenticate explicitly. + await login(email, password); + } + }, + [adoptSession, login, refreshUser], + ); + + const logout = useCallback(() => { + setToken(null); + setTokenState(null); + setUser(null); + queryClient.clear(); + }, []); + + const value = useMemo( + () => ({ + token, + user, + isAuthenticated: token !== null, + login, + setup, + logout, + refreshUser, + }), + [token, user, login, setup, logout, refreshUser], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within "); + return ctx; +} diff --git a/apps/web/src/app/auth/ProtectedRoute.tsx b/apps/web/src/app/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..d4da0a5 --- /dev/null +++ b/apps/web/src/app/auth/ProtectedRoute.tsx @@ -0,0 +1,62 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { Navigate, useLocation } from "react-router-dom"; + +import { Button } from "../../components/ui/Button"; +import { CenteredSpinner } from "../../components/ui/Spinner"; +import { api } from "../../lib/api"; +import { useAuth } from "./AuthContext"; + +/** GET /api/auth/status — tolerant to both field spellings. */ +export interface AuthStatus { + setup_required?: boolean; + needs_setup?: boolean; +} + +export function useAuthStatus() { + return useQuery({ + queryKey: ["auth", "status"], + queryFn: () => api("/auth/status"), + staleTime: 5 * 60_000, + retry: 1, + }); +} + +export function isSetupNeeded(status: AuthStatus | undefined): boolean { + return Boolean(status?.setup_required ?? status?.needs_setup); +} + +/** + * Guards the application shell: redirects to /setup while the first account + * does not exist, to /login without a token, renders children otherwise. + */ +export function ProtectedRoute({ children }: { children: ReactNode }) { + const { token } = useAuth(); + const location = useLocation(); + const { data, isLoading, isError, refetch } = useAuthStatus(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+

Serveur inaccessible

+

+ Impossible de contacter le serveur LifeTrack. Vérifiez que l'API est démarrée, puis + réessayez. +

+ +
+ ); + } + + if (isSetupNeeded(data)) return ; + if (!token) return ; + return <>{children}; +} diff --git a/apps/web/src/app/layout/AppLayout.tsx b/apps/web/src/app/layout/AppLayout.tsx new file mode 100644 index 0000000..47f07be --- /dev/null +++ b/apps/web/src/app/layout/AppLayout.tsx @@ -0,0 +1,46 @@ +import { Suspense, useState } from "react"; +import { Outlet } from "react-router-dom"; + +import { CenteredSpinner } from "../../components/ui/Spinner"; +import { Sidebar } from "./Sidebar"; +import { Topbar } from "./Topbar"; + +const COLLAPSE_KEY = "lifetrack.sidebar.collapsed"; + +/** + * Application shell: 260px collapsible sidebar + 56px topbar + routed content. + * Module pages are lazy-loaded, hence the Suspense boundary around the Outlet. + */ +export function AppLayout() { + const [collapsed, setCollapsed] = useState( + () => localStorage.getItem(COLLAPSE_KEY) === "1", + ); + const [mobileOpen, setMobileOpen] = useState(false); + + const toggleCollapsed = () => { + setCollapsed((prev) => { + const next = !prev; + localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0"); + return next; + }); + }; + + return ( +
+ setMobileOpen(false)} + /> +
+ setMobileOpen(true)} /> +
+ }> + + +
+
+
+ ); +} diff --git a/apps/web/src/app/layout/Sidebar.tsx b/apps/web/src/app/layout/Sidebar.tsx new file mode 100644 index 0000000..75ff010 --- /dev/null +++ b/apps/web/src/app/layout/Sidebar.tsx @@ -0,0 +1,150 @@ +import clsx from "clsx"; +import { Activity, ChevronsLeft, ChevronsRight } from "lucide-react"; +import { Link, useLocation } from "react-router-dom"; + +import { modules } from "../modules"; + +const APP_VERSION = "v1.0.0"; + +/** Longest-prefix nav matching: only the most specific nav entry is active. */ +function isNavActive(path: string, pathname: string, allPaths: string[]): boolean { + const matches = (p: string) => + p === "/" ? pathname === "/" : pathname === p || pathname.startsWith(`${p}/`); + if (!matches(path)) return false; + const best = allPaths.filter(matches).sort((a, b) => b.length - a.length)[0]; + return best === path; +} + +interface SidebarNavProps { + collapsed: boolean; + onNavigate?: () => void; +} + +function SidebarNav({ collapsed, onNavigate }: SidebarNavProps) { + const { pathname } = useLocation(); + const withNav = modules.filter((m) => m.nav.length > 0); + const allPaths = withNav.flatMap((m) => m.nav.map((item) => item.path)); + + return ( + + ); +} + +function Logo({ collapsed }: { collapsed: boolean }) { + return ( +
+ + + + {!collapsed ? LifeTrack : null} +
+ ); +} + +export interface SidebarProps { + collapsed: boolean; + onToggleCollapsed: () => void; + mobileOpen: boolean; + onCloseMobile: () => void; +} + +/** + * Sidebar (ux-pages §1.1): 260px, collapsible to 72px icon mode (persisted), + * nav generated from the module manifests. On + {/* Desktop */} + + + {/* Mobile drawer */} + {mobileOpen ? ( +
+
+ +
+ ) : null} + + ); +} diff --git a/apps/web/src/app/layout/Topbar.tsx b/apps/web/src/app/layout/Topbar.tsx new file mode 100644 index 0000000..854b21f --- /dev/null +++ b/apps/web/src/app/layout/Topbar.tsx @@ -0,0 +1,114 @@ +import { LogOut, Menu } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { useAuth } from "../auth/AuthContext"; +import { modules } from "../modules"; + +function usePageTitle(): string { + const { pathname } = useLocation(); + return useMemo(() => { + const items = modules.flatMap((m) => m.nav); + const matches = items.filter((item) => + item.path === "/" ? pathname === "/" : pathname === item.path || pathname.startsWith(`${item.path}/`), + ); + const best = matches.sort((a, b) => b.path.length - a.path.length)[0]; + return best?.label ?? "LifeTrack"; + }, [pathname]); +} + +function initialsOf(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} + +export interface TopbarProps { + onOpenMobileMenu: () => void; +} + +/** Topbar (ux-pages §1.1): 56px — page title + user menu with logout. */ +export function Topbar({ onOpenMobileMenu }: TopbarProps) { + const title = usePageTitle(); + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + document.title = title === "LifeTrack" ? "LifeTrack" : `${title} — LifeTrack`; + }, [title]); + + useEffect(() => { + if (!menuOpen) return; + const onPointerDown = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setMenuOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setMenuOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [menuOpen]); + + const displayName = user?.display_name || user?.email || ""; + + return ( +
+ + +

{title}

+ +
+ + {menuOpen ? ( +
+
+

{displayName || "—"}

+ {user?.email ?

{user.email}

: null} +
+ +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/app/modules.ts b/apps/web/src/app/modules.ts new file mode 100644 index 0000000..87efff1 --- /dev/null +++ b/apps/web/src/app/modules.ts @@ -0,0 +1,12 @@ +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); diff --git a/apps/web/src/app/pages/LoginPage.tsx b/apps/web/src/app/pages/LoginPage.tsx new file mode 100644 index 0000000..2f66e10 --- /dev/null +++ b/apps/web/src/app/pages/LoginPage.tsx @@ -0,0 +1,92 @@ +import { Activity } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { FormEvent } from "react"; +import { Navigate, useLocation, useNavigate } from "react-router-dom"; + +import { Button } from "../../components/ui/Button"; +import { Input } from "../../components/ui/Input"; +import { ApiError } from "../../lib/api"; +import { useAuth } from "../auth/AuthContext"; +import { isSetupNeeded, useAuthStatus } from "../auth/ProtectedRoute"; + +export function LoginPage() { + const { login, isAuthenticated } = useAuth(); + const { data: status } = useAuthStatus(); + const navigate = useNavigate(); + const location = useLocation(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + document.title = "Connexion — LifeTrack"; + }, []); + + if (status && isSetupNeeded(status)) return ; + + const from = (location.state as { from?: string } | null)?.from; + if (isAuthenticated) return ; + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + await login(email, password); + navigate(from ?? "/", { replace: true }); + } catch (err) { + setError( + err instanceof ApiError ? err.message : "Impossible de contacter le serveur. Réessayez.", + ); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+ + + +
+

LifeTrack

+

Connexion à votre espace

+
+
+ +
void onSubmit(e)} className="flex flex-col gap-4" noValidate> + setEmail(e.target.value)} + placeholder="vous@exemple.fr" + /> + setPassword(e.target.value)} + /> + {error ? ( +

+ {error} +

+ ) : null} + + +
+
+ ); +} diff --git a/apps/web/src/app/pages/NotFoundPage.tsx b/apps/web/src/app/pages/NotFoundPage.tsx new file mode 100644 index 0000000..65b4f37 --- /dev/null +++ b/apps/web/src/app/pages/NotFoundPage.tsx @@ -0,0 +1,24 @@ +import { Compass } from "lucide-react"; +import { Link } from "react-router-dom"; + +import { EmptyState } from "../../components/ui/EmptyState"; + +export function NotFoundPage() { + return ( +
+ + Retour à l'accueil + + } + /> +
+ ); +} diff --git a/apps/web/src/app/pages/SetupPage.tsx b/apps/web/src/app/pages/SetupPage.tsx new file mode 100644 index 0000000..d5eae88 --- /dev/null +++ b/apps/web/src/app/pages/SetupPage.tsx @@ -0,0 +1,141 @@ +import { Activity } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { FormEvent } from "react"; +import { Navigate, useNavigate } from "react-router-dom"; + +import { Button } from "../../components/ui/Button"; +import { CenteredSpinner } from "../../components/ui/Spinner"; +import { Input } from "../../components/ui/Input"; +import { ApiError } from "../../lib/api"; +import { useAuth } from "../auth/AuthContext"; +import { isSetupNeeded, useAuthStatus } from "../auth/ProtectedRoute"; + +const MIN_PASSWORD_LENGTH = 8; + +/** + * First-run assistant, step 1 (ux-pages §15.8): create the admin account, + * authenticate, then land on the welcome screen pointing to Réglages. + */ +export function SetupPage() { + const { setup } = useAuth(); + const { data: status, isLoading } = useAuthStatus(); + const navigate = useNavigate(); + + const [email, setEmail] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState<{ password?: string; confirm?: string }>({}); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + document.title = "Premier démarrage — LifeTrack"; + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + if (status && !isSetupNeeded(status)) return ; + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + + const errors: { password?: string; confirm?: string } = {}; + if (password.length < MIN_PASSWORD_LENGTH) { + errors.password = `Le mot de passe doit contenir au moins ${MIN_PASSWORD_LENGTH} caractères.`; + } + if (confirm !== password) { + errors.confirm = "Les deux mots de passe ne correspondent pas."; + } + setFieldErrors(errors); + if (Object.keys(errors).length > 0) return; + + setSubmitting(true); + try { + await setup(email, password, displayName.trim() || email.split("@")[0]); + navigate("/", { replace: true }); + } catch (err) { + setError( + err instanceof ApiError ? err.message : "Impossible de contacter le serveur. Réessayez.", + ); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+ + + +
+

Bienvenue sur LifeTrack 👋

+

Premier démarrage de votre instance

+
+
+ +

Créez votre compte administrateur

+

+ Ce compte vous servira à vous connecter à LifeTrack. Vous pourrez ensuite compléter votre + profil dans les Réglages. +

+ +
void onSubmit(e)} className="flex flex-col gap-4" noValidate> + setEmail(e.target.value)} + placeholder="vous@exemple.fr" + /> + setDisplayName(e.target.value)} + hint="Utilisé pour l'affichage dans l'application." + /> + setPassword(e.target.value)} + error={fieldErrors.password} + hint={`${MIN_PASSWORD_LENGTH} caractères minimum.`} + /> + setConfirm(e.target.value)} + error={fieldErrors.confirm} + /> + {error ? ( +

+ {error} +

+ ) : null} + + +
+
+ ); +} diff --git a/apps/web/src/app/pages/WelcomePage.tsx b/apps/web/src/app/pages/WelcomePage.tsx new file mode 100644 index 0000000..dbbbd9e --- /dev/null +++ b/apps/web/src/app/pages/WelcomePage.tsx @@ -0,0 +1,56 @@ +import { Settings, Upload } from "lucide-react"; +import { Link } from "react-router-dom"; +import type { RouteObject } from "react-router-dom"; + +import { modules } from "../modules"; + +function hasRoute(prefix: string): boolean { + const flat: RouteObject[] = modules.flatMap((m) => m.routes); + return flat.some((r) => typeof r.path === "string" && (r.path === prefix || r.path.startsWith(`${prefix}/`))); +} + +const linkClass = + "inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-accent px-4 text-sm font-medium text-white transition-colors hover:bg-accent/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"; +const secondaryLinkClass = + "inline-flex h-10 items-center justify-center gap-2 rounded-lg border bg-surface-2 px-4 text-sm font-medium text-ink transition-colors hover:bg-surface-2/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"; + +/** + * Placeholder home shown when no module claims "/": welcome screen pointing + * to Réglages (and Imports) when those modules are installed. + */ +export function WelcomePage() { + const settingsAvailable = hasRoute("/reglages"); + const importsAvailable = hasRoute("/imports"); + + return ( +
+
+

Bienvenue sur LifeTrack 👋

+

+ Votre compte est prêt. Commencez par renseigner votre profil dans les Réglages, puis + importez vos premières données pour voir vos tableaux de bord prendre vie. +

+
+ {settingsAvailable ? ( + + + Ouvrir les Réglages + + ) : null} + {importsAvailable ? ( + + + Importer des données + + ) : null} +
+ {!settingsAvailable && !importsAvailable ? ( +

+ Aucun module n'est encore installé — les tableaux de bord apparaîtront ici dès qu'un + module sera disponible. +

+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx new file mode 100644 index 0000000..8acdcb0 --- /dev/null +++ b/apps/web/src/app/router.tsx @@ -0,0 +1,43 @@ +import { Navigate, createBrowserRouter } from "react-router-dom"; +import type { RouteObject } from "react-router-dom"; + +import { ProtectedRoute } from "./auth/ProtectedRoute"; +import { AppLayout } from "./layout/AppLayout"; +import { modules } from "./modules"; +import { LoginPage } from "./pages/LoginPage"; +import { NotFoundPage } from "./pages/NotFoundPage"; +import { SetupPage } from "./pages/SetupPage"; +import { WelcomePage } from "./pages/WelcomePage"; + +const moduleRoutes: RouteObject[] = modules.flatMap((m) => m.routes); + +// If no module claims "/", redirect the root to the first nav route, or show +// a placeholder welcome screen when there is no module at all. +const claimsRoot = moduleRoutes.some((r) => r.path === "/" || r.index === true); +const firstNavPath = + modules + .find((m) => m.nav.length > 0) + ?.nav.slice() + .sort((a, b) => a.order - b.order)[0]?.path ?? null; + +const protectedChildren: RouteObject[] = [...moduleRoutes]; +if (!claimsRoot) { + protectedChildren.push({ + path: "/", + element: firstNavPath ? : , + }); +} +protectedChildren.push({ path: "*", element: }); + +export const router = createBrowserRouter([ + { path: "/login", element: }, + { path: "/setup", element: }, + { + element: ( + + + + ), + children: protectedChildren, + }, +]); diff --git a/apps/web/src/components/PeriodSelector.tsx b/apps/web/src/components/PeriodSelector.tsx new file mode 100644 index 0000000..f52e2a8 --- /dev/null +++ b/apps/web/src/components/PeriodSelector.tsx @@ -0,0 +1,273 @@ +import clsx from "clsx"; +import { CalendarRange } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useLocation, useSearchParams } from "react-router-dom"; + +import { daysAgoIso, isValidIsoDate, todayIso } from "../lib/dates"; +import { formatDate } from "../lib/format"; +import { Button } from "./ui/Button"; +import { DateRangePicker } from "./ui/DateRangePicker"; + +export type PeriodKey = "7j" | "30j" | "90j" | "1an" | "tout" | "perso"; + +export interface PeriodRange { + key: PeriodKey; + /** Inclusive start (YYYY-MM-DD) — null for « Tout ». */ + from: string | null; + /** Inclusive end (YYYY-MM-DD) — null for « Tout ». */ + to: string | null; + /** French display label of the current selection. */ + label: string; +} + +interface Preset { + key: Exclude; + label: string; + days: number | null; +} + +const PRESETS: Preset[] = [ + { key: "7j", label: "7 j", days: 7 }, + { key: "30j", label: "30 j", days: 30 }, + { key: "90j", label: "90 j", days: 90 }, + { key: "1an", label: "1 an", days: 365 }, + { key: "tout", label: "Tout", days: null }, +]; + +const PRESET_KEYS = new Set(PRESETS.map((p) => p.key)); + +interface Stored { + key: PeriodKey; + from?: string | null; + to?: string | null; +} + +function storageKey(pathname: string): string { + return `period:${pathname}`; +} + +function readStored(pathname: string): Stored | null { + try { + const raw = localStorage.getItem(storageKey(pathname)); + if (!raw) return null; + const parsed = JSON.parse(raw) as Stored; + if (parsed.key === "perso") { + if ( + typeof parsed.from === "string" && + typeof parsed.to === "string" && + isValidIsoDate(parsed.from) && + isValidIsoDate(parsed.to) + ) { + return parsed; + } + return null; + } + return PRESET_KEYS.has(parsed.key) ? { key: parsed.key } : null; + } catch { + return null; + } +} + +function rangeFor(key: PeriodKey, custom: { from: string; to: string } | null): PeriodRange { + if (key === "perso" && custom) { + return { + key, + from: custom.from, + to: custom.to, + label: `${formatDate(custom.from)} – ${formatDate(custom.to)}`, + }; + } + const preset = PRESETS.find((p) => p.key === key) ?? PRESETS[1]; + if (preset.days === null) return { key: preset.key, from: null, to: null, label: preset.label }; + return { + key: preset.key, + from: daysAgoIso(preset.days - 1), + to: todayIso(), + label: preset.label, + }; +} + +export interface PeriodSelectorProps { + /** Default preset when nothing is persisted (30 j everywhere per ux-pages §5.1). */ + defaultKey?: PeriodKey; + onChange?: (range: PeriodRange) => void; + className?: string; +} + +/** + * Global page period selector (ux-pages §5.1): segmented control + * 7 j · 30 j · 90 j · 1 an · Tout · Personnalisé. The selection is persisted + * per page (localStorage `period:`) and mirrored in the URL + * (`?periode=30j` or `?du=…&au=…`; `?period=` is accepted when reading). + * Returns {from,to} inclusive ISO dates through `onChange`. + */ +export function PeriodSelector({ defaultKey = "30j", onChange, className }: PeriodSelectorProps) { + const { pathname } = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const rootRef = useRef(null); + + // Initial resolution: URL → localStorage → default. + const [state, setState] = useState<{ key: PeriodKey; custom: { from: string; to: string } | null }>( + () => { + const du = searchParams.get("du"); + const au = searchParams.get("au"); + if (du && au && isValidIsoDate(du) && isValidIsoDate(au) && du <= au) { + return { key: "perso", custom: { from: du, to: au } }; + } + const urlKey = searchParams.get("periode") ?? searchParams.get("period"); + if (urlKey && PRESET_KEYS.has(urlKey)) { + return { key: urlKey as PeriodKey, custom: null }; + } + const stored = readStored(pathname); + if (stored) { + return { + key: stored.key, + custom: + stored.key === "perso" && stored.from && stored.to + ? { from: stored.from, to: stored.to } + : null, + }; + } + return { key: defaultKey, custom: null }; + }, + ); + + const [popoverOpen, setPopoverOpen] = useState(false); + const [draft, setDraft] = useState<{ from: string | null; to: string | null }>({ + from: null, + to: null, + }); + + const range = useMemo(() => rangeFor(state.key, state.custom), [state]); + + // Persist + mirror in the URL + notify the page. + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + useEffect(() => { + localStorage.setItem( + storageKey(pathname), + JSON.stringify({ key: state.key, from: state.custom?.from ?? null, to: state.custom?.to ?? null }), + ); + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete("period"); + if (state.key === "perso" && state.custom) { + next.delete("periode"); + next.set("du", state.custom.from); + next.set("au", state.custom.to); + } else { + next.set("periode", state.key); + next.delete("du"); + next.delete("au"); + } + return next; + }, + { replace: true }, + ); + onChangeRef.current?.(rangeFor(state.key, state.custom)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state, pathname]); + + // Close the popover on outside click / Escape. + useEffect(() => { + if (!popoverOpen) return; + const onPointerDown = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setPopoverOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setPopoverOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [popoverOpen]); + + const openCustom = () => { + setDraft({ + from: state.custom?.from ?? range.from, + to: state.custom?.to ?? range.to ?? todayIso(), + }); + setPopoverOpen(true); + }; + + const applyCustom = () => { + if (!draft.from || !draft.to || draft.from > draft.to) return; + setState({ key: "perso", custom: { from: draft.from, to: draft.to } }); + setPopoverOpen(false); + }; + + const segmentClass = (active: boolean) => + clsx( + "h-8 rounded-md px-2.5 text-xs font-medium transition-colors", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", + active ? "bg-surface-2 text-ink" : "text-ink-secondary hover:text-ink", + ); + + return ( +
+
+ {PRESETS.map((preset) => ( + + ))} + +
+ + {popoverOpen ? ( +
+ setDraft({ from, to })} + /> +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/charts/ChartCard.tsx b/apps/web/src/components/charts/ChartCard.tsx new file mode 100644 index 0000000..f253eef --- /dev/null +++ b/apps/web/src/components/charts/ChartCard.tsx @@ -0,0 +1,366 @@ +import type { EChartsOption } from "echarts"; +import { Download, FileSpreadsheet, MoreVertical, Table2 } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; + +import { formatDate, formatNumberAuto } from "../../lib/format"; +import { Badge } from "../ui/Badge"; +import { EChart } from "./EChart"; +import type { EChartHandle } from "./EChart"; + +/* ------------------------------------------------------------------ */ +/* Series → accessible table extraction */ +/* ------------------------------------------------------------------ */ + +interface SeriesLike { + type?: string; + name?: unknown; + data?: unknown; +} + +interface AxisLike { + type?: string; + data?: unknown[]; +} + +export interface ChartTable { + columns: string[]; + rows: (string | number | null)[][]; +} + +function seriesName(s: SeriesLike, index: number): string { + return typeof s.name === "string" && s.name.length > 0 ? s.name : `Série ${index + 1}`; +} + +function unwrapPoint(item: unknown): { x: unknown; y: number | string | null } { + if (typeof item === "number") return { x: null, y: item }; + if (typeof item === "string") return { x: null, y: item }; + if (Array.isArray(item)) { + const [x, y] = item as unknown[]; + return { x, y: typeof y === "number" || typeof y === "string" ? y : null }; + } + if (item !== null && typeof item === "object") { + const obj = item as { value?: unknown; name?: unknown }; + const inner = unwrapPoint(obj.value ?? null); + return { x: inner.x ?? obj.name ?? null, y: inner.y }; + } + return { x: null, y: null }; +} + +function looksLikeDate(x: unknown): boolean { + if (x instanceof Date) return true; + if (typeof x === "number") return x > 10_000_000_000; // ms timestamp + if (typeof x === "string") return /^\d{4}-\d{2}-\d{2}/.test(x); + return false; +} + +function labelOf(x: unknown): string { + if (x === null || x === undefined) return ""; + if (looksLikeDate(x)) return formatDate(x as string | number | Date); + return String(x); +} + +/** Extract a table view (columns + rows) from an ECharts option — accessibility + CSV. */ +export function extractChartTable(option: EChartsOption): ChartTable | null { + const rawSeries = option.series; + const seriesList: SeriesLike[] = Array.isArray(rawSeries) + ? (rawSeries as SeriesLike[]) + : rawSeries + ? [rawSeries as SeriesLike] + : []; + const withData = seriesList.filter((s) => Array.isArray(s.data)); + if (withData.length === 0) return null; + + // Pie/donut → « Libellé / Valeur ». + if (withData.length === 1 && withData[0].type === "pie") { + const rows: (string | number | null)[][] = (withData[0].data as unknown[]).map((item) => { + const point = unwrapPoint(item); + const name = + item !== null && typeof item === "object" && "name" in (item as object) + ? String((item as { name?: unknown }).name ?? "") + : labelOf(point.x); + return [name, typeof point.y === "number" ? point.y : (point.y ?? null)]; + }); + return { columns: ["Libellé", seriesName(withData[0], 0)], rows }; + } + + const rawXAxis = option.xAxis; + const xAxis: AxisLike | undefined = Array.isArray(rawXAxis) + ? (rawXAxis[0] as AxisLike) + : (rawXAxis as AxisLike | undefined); + const categories = Array.isArray(xAxis?.data) ? xAxis.data : null; + + const labels: string[] = []; + const labelIndex = new Map(); + const ensureRow = (label: string): number => { + const existing = labelIndex.get(label); + if (existing !== undefined) return existing; + labelIndex.set(label, labels.length); + labels.push(label); + return labels.length - 1; + }; + + const values: (string | number | null)[][] = withData.map(() => []); + let isTime = xAxis?.type === "time"; + + withData.forEach((s, si) => { + (s.data as unknown[]).forEach((item, di) => { + const point = unwrapPoint(item); + let label: string; + if (point.x !== null && point.x !== undefined) { + if (looksLikeDate(point.x)) isTime = true; + label = labelOf(point.x); + } else if (categories && di < categories.length) { + label = labelOf(categories[di]); + } else { + label = String(di + 1); + } + const row = ensureRow(label); + values[si][row] = point.y; + }); + }); + + if (labels.length === 0) return null; + const rows: (string | number | null)[][] = labels.map((label, ri) => [ + label, + ...values.map((col) => col[ri] ?? null), + ]); + return { + columns: [isTime ? "Date" : "Libellé", ...withData.map((s, i) => seriesName(s, i))], + rows, + }; +} + +/* ------------------------------------------------------------------ */ +/* Exports */ +/* ------------------------------------------------------------------ */ + +const COMBINING_MARKS = /[̀-ͯ]/g; + +function slugify(text: string): string { + return ( + text + .normalize("NFD") + .replace(COMBINING_MARKS, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "graphique" + ); +} + +function triggerDownload(href: string, filename: string): void { + const a = document.createElement("a"); + a.href = href; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); +} + +function toCsv(table: ChartTable): string { + const escapeCell = (cell: string | number | null): string => { + if (cell === null || cell === undefined) return ""; + if (typeof cell === "number") return String(cell).replace(".", ","); + return /[;"\n]/.test(cell) ? `"${cell.replace(/"/g, '""')}"` : cell; + }; + const lines = [table.columns.map(escapeCell).join(";")]; + for (const row of table.rows) lines.push(row.map(escapeCell).join(";")); + return `${lines.join("\r\n")}`; +} + +/* ------------------------------------------------------------------ */ +/* ChartCard */ +/* ------------------------------------------------------------------ */ + +export interface ChartCardProps { + title: string; + subtitle?: string; + /** Local period override badge (ux-pages §5.1). */ + periodLabel?: string; + option: EChartsOption; + height?: number | string; + loading?: boolean; + ariaLabel?: string; + /** Extra header actions rendered left of the ⋯ menu. */ + actions?: ReactNode; + className?: string; +} + +/** + * Card wrapper for every chart: title, optional period badge, ⋯ menu with + * « Voir les données » (accessible table view), PNG and CSV exports. + */ +export function ChartCard({ + title, + subtitle, + periodLabel, + option, + height = 280, + loading = false, + ariaLabel, + actions, + className, +}: ChartCardProps) { + const chartRef = useRef(null); + const menuRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + const [showData, setShowData] = useState(false); + + const table = useMemo(() => extractChartTable(option), [option]); + + useEffect(() => { + if (!menuOpen) return; + const onPointerDown = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setMenuOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setMenuOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [menuOpen]); + + const exportPng = () => { + const chart = chartRef.current?.getChart(); + if (!chart) return; + const url = chart.getDataURL({ type: "png", pixelRatio: 2, backgroundColor: "#1A1A19" }); + triggerDownload(url, `${slugify(title)}.png`); + setMenuOpen(false); + }; + + const exportCsv = () => { + if (!table) return; + const blob = new Blob([toCsv(table)], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + triggerDownload(url, `${slugify(title)}.csv`); + URL.revokeObjectURL(url); + setMenuOpen(false); + }; + + const menuItemClass = + "flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-ink-secondary hover:bg-surface-2 hover:text-ink"; + + return ( +
+
+
+

+ {title} + {periodLabel ? {periodLabel} : null} +

+ {subtitle ?

{subtitle}

: null} +
+
+ {actions} +
+ + {menuOpen ? ( +
+ + + +
+ ) : null} +
+
+
+ + {showData && table ? ( +
+
+ + + + {table.columns.map((col, i) => ( + + ))} + + + + {table.rows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
{`Données du graphique « ${title} »`}
0 ? "text-right" : ""}`} + > + {col} +
0 + ? "px-2 py-1.5 text-right tabular-nums text-ink" + : "px-2 py-1.5 text-ink-secondary" + } + > + {typeof cell === "number" ? formatNumberAuto(cell) : (cell ?? "—")} +
+ + ) : ( + + )} + + ); +} diff --git a/apps/web/src/components/charts/EChart.tsx b/apps/web/src/components/charts/EChart.tsx new file mode 100644 index 0000000..7aa2213 --- /dev/null +++ b/apps/web/src/components/charts/EChart.tsx @@ -0,0 +1,132 @@ +import type { EChartsOption, TooltipComponentOption } from "echarts"; +import type { EChartsType } from "echarts/core"; +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; + +import { formatNumberAuto } from "../../lib/format"; +import { CHART_LOCALE, CHART_THEME_NAME, echarts } from "./theme"; + +export interface EChartHandle { + /** Access the underlying ECharts instance (exports, echarts.connect…). */ + getChart: () => EChartsType | null; +} + +export interface EChartProps { + option: EChartsOption; + height?: number | string; + loading?: boolean; + onEvents?: Record void>; + /** Descriptive French label for screen readers (ux-pages §17). */ + ariaLabel?: string; + className?: string; +} + +function prefersReducedMotion(): boolean { + return ( + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +/** Apply the common chart rules of ux-pages §6 when the option does not set them. */ +function withDefaults(option: EChartsOption): EChartsOption { + const opt: EChartsOption = { ...option }; + if (opt.grid === undefined) { + opt.grid = { left: 8, right: 16, top: 36, bottom: 8, containLabel: true }; + } + if (opt.animationDuration === undefined) opt.animationDuration = 300; + if (prefersReducedMotion()) opt.animation = false; + + // French number formatting in tooltips by default. + const tooltip = opt.tooltip; + if (tooltip && !Array.isArray(tooltip)) { + const t = tooltip as TooltipComponentOption; + if (t.formatter === undefined && t.valueFormatter === undefined) { + opt.tooltip = { + ...t, + valueFormatter: (value: unknown) => + typeof value === "number" ? formatNumberAuto(value) : String(value ?? "—"), + } as TooltipComponentOption; + } + } + return opt; +} + +/** + * Shared ECharts wrapper — the ONLY sanctioned way to render a chart + * (CONVENTIONS C5.5): theme "lifetrack-dark", FR locale, ResizeObserver, + * dispose on unmount. + */ +export const EChart = forwardRef(function EChart( + { option, height = 280, loading = false, onEvents, ariaLabel, className }, + ref, +) { + const containerRef = useRef(null); + const chartRef = useRef(null); + + useImperativeHandle(ref, () => ({ getChart: () => chartRef.current }), []); + + // Init once; resize with the container; dispose on unmount. + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const chart = echarts.init(el, CHART_THEME_NAME, { + renderer: "canvas", + locale: CHART_LOCALE, + }); + chartRef.current = chart; + const observer = new ResizeObserver(() => { + if (!chart.isDisposed()) chart.resize(); + }); + observer.observe(el); + return () => { + observer.disconnect(); + chart.dispose(); + chartRef.current = null; + }; + }, []); + + // Push the option (no merge: the option is the full source of truth). + useEffect(() => { + const chart = chartRef.current; + if (!chart || chart.isDisposed()) return; + chart.setOption(withDefaults(option), { notMerge: true }); + }, [option]); + + // Loading overlay. + useEffect(() => { + const chart = chartRef.current; + if (!chart || chart.isDisposed()) return; + if (loading) { + chart.showLoading("default", { + text: "Chargement…", + color: "#3987E5", + textColor: "#C3C2B7", + maskColor: "rgba(13,13,13,0.45)", + }); + } else { + chart.hideLoading(); + } + }, [loading]); + + // Event bindings. + useEffect(() => { + const chart = chartRef.current; + if (!chart || !onEvents) return; + const entries = Object.entries(onEvents); + for (const [event, handler] of entries) chart.on(event, handler); + return () => { + if (chart.isDisposed()) return; + for (const [event, handler] of entries) chart.off(event, handler); + }; + }, [onEvents]); + + return ( +
+ ); +}); diff --git a/apps/web/src/components/charts/theme.ts b/apps/web/src/components/charts/theme.ts new file mode 100644 index 0000000..007f74b --- /dev/null +++ b/apps/web/src/components/charts/theme.ts @@ -0,0 +1,262 @@ +/** + * Shared ECharts setup: tree-shaken registration, FR locale and the + * "lifetrack-dark" theme (palette from docs/design/ux-pages.md §4.4/§6 — + * CVD-validated, slot order is NORMATIVE, never reorder). + */ +import { + BarChart, + HeatmapChart, + LineChart, + PieChart, + SankeyChart, + ScatterChart, +} from "echarts/charts"; +import { + AriaComponent, + DataZoomComponent, + GraphicComponent, + GridComponent, + LegendComponent, + MarkAreaComponent, + MarkLineComponent, + MarkPointComponent, + TitleComponent, + TooltipComponent, + VisualMapComponent, +} from "echarts/components"; +import * as echarts from "echarts/core"; +import { CanvasRenderer } from "echarts/renderers"; + +echarts.use([ + LineChart, + BarChart, + PieChart, + ScatterChart, + HeatmapChart, + SankeyChart, + GridComponent, + TooltipComponent, + LegendComponent, + DataZoomComponent, + MarkLineComponent, + MarkPointComponent, + MarkAreaComponent, + VisualMapComponent, + GraphicComponent, + TitleComponent, + AriaComponent, + CanvasRenderer, +]); + +export const CHART_THEME_NAME = "lifetrack-dark"; +export const CHART_LOCALE = "FR"; + +/** The 8 series slots — fixed identity attribution, never reordered (ux-pages §4.4). */ +export const CHART_COLORS = [ + "#3987E5", // 1 blue — weight trend, kcal out, distance, total balance + "#D95926", // 2 orange — kcal in, active kcal + "#199E70", // 3 teal — proteins, daily steps + "#C98500", // 4 yellow — carbs, vape cost + "#D55181", // 5 magenta — fats, nicotine + "#008300", // 6 green — reserved (avoid near semantic-positive) + "#9085E9", // 7 violet — vape ml, theoretical weight + "#E66767", // 8 red — last resort (never for a "negative" meaning) +] as const; + +/** Sequential tints of slot 1 for variants of the same entity (ex: weight projection). */ +export const CHART_BLUE_TINTS = { + light: "#86B6EF", + base: "#3987E5", + dark: "#1C5CAB", +} as const; + +/** Surface / structure colors shared with the Tailwind theme (ux-pages §4.1). */ +export const CHART_SURFACE = { + base: "#0D0D0D", + surface: "#1A1A19", + surface2: "#242423", + border: "rgba(255,255,255,0.10)", + grid: "#2C2C2A", + axis: "#383835", + ink: "#FFFFFF", + inkSecondary: "#C3C2B7", + inkMuted: "#898781", + accent: "#3987E5", +} as const; + +/** Semantic (polarity) colors — never used as ordinary series colors. */ +export const SEMANTIC_COLORS = { + positive: "#0CA30C", + negative: "#D03B3B", + warning: "#FAB219", + serious: "#EC835A", +} as const; + +const FONT_FAMILY = 'system-ui, "Segoe UI", sans-serif'; + +const axisDefaults = { + axisLine: { lineStyle: { color: CHART_SURFACE.axis } }, + axisTick: { lineStyle: { color: CHART_SURFACE.axis } }, + splitLine: { lineStyle: { color: CHART_SURFACE.grid } }, + axisLabel: { color: CHART_SURFACE.inkMuted, fontSize: 11 }, +}; + +echarts.registerTheme(CHART_THEME_NAME, { + backgroundColor: "transparent", + color: [...CHART_COLORS], + textStyle: { color: CHART_SURFACE.inkSecondary, fontFamily: FONT_FAMILY }, + title: { + textStyle: { color: CHART_SURFACE.ink, fontFamily: FONT_FAMILY }, + subtextStyle: { color: CHART_SURFACE.inkMuted }, + }, + categoryAxis: axisDefaults, + valueAxis: axisDefaults, + timeAxis: axisDefaults, + logAxis: axisDefaults, + legend: { + textStyle: { color: CHART_SURFACE.inkSecondary }, + icon: "circle", + itemWidth: 8, + itemHeight: 8, + top: 0, + }, + tooltip: { + backgroundColor: CHART_SURFACE.surface, + borderColor: CHART_SURFACE.border, + textStyle: { color: CHART_SURFACE.ink }, + padding: [8, 12], + }, + dataZoom: { + borderColor: CHART_SURFACE.border, + textStyle: { color: CHART_SURFACE.inkMuted }, + }, + line: { lineStyle: { width: 2 }, symbol: "circle", symbolSize: 6 }, + bar: { barMaxWidth: 28 }, +}); + +/** French locale (contents of echarts i18n/langFR, inlined — no deep untyped import). */ +const frLocale = { + time: { + month: [ + "janvier", + "février", + "mars", + "avril", + "mai", + "juin", + "juillet", + "août", + "septembre", + "octobre", + "novembre", + "décembre", + ], + monthAbbr: [ + "janv.", + "févr.", + "mars", + "avr.", + "mai", + "juin", + "juil.", + "août", + "sept.", + "oct.", + "nov.", + "déc.", + ], + dayOfWeek: ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"], + dayOfWeekAbbr: ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."], + }, + legend: { + selector: { all: "Tout", inverse: "Inverser" }, + }, + toolbox: { + brush: { + title: { + rect: "Sélection rectangulaire", + polygon: "Sélection au lasso", + lineX: "Sélection horizontale", + lineY: "Sélection verticale", + keep: "Conserver la sélection", + clear: "Effacer la sélection", + }, + }, + dataView: { + title: "Voir les données", + lang: ["Voir les données", "Fermer", "Actualiser"], + }, + dataZoom: { title: { zoom: "Zoom", back: "Réinitialiser le zoom" } }, + magicType: { + title: { + line: "Basculer en ligne", + bar: "Basculer en barres", + stack: "Empiler", + tiled: "Séparer", + }, + }, + restore: { title: "Restaurer" }, + saveAsImage: { + title: "Enregistrer l'image", + lang: ["Clic droit pour enregistrer l'image"], + }, + }, + series: { + typeNames: { + pie: "Graphique en secteurs", + bar: "Graphique en barres", + line: "Graphique en lignes", + scatter: "Nuage de points", + effectScatter: "Nuage de points avec effet", + radar: "Graphique radar", + tree: "Arbre", + treemap: "Treemap", + boxplot: "Boîte à moustaches", + candlestick: "Graphique en chandeliers", + k: "Graphique en courbes K", + heatmap: "Carte de chaleur", + map: "Carte", + parallel: "Coordonnées parallèles", + lines: "Graphique de lignes", + graph: "Graphe de relations", + sankey: "Diagramme de Sankey", + funnel: "Entonnoir", + gauge: "Jauge", + pictorialBar: "Barres picturales", + themeRiver: "Rivière thématique", + sunburst: "Sunburst", + custom: "Graphique personnalisé", + chart: "Graphique", + }, + }, + aria: { + general: { + withTitle: "Graphique « {title} ».", + withoutTitle: "Graphique.", + }, + series: { + single: { + prefix: "", + withName: " Graphique de type {seriesType} nommé {seriesName}.", + withoutName: " Graphique de type {seriesType}.", + }, + multiple: { + prefix: " Composé de {seriesCount} séries.", + withName: " La série {seriesId} est de type {seriesType} et s'intitule {seriesName}.", + withoutName: " La série {seriesId} est de type {seriesType}.", + separator: { middle: "", end: "" }, + }, + }, + data: { + allData: "Les données sont : ", + partialData: "Les {displayCnt} premiers éléments sont : ", + withName: "pour {name}, {value}", + withoutName: "{value}", + separator: { middle: ", ", end: ". " }, + }, + }, +}; + +echarts.registerLocale(CHART_LOCALE, frLocale as Parameters[1]); + +export { echarts }; diff --git a/apps/web/src/components/ui/Badge.tsx b/apps/web/src/components/ui/Badge.tsx new file mode 100644 index 0000000..e8e8701 --- /dev/null +++ b/apps/web/src/components/ui/Badge.tsx @@ -0,0 +1,41 @@ +import clsx from "clsx"; +import type { ReactNode } from "react"; + +export type BadgeTone = + | "neutral" + | "positive" + | "negative" + | "warning" + | "serious" + | "accent" + | "muted"; + +export interface BadgeProps { + tone?: BadgeTone; + children: ReactNode; + className?: string; +} + +const TONE_CLASSES: Record = { + neutral: "bg-surface-2 text-ink-secondary", + positive: "bg-semantic-positive/15 text-semantic-positive", + negative: "bg-semantic-negative/15 text-semantic-negative", + warning: "bg-semantic-warning/15 text-semantic-warning", + serious: "bg-semantic-serious/15 text-semantic-serious", + accent: "bg-accent/15 text-accent", + muted: "border border-dashed text-ink-muted", +}; + +export function Badge({ tone = "neutral", children, className }: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/ui/Button.tsx b/apps/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..8fc8df6 --- /dev/null +++ b/apps/web/src/components/ui/Button.tsx @@ -0,0 +1,57 @@ +import clsx from "clsx"; +import type { LucideIcon } from "lucide-react"; +import { forwardRef } from "react"; +import type { ButtonHTMLAttributes } from "react"; + +import { Spinner } from "./Spinner"; + +export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost"; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: "sm" | "md"; + icon?: LucideIcon; + loading?: boolean; +} + +const VARIANT_CLASSES: Record = { + primary: "bg-accent text-white hover:bg-accent/90", + secondary: "border bg-surface-2 text-ink hover:bg-surface-2/70", + danger: "bg-semantic-negative text-white hover:bg-semantic-negative/90", + ghost: "text-accent hover:bg-surface-2/50", +}; + +export const Button = forwardRef(function Button( + { + variant = "primary", + size = "md", + icon: Icon, + loading = false, + disabled, + className, + children, + type = "button", + ...rest + }, + ref, +) { + return ( + + ); +}); diff --git a/apps/web/src/components/ui/Card.tsx b/apps/web/src/components/ui/Card.tsx new file mode 100644 index 0000000..40f58a7 --- /dev/null +++ b/apps/web/src/components/ui/Card.tsx @@ -0,0 +1,42 @@ +import clsx from "clsx"; +import type { ReactNode } from "react"; + +export interface CardProps { + title?: ReactNode; + subtitle?: ReactNode; + /** Header actions rendered on the right. */ + actions?: ReactNode; + children?: ReactNode; + className?: string; + /** Remove the default padding (tables bleeding to the edges). */ + noPadding?: boolean; +} + +/** + * Base of every card (ux-pages §4.2): bg-surface, 12px radius, hairline + * border, no drop shadow. + */ +export function Card({ title, subtitle, actions, children, className, noPadding }: CardProps) { + return ( +
+ {title || actions ? ( +
+
+ {title ?

{title}

: null} + {subtitle ?

{subtitle}

: null} +
+ {actions ?
{actions}
: null} +
+ ) : null} + {children} +
+ ); +} diff --git a/apps/web/src/components/ui/ConfirmDialog.tsx b/apps/web/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..3e4997c --- /dev/null +++ b/apps/web/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; + +import { Button } from "./Button"; +import { Modal } from "./Modal"; + +export interface ConfirmDialogProps { + open: boolean; + title: ReactNode; + /** French confirmation message, ex: « Cette action est irréversible. ». */ + message: ReactNode; + confirmLabel?: string; + cancelLabel?: string; + /** Destructive styling for the confirm button. */ + danger?: boolean; + loading?: boolean; + onConfirm: () => void; + onClose: () => void; +} + +/** Confirmation modal — « Annuler » + confirm action (destructive variant). */ +export function ConfirmDialog({ + open, + title, + message, + confirmLabel = "Confirmer", + cancelLabel = "Annuler", + danger = false, + loading = false, + onConfirm, + onClose, +}: ConfirmDialogProps) { + return ( + + + + + } + > +
{message}
+
+ ); +} diff --git a/apps/web/src/components/ui/DateRangePicker.tsx b/apps/web/src/components/ui/DateRangePicker.tsx new file mode 100644 index 0000000..6cf685d --- /dev/null +++ b/apps/web/src/components/ui/DateRangePicker.tsx @@ -0,0 +1,74 @@ +import clsx from "clsx"; + +import { + endOfPreviousMonthIso, + startOfMonthIso, + startOfPreviousMonthIso, + startOfYearIso, + todayIso, +} from "../../lib/dates"; +import { Input } from "./Input"; + +export interface DateRangePickerProps { + from: string | null; // YYYY-MM-DD + to: string | null; // YYYY-MM-DD + onChange: (from: string | null, to: string | null) => void; + /** Show « Ce mois-ci · Le mois dernier · Cette année » shortcuts. */ + withShortcuts?: boolean; + className?: string; +} + +/** Two date fields (Du / Au) + French range shortcuts (ux-pages §5.1). */ +export function DateRangePicker({ + from, + to, + onChange, + withShortcuts = true, + className, +}: DateRangePickerProps) { + const shortcuts: { label: string; from: string; to: string }[] = [ + { label: "Ce mois-ci", from: startOfMonthIso(), to: todayIso() }, + { label: "Le mois dernier", from: startOfPreviousMonthIso(), to: endOfPreviousMonthIso() }, + { label: "Cette année", from: startOfYearIso(), to: todayIso() }, + ]; + + return ( +
+
+ onChange(e.target.value || null, to)} + className="flex-1" + /> + onChange(from, e.target.value || null)} + className="flex-1" + /> +
+ {withShortcuts ? ( +
+ {shortcuts.map((s) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/ui/EmptyState.tsx b/apps/web/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..d5b08a8 --- /dev/null +++ b/apps/web/src/components/ui/EmptyState.tsx @@ -0,0 +1,28 @@ +import clsx from "clsx"; +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +export interface EmptyStateProps { + icon?: LucideIcon; + /** French title, ex: « Aucune pesée pour l'instant ». */ + title: string; + /** French helper text. */ + hint?: ReactNode; + /** 1–2 action buttons (primary = saisie or import). */ + actions?: ReactNode; + className?: string; +} + +/** Empty state (ux-pages §5.4/§16): 48px muted icon, title, hint, actions. */ +export function EmptyState({ icon: Icon, title, hint, actions, className }: EmptyStateProps) { + return ( +
+ {Icon ? : null} +

{title}

+ {hint ?

{hint}

: null} + {actions ?
{actions}
: null} +
+ ); +} diff --git a/apps/web/src/components/ui/Input.tsx b/apps/web/src/components/ui/Input.tsx new file mode 100644 index 0000000..807bb1d --- /dev/null +++ b/apps/web/src/components/ui/Input.tsx @@ -0,0 +1,52 @@ +import clsx from "clsx"; +import { forwardRef, useId } from "react"; +import type { InputHTMLAttributes, ReactNode } from "react"; + +export interface InputProps extends InputHTMLAttributes { + /** French label rendered above the field. */ + label?: ReactNode; + /** French error message rendered below the field. */ + error?: string; + /** Muted helper text (hidden while an error is shown). */ + hint?: ReactNode; +} + +export const Input = forwardRef(function Input( + { label, error, hint, id, className, ...rest }, + ref, +) { + const autoId = useId(); + const inputId = id ?? autoId; + const errorId = `${inputId}-error`; + + return ( +
+ {label ? ( + + ) : null} + + {error ? ( +

+ {error} +

+ ) : hint ? ( +

{hint}

+ ) : null} +
+ ); +}); diff --git a/apps/web/src/components/ui/Modal.tsx b/apps/web/src/components/ui/Modal.tsx new file mode 100644 index 0000000..6970492 --- /dev/null +++ b/apps/web/src/components/ui/Modal.tsx @@ -0,0 +1,102 @@ +import clsx from "clsx"; +import { X } from "lucide-react"; +import { useEffect, useRef } from "react"; +import type { ReactNode } from "react"; +import { createPortal } from "react-dom"; + +export interface ModalProps { + open: boolean; + onClose: () => void; + title: ReactNode; + children: ReactNode; + /** Footer slot — typically « Annuler » + primary action. */ + footer?: ReactNode; + /** max-w-md (quick forms) · max-w-lg · max-w-2xl (rules, import mapping). */ + size?: "md" | "lg" | "2xl"; + className?: string; +} + +const SIZE_CLASSES = { md: "max-w-md", lg: "max-w-lg", "2xl": "max-w-2xl" } as const; + +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * Modal (ux-pages §5.5): 60% black overlay, centered card, Esc closes, + * focus trapped inside the panel. + */ +export function Modal({ open, onClose, title, children, footer, size = "md", className }: ModalProps) { + const panelRef = useRef(null); + + useEffect(() => { + if (!open) return; + const previouslyFocused = document.activeElement as HTMLElement | null; + const panel = panelRef.current; + const first = panel?.querySelector(FOCUSABLE); + (first ?? panel)?.focus(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + return; + } + if (event.key !== "Tab" || !panel) return; + const focusables = Array.from(panel.querySelectorAll(FOCUSABLE)); + if (focusables.length === 0) return; + const firstEl = focusables[0]; + const lastEl = focusables[focusables.length - 1]; + if (event.shiftKey && document.activeElement === firstEl) { + event.preventDefault(); + lastEl.focus(); + } else if (!event.shiftKey && document.activeElement === lastEl) { + event.preventDefault(); + firstEl.focus(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + previouslyFocused?.focus(); + }; + }, [open, onClose]); + + if (!open) return null; + + return createPortal( +
+
+
+
+

{title}

+ +
+
{children}
+ {footer ? ( +
+ {footer} +
+ ) : null} +
+
, + document.body, + ); +} diff --git a/apps/web/src/components/ui/PageHeader.tsx b/apps/web/src/components/ui/PageHeader.tsx new file mode 100644 index 0000000..8f074b0 --- /dev/null +++ b/apps/web/src/components/ui/PageHeader.tsx @@ -0,0 +1,29 @@ +import clsx from "clsx"; +import { useEffect } from "react"; +import type { ReactNode } from "react"; + +export interface PageHeaderProps { + /** French page title, ex: « Poids & Objectif ». */ + title: string; + description?: ReactNode; + /** Right side: PeriodSelector, primary actions… */ + actions?: ReactNode; + className?: string; +} + +/** In-content page header: h1 + actions row; also sets document.title. */ +export function PageHeader({ title, description, actions, className }: PageHeaderProps) { + useEffect(() => { + document.title = `${title} — LifeTrack`; + }, [title]); + + return ( +
+
+

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/apps/web/src/components/ui/Select.tsx b/apps/web/src/components/ui/Select.tsx new file mode 100644 index 0000000..350f762 --- /dev/null +++ b/apps/web/src/components/ui/Select.tsx @@ -0,0 +1,52 @@ +import clsx from "clsx"; +import { forwardRef, useId } from "react"; +import type { ReactNode, SelectHTMLAttributes } from "react"; + +export interface SelectProps extends SelectHTMLAttributes { + /** French label rendered above the field. */ + label?: ReactNode; + /** French error message rendered below the field. */ + error?: string; + hint?: ReactNode; +} + +export const Select = forwardRef(function Select( + { label, error, hint, id, className, children, ...rest }, + ref, +) { + const autoId = useId(); + const selectId = id ?? autoId; + const errorId = `${selectId}-error`; + + return ( +
+ {label ? ( + + ) : null} + + {error ? ( +

+ {error} +

+ ) : hint ? ( +

{hint}

+ ) : null} +
+ ); +}); diff --git a/apps/web/src/components/ui/Spinner.tsx b/apps/web/src/components/ui/Spinner.tsx new file mode 100644 index 0000000..2ea3ecb --- /dev/null +++ b/apps/web/src/components/ui/Spinner.tsx @@ -0,0 +1,37 @@ +import clsx from "clsx"; + +export interface SpinnerProps { + size?: "sm" | "md" | "lg"; + className?: string; + /** Accessible label — French. */ + label?: string; +} + +const SIZE_CLASSES = { + sm: "h-4 w-4 border-2", + md: "h-6 w-6 border-2", + lg: "h-8 w-8 border-[3px]", +} as const; + +export function Spinner({ size = "md", className, label = "Chargement…" }: SpinnerProps) { + return ( + + ); +} + +/** Convenience: spinner centered in its container (page/section loading state). */ +export function CenteredSpinner({ className }: { className?: string }) { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/ui/StatCard.tsx b/apps/web/src/components/ui/StatCard.tsx new file mode 100644 index 0000000..69f888d --- /dev/null +++ b/apps/web/src/components/ui/StatCard.tsx @@ -0,0 +1,99 @@ +import clsx from "clsx"; +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { toneClass } from "../../lib/format"; +import type { DeltaToneName } from "../../lib/format"; + +export interface StatCardProps { + /** Uppercase muted label, ex: « Poids actuel ». */ + label: string; + /** Main value, ex: « 82,4 kg » — already formatted via lib/format. */ + value: ReactNode; + /** Delta line, ex: « −0,4 kg sur 7 j » — colored by `tone`. */ + delta?: ReactNode; + /** Semantic tone of the delta (use deltaToneName() for goal-aware tones). */ + tone?: DeltaToneName | "warning"; + deltaIcon?: LucideIcon; + /** Secondary line under the value (muted). */ + sub?: ReactNode; + /** 0–100 progress gauge (values > 100 are clamped, shown in negative tone). */ + progress?: number; + progressTone?: "positive" | "negative" | "warning" | "accent"; + onClick?: () => void; + className?: string; + /** Optional sparkline slot (≈40px high). */ + children?: ReactNode; +} + +const PROGRESS_CLASSES = { + positive: "bg-semantic-positive", + negative: "bg-semantic-negative", + warning: "bg-semantic-warning", + accent: "bg-accent", +} as const; + +/** KPI card (ux-pages §4.3): label, value, toned delta, optional gauge/sparkline. */ +export function StatCard({ + label, + value, + delta, + tone = "neutral", + deltaIcon: DeltaIcon, + sub, + progress, + progressTone = "accent", + onClick, + className, + children, +}: StatCardProps) { + const body = ( + <> +

{label}

+

+ {value} +

+ {delta !== undefined ? ( +

+ {DeltaIcon ? : null} + {delta} +

+ ) : null} + {progress !== undefined ? ( +
+
+
+ ) : null} + {sub ?

{sub}

: null} + {children ?
{children}
: null} + + ); + + const baseClass = clsx("rounded-card border bg-surface p-4 md:p-5", className); + + if (onClick) { + return ( + + ); + } + return
{body}
; +} diff --git a/apps/web/src/components/ui/Table.tsx b/apps/web/src/components/ui/Table.tsx new file mode 100644 index 0000000..69b9cbf --- /dev/null +++ b/apps/web/src/components/ui/Table.tsx @@ -0,0 +1,207 @@ +import clsx from "clsx"; +import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react"; +import { useMemo, useState } from "react"; +import type { ReactNode } from "react"; + +import { formatNumber } from "../../lib/format"; + +export interface TableColumn { + key: string; + header: ReactNode; + align?: "left" | "right" | "center"; + /** Enables client-side sorting on this column. */ + sortable?: boolean; + /** Value used for sorting (defaults to row[key]). */ + sortValue?: (row: T) => string | number | null; + render?: (row: T, index: number) => ReactNode; + className?: string; +} + +export interface TablePagination { + page: number; // 1-based + pageSize: number; + total: number; + onPageChange: (page: number) => void; +} + +export interface TableProps { + columns: TableColumn[]; + rows: T[]; + rowKey: (row: T, index: number) => string | number; + /** Rendered when `rows` is empty (typically an ). */ + empty?: ReactNode; + /** Server-side pagination footer « 1–20 sur 254 ». */ + pagination?: TablePagination; + onRowClick?: (row: T) => void; + className?: string; +} + +type SortState = { key: string; dir: "asc" | "desc" } | null; + +function defaultSortValue(row: T, key: string): string | number | null { + const value = (row as Record)[key]; + if (typeof value === "number" || typeof value === "string") return value; + return value === null || value === undefined ? null : String(value); +} + +const ALIGN_CLASSES = { left: "text-left", right: "text-right", center: "text-center" } as const; + +/** + * Data table (ux-pages §5.3): sortable headers, 44px rows, hairline row + * borders (no zebra), hover surface, « 1–20 sur 254 » pagination footer. + */ +export function Table({ + columns, + rows, + rowKey, + empty, + pagination, + onRowClick, + className, +}: TableProps) { + const [sort, setSort] = useState(null); + + const sortedRows = useMemo(() => { + if (!sort) return rows; + const col = columns.find((c) => c.key === sort.key); + if (!col) return rows; + const getValue = col.sortValue ?? ((row: T) => defaultSortValue(row, col.key)); + const factor = sort.dir === "asc" ? 1 : -1; + return [...rows].sort((a, b) => { + const va = getValue(a); + const vb = getValue(b); + if (va === null && vb === null) return 0; + if (va === null) return 1; + if (vb === null) return -1; + if (typeof va === "number" && typeof vb === "number") return (va - vb) * factor; + return String(va).localeCompare(String(vb), "fr") * factor; + }); + }, [rows, sort, columns]); + + const toggleSort = (key: string) => { + setSort((prev) => + prev?.key === key + ? prev.dir === "asc" + ? { key, dir: "desc" } + : null + : { key, dir: "asc" }, + ); + }; + + if (rows.length === 0 && empty) return <>{empty}; + + const totalPages = pagination ? Math.max(1, Math.ceil(pagination.total / pagination.pageSize)) : 1; + const rangeStart = pagination ? (pagination.page - 1) * pagination.pageSize + 1 : 1; + const rangeEnd = pagination + ? Math.min(pagination.page * pagination.pageSize, pagination.total) + : rows.length; + + return ( +
+
+ + + + {columns.map((col) => { + const sortIcon = + sort?.key === col.key ? ( + sort.dir === "asc" ? ( + + ) : ( + + ) + ) : null; + return ( + + ); + })} + + + + {sortedRows.map((row, index) => ( + onRowClick(row) : undefined} + className={clsx( + "h-11 border-b border-border/50 transition-colors", + onRowClick ? "cursor-pointer hover:bg-surface-2/40" : "hover:bg-surface-2/40", + )} + > + {columns.map((col) => ( + + ))} + + ))} + +
+ {col.sortable ? ( + + ) : ( + col.header + )} +
+ {col.render + ? col.render(row, index) + : ((row as Record)[col.key] as ReactNode)} +
+
+ {pagination && pagination.total > 0 ? ( +
+ + {formatNumber(rangeStart)}–{formatNumber(rangeEnd)} sur {formatNumber(pagination.total)} + +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/ui/Tabs.tsx b/apps/web/src/components/ui/Tabs.tsx new file mode 100644 index 0000000..34d0386 --- /dev/null +++ b/apps/web/src/components/ui/Tabs.tsx @@ -0,0 +1,59 @@ +import clsx from "clsx"; +import type { ReactNode } from "react"; +import { NavLink } from "react-router-dom"; + +export interface TabItem { + id: string; + label: ReactNode; + /** Optional counter/status badge. */ + badge?: ReactNode; + /** When set, the tab is a NavLink to this route (URL-driven tabs). */ + to?: string; +} + +export interface TabsProps { + items: TabItem[]; + /** Active tab id — required for state-driven tabs (ignored for `to` links). */ + value?: string; + onChange?: (id: string) => void; + className?: string; +} + +const baseClass = + "inline-flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"; +const activeClass = "border-accent text-ink"; +const inactiveClass = "border-transparent text-ink-secondary hover:text-ink"; + +/** Horizontal tabs (Finances sub-pages, Réglages sections…). */ +export function Tabs({ items, value, onChange, className }: TabsProps) { + return ( + + ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..09a3f27 --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,86 @@ +import type { ApiErrorShape } from "../types/api"; + +export type { ApiErrorShape } from "../types/api"; + +/** Typed error thrown by the api() wrapper — `message` is French, displayable as-is. */ +export class ApiError extends Error { + constructor( + public status: number, + public code: string, + message: string, + public details: Record = {}, + ) { + super(message); + this.name = "ApiError"; + } +} + +/** + * True when the API answered « ce module n'est pas encore configuré » rather + * than « cette ressource n'existe pas » : a 404 carrying + * `details.setup_required = true` (ex. GET /vape/dashboard sur un compte neuf). + * Pages must render the documented French empty state (ux-pages §16) for this + * case, never an error message. + */ +export function isSetupRequiredError(error: unknown): error is ApiError { + return ( + error instanceof ApiError && error.status === 404 && error.details.setup_required === true + ); +} + +/** True for any 404 — « pas encore de données / pas encore configuré ». */ +export function isNotFoundError(error: unknown): error is ApiError { + return error instanceof ApiError && error.status === 404; +} + +const TOKEN_KEY = "lifetrack.token"; + +export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY); + +export const setToken = (t: string | null): void => { + if (t) localStorage.setItem(TOKEN_KEY, t); + else localStorage.removeItem(TOKEN_KEY); +}; + +/** Routes where a 401 must NOT trigger the global redirect (login failure, setup). */ +const PUBLIC_PATHS = ["/login", "/setup"]; + +/** + * Fetch wrapper — baseURL /api, JWT from localStorage, unified error parsing. + * On 401 (outside the login/setup pages) the token is cleared and the user is + * redirected to /login. + */ +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 && !PUBLIC_PATHS.includes(window.location.pathname)) { + 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); +} + +/** Build a query string from a params object, skipping null/undefined/empty values. */ +export function qs(params: Record): string { + const sp = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === "") continue; + sp.set(key, String(value)); + } + return sp.toString(); +} diff --git a/apps/web/src/lib/dates.ts b/apps/web/src/lib/dates.ts new file mode 100644 index 0000000..9a25044 --- /dev/null +++ b/apps/web/src/lib/dates.ts @@ -0,0 +1,86 @@ +/** + * Date helpers on local-day ISO strings (YYYY-MM-DD). + * The API stores UTC datetimes; local days are handled as plain dates. + */ + +export const DAY_MS = 86_400_000; + +/** Format a Date as a local-day ISO string (YYYY-MM-DD), using the browser's timezone. */ +export function toIsoDate(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +/** Parse a YYYY-MM-DD string as a Date at local midnight. */ +export function parseIsoDate(iso: string): Date { + const [y, m, d] = iso.split("-").map(Number); + return new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1); +} + +export function isValidIsoDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const d = parseIsoDate(value); + return !Number.isNaN(d.getTime()) && toIsoDate(d) === value; +} + +/** Today as YYYY-MM-DD (local). */ +export function todayIso(): string { + return toIsoDate(new Date()); +} + +/** Add (or subtract) days to a YYYY-MM-DD string. */ +export function addDaysIso(iso: string, days: number): string { + const d = parseIsoDate(iso); + d.setDate(d.getDate() + days); + return toIsoDate(d); +} + +/** N days ago as YYYY-MM-DD (0 = today). */ +export function daysAgoIso(days: number): string { + return addDaysIso(todayIso(), -days); +} + +/** First day of the month containing `iso` (defaults to today). */ +export function startOfMonthIso(iso?: string): string { + const d = iso ? parseIsoDate(iso) : new Date(); + return toIsoDate(new Date(d.getFullYear(), d.getMonth(), 1)); +} + +/** Last day of the month containing `iso` (defaults to today). */ +export function endOfMonthIso(iso?: string): string { + const d = iso ? parseIsoDate(iso) : new Date(); + return toIsoDate(new Date(d.getFullYear(), d.getMonth() + 1, 0)); +} + +/** First day of the previous month. */ +export function startOfPreviousMonthIso(): string { + const now = new Date(); + return toIsoDate(new Date(now.getFullYear(), now.getMonth() - 1, 1)); +} + +/** Last day of the previous month. */ +export function endOfPreviousMonthIso(): string { + const now = new Date(); + return toIsoDate(new Date(now.getFullYear(), now.getMonth(), 0)); +} + +/** January 1st of the current year. */ +export function startOfYearIso(): string { + return toIsoDate(new Date(new Date().getFullYear(), 0, 1)); +} + +/** ISO 8601 week number (weeks start on Monday). */ +export function isoWeek(date: Date): number { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d.getTime() - yearStart.getTime()) / DAY_MS + 1) / 7); +} + +/** Whole days between two YYYY-MM-DD strings (b − a). */ +export function diffDays(a: string, b: string): number { + return Math.round((parseIsoDate(b).getTime() - parseIsoDate(a).getTime()) / DAY_MS); +} diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts new file mode 100644 index 0000000..b7d5503 --- /dev/null +++ b/apps/web/src/lib/format.ts @@ -0,0 +1,211 @@ +/** + * fr-FR display helpers (docs/design/ux-pages.md §2 — normative). + * Thousands separator: narrow no-break space (U+202F); decimal separator: comma; + * minus sign: U+2212; € after the amount. + */ + +import { isoWeek } from "./dates"; + +const NNBSP = " "; // narrow no-break space (thousands, before % and units) +const MINUS = "−"; // typographic minus sign + +const PARIS_TZ = "Europe/Paris"; + +/** Normalize Intl output: grouping spaces → U+202F, hyphen-minus → U+2212. */ +function normalizeNumberString(s: string): string { + return s.replace(/[  ](?=\d)/g, NNBSP).replace(/-/g, MINUS); +} + +function toDate(value: Date | string | number): Date { + return value instanceof Date ? value : new Date(value); +} + +/** Plain fr-FR number: U+202F thousands, comma decimals, U+2212 minus. */ +export function formatNumber(value: number, decimals = 0): string { + return normalizeNumberString( + new Intl.NumberFormat("fr-FR", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value), + ); +} + +/** fr-FR number with up to `maxDecimals` decimals (no trailing zeros) — tooltips. */ +export function formatNumberAuto(value: number, maxDecimals = 2): string { + return normalizeNumberString( + new Intl.NumberFormat("fr-FR", { maximumFractionDigits: maxDecimals }).format(value), + ); +} + +/** Euro amount from CENTS (canonical unit): 123456 → "1 234,56 €". */ +export function formatEuros(cents: number): string { + return formatEuroAmount(cents / 100); +} + +/** Alias of formatEuros (amount in cents). */ +export const formatEuro = formatEuros; + +/** Euro amount from a EUR float (already in euros): 1234.56 → "1 234,56 €". */ +export function formatEuroAmount(euros: number): string { + return normalizeNumberString( + new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(euros), + ); +} + +/** Weight in kg, 1 decimal: "82,4 kg". */ +export function formatWeight(kg: number): string { + return `${formatNumber(kg, 1)}${NNBSP}kg`; +} + +/** Calories, grouped integer: "1 850 kcal". */ +export function formatKcal(kcal: number): string { + return `${formatNumber(Math.round(kcal))}${NNBSP}kcal`; +} + +/** Millilitres, 1 decimal: "4,2 ml". */ +export function formatMl(ml: number): string { + return `${formatNumber(ml, 1)}${NNBSP}ml`; +} + +/** Milligrams (nicotine), 1 decimal: "12,6 mg". */ +export function formatMg(mg: number): string { + return `${formatNumber(mg, 1)}${NNBSP}mg`; +} + +/** Kilometres, 2 decimals: "5,25 km". */ +export function formatKm(km: number): string { + return `${formatNumber(km, 2)}${NNBSP}km`; +} + +/** Distance from canonical metres → km display. */ +export function formatDistance(metres: number): string { + return formatKm(metres / 1000); +} + +/** Steps, grouped integer: "9 542". */ +export function formatSteps(steps: number): string { + return formatNumber(Math.round(steps)); +} + +/** Percentage with narrow space before %: "82 %". `value` is 0–100. */ +export function formatPercent(value: number, decimals = 0): string { + return `${formatNumber(value, decimals)}${NNBSP}%`; +} + +/** Grams (macros), integer: "132 g". */ +export function formatG(grams: number): string { + return `${formatNumber(Math.round(grams))}${NNBSP}g`; +} + +/** Duration from minutes: "1 h 05" · "45 min". */ +export function formatDuration(minutes: number): string { + const total = Math.round(minutes); + const h = Math.floor(total / 60); + const m = total % 60; + if (h === 0) return `${m}${NNBSP}min`; + return `${h}${NNBSP}h${NNBSP}${String(m).padStart(2, "0")}`; +} + +/** Date dd/MM/yyyy (Europe/Paris): "13/08/2026". */ +export function formatDate(value: Date | string | number): string { + return new Intl.DateTimeFormat("fr-FR", { + timeZone: PARIS_TZ, + day: "2-digit", + month: "2-digit", + year: "numeric", + }).format(toDate(value)); +} + +/** Date + time dd/MM/yyyy HH:mm (Europe/Paris): "13/08/2026 19:30". */ +export function formatDateTime(value: Date | string | number): string { + const d = toDate(value); + const date = formatDate(d); + const time = new Intl.DateTimeFormat("fr-FR", { + timeZone: PARIS_TZ, + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(d); + return `${date} ${time.replace(/ | /g, "")}`; +} + +/** Short date for chart axes: "13/08". */ +export function formatDateShort(value: Date | string | number): string { + return new Intl.DateTimeFormat("fr-FR", { + timeZone: PARIS_TZ, + day: "2-digit", + month: "2-digit", + }).format(toDate(value)); +} + +/** Month label: "août 2026". */ +export function formatMonth(value: Date | string | number): string { + return new Intl.DateTimeFormat("fr-FR", { + timeZone: PARIS_TZ, + month: "long", + year: "numeric", + }).format(toDate(value)); +} + +/** Compact month label for chart axes: "août 26". */ +export function formatMonthShort(value: Date | string | number): string { + return new Intl.DateTimeFormat("fr-FR", { + timeZone: PARIS_TZ, + month: "long", + year: "2-digit", + }).format(toDate(value)); +} + +/** ISO week label: "Sem. 33". */ +export function formatWeek(value: Date | string | number): string { + return `Sem.${NNBSP}${isoWeek(toDate(value))}`; +} + +/** Signed number with explicit sign (U+2212 minus): "+0,3 kg" · "−450 kcal". */ +export function formatSigned(value: number, decimals = 0, unit?: string): string { + const abs = formatNumber(Math.abs(value), decimals); + const sign = value > 0 ? "+" : value < 0 ? MINUS : ""; + return `${sign}${abs}${unit ? `${NNBSP}${unit}` : ""}`; +} + +/** Signed euro delta from cents: "+12,50 €" · "−45,90 €". */ +export function formatSignedEuros(cents: number): string { + const abs = formatEuros(Math.abs(cents)); + const sign = cents > 0 ? "+" : cents < 0 ? MINUS : ""; + return `${sign}${abs}`; +} + +/* ------------------------------------------------------------------ */ +/* Goal-aware delta tones (docs/design/ux-pages.md §3) */ +/* ------------------------------------------------------------------ */ + +/** Direction of the user's goal: "lose" → a decrease is favorable. */ +export type GoalDirection = "lose" | "gain"; + +export type DeltaToneName = "positive" | "negative" | "neutral"; + +/** + * Semantic tone of a delta, aware of the goal direction: + * green means "moving toward the goal", never simply "going down". + */ +export function deltaToneName(value: number, goalDirection: GoalDirection): DeltaToneName { + if (value === 0) return "neutral"; + const favorable = goalDirection === "lose" ? value < 0 : value > 0; + return favorable ? "positive" : "negative"; +} + +const TONE_CLASSES: Record = { + positive: "text-semantic-positive", + negative: "text-semantic-negative", + neutral: "text-ink-secondary", +}; + +/** Tailwind text-color classes for a delta, goal-direction aware. */ +export function deltaTone(value: number, goalDirection: GoalDirection): string { + return TONE_CLASSES[deltaToneName(value, goalDirection)]; +} + +/** Tailwind text-color classes from an explicit tone name. */ +export function toneClass(tone: DeltaToneName | "warning"): string { + return tone === "warning" ? "text-semantic-warning" : TONE_CLASSES[tone]; +} diff --git a/apps/web/src/lib/queryClient.ts b/apps/web/src/lib/queryClient.ts new file mode 100644 index 0000000..d57b28a --- /dev/null +++ b/apps/web/src/lib/queryClient.ts @@ -0,0 +1,12 @@ +import { QueryClient } from "@tanstack/react-query"; + +/** Single QueryClient instance for the whole app. */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..597b23f --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./app/App"; +import "./styles/index.css"; + +const rootElement = document.getElementById("root"); +if (!rootElement) throw new Error("Missing #root element"); + +createRoot(rootElement).render( + + + , +); diff --git a/apps/web/src/modules/finance/api.ts b/apps/web/src/modules/finance/api.ts new file mode 100644 index 0000000..ce7b94d --- /dev/null +++ b/apps/web/src/modules/finance/api.ts @@ -0,0 +1,665 @@ +/** + * Finance API layer — typed React Query hooks over the api() wrapper + * (CONVENTIONS C5.4). Endpoints and response shapes come from + * docs/design/datamodel-finance.md §9; the router is mounted at /api/finance + * (architecture.md §10 C2.1 — the api() wrapper already prefixes /api). + * + * Money: signed decimal euros with 2 decimals (datamodel-finance §1.1); + * expense figures returned by /stats/* are absolute positive values. + */ + +import { keepPreviousData, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { UseQueryResult } from "@tanstack/react-query"; + +import { api } from "../../lib/api"; +import type { Page } from "../../types/api"; + +export const MODULE_ID = "finance"; +const BASE = "/finance"; + +/* ------------------------------------------------------------------ */ +/* Types (mirror of the Pydantic schemas) */ +/* ------------------------------------------------------------------ */ + +export type Uuid = string; +export type AccountKind = "checking" | "savings" | "paypal" | "cash" | "other"; +export type CategoryKind = "income" | "expense" | "transfer"; +export type CategorySource = "rule" | "user"; +export type Direction = "debit" | "credit"; +export type RuleDirection = Direction | "any"; +export type BudgetStatus = "ok" | "warning" | "over"; +export type Periodicity = "weekly" | "monthly" | "quarterly" | "yearly"; +export type RuleScope = "uncategorized" | "all_non_manual" | "all"; + +export interface Account { + id: Uuid; + name: string; + kind: AccountKind; + currency: string; + institution: string | null; + iban_masked: string | null; + initial_balance: number; + is_archived: boolean; + balance: number; + transaction_count: number; + last_transaction_date?: string | null; +} + +export interface AccountPayload { + name: string; + kind: AccountKind; + currency?: string; + institution?: string | null; + iban_masked?: string | null; + initial_balance?: number; + is_archived?: boolean; +} + +export interface Category { + id: Uuid; + parent_id: Uuid | null; + name: string; + icon: string | null; + color: string | null; + kind: CategoryKind; + is_system: boolean; + sort_order: number; + transaction_count?: number; + children?: Category[]; +} + +export interface CategoryPayload { + name: string; + parent_id?: Uuid | null; + icon?: string | null; + color?: string | null; + kind?: CategoryKind; + sort_order?: number; +} + +export interface Transaction { + id: Uuid; + account_id: Uuid; + account_name: string | null; + booked_date: string; + value_date: string | null; + amount: number; + currency: string; + label_raw: string; + label_clean: string; + counterparty: string | null; + category_id: Uuid | null; + category_name: string | null; + category_color: string | null; + category_source: CategorySource | null; + notes: string | null; + transfer_group_id: Uuid | null; + external_id: string | null; + /** Central `import_runs.id` (integer), not a UUID. */ + import_run_id: number | null; +} + +export interface TransactionCreate { + account_id: Uuid; + booked_date: string; + amount: number; + label_clean: string; + category_id?: Uuid | null; + counterparty?: string | null; + notes?: string | null; +} + +export interface TransactionUpdate { + label_clean?: string; + counterparty?: string | null; + category_id?: Uuid | null; + notes?: string | null; + booked_date?: string; + amount?: number; +} + +export interface RuleMatchers { + label_contains?: string[] | null; + label_regex?: string | null; + amount_min?: number | null; + amount_max?: number | null; + direction?: RuleDirection | null; + account_id?: Uuid | null; +} + +export interface RuleActions { + set_category_id?: Uuid | null; + set_label_clean?: string | null; + set_counterparty?: string | null; + mark_transfer?: boolean | null; +} + +export interface Rule { + id: Uuid; + name: string; + priority: number; + enabled: boolean; + stop: boolean; + matchers: RuleMatchers; + actions: RuleActions; + hit_count: number; + last_applied_at: string | null; +} + +export interface RulePayload { + name: string; + priority?: number; + enabled?: boolean; + stop?: boolean; + matchers: RuleMatchers; + actions: RuleActions; +} + +export interface RulePreview { + total_matched: number; + items: Transaction[]; +} + +export interface RuleApplyBody { + scope?: RuleScope; + date_from?: string | null; + date_to?: string | null; + account_id?: Uuid | null; + rule_id?: Uuid | null; + dry_run?: boolean; + force?: boolean; +} + +export interface RuleApplyResult { + scanned: number; + matched: number; + updated: number; + dry_run: boolean; + by_rule: { rule_id: Uuid; name: string; matched: number }[]; +} + +export interface Budget { + id: Uuid; + category_id: Uuid; + category_name?: string | null; + category_color?: string | null; + monthly_amount: number; + start_month: string; + end_month: string | null; + actual?: number; + progress_pct?: number; + remaining?: number; + projected_eom?: number | null; + status?: BudgetStatus | string; +} + +export interface BudgetCreate { + category_id: Uuid; + monthly_amount: number; + start_month: string; + end_month?: string | null; +} + +export interface BudgetUpdate { + monthly_amount?: number; + end_month?: string | null; + effective_from?: string; +} + +export interface BudgetProgressItem { + budget_id: Uuid; + category_id: Uuid; + category_name: string; + category_color: string | null; + budget: number; + actual: number; + remaining: number; + progress_pct: number; + projected_eom: number | null; + status: BudgetStatus; +} + +export interface BudgetProgress { + month: string; + items: BudgetProgressItem[]; + totals: { budget: number; actual: number; progress_pct: number }; +} + +export interface MonthlyCategorySeries { + category_id: Uuid | null; + name: string; + color: string | null; + data: number[]; +} + +export interface MonthlyByCategory { + months: string[]; + series: MonthlyCategorySeries[]; + totals: number[]; +} + +export interface Cashflow { + months: string[]; + income: number[]; + expenses: number[]; + net: number[]; + cumulative_net: number[]; +} + +export interface SankeyFlow { + period: { from: string; to: string }; + nodes: { name: string; color?: string | null }[]; + links: { source: string; target: string; value: number }[]; +} + +export interface RecurringItem { + merchant_key: string; + label_display: string; + category_id: Uuid | null; + category_name: string | null; + periodicity: Periodicity; + occurrences: number; + average_amount: number; + expected_amount: number; + last_date: string; + next_date_predicted: string; + is_active: boolean; +} + +export interface RecurringResult { + items: RecurringItem[]; + monthly_total_estimate: number; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +type QueryValue = string | number | boolean | null | undefined | readonly (string | number)[]; + +/** Query string builder supporting repeatable params (account_id, category_id). */ +export function toQuery(params: Record): string { + const sp = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === "") continue; + if (Array.isArray(value)) { + for (const item of value) { + if (item === undefined || item === null || item === "") continue; + sp.append(key, String(item)); + } + continue; + } + sp.set(key, String(value)); + } + const query = sp.toString(); + return query ? `?${query}` : ""; +} + +/** Accept both a bare array and a Page envelope (drift-tolerant). */ +function asList(data: T[] | Page | null | undefined): T[] { + if (Array.isArray(data)) return data; + if (data && Array.isArray((data as Page).items)) return (data as Page).items; + return []; +} + +function useInvalidate(): (...resources: string[]) => void { + const queryClient = useQueryClient(); + return (...resources: string[]) => { + for (const resource of resources) { + void queryClient.invalidateQueries({ queryKey: [MODULE_ID, resource] }); + } + }; +} + +/* ------------------------------------------------------------------ */ +/* Accounts */ +/* ------------------------------------------------------------------ */ + +export function useAccounts(includeArchived = false) { + return useQuery({ + queryKey: [MODULE_ID, "accounts", { include_archived: includeArchived }], + queryFn: async () => + asList( + await api>( + `${BASE}/accounts${toQuery({ include_archived: includeArchived })}`, + ), + ), + }); +} + +export function useCreateAccount() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (payload: AccountPayload) => + api(`${BASE}/accounts`, { method: "POST", body: JSON.stringify(payload) }), + onSuccess: () => invalidate("accounts", "transactions", "stats"), + }); +} + +export function useUpdateAccount() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, payload }: { id: Uuid; payload: Partial }) => + api(`${BASE}/accounts/${id}`, { method: "PATCH", body: JSON.stringify(payload) }), + onSuccess: () => invalidate("accounts", "transactions", "stats"), + }); +} + +export function useDeleteAccount() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: Uuid) => api(`${BASE}/accounts/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate("accounts", "transactions", "stats"), + }); +} + +/* ------------------------------------------------------------------ */ +/* Categories */ +/* ------------------------------------------------------------------ */ + +export function useCategories() { + return useQuery({ + queryKey: [MODULE_ID, "categories", {}], + queryFn: async () => + asList(await api>(`${BASE}/categories`)), + }); +} + +export function useCreateCategory() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (payload: CategoryPayload) => + api(`${BASE}/categories`, { method: "POST", body: JSON.stringify(payload) }), + onSuccess: () => invalidate("categories", "transactions", "stats", "budgets"), + }); +} + +export function useUpdateCategory() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, payload }: { id: Uuid; payload: Partial }) => + api(`${BASE}/categories/${id}`, { method: "PATCH", body: JSON.stringify(payload) }), + onSuccess: () => invalidate("categories", "transactions", "stats", "budgets"), + }); +} + +export function useDeleteCategory() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: Uuid) => api(`${BASE}/categories/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate("categories", "transactions", "stats", "budgets", "rules"), + }); +} + +/* ------------------------------------------------------------------ */ +/* Transactions */ +/* ------------------------------------------------------------------ */ + +export interface TransactionFilters { + date_from?: string | null; + date_to?: string | null; + account_id?: Uuid[]; + /** UUIDs, plus the literal "none" for uncategorized transactions. */ + category_id?: string[]; + q?: string; + direction?: Direction | null; + amount_min?: number | null; + amount_max?: number | null; + is_transfer?: boolean | null; + sort?: string; + page?: number; + page_size?: number; +} + +export function useTransactions(filters: TransactionFilters) { + return useQuery({ + queryKey: [MODULE_ID, "transactions", filters], + queryFn: () => + api>( + `${BASE}/transactions${toQuery({ + date_from: filters.date_from, + date_to: filters.date_to, + account_id: filters.account_id, + category_id: filters.category_id, + q: filters.q, + direction: filters.direction, + amount_min: filters.amount_min, + amount_max: filters.amount_max, + is_transfer: filters.is_transfer, + sort: filters.sort, + page: filters.page, + page_size: filters.page_size, + })}`, + ), + placeholderData: keepPreviousData, + }); +} + +/** Count-only query (page_size=1) — used by the « À catégoriser » KPI. */ +export function useUncategorizedCount(filters: Pick) { + return useQuery({ + queryKey: [MODULE_ID, "transactions", { ...filters, category_id: ["none"], page_size: 1 }], + queryFn: () => + api>( + `${BASE}/transactions${toQuery({ + date_from: filters.date_from, + date_to: filters.date_to, + category_id: ["none"], + page: 1, + page_size: 1, + })}`, + ), + select: (page) => page.total, + }); +} + +export function useCreateTransaction() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (payload: TransactionCreate) => + api(`${BASE}/transactions`, { method: "POST", body: JSON.stringify(payload) }), + onSuccess: () => invalidate("transactions", "accounts", "stats"), + }); +} + +export function useUpdateTransaction() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, payload }: { id: Uuid; payload: TransactionUpdate }) => + api(`${BASE}/transactions/${id}`, { + method: "PATCH", + body: JSON.stringify(payload), + }), + onSuccess: () => invalidate("transactions", "accounts", "stats"), + }); +} + +export function useDeleteTransaction() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: Uuid) => api(`${BASE}/transactions/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate("transactions", "accounts", "stats"), + }); +} + +export function useBulkCategorize() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (payload: { transaction_ids: Uuid[]; category_id: Uuid | null }) => + api<{ updated: number }>(`${BASE}/transactions/bulk-categorize`, { + method: "POST", + body: JSON.stringify(payload), + }), + onSuccess: () => invalidate("transactions", "stats"), + }); +} + +/* ------------------------------------------------------------------ */ +/* Rules */ +/* ------------------------------------------------------------------ */ + +export function useRules() { + return useQuery({ + queryKey: [MODULE_ID, "rules", {}], + queryFn: async () => asList(await api>(`${BASE}/rules`)), + }); +} + +export function useCreateRule() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (payload: RulePayload) => + api(`${BASE}/rules`, { method: "POST", body: JSON.stringify(payload) }), + onSuccess: () => invalidate("rules", "transactions", "stats"), + }); +} + +export function useUpdateRule() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, payload }: { id: Uuid; payload: Partial }) => + api(`${BASE}/rules/${id}`, { method: "PATCH", body: JSON.stringify(payload) }), + onSuccess: () => invalidate("rules", "transactions", "stats"), + }); +} + +export function useDeleteRule() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: Uuid) => api(`${BASE}/rules/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate("rules"), + }); +} + +export function useApplyRules() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (body: RuleApplyBody) => + api(`${BASE}/rules/apply`, { method: "POST", body: JSON.stringify(body) }), + onSuccess: (_data, variables) => { + if (!variables.dry_run) invalidate("transactions", "rules", "stats"); + }, + }); +} + +/** + * Live preview of an unsaved rule (POST /rules/preview) — drives the + * « X transactions correspondent » counter of the rule modal. + */ +export function useRulePreview(matchers: RuleMatchers, enabled: boolean) { + return useQuery({ + queryKey: [MODULE_ID, "rules", { preview: matchers }], + queryFn: () => + api(`${BASE}/rules/preview`, { + method: "POST", + body: JSON.stringify({ matchers }), + }), + enabled, + placeholderData: keepPreviousData, + }); +} + +/* ------------------------------------------------------------------ */ +/* Budgets */ +/* ------------------------------------------------------------------ */ + +export function useBudgets(month: string) { + return useQuery({ + queryKey: [MODULE_ID, "budgets", { month }], + queryFn: async () => + asList(await api>(`${BASE}/budgets${toQuery({ month })}`)), + }); +} + +export function useCreateBudget() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (payload: BudgetCreate) => + api(`${BASE}/budgets`, { method: "POST", body: JSON.stringify(payload) }), + onSuccess: () => invalidate("budgets", "stats"), + }); +} + +export function useUpdateBudget() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, payload }: { id: Uuid; payload: BudgetUpdate }) => + api(`${BASE}/budgets/${id}`, { + method: "PATCH", + body: JSON.stringify(payload), + }), + onSuccess: () => invalidate("budgets", "stats"), + }); +} + +export function useDeleteBudget() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: Uuid) => api(`${BASE}/budgets/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate("budgets", "stats"), + }); +} + +/* ------------------------------------------------------------------ */ +/* Stats (chart-ready — datamodel-finance §9.8) */ +/* ------------------------------------------------------------------ */ + +export interface StatsScope { + account_id?: Uuid[]; +} + +export function useMonthlyByCategory( + params: { months: number; level?: "root" | "child"; direction?: Direction } & StatsScope, +) { + const { months, level = "root", direction = "debit", account_id } = params; + return useQuery({ + queryKey: [MODULE_ID, "stats", { resource: "monthly-by-category", months, level, direction, account_id }], + queryFn: () => + api( + `${BASE}/stats/monthly-by-category${toQuery({ months, level, direction, account_id })}`, + ), + }); +} + +export function useCashflow(params: { months: number } & StatsScope) { + const { months, account_id } = params; + return useQuery({ + queryKey: [MODULE_ID, "stats", { resource: "cashflow", months, account_id }], + queryFn: () => api(`${BASE}/stats/cashflow${toQuery({ months, account_id })}`), + }); +} + +export function useSankey(params: { month?: string; months?: number } & StatsScope) { + const { month, months, account_id } = params; + return useQuery({ + queryKey: [MODULE_ID, "stats", { resource: "sankey", month, months, account_id }], + queryFn: () => api(`${BASE}/stats/sankey${toQuery({ month, months, account_id })}`), + }); +} + +export function useRecurring(params: { direction?: Direction; include_inactive?: boolean } = {}) { + const { direction = "debit", include_inactive = false } = params; + return useQuery({ + queryKey: [MODULE_ID, "stats", { resource: "recurring", direction, include_inactive }], + queryFn: () => + api(`${BASE}/stats/recurring${toQuery({ direction, include_inactive })}`), + }); +} + +export function useBudgetProgress(month: string) { + return useQuery({ + queryKey: [MODULE_ID, "stats", { resource: "budget-progress", month }], + queryFn: () => + api(`${BASE}/stats/budget-progress${toQuery({ month })}`), + }); +} + +/** Budget vs réalisé over several months (ux-pages §13.4 G6). */ +export function useBudgetProgressSeries(months: string[]): UseQueryResult[] { + return useQueries({ + queries: months.map((month) => ({ + queryKey: [MODULE_ID, "stats", { resource: "budget-progress", month }], + queryFn: () => api(`${BASE}/stats/budget-progress${toQuery({ month })}`), + })), + }); +} diff --git a/apps/web/src/modules/finance/charts/options.ts b/apps/web/src/modules/finance/charts/options.ts new file mode 100644 index 0000000..cc3fe81 --- /dev/null +++ b/apps/web/src/modules/finance/charts/options.ts @@ -0,0 +1,341 @@ +/** + * ECharts option builders for the finance module (ux-pages §6 + §13.2). + * Pure functions — rendering always goes through / with the + * "lifetrack-dark" theme (CONVENTIONS C5.5). No dual Y axis anywhere, legend + * as soon as there are 2+ series, dashed markLine for budgets/targets. + */ + +import type { EChartsOption } from "echarts"; + +import { CHART_COLORS, CHART_SURFACE, SEMANTIC_COLORS } from "../../../components/charts/theme"; +import { formatNumber, formatPercent } from "../../../lib/format"; +import type { Cashflow, MonthlyByCategory, SankeyFlow } from "../api"; +import { S } from "../strings"; +import { formatAmount, formatSignedAmount, monthAxisLabel, monthLabel } from "../utils"; +import type { CategoryShare } from "../utils"; + +/* ------------------------------------------------------------------ */ +/* Shared helpers */ +/* ------------------------------------------------------------------ */ + +interface TooltipParam { + axisValue?: string; + seriesName?: string; + name?: string; + value?: unknown; + percent?: number; + marker?: string; + dataIndex?: number; + dataType?: string; + data?: unknown; +} + +function asParams(params: unknown): TooltipParam[] { + return (Array.isArray(params) ? params : [params]) as TooltipParam[]; +} + +function numberOf(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +/** Compact euro label for value axes: « 1,2 k€ » · « 450 € ». */ +function axisEuroLabel(value: number): string { + if (Math.abs(value) >= 1000) return `${formatNumber(value / 1000, 1)} k€`; + return `${formatNumber(value)} €`; +} + +const TOOLTIP_ROW = (marker: string | undefined, label: string, value: string): string => + `
${marker ?? ""}${label}${value}
`; + +const DASHED_MARKLINE = { + symbol: "none" as const, + lineStyle: { color: CHART_SURFACE.inkSecondary, type: "dashed" as const, width: 1 }, + label: { color: CHART_SURFACE.inkSecondary, fontSize: 11, position: "end" as const }, +}; + +/** Diagonal hatching for the running (incomplete) month — ux-pages §13.2 G3. */ +const CURRENT_MONTH_DECAL = { + symbol: "rect", + dashArrayX: [1, 0], + dashArrayY: [3, 4], + rotation: -Math.PI / 4, + color: "rgba(255,255,255,0.25)", +}; + +/* ------------------------------------------------------------------ */ +/* G2 — Dépenses par catégorie (donut) */ +/* ------------------------------------------------------------------ */ + +export function buildCategoryDonutOption(shares: CategoryShare[], total: number): EChartsOption { + return { + grid: { left: 0, right: 0, top: 8, bottom: 0 }, + tooltip: { + trigger: "item", + formatter: (params: unknown) => { + const [p] = asParams(params); + if (!p) return ""; + return `${p.name ?? ""}
${TOOLTIP_ROW(p.marker, "", `${formatAmount(numberOf(p.value))} (${formatPercent(p.percent ?? 0, 1)})`)}`; + }, + }, + title: { + text: formatAmount(total), + subtext: S.charts.expenseSeries, + left: "center", + top: "42%", + textStyle: { color: CHART_SURFACE.ink, fontSize: 18, fontWeight: 600 }, + subtextStyle: { color: CHART_SURFACE.inkMuted, fontSize: 11 }, + }, + series: [ + { + name: S.charts.categoryDonut, + type: "pie", + radius: ["50%", "78%"], + center: ["50%", "52%"], + avoidLabelOverlap: true, + minAngle: 3, + itemStyle: { borderColor: CHART_SURFACE.surface, borderWidth: 2 }, + label: { + color: CHART_SURFACE.inkSecondary, + fontSize: 11, + formatter: (params: unknown) => { + const [p] = asParams(params); + return `${p?.name ?? ""} ${formatPercent(p?.percent ?? 0)}`; + }, + }, + labelLine: { length: 8, length2: 10, lineStyle: { color: CHART_SURFACE.axis } }, + data: shares.map((share) => ({ + name: share.name, + value: share.value, + itemStyle: { color: share.color }, + })), + }, + ], + }; +} + +/* ------------------------------------------------------------------ */ +/* G3 — Dépenses mensuelles par catégorie (barres empilées) */ +/* ------------------------------------------------------------------ */ + +export function buildMonthlyStackedOption( + data: MonthlyByCategory, + options: { currentMonth: string; budgetLine?: number | null }, +): EChartsOption { + const { months, series, totals } = data; + const runningIndex = months.indexOf(options.currentMonth); + const labels = months.map(monthAxisLabel); + + return { + legend: { type: "scroll", top: 0 }, + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" }, + formatter: (params: unknown) => { + const items = asParams(params).filter((p) => numberOf(p.value) > 0); + if (items.length === 0) return ""; + const index = items[0].dataIndex ?? 0; + const rows = items + .sort((a, b) => numberOf(b.value) - numberOf(a.value)) + .map((p) => TOOLTIP_ROW(p.marker, p.seriesName ?? "", formatAmount(numberOf(p.value)))) + .join(""); + const total = totals?.[index]; + const totalRow = + typeof total === "number" + ? TOOLTIP_ROW("", S.charts.total, formatAmount(total)) + : ""; + return `${monthLabel(months[index] ?? "")}${rows}${totalRow}`; + }, + }, + xAxis: { type: "category", data: labels, axisLabel: { interval: "auto" } }, + yAxis: { type: "value", axisLabel: { formatter: (value: number) => axisEuroLabel(value) } }, + dataZoom: [{ type: "inside" }], + series: series.map((serie, index) => ({ + name: serie.name, + type: "bar" as const, + stack: "total", + barMaxWidth: 28, + itemStyle: { + color: serie.color ?? CHART_COLORS[index % CHART_COLORS.length], + borderColor: CHART_SURFACE.surface, + borderWidth: 1, + }, + data: serie.data.map((value, dataIndex) => + dataIndex === runningIndex + ? { value, itemStyle: { decal: CURRENT_MONTH_DECAL } } + : value, + ), + ...(index === 0 && options.budgetLine + ? { + markLine: { + ...DASHED_MARKLINE, + label: { + ...DASHED_MARKLINE.label, + formatter: () => `${S.charts.budgetSeries} ${formatAmount(options.budgetLine ?? 0)}`, + }, + data: [{ yAxis: options.budgetLine }], + }, + } + : {}), + })), + }; +} + +/* ------------------------------------------------------------------ */ +/* G4 — Cashflow mensuel (barres groupées + ligne nette) */ +/* ------------------------------------------------------------------ */ + +export function buildCashflowOption(data: Cashflow): EChartsOption { + const labels = data.months.map(monthAxisLabel); + return { + legend: { top: 0 }, + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" }, + formatter: (params: unknown) => { + const items = asParams(params); + if (items.length === 0) return ""; + const index = items[0].dataIndex ?? 0; + const rows = items + .map((p) => { + const value = numberOf(p.value); + const text = + p.seriesName === S.charts.netSeries ? formatSignedAmount(value) : formatAmount(value); + return TOOLTIP_ROW(p.marker, p.seriesName ?? "", text); + }) + .join(""); + return `${monthLabel(data.months[index] ?? "")}${rows}`; + }, + }, + xAxis: { type: "category", data: labels, axisLabel: { interval: "auto" } }, + yAxis: { type: "value", axisLabel: { formatter: (value: number) => axisEuroLabel(value) } }, + dataZoom: [{ type: "inside" }], + series: [ + { + name: S.charts.incomeSeries, + type: "bar", + barMaxWidth: 20, + itemStyle: { color: SEMANTIC_COLORS.positive, borderRadius: [4, 4, 0, 0] }, + data: data.income, + }, + { + name: S.charts.expenseSeries, + type: "bar", + barMaxWidth: 20, + itemStyle: { color: SEMANTIC_COLORS.negative, borderRadius: [4, 4, 0, 0] }, + data: data.expenses, + }, + { + name: S.charts.netSeries, + type: "line", + smooth: 0.2, + showSymbol: false, + z: 3, + lineStyle: { color: CHART_SURFACE.ink, width: 2 }, + itemStyle: { color: CHART_SURFACE.ink }, + data: data.net, + markLine: { ...DASHED_MARKLINE, label: { show: false }, data: [{ yAxis: 0 }] }, + }, + ], + }; +} + +/* ------------------------------------------------------------------ */ +/* G5 — Flux du mois (sankey) */ +/* ------------------------------------------------------------------ */ + +export function buildSankeyOption(data: SankeyFlow): EChartsOption { + return { + grid: { left: 0, right: 0, top: 8, bottom: 0 }, + tooltip: { + trigger: "item", + triggerOn: "mousemove", + formatter: (params: unknown) => { + const [p] = asParams(params); + if (!p) return ""; + const value = formatAmount(numberOf(p.value)); + if (p.dataType === "edge") { + const edge = p.data as { source?: string; target?: string } | undefined; + return `${edge?.source ?? ""} → ${edge?.target ?? ""} : ${value}`; + } + return `${p.name ?? ""} : ${value}`; + }, + }, + series: [ + { + type: "sankey", + left: 8, + right: 130, + top: 12, + bottom: 8, + draggable: false, + nodeGap: 10, + nodeWidth: 14, + emphasis: { focus: "adjacency" }, + lineStyle: { color: "target", opacity: 0.4, curveness: 0.5 }, + label: { + color: CHART_SURFACE.inkSecondary, + fontSize: 11, + formatter: (params: unknown) => { + const [p] = asParams(params); + if (!p) return ""; + return `${p.name ?? ""} · ${formatAmount(numberOf(p.value))}`; + }, + }, + data: data.nodes.map((node) => ({ + name: node.name, + itemStyle: { color: node.color ?? CHART_SURFACE.inkMuted }, + })), + links: data.links.map((link) => ({ + source: link.source, + target: link.target, + value: link.value, + })), + }, + ], + }; +} + +/* ------------------------------------------------------------------ */ +/* G6 — Budget vs réalisé (6 mois) */ +/* ------------------------------------------------------------------ */ + +export function buildBudgetVsActualOption( + months: string[], + budgets: number[], + actuals: number[], +): EChartsOption { + return { + legend: { top: 0 }, + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" }, + formatter: (params: unknown) => { + const items = asParams(params); + if (items.length === 0) return ""; + const index = items[0].dataIndex ?? 0; + const rows = items + .map((p) => TOOLTIP_ROW(p.marker, p.seriesName ?? "", formatAmount(numberOf(p.value)))) + .join(""); + return `${monthLabel(months[index] ?? "")}${rows}`; + }, + }, + xAxis: { type: "category", data: months.map(monthAxisLabel) }, + yAxis: { type: "value", axisLabel: { formatter: (value: number) => axisEuroLabel(value) } }, + series: [ + { + name: S.charts.budgetSeries, + type: "bar", + barMaxWidth: 24, + itemStyle: { color: "rgba(137,135,129,0.4)", borderRadius: [4, 4, 0, 0] }, + data: budgets, + }, + { + name: S.charts.spentSeries, + type: "bar", + barMaxWidth: 24, + itemStyle: { color: CHART_COLORS[0], borderRadius: [4, 4, 0, 0] }, + data: actuals, + }, + ], + }; +} diff --git a/apps/web/src/modules/finance/components/AccountModal.tsx b/apps/web/src/modules/finance/components/AccountModal.tsx new file mode 100644 index 0000000..db63271 --- /dev/null +++ b/apps/web/src/modules/finance/components/AccountModal.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { Select } from "../../../components/ui/Select"; +import { useCreateAccount, useUpdateAccount } from "../api"; +import type { Account, AccountKind, AccountPayload } from "../api"; +import { ACCOUNT_KIND_LABELS, S } from "../strings"; +import { decimalInputValue, parseDecimalInput } from "../utils"; +import { errorMessage } from "./StateViews"; +import { Notice } from "./Notice"; + +const KINDS: AccountKind[] = ["checking", "savings", "paypal", "cash", "other"]; + +export interface AccountModalProps { + open: boolean; + onClose: () => void; + /** Existing account for edition; undefined = creation. */ + account?: Account | null; +} + +/** Create / edit an account (ux-pages §15.5 carte « Comptes »). */ +export function AccountModal({ open, onClose, account }: AccountModalProps) { + const create = useCreateAccount(); + const update = useUpdateAccount(); + const [name, setName] = useState(""); + const [kind, setKind] = useState("checking"); + const [institution, setInstitution] = useState(""); + const [ibanMasked, setIbanMasked] = useState(""); + const [initialBalance, setInitialBalance] = useState("0"); + const [nameError, setNameError] = useState(undefined); + + useEffect(() => { + if (!open) return; + setName(account?.name ?? ""); + setKind(account?.kind ?? "checking"); + setInstitution(account?.institution ?? ""); + setIbanMasked(account?.iban_masked ?? ""); + setInitialBalance(decimalInputValue(account?.initial_balance ?? 0)); + setNameError(undefined); + create.reset(); + update.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, account]); + + const pending = create.isPending || update.isPending; + const error = create.error ?? update.error; + + const submit = () => { + if (name.trim().length === 0) { + setNameError("Le nom est obligatoire."); + return; + } + const payload: AccountPayload = { + name: name.trim(), + kind, + currency: "EUR", + institution: institution.trim() || null, + iban_masked: ibanMasked.trim() || null, + initial_balance: parseDecimalInput(initialBalance) ?? 0, + }; + const onSuccess = () => onClose(); + if (account) update.mutate({ id: account.id, payload }, { onSuccess }); + else create.mutate(payload, { onSuccess }); + }; + + return ( + + + + + } + > +
+ {error ? {errorMessage(error)} : null} + { + setName(event.target.value); + setNameError(undefined); + }} + /> + + setInstitution(event.target.value)} + /> + setIbanMasked(event.target.value)} + /> + setInitialBalance(event.target.value)} + /> + +
+
+ ); +} diff --git a/apps/web/src/modules/finance/components/ApplyRulesModal.tsx b/apps/web/src/modules/finance/components/ApplyRulesModal.tsx new file mode 100644 index 0000000..5d7d93b --- /dev/null +++ b/apps/web/src/modules/finance/components/ApplyRulesModal.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Modal } from "../../../components/ui/Modal"; +import { Select } from "../../../components/ui/Select"; +import { formatNumber } from "../../../lib/format"; +import { useApplyRules } from "../api"; +import type { RuleApplyResult, RuleScope } from "../api"; +import { S } from "../strings"; +import { errorMessage } from "./StateViews"; +import { Notice } from "./Notice"; + +export interface ApplyRulesModalProps { + open: boolean; + onClose: () => void; +} + +/** + * Re-run the rule engine (POST /rules/apply, datamodel-finance §5.4) with a + * scope selector and a dry-run preview before writing anything. + */ +export function ApplyRulesModal({ open, onClose }: ApplyRulesModalProps) { + const applyRules = useApplyRules(); + const [scope, setScope] = useState("uncategorized"); + const [force, setForce] = useState(false); + const [preview, setPreview] = useState(null); + const [applied, setApplied] = useState(null); + const [running, setRunning] = useState<"dry" | "apply" | null>(null); + + useEffect(() => { + if (!open) return; + setScope("uncategorized"); + setForce(false); + setPreview(null); + setApplied(null); + setRunning(null); + applyRules.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const run = (dryRun: boolean) => { + setRunning(dryRun ? "dry" : "apply"); + applyRules.mutate( + { scope, dry_run: dryRun, force: scope === "all" ? force : undefined }, + { + onSuccess: (result) => { + if (dryRun) { + setPreview(result); + setApplied(null); + } else { + setApplied(result); + setPreview(null); + } + }, + onSettled: () => setRunning(null), + }, + ); + }; + + const blocked = scope === "all" && !force; + + return ( + + + + + + } + > +
+ {applyRules.error ? {errorMessage(applyRules.error)} : null} + + + {scope === "all" ? ( + <> + {S.rules.scopeAllWarning} + + + ) : null} + + {preview ? ( + +

{S.rules.dryRunResult(preview.scanned, preview.matched)}

+ {preview.by_rule?.length ? ( +
    + {preview.by_rule.slice(0, 8).map((entry) => ( +
  • + {entry.name} : {formatNumber(entry.matched)} +
  • + ))} +
+ ) : null} +
+ ) : null} + + {applied ? {S.rules.applyResult(applied.updated)} : null} +
+
+ ); +} diff --git a/apps/web/src/modules/finance/components/BudgetModal.tsx b/apps/web/src/modules/finance/components/BudgetModal.tsx new file mode 100644 index 0000000..40dfba4 --- /dev/null +++ b/apps/web/src/modules/finance/components/BudgetModal.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { useCreateBudget, useUpdateBudget } from "../api"; +import type { BudgetProgressItem, Category, Uuid } from "../api"; +import { S } from "../strings"; +import { decimalInputValue, monthLabel, monthStartIso, parseDecimalInput } from "../utils"; +import { CategorySelect } from "./CategoryPicker"; +import { errorMessage } from "./StateViews"; +import { Notice } from "./Notice"; + +export interface BudgetModalProps { + open: boolean; + onClose: () => void; + categories: Category[]; + /** Displayed month "YYYY-MM" — start_month / effective_from of the budget. */ + month: string; + /** Existing budget line for edition; undefined = creation. */ + budget?: BudgetProgressItem | null; +} + +/** + * Create / edit a monthly budget. Editing goes through + * PATCH {monthly_amount, effective_from} which closes the previous period and + * opens a new one (datamodel-finance §2.7). + */ +export function BudgetModal({ open, onClose, categories, month, budget }: BudgetModalProps) { + const create = useCreateBudget(); + const update = useUpdateBudget(); + const [categoryId, setCategoryId] = useState(null); + const [amount, setAmount] = useState(""); + const [endMonth, setEndMonth] = useState(""); + const [formError, setFormError] = useState(undefined); + + useEffect(() => { + if (!open) return; + setCategoryId(budget?.category_id ?? null); + setAmount(decimalInputValue(budget?.budget ?? null)); + setEndMonth(""); + setFormError(undefined); + create.reset(); + update.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, budget]); + + const pending = create.isPending || update.isPending; + const error = create.error ?? update.error; + + const submit = () => { + const value = parseDecimalInput(amount); + if (!categoryId) { + setFormError("Choisissez une catégorie de dépense."); + return; + } + if (value === null || value <= 0) { + setFormError("Saisissez un montant mensuel supérieur à zéro."); + return; + } + const onSuccess = () => onClose(); + if (budget) { + update.mutate( + { id: budget.budget_id, payload: { monthly_amount: value, effective_from: month } }, + { onSuccess }, + ); + } else { + create.mutate( + { + category_id: categoryId, + monthly_amount: value, + start_month: monthStartIso(month), + end_month: endMonth ? monthStartIso(endMonth) : null, + }, + { onSuccess }, + ); + } + }; + + return ( + + + + + } + > +
+ {error ? {errorMessage(error)} : null} + {formError ? {formError} : null} + {budget ? ( + {S.budgets.effectiveFromHint} + ) : null} + {budget ? ( + + ) : ( + + )} + { + setAmount(event.target.value); + setFormError(undefined); + }} + /> + + {budget ? null : ( + setEndMonth(event.target.value)} + /> + )} +
+
+ ); +} diff --git a/apps/web/src/modules/finance/components/BulkCategorizeModal.tsx b/apps/web/src/modules/finance/components/BulkCategorizeModal.tsx new file mode 100644 index 0000000..8f97886 --- /dev/null +++ b/apps/web/src/modules/finance/components/BulkCategorizeModal.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Modal } from "../../../components/ui/Modal"; +import { useBulkCategorize } from "../api"; +import type { Category, Uuid } from "../api"; +import { S } from "../strings"; +import { CategorySelect } from "./CategoryPicker"; +import { errorMessage } from "./StateViews"; +import { Notice } from "./Notice"; + +export interface BulkCategorizeModalProps { + open: boolean; + onClose: () => void; + categories: Category[]; + transactionIds: Uuid[]; + /** Called after a successful bulk update (clears the selection). */ + onDone: () => void; +} + +/** Bulk categorization of the selected rows (POST /transactions/bulk-categorize). */ +export function BulkCategorizeModal({ + open, + onClose, + categories, + transactionIds, + onDone, +}: BulkCategorizeModalProps) { + const bulk = useBulkCategorize(); + const [categoryId, setCategoryId] = useState(null); + + useEffect(() => { + if (!open) return; + setCategoryId(null); + bulk.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const submit = () => { + bulk.mutate( + { transaction_ids: transactionIds, category_id: categoryId }, + { + onSuccess: () => { + onDone(); + onClose(); + }, + }, + ); + }; + + return ( + + + + + } + > +
+ {bulk.error ? {errorMessage(bulk.error)} : null} +

+ {S.transactions.selectedCount(transactionIds.length)} +

+ +
+
+ ); +} diff --git a/apps/web/src/modules/finance/components/CategoryModal.tsx b/apps/web/src/modules/finance/components/CategoryModal.tsx new file mode 100644 index 0000000..6eec2ef --- /dev/null +++ b/apps/web/src/modules/finance/components/CategoryModal.tsx @@ -0,0 +1,215 @@ +import clsx from "clsx"; +import { useEffect, useState } from "react"; + +import { CHART_COLORS } from "../../../components/charts/theme"; +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { Select } from "../../../components/ui/Select"; +import { useCreateCategory, useUpdateCategory } from "../api"; +import type { Category, CategoryKind, CategoryPayload, Uuid } from "../api"; +import { categoryIcon, ICON_OPTIONS } from "../icons"; +import { CATEGORY_KIND_LABELS, S } from "../strings"; +import { buildCategoryTree } from "../utils"; +import { errorMessage } from "./StateViews"; +import { Notice } from "./Notice"; + +export interface CategoryModalProps { + open: boolean; + onClose: () => void; + categories: Category[]; + /** Existing category for edition; undefined = creation. */ + category?: Category | null; + /** Pre-selected parent when creating a sub-category. */ + defaultParentId?: Uuid | null; +} + +/** Create / edit a category — depth is limited to 2 levels (datamodel §2.2). */ +export function CategoryModal({ + open, + onClose, + categories, + category, + defaultParentId = null, +}: CategoryModalProps) { + const create = useCreateCategory(); + const update = useUpdateCategory(); + const roots = buildCategoryTree(categories) + .map((node) => node.category) + .filter((root) => root.kind !== "transfer"); + + const [name, setName] = useState(""); + const [parentId, setParentId] = useState(null); + const [kind, setKind] = useState("expense"); + const [color, setColor] = useState(null); + const [icon, setIcon] = useState(null); + const [nameError, setNameError] = useState(undefined); + + useEffect(() => { + if (!open) return; + setName(category?.name ?? ""); + setParentId(category ? category.parent_id : defaultParentId); + setKind(category?.kind ?? roots.find((r) => r.id === defaultParentId)?.kind ?? "expense"); + setColor(category?.color ?? null); + setIcon(category?.icon ?? null); + setNameError(undefined); + create.reset(); + update.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, category, defaultParentId]); + + const pending = create.isPending || update.isPending; + const error = create.error ?? update.error; + const hasChildren = Boolean(category?.children && category.children.length > 0); + + const submit = () => { + if (name.trim().length === 0) { + setNameError("Le nom est obligatoire."); + return; + } + const parent = parentId ? roots.find((root) => root.id === parentId) : undefined; + const payload: CategoryPayload = { + name: name.trim(), + parent_id: parentId, + icon: icon, + color: color, + kind: parent ? parent.kind : kind, + }; + const onSuccess = () => onClose(); + if (category) update.mutate({ id: category.id, payload }, { onSuccess }); + else create.mutate(payload, { onSuccess }); + }; + + const swatchClass = (active: boolean) => + clsx( + "h-7 w-7 rounded-full border-2 transition-transform", + active ? "scale-110 border-ink" : "border-transparent hover:scale-105", + ); + + return ( + + + + + } + > +
+ {error ? {errorMessage(error)} : null} + {category?.is_system ? ( + + Cette catégorie système ne peut être ni renommée ni supprimée. + + ) : null} + { + setName(event.target.value); + setNameError(undefined); + }} + /> +
+ + +
+ +
+ + {S.common.color} + +
+ + {CHART_COLORS.map((value) => ( +
+
+ +
+ {S.common.icon} +
+ {ICON_OPTIONS.map((value) => { + const Icon = categoryIcon(value); + const active = icon === value; + return ( + + ); + })} +
+
+
+
+ ); +} diff --git a/apps/web/src/modules/finance/components/CategoryPicker.tsx b/apps/web/src/modules/finance/components/CategoryPicker.tsx new file mode 100644 index 0000000..e88f193 --- /dev/null +++ b/apps/web/src/modules/finance/components/CategoryPicker.tsx @@ -0,0 +1,174 @@ +import clsx from "clsx"; +import { useMemo } from "react"; + +import { Badge } from "../../../components/ui/Badge"; +import { Select } from "../../../components/ui/Select"; +import type { Category, CategoryKind, Uuid } from "../api"; +import { S } from "../strings"; +import { categoryOptions, OTHERS_COLOR } from "../utils"; +import type { CategoryOption } from "../utils"; + +/* ------------------------------------------------------------------ */ +/* Badge */ +/* ------------------------------------------------------------------ */ + +export interface CategoryPillProps { + name: string | null; + color?: string | null; + className?: string; +} + +/** Colored category badge — « Sans catégorie » is a dashed warning badge. */ +export function CategoryPill({ name, color, className }: CategoryPillProps) { + if (!name) { + return ( + + {S.transactions.noCategory} + + ); + } + return ( + + + {name} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Options */ +/* ------------------------------------------------------------------ */ + +function useGroupedOptions(categories: Category[], kind?: CategoryKind) { + return useMemo(() => { + const options = categoryOptions(categories).filter((o) => !kind || o.kind === kind); + const groups = new Map(); + for (const option of options) { + const list = groups.get(option.groupName); + if (list) list.push(option); + else groups.set(option.groupName, [option]); + } + return [...groups.entries()]; + }, [categories, kind]); +} + +interface OptionsProps { + groups: [string, CategoryOption[]][]; +} + +function GroupedOptions({ groups }: OptionsProps) { + return ( + <> + {groups.map(([groupName, options]) => ( + + {options.map((option) => ( + + ))} + + ))} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Form select */ +/* ------------------------------------------------------------------ */ + +export interface CategorySelectProps { + categories: Category[]; + value: Uuid | null; + onChange: (value: Uuid | null) => void; + label?: string; + /** Restrict to a single kind (budgets target expense categories only). */ + kind?: CategoryKind; + noneLabel?: string; + disabled?: boolean; + error?: string; + hint?: string; + className?: string; +} + +/** Category picker used in forms (grouped by root category). */ +export function CategorySelect({ + categories, + value, + onChange, + label = S.transactions.categoryField, + kind, + noneLabel = S.transactions.noCategory, + disabled, + error, + hint, + className, +}: CategorySelectProps) { + const groups = useGroupedOptions(categories, kind); + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Inline select (transactions table) */ +/* ------------------------------------------------------------------ */ + +export interface InlineCategorySelectProps { + categories: Category[]; + value: Uuid | null; + onChange: (value: Uuid | null) => void; + disabled?: boolean; + ariaLabel: string; +} + +/** + * Compact in-row category picker (ux-pages §13.3) — choosing a category here + * marks the transaction as manually categorized (category_source = 'user'). + */ +export function InlineCategorySelect({ + categories, + value, + onChange, + disabled, + ariaLabel, +}: InlineCategorySelectProps) { + const groups = useGroupedOptions(categories); + return ( + + ); +} diff --git a/apps/web/src/modules/finance/components/MultiSelect.tsx b/apps/web/src/modules/finance/components/MultiSelect.tsx new file mode 100644 index 0000000..6e1e777 --- /dev/null +++ b/apps/web/src/modules/finance/components/MultiSelect.tsx @@ -0,0 +1,157 @@ +import clsx from "clsx"; +import { Check, ChevronDown } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +export interface MultiSelectOption { + value: string; + label: string; + /** Optional color dot (categories). */ + color?: string | null; + /** Optional group header (root category name). */ + group?: string; +} + +export interface MultiSelectProps { + label: string; + /** Label shown when nothing is selected, ex. « Tous les comptes ». */ + allLabel: string; + options: MultiSelectOption[]; + values: string[]; + onChange: (values: string[]) => void; + className?: string; +} + +/** + * Checkbox dropdown for the repeatable filters of the transactions table + * (compte, catégorie) — the API accepts repeated query params. + */ +export function MultiSelect({ + label, + allLabel, + options, + values, + onChange, + className, +}: MultiSelectProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + const grouped = useMemo(() => { + const groups = new Map(); + for (const option of options) { + const key = option.group ?? ""; + const list = groups.get(key); + if (list) list.push(option); + else groups.set(key, [option]); + } + return [...groups.entries()]; + }, [options]); + + const summary = useMemo(() => { + if (values.length === 0) return allLabel; + if (values.length === 1) { + return options.find((o) => o.value === values[0])?.label ?? `1 sélectionné`; + } + return `${values.length} sélectionnés`; + }, [values, options, allLabel]); + + const toggle = (value: string) => { + onChange(values.includes(value) ? values.filter((v) => v !== value) : [...values, value]); + }; + + return ( +
+ {label} + + + {open ? ( +
+ {values.length > 0 ? ( + + ) : null} + {grouped.map(([group, groupOptions]) => ( +
+ {group ? ( +

+ {group} +

+ ) : null} + {groupOptions.map((option) => { + const selected = values.includes(option.value); + return ( + + ); + })} +
+ ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/modules/finance/components/Notice.tsx b/apps/web/src/modules/finance/components/Notice.tsx new file mode 100644 index 0000000..9fbaa01 --- /dev/null +++ b/apps/web/src/modules/finance/components/Notice.tsx @@ -0,0 +1,43 @@ +import clsx from "clsx"; +import { CircleAlert, Info, TriangleAlert } from "lucide-react"; +import type { ReactNode } from "react"; + +export type NoticeTone = "info" | "warning" | "error" | "success"; + +const TONE_CLASSES: Record = { + info: "border-accent/40 bg-accent/10 text-ink-secondary", + warning: "border-semantic-warning/40 bg-semantic-warning/10 text-semantic-warning", + error: "border-semantic-negative/40 bg-semantic-negative/10 text-semantic-negative", + success: "border-semantic-positive/40 bg-semantic-positive/10 text-semantic-positive", +}; + +const TONE_ICONS = { + info: Info, + warning: TriangleAlert, + error: CircleAlert, + success: Info, +} as const; + +export interface NoticeProps { + tone?: NoticeTone; + children: ReactNode; + className?: string; +} + +/** Inline banner for form errors and confirmations (French messages). */ +export function Notice({ tone = "info", children, className }: NoticeProps) { + const Icon = TONE_ICONS[tone]; + return ( +
+ +
{children}
+
+ ); +} diff --git a/apps/web/src/modules/finance/components/ProgressBar.tsx b/apps/web/src/modules/finance/components/ProgressBar.tsx new file mode 100644 index 0000000..e480812 --- /dev/null +++ b/apps/web/src/modules/finance/components/ProgressBar.tsx @@ -0,0 +1,61 @@ +import clsx from "clsx"; + +export type ProgressTone = "positive" | "warning" | "negative" | "accent" | "muted"; + +const TONE_CLASSES: Record = { + positive: "bg-semantic-positive", + warning: "bg-semantic-warning", + negative: "bg-semantic-negative", + accent: "bg-accent", + muted: "bg-ink-muted", +}; + +export interface ProgressBarProps { + /** Consumption in percent — values above 100 are clamped (bar turns red). */ + pct: number; + tone?: ProgressTone; + /** Second marker: share of the month elapsed (ux-pages §13.4 « Rythme »). */ + markerPct?: number; + markerLabel?: string; + /** Bar thickness in pixels (8 px for budgets). */ + height?: number; + ariaLabel?: string; + className?: string; +} + +/** Budget progress bar (component, not a chart — ux-pages §13.4). */ +export function ProgressBar({ + pct, + tone = "accent", + markerPct, + markerLabel, + height = 8, + ariaLabel, + className, +}: ProgressBarProps) { + const clamped = Math.min(Math.max(pct, 0), 100); + return ( +
+
+ {markerPct !== undefined ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/modules/finance/components/RecurringTimeline.tsx b/apps/web/src/modules/finance/components/RecurringTimeline.tsx new file mode 100644 index 0000000..ed7c698 --- /dev/null +++ b/apps/web/src/modules/finance/components/RecurringTimeline.tsx @@ -0,0 +1,84 @@ +import clsx from "clsx"; + +import { formatDate } from "../../../lib/format"; +import type { RecurringItem } from "../api"; +import { S } from "../strings"; +import { formatAmount, monthEndIso, monthLabel, monthOf, todayDay } from "../utils"; + +export interface RecurringTimelineProps { + items: RecurringItem[]; + /** Month "YYYY-MM" displayed by the timeline. */ + month: string; +} + +/** + * « Échéancier du mois » (ux-pages §13.5 G7) — light custom component, not a + * chart: each predicted due date is placed on the month axis, past ones + * filled, upcoming ones outlined. + */ +export function RecurringTimeline({ items, month }: RecurringTimelineProps) { + const daysInMonth = Number(monthEndIso(month).slice(-2)); + const today = todayDay(month); + const dues = items + .filter((item) => monthOf(item.next_date_predicted) === month) + .map((item) => ({ + item, + day: Number(item.next_date_predicted.slice(-2)), + })) + .sort((a, b) => a.day - b.day); + + if (dues.length === 0) { + return ( +

+ Aucune échéance prévue en {monthLabel(month)}. +

+ ); + } + + const position = (day: number) => ((day - 0.5) / daysInMonth) * 100; + + return ( +
+
+ {today !== null ? ( + + ) : null} + {dues.map(({ item, day }) => { + const past = today !== null && day <= today; + return ( + + ); + })} +
+
+ 1 + {Math.round(daysInMonth / 2)} + {daysInMonth} +
+
+ + + {S.recurring.timelinePast} + + + + {S.recurring.timelineUpcoming} + +
+
+ ); +} diff --git a/apps/web/src/modules/finance/components/RuleModal.tsx b/apps/web/src/modules/finance/components/RuleModal.tsx new file mode 100644 index 0000000..e092fa9 --- /dev/null +++ b/apps/web/src/modules/finance/components/RuleModal.tsx @@ -0,0 +1,379 @@ +import { useEffect, useMemo, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { Select } from "../../../components/ui/Select"; +import { Spinner } from "../../../components/ui/Spinner"; +import { formatDate } from "../../../lib/format"; +import { + useApplyRules, + useCreateRule, + useRulePreview, + useUpdateRule, +} from "../api"; +import type { + Account, + Category, + Rule, + RuleActions, + RuleDirection, + RuleMatchers, + Transaction, + Uuid, +} from "../api"; +import { useDebouncedValue } from "../hooks"; +import { S } from "../strings"; +import { + decimalInputValue, + escapeRegExp, + formatSignedAmount, + parseDecimalInput, + significantLabelToken, +} from "../utils"; +import { CategorySelect } from "./CategoryPicker"; +import { errorMessage } from "./StateViews"; +import { Notice } from "./Notice"; + +type LabelOperator = "contains" | "starts_with" | "regex"; + +export interface RuleModalProps { + open: boolean; + onClose: () => void; + categories: Category[]; + accounts: Account[]; + /** Transaction the rule is created from (pre-fills the label matcher). */ + transaction?: Transaction | null; + /** Existing rule for edition. */ + rule?: Rule | null; +} + +function initialOperator(rule?: Rule | null): LabelOperator { + if (!rule) return "contains"; + if (rule.matchers.label_regex) { + return rule.matchers.label_regex.startsWith("^") ? "starts_with" : "regex"; + } + return "contains"; +} + +function initialLabelText(rule?: Rule | null, transaction?: Transaction | null): string { + if (rule) { + if (rule.matchers.label_contains?.length) return rule.matchers.label_contains[0]; + if (rule.matchers.label_regex) return rule.matchers.label_regex.replace(/^\^/, ""); + return ""; + } + if (transaction) return significantLabelToken(transaction.label_clean || transaction.label_raw); + return ""; +} + +/** + * Create / edit a categorization rule (ux-pages §13.3). The « X transactions + * correspondent » counter is a live dry-run through POST /rules/preview. + */ +export function RuleModal({ + open, + onClose, + categories, + accounts, + transaction, + rule, +}: RuleModalProps) { + const create = useCreateRule(); + const update = useUpdateRule(); + const applyRules = useApplyRules(); + + const [name, setName] = useState(""); + const [operator, setOperator] = useState("contains"); + const [labelText, setLabelText] = useState(""); + const [direction, setDirection] = useState("any"); + const [accountId, setAccountId] = useState(""); + const [amountMin, setAmountMin] = useState(""); + const [amountMax, setAmountMax] = useState(""); + const [categoryId, setCategoryId] = useState(null); + const [counterparty, setCounterparty] = useState(""); + const [applyExisting, setApplyExisting] = useState(true); + const [formError, setFormError] = useState(undefined); + const [showConditions, setShowConditions] = useState(false); + + useEffect(() => { + if (!open) return; + const matchers = rule?.matchers; + setName(rule?.name ?? (transaction ? significantLabelToken(transaction.label_clean) : "")); + setOperator(initialOperator(rule)); + setLabelText(initialLabelText(rule, transaction)); + setDirection( + (matchers?.direction as RuleDirection | undefined) ?? + (transaction ? (transaction.amount < 0 ? "debit" : "credit") : "any"), + ); + setAccountId(matchers?.account_id ?? ""); + setAmountMin( + matchers?.amount_min !== null && matchers?.amount_min !== undefined + ? decimalInputValue(Math.abs(matchers.amount_min)) + : "", + ); + setAmountMax( + matchers?.amount_max !== null && matchers?.amount_max !== undefined + ? decimalInputValue(Math.abs(matchers.amount_max)) + : "", + ); + setCategoryId(rule?.actions.set_category_id ?? transaction?.category_id ?? null); + setCounterparty(rule?.actions.set_counterparty ?? ""); + setApplyExisting(true); + setFormError(undefined); + setShowConditions( + Boolean(matchers?.account_id || matchers?.amount_min || matchers?.amount_max), + ); + create.reset(); + update.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, rule, transaction]); + + const regexError = useMemo(() => { + if (operator !== "regex" || labelText.trim() === "") return undefined; + try { + new RegExp(labelText); + return undefined; + } catch { + return "Expression régulière invalide."; + } + }, [operator, labelText]); + + const matchers = useMemo(() => { + const built: RuleMatchers = {}; + const text = labelText.trim(); + if (text) { + if (operator === "contains") built.label_contains = [text]; + else if (operator === "starts_with") built.label_regex = `^${escapeRegExp(text)}`; + else built.label_regex = text; + } + if (direction !== "any") built.direction = direction; + const min = parseDecimalInput(amountMin); + const max = parseDecimalInput(amountMax); + // The UI works with absolute amounts; the API stores signed bounds (§5.1). + if (direction === "debit") { + if (max !== null) built.amount_min = -Math.abs(max); + if (min !== null) built.amount_max = -Math.abs(min); + } else { + if (min !== null) built.amount_min = Math.abs(min); + if (max !== null) built.amount_max = Math.abs(max); + } + if (accountId) built.account_id = accountId; + return built; + }, [labelText, operator, direction, amountMin, amountMax, accountId]); + + const debouncedMatchers = useDebouncedValue(matchers, 400); + const hasMatcher = Object.keys(debouncedMatchers).length > 0; + const preview = useRulePreview(debouncedMatchers, open && hasMatcher && !regexError); + + const pending = create.isPending || update.isPending || applyRules.isPending; + const mutationError = create.error ?? update.error; + + const submit = () => { + if (name.trim().length === 0) { + setFormError("Donnez un nom à la règle."); + return; + } + if (Object.keys(matchers).length === 0) { + setFormError("Ajoutez au moins une condition."); + return; + } + const actions: RuleActions = { + set_category_id: categoryId, + set_counterparty: counterparty.trim() || null, + }; + if (!categoryId && !actions.set_counterparty) { + setFormError("Choisissez une catégorie à appliquer."); + return; + } + if (rule) { + update.mutate( + { id: rule.id, payload: { name: name.trim(), matchers, actions } }, + { onSuccess: () => onClose() }, + ); + return; + } + create.mutate( + { name: name.trim(), matchers, actions }, + { + onSuccess: (created) => { + if (!applyExisting) { + onClose(); + return; + } + applyRules.mutate( + { rule_id: created.id, scope: "all_non_manual", dry_run: false }, + { onSettled: () => onClose() }, + ); + }, + }, + ); + }; + + const previewItems = preview.data?.items?.slice(0, 5) ?? []; + + return ( + + + + + } + > +
+ {mutationError ? {errorMessage(mutationError)} : null} + {formError ? {formError} : null} + + { + setName(event.target.value); + setFormError(undefined); + }} + /> + +
+ + { + setLabelText(event.target.value); + setFormError(undefined); + }} + /> +
+ + {showConditions ? ( +
+ + setAmountMin(event.target.value)} + /> + setAmountMax(event.target.value)} + /> +
+ ) : ( + + )} + + + +
+ { + setCategoryId(value); + setFormError(undefined); + }} + /> + setCounterparty(event.target.value)} + /> +
+ +
+

+ {preview.isFetching ? ( + <> + + {S.rules.previewLoading} + + ) : preview.error ? ( + {errorMessage(preview.error)} + ) : !hasMatcher ? ( + S.rules.previewNone + ) : (preview.data?.total_matched ?? 0) === 0 ? ( + S.rules.previewNone + ) : ( + {S.rules.previewCount(preview.data?.total_matched ?? 0)} + )} +

+ {previewItems.length > 0 ? ( + <> +

+ {S.rules.previewSample} +

+
    + {previewItems.map((item) => ( +
  • + + {formatDate(item.booked_date)} · {item.label_clean} + + {formatSignedAmount(item.amount)} +
  • + ))} +
+ + ) : null} +
+ + {rule ? null : ( + + )} +
+
+ ); +} diff --git a/apps/web/src/modules/finance/components/StateViews.tsx b/apps/web/src/modules/finance/components/StateViews.tsx new file mode 100644 index 0000000..fa056ca --- /dev/null +++ b/apps/web/src/modules/finance/components/StateViews.tsx @@ -0,0 +1,130 @@ +import { TriangleAlert } from "lucide-react"; +import type { ReactNode } from "react"; + +import { Card } from "../../../components/ui/Card"; +import { Button } from "../../../components/ui/Button"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { ApiError } from "../../../lib/api"; +import { S } from "../strings"; + +/** French message of an API error, displayable as-is (api.ts contract). */ +export function errorMessage(error: unknown): string { + if (error instanceof ApiError) return error.message; + if (error instanceof Error && error.message) return error.message; + return "Une erreur est survenue."; +} + +export interface ErrorStateProps { + error: unknown; + onRetry?: () => void; + className?: string; +} + +/** Error block — always shows the French message coming from the API. */ +export function ErrorState({ error, onRetry, className }: ErrorStateProps) { + return ( + + {S.common.retry} + + ) : undefined + } + /> + ); +} + +export interface SectionStateProps { + loading?: boolean; + error?: unknown; + onRetry?: () => void; + /** Card title kept visible while loading/failing. */ + title?: ReactNode; + children?: ReactNode; + className?: string; +} + +/** + * Card wrapper handling the three mandatory states of a section + * (CONVENTIONS C5.7): Spinner, French API error, then content. + */ +export function SectionState({ + loading, + error, + onRetry, + title, + children, + className, +}: SectionStateProps) { + if (loading) { + return ( + + + + ); + } + if (error) { + return ( + + + + ); + } + return <>{children}; +} + +export interface ChartStateCardProps { + title: string; + subtitle?: string; + loading?: boolean; + error?: unknown; + onRetry?: () => void; + /** Switches the page period to « Tout » (ux-pages §16). */ + onWidenPeriod?: () => void; + height?: number; + className?: string; +} + +/** + * Placeholder rendered instead of a while a chart has no data + * yet: « Pas de données sur cette période » + « Élargir la période ». + */ +export function ChartStateCard({ + title, + subtitle, + loading, + error, + onRetry, + onWidenPeriod, + height = 280, + className, +}: ChartStateCardProps) { + return ( + +
+ {loading ? ( + + ) : error ? ( + + ) : ( + + {S.empty.chartAction} + + ) : undefined + } + /> + )} +
+
+ ); +} diff --git a/apps/web/src/modules/finance/components/TransactionModal.tsx b/apps/web/src/modules/finance/components/TransactionModal.tsx new file mode 100644 index 0000000..ff8ff65 --- /dev/null +++ b/apps/web/src/modules/finance/components/TransactionModal.tsx @@ -0,0 +1,233 @@ +import clsx from "clsx"; +import { useEffect, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { Select } from "../../../components/ui/Select"; +import { todayIso } from "../../../lib/dates"; +import { useCreateTransaction, useUpdateTransaction } from "../api"; +import type { Account, Category, Transaction, Uuid } from "../api"; +import { S } from "../strings"; +import { decimalInputValue, parseDecimalInput } from "../utils"; +import { CategorySelect } from "./CategoryPicker"; +import { errorMessage } from "./StateViews"; +import { Notice } from "./Notice"; + +export interface TransactionModalProps { + open: boolean; + onClose: () => void; + accounts: Account[]; + categories: Category[]; + /** Existing transaction for edition; undefined = manual creation. */ + transaction?: Transaction | null; +} + +/** Manual transaction modal (ux-pages §13.6) + edition of an existing line. */ +export function TransactionModal({ + open, + onClose, + accounts, + categories, + transaction, +}: TransactionModalProps) { + const create = useCreateTransaction(); + const update = useUpdateTransaction(); + const [isExpense, setIsExpense] = useState(true); + const [amount, setAmount] = useState(""); + const [bookedDate, setBookedDate] = useState(todayIso()); + const [label, setLabel] = useState(""); + const [accountId, setAccountId] = useState(""); + const [categoryId, setCategoryId] = useState(null); + const [counterparty, setCounterparty] = useState(""); + const [notes, setNotes] = useState(""); + const [formError, setFormError] = useState(undefined); + + const imported = Boolean(transaction && transaction.import_run_id); + + useEffect(() => { + if (!open) return; + setIsExpense(transaction ? transaction.amount < 0 : true); + setAmount(transaction ? decimalInputValue(Math.abs(transaction.amount)) : ""); + setBookedDate(transaction?.booked_date ?? todayIso()); + setLabel(transaction?.label_clean ?? ""); + setAccountId(transaction?.account_id ?? accounts[0]?.id ?? ""); + setCategoryId(transaction?.category_id ?? null); + setCounterparty(transaction?.counterparty ?? ""); + setNotes(transaction?.notes ?? ""); + setFormError(undefined); + create.reset(); + update.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, transaction, accounts]); + + const pending = create.isPending || update.isPending; + const error = create.error ?? update.error; + + const submit = () => { + const parsed = parseDecimalInput(amount); + if (label.trim().length === 0) { + setFormError("Le libellé est obligatoire."); + return; + } + const onSuccess = () => onClose(); + if (transaction) { + update.mutate( + { + id: transaction.id, + payload: { + label_clean: label.trim(), + counterparty: counterparty.trim() || null, + category_id: categoryId, + notes: notes.trim() || null, + ...(imported + ? {} + : { + booked_date: bookedDate, + amount: + parsed === null + ? transaction.amount + : isExpense + ? -Math.abs(parsed) + : Math.abs(parsed), + }), + }, + }, + { onSuccess }, + ); + return; + } + if (!accountId) { + setFormError("Choisissez un compte."); + return; + } + if (parsed === null || parsed === 0) { + setFormError("Saisissez un montant."); + return; + } + create.mutate( + { + account_id: accountId, + booked_date: bookedDate, + amount: isExpense ? -Math.abs(parsed) : Math.abs(parsed), + label_clean: label.trim(), + category_id: categoryId, + counterparty: counterparty.trim() || null, + notes: notes.trim() || null, + }, + { onSuccess }, + ); + }; + + const toggleClass = (active: boolean) => + clsx( + "h-9 flex-1 rounded-lg border text-sm font-medium transition-colors", + active ? "border-accent bg-accent/15 text-ink" : "bg-surface-2 text-ink-secondary", + ); + + return ( + + + + + } + > +
+ {error ? {errorMessage(error)} : null} + {formError ? {formError} : null} + {imported ? {S.transactions.amountLockedHint} : null} + +
+ + {S.transactions.typeLabel} + +
+ + +
+
+ +
+ { + setAmount(event.target.value); + setFormError(undefined); + }} + /> + setBookedDate(event.target.value)} + /> +
+ + { + setLabel(event.target.value); + setFormError(undefined); + }} + /> + + {transaction ? null : ( + + )} + + + setCounterparty(event.target.value)} + /> + setNotes(event.target.value)} + /> +
+
+ ); +} diff --git a/apps/web/src/modules/finance/hooks.ts b/apps/web/src/modules/finance/hooks.ts new file mode 100644 index 0000000..43a9577 --- /dev/null +++ b/apps/web/src/modules/finance/hooks.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; + +/** Debounce a value — used by the live rule preview (dry-run count). */ +export function useDebouncedValue(value: T, delay = 400): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timer = window.setTimeout(() => setDebounced(value), delay); + return () => window.clearTimeout(timer); + }, [value, delay]); + return debounced; +} diff --git a/apps/web/src/modules/finance/icons.ts b/apps/web/src/modules/finance/icons.ts new file mode 100644 index 0000000..21827be --- /dev/null +++ b/apps/web/src/modules/finance/icons.ts @@ -0,0 +1,133 @@ +/** + * Curated lucide-react icon set for finance categories. `fin_categories.icon` + * stores a kebab-case icon name (datamodel-finance §2.2); only the names of + * this map are offered by the picker — no dynamic import, no network. + */ + +import { + ArrowLeftRight, + Baby, + Banknote, + Briefcase, + Bus, + Car, + CircleParking, + Cloud, + Coins, + CreditCard, + Droplets, + Dumbbell, + Fuel, + Gamepad2, + Gift, + HeartPulse, + House, + Landmark, + PawPrint, + PiggyBank, + Pill, + Plane, + Receipt, + Shield, + Shirt, + ShoppingCart, + Smartphone, + Sofa, + Stethoscope, + Tag, + TrendingUp, + Tv, + Utensils, + Wifi, + Wrench, + Zap, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +/** icon name (as stored) → component. */ +export const CATEGORY_ICONS: Record = { + "shopping-cart": ShoppingCart, + utensils: Utensils, + house: House, + home: House, + car: Car, + bus: Bus, + fuel: Fuel, + "circle-parking": CircleParking, + "parking-circle": CircleParking, + "heart-pulse": HeartPulse, + pill: Pill, + stethoscope: Stethoscope, + "gamepad-2": Gamepad2, + gamepad2: Gamepad2, + tv: Tv, + dumbbell: Dumbbell, + plane: Plane, + shirt: Shirt, + smartphone: Smartphone, + sofa: Sofa, + cloud: Cloud, + landmark: Landmark, + "credit-card": CreditCard, + receipt: Receipt, + baby: Baby, + "paw-print": PawPrint, + gift: Gift, + banknote: Banknote, + "piggy-bank": PiggyBank, + "trending-up": TrendingUp, + briefcase: Briefcase, + coins: Coins, + zap: Zap, + droplets: Droplets, + wifi: Wifi, + shield: Shield, + wrench: Wrench, + "arrow-left-right": ArrowLeftRight, + tag: Tag, +}; + +/** Names offered by the icon picker (aliases excluded). */ +export const ICON_OPTIONS: string[] = [ + "tag", + "shopping-cart", + "utensils", + "house", + "zap", + "droplets", + "wifi", + "shield", + "wrench", + "car", + "fuel", + "circle-parking", + "bus", + "heart-pulse", + "pill", + "stethoscope", + "gamepad-2", + "tv", + "dumbbell", + "plane", + "shirt", + "smartphone", + "sofa", + "cloud", + "landmark", + "credit-card", + "receipt", + "baby", + "paw-print", + "gift", + "banknote", + "piggy-bank", + "trending-up", + "briefcase", + "coins", + "arrow-left-right", +]; + +export function categoryIcon(name: string | null | undefined): LucideIcon { + if (!name) return Tag; + return CATEGORY_ICONS[name] ?? Tag; +} diff --git a/apps/web/src/modules/finance/index.ts b/apps/web/src/modules/finance/index.ts new file mode 100644 index 0000000..a7ed61d --- /dev/null +++ b/apps/web/src/modules/finance/index.ts @@ -0,0 +1,38 @@ +import { Wallet } from "lucide-react"; +import { createElement, lazy } from "react"; + +import type { ModuleManifest } from "../../types/module"; + +// Pages are code-split (CONVENTIONS C5.7). index.ts is a plain .ts file — the +// route elements are built with createElement, no JSX here. +const FinancePage = lazy(() => import("./pages/FinancePage")); +const OverviewPage = lazy(() => import("./pages/OverviewPage")); +const TransactionsPage = lazy(() => import("./pages/TransactionsPage")); +const BudgetsPage = lazy(() => import("./pages/BudgetsPage")); +const RecurringPage = lazy(() => import("./pages/RecurringPage")); +const AccountsPage = lazy(() => import("./pages/AccountsPage")); +const CategoriesPage = lazy(() => import("./pages/CategoriesPage")); + +/** Finance module manifest — auto-discovered by src/app/modules.ts. */ +const manifest: ModuleManifest = { + id: "finance", + title: "Finances", + order: 30, + routes: [ + { + path: "/finances", + element: createElement(FinancePage), + children: [ + { index: true, element: createElement(OverviewPage) }, + { path: "transactions", element: createElement(TransactionsPage) }, + { path: "budgets", element: createElement(BudgetsPage) }, + { path: "recurrents", element: createElement(RecurringPage) }, + ], + }, + { path: "/finances/comptes", element: createElement(AccountsPage) }, + { path: "/finances/categories", element: createElement(CategoriesPage) }, + ], + nav: [{ path: "/finances", label: "Finances", icon: Wallet, order: 30 }], +}; + +export default manifest; diff --git a/apps/web/src/modules/finance/pages/AccountsPage.tsx b/apps/web/src/modules/finance/pages/AccountsPage.tsx new file mode 100644 index 0000000..c583f65 --- /dev/null +++ b/apps/web/src/modules/finance/pages/AccountsPage.tsx @@ -0,0 +1,217 @@ +import { Archive, ArchiveRestore, ArrowLeft, Landmark, Pencil, Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { ConfirmDialog } from "../../../components/ui/ConfirmDialog"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { PageHeader } from "../../../components/ui/PageHeader"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { Table } from "../../../components/ui/Table"; +import type { TableColumn } from "../../../components/ui/Table"; +import { formatNumber } from "../../../lib/format"; +import { useAccounts, useDeleteAccount, useUpdateAccount } from "../api"; +import type { Account } from "../api"; +import { AccountModal } from "../components/AccountModal"; +import { Notice } from "../components/Notice"; +import { ErrorState, errorMessage } from "../components/StateViews"; +import { ACCOUNT_KIND_LABELS, S } from "../strings"; +import { formatAmount } from "../utils"; + +export default function AccountsPage() { + const navigate = useNavigate(); + const [includeArchived, setIncludeArchived] = useState(true); + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + const [deleting, setDeleting] = useState(null); + + const accounts = useAccounts(includeArchived); + const updateAccount = useUpdateAccount(); + const deleteAccount = useDeleteAccount(); + + const columns: TableColumn[] = [ + { + key: "name", + header: S.accountsPage.columns.name, + sortable: true, + render: (row) => {row.name}, + }, + { + key: "kind", + header: S.accountsPage.columns.kind, + render: (row) => {ACCOUNT_KIND_LABELS[row.kind] ?? row.kind}, + }, + { + key: "institution", + header: S.accountsPage.columns.institution, + render: (row) => row.institution ?? S.common.none, + }, + { key: "currency", header: S.accountsPage.columns.currency }, + { + key: "initial_balance", + header: S.accountsPage.columns.initialBalance, + align: "right", + sortable: true, + render: (row) => formatAmount(row.initial_balance ?? 0), + }, + { + key: "balance", + header: S.accountsPage.columns.balance, + align: "right", + sortable: true, + render: (row) => ( + {formatAmount(row.balance ?? 0)} + ), + }, + { + key: "transaction_count", + header: S.accountsPage.columns.transactions, + align: "right", + sortable: true, + render: (row) => formatNumber(row.transaction_count ?? 0), + }, + { + key: "is_archived", + header: S.accountsPage.columns.status, + render: (row) => + row.is_archived ? ( + {S.accountsPage.archived} + ) : ( + {S.accountsPage.active} + ), + }, + { + key: "actions", + align: "right", + header: {S.accountsPage.columns.actions}, + render: (row) => ( +
+ + + +
+ ), + }, + ]; + + return ( +
+ + + + + } + /> + + {deleteAccount.error ? ( + + {errorMessage(deleteAccount.error)} + + ) : null} + {updateAccount.error ? ( + + {errorMessage(updateAccount.error)} + + ) : null} + + +
+ +
+ {accounts.isLoading ? ( + + ) : accounts.error ? ( + void accounts.refetch()} /> + ) : ( + row.id} + className="p-2 md:p-3" + empty={ + setCreating(true)}> + {S.accountsPage.create} + + } + /> + } + /> + )} + + + { + setCreating(false); + setEditing(null); + }} + account={editing} + /> + + setDeleting(null)} + onConfirm={() => { + if (!deleting) return; + deleteAccount.mutate(deleting.id, { onSuccess: () => setDeleting(null) }); + }} + /> + + ); +} diff --git a/apps/web/src/modules/finance/pages/BudgetsPage.tsx b/apps/web/src/modules/finance/pages/BudgetsPage.tsx new file mode 100644 index 0000000..05bcab1 --- /dev/null +++ b/apps/web/src/modules/finance/pages/BudgetsPage.tsx @@ -0,0 +1,257 @@ +import { ChevronLeft, ChevronRight, Pencil, Plus, Target, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { ChartCard } from "../../../components/charts/ChartCard"; +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { ConfirmDialog } from "../../../components/ui/ConfirmDialog"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { formatPercent } from "../../../lib/format"; +import { useBudgetProgress, useBudgetProgressSeries, useCategories, useDeleteBudget } from "../api"; +import type { BudgetProgressItem, BudgetStatus } from "../api"; +import { buildBudgetVsActualOption } from "../charts/options"; +import { BudgetModal } from "../components/BudgetModal"; +import { Notice } from "../components/Notice"; +import { ProgressBar } from "../components/ProgressBar"; +import type { ProgressTone } from "../components/ProgressBar"; +import { ErrorState, errorMessage } from "../components/StateViews"; +import { BUDGET_STATUS_LABELS, S } from "../strings"; +import { + addMonths, + formatAmount, + lastMonths, + monthElapsedPct, + monthLabel, + OTHERS_COLOR, +} from "../utils"; +import { useFinanceContext } from "./FinancePage"; + +const STATUS_TONE: Record = { + ok: "positive", + warning: "warning", + over: "negative", +}; + +const STATUS_BADGE: Record = { + ok: "positive", + warning: "warning", + over: "negative", +}; + +export default function BudgetsPage() { + const { month: contextMonth } = useFinanceContext(); + const [month, setMonth] = useState(contextMonth); + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + const [deleting, setDeleting] = useState(null); + + const progress = useBudgetProgress(month); + const categories = useCategories(); + const deleteBudget = useDeleteBudget(); + + const chartMonths = useMemo(() => lastMonths(6, month), [month]); + const series = useBudgetProgressSeries(chartMonths); + const seriesReady = series.every((query) => query.data !== undefined); + const budgetValues = series.map((query) => query.data?.totals.budget ?? 0); + const actualValues = series.map((query) => query.data?.totals.actual ?? 0); + const budgetKey = budgetValues.join("|"); + const actualKey = actualValues.join("|"); + const chartOption = useMemo(() => { + if (!seriesReady) return null; + return buildBudgetVsActualOption( + chartMonths, + budgetKey.split("|").map(Number), + actualKey.split("|").map(Number), + ); + }, [chartMonths, budgetKey, actualKey, seriesReady]); + + const items = progress.data?.items ?? []; + const totals = progress.data?.totals; + const elapsedPct = monthElapsedPct(month); + const totalPct = totals?.progress_pct ?? 0; + + if (progress.isLoading && !progress.data) return ; + if (progress.error) { + return ( + + void progress.refetch()} /> + + ); + } + + return ( +
+ +
+
+
+ +
+ + {totals && totals.budget > 0 ? ( +
+
+ {S.budgets.global} + + {formatAmount(totals.actual)} / {formatAmount(totals.budget)} ·{" "} + {formatPercent(totalPct)} + +
+ 100 ? "negative" : totalPct >= 80 ? "warning" : "positive"} + /> +

+ {S.budgets.pace(elapsedPct, Math.round(totalPct))} +

+
+ ) : null} +
+ + {deleteBudget.error ? {errorMessage(deleteBudget.error)} : null} + + + {items.length === 0 ? ( + setCreating(true)}> + {S.budgets.create} + + } + /> + ) : ( +
    + {items.map((item) => { + const overspent = item.remaining < 0; + return ( +
  • +
    + + + {item.category_name} + + {BUDGET_STATUS_LABELS[item.status] ?? item.status} + + + + + {formatAmount(item.actual)} / {formatAmount(item.budget)} + + + + + + +
    + +
    + + {overspent + ? S.budgets.over(formatAmount(Math.abs(item.remaining))) + : S.budgets.remaining(formatAmount(item.remaining))} + + + {formatPercent(item.progress_pct, 1)} + {item.projected_eom !== null && item.projected_eom !== undefined + ? ` · ${S.budgets.projected(formatAmount(item.projected_eom))}` + : ""} + +
    +
  • + ); + })} +
+ )} +
+ + {chartOption ? ( + + ) : ( + + + + )} + + { + setCreating(false); + setEditing(null); + }} + categories={categories.data ?? []} + month={month} + budget={editing} + /> + + setDeleting(null)} + onConfirm={() => { + if (!deleting) return; + deleteBudget.mutate(deleting.budget_id, { onSettled: () => setDeleting(null) }); + }} + /> +
+ ); +} diff --git a/apps/web/src/modules/finance/pages/CategoriesPage.tsx b/apps/web/src/modules/finance/pages/CategoriesPage.tsx new file mode 100644 index 0000000..96af3d4 --- /dev/null +++ b/apps/web/src/modules/finance/pages/CategoriesPage.tsx @@ -0,0 +1,361 @@ +import { ArrowLeft, FolderTree, ListFilter, Pencil, Plus, Trash2, Zap } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { ConfirmDialog } from "../../../components/ui/ConfirmDialog"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { PageHeader } from "../../../components/ui/PageHeader"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { Table } from "../../../components/ui/Table"; +import type { TableColumn } from "../../../components/ui/Table"; +import { formatNumber } from "../../../lib/format"; +import { + useAccounts, + useCategories, + useDeleteCategory, + useDeleteRule, + useRules, + useUpdateRule, +} from "../api"; +import type { Category, Rule } from "../api"; +import { ApplyRulesModal } from "../components/ApplyRulesModal"; +import { CategoryModal } from "../components/CategoryModal"; +import { CategoryPill } from "../components/CategoryPicker"; +import { Notice } from "../components/Notice"; +import { RuleModal } from "../components/RuleModal"; +import { ErrorState, errorMessage } from "../components/StateViews"; +import { categoryIcon } from "../icons"; +import { CATEGORY_KIND_LABELS, S } from "../strings"; +import { buildCategoryTree, categoryById, describeMatchers, OTHERS_COLOR } from "../utils"; + +export default function CategoriesPage() { + const navigate = useNavigate(); + const categories = useCategories(); + const rules = useRules(); + const accounts = useAccounts(true); + const deleteCategory = useDeleteCategory(); + const deleteRule = useDeleteRule(); + const updateRule = useUpdateRule(); + + const [editingCategory, setEditingCategory] = useState(null); + const [creatingUnder, setCreatingUnder] = useState(undefined); + const [deletingCategory, setDeletingCategory] = useState(null); + const [editingRule, setEditingRule] = useState(null); + const [creatingRule, setCreatingRule] = useState(false); + const [deletingRule, setDeletingRule] = useState(null); + const [applyOpen, setApplyOpen] = useState(false); + + const tree = useMemo(() => buildCategoryTree(categories.data ?? []), [categories.data]); + const byId = useMemo(() => categoryById(categories.data ?? []), [categories.data]); + const accountName = (id: string | null | undefined) => + (accounts.data ?? []).find((account) => account.id === id)?.name ?? null; + + const categoryRow = (category: Category, isChild: boolean) => { + const Icon = categoryIcon(category.icon); + const color = + category.color ?? + (category.parent_id ? (byId.get(category.parent_id)?.color ?? OTHERS_COLOR) : OTHERS_COLOR); + return ( +
+ + + + + {category.name} + {isChild ? null : ( + {CATEGORY_KIND_LABELS[category.kind] ?? category.kind} + )} + {category.is_system ? {S.categoriesPage.system} : null} + {category.transaction_count !== undefined ? ( + + {formatNumber(category.transaction_count)} + + ) : null} + + + {isChild ? null : ( + + )} + + + +
+ ); + }; + + const ruleColumns: TableColumn[] = [ + { + key: "priority", + header: S.rules.columns.priority, + align: "right", + sortable: true, + render: (row) => formatNumber(row.priority), + }, + { + key: "name", + header: S.rules.columns.name, + sortable: true, + render: (row) => {row.name}, + }, + { + key: "matchers", + header: S.rules.columns.conditions, + render: (row) => { + const parts = describeMatchers(row.matchers, accountName(row.matchers.account_id)); + if (parts.length === 0) return S.rules.noCondition; + return ( +
    + {parts.map((part) => ( +
  • {part}
  • + ))} +
+ ); + }, + }, + { + key: "actions_summary", + header: S.rules.columns.action, + render: (row) => { + const category = row.actions.set_category_id + ? byId.get(row.actions.set_category_id) + : undefined; + if (row.actions.mark_transfer) return {S.transactions.transfer}; + if (!category) return S.rules.noAction; + return ; + }, + }, + { + key: "hit_count", + header: S.rules.columns.hits, + align: "right", + sortable: true, + render: (row) => formatNumber(row.hit_count ?? 0), + }, + { + key: "enabled", + header: S.rules.columns.enabled, + render: (row) => ( + + ), + }, + { + key: "actions", + align: "right", + header: {S.rules.columns.actions}, + render: (row) => ( +
+ + +
+ ), + }, + ]; + + return ( +
+ + + + + } + /> + + {deleteCategory.error ? ( + {errorMessage(deleteCategory.error)} + ) : null} + {updateRule.error ? {errorMessage(updateRule.error)} : null} + {deleteRule.error ? {errorMessage(deleteRule.error)} : null} + + + {categories.isLoading ? ( + + ) : categories.error ? ( + void categories.refetch()} /> + ) : tree.length === 0 ? ( + setCreatingUnder(null)}> + {S.categoriesPage.createRoot} + + } + /> + ) : ( +
+ {tree.map((node) => ( +
+ {categoryRow(node.category, false)} + {node.children.map((child) => categoryRow(child, true))} +
+ ))} +
+ )} +
+ + + + +
+ } + > + {rules.isLoading ? ( + + ) : rules.error ? ( + void rules.refetch()} /> + ) : ( +
row.id} + className="p-2 md:p-3" + empty={ + setCreatingRule(true)}> + {S.rules.create} + + } + /> + } + /> + )} + + + { + setCreatingUnder(undefined); + setEditingCategory(null); + }} + categories={categories.data ?? []} + category={editingCategory} + defaultParentId={creatingUnder ?? null} + /> + + { + setCreatingRule(false); + setEditingRule(null); + }} + categories={categories.data ?? []} + accounts={accounts.data ?? []} + rule={editingRule} + /> + + setApplyOpen(false)} /> + + setDeletingCategory(null)} + onConfirm={() => { + if (!deletingCategory) return; + deleteCategory.mutate(deletingCategory.id, { onSuccess: () => setDeletingCategory(null) }); + }} + /> + + setDeletingRule(null)} + onConfirm={() => { + if (!deletingRule) return; + deleteRule.mutate(deletingRule.id, { onSuccess: () => setDeletingRule(null) }); + }} + /> + + ); +} diff --git a/apps/web/src/modules/finance/pages/FinancePage.tsx b/apps/web/src/modules/finance/pages/FinancePage.tsx new file mode 100644 index 0000000..f49b878 --- /dev/null +++ b/apps/web/src/modules/finance/pages/FinancePage.tsx @@ -0,0 +1,119 @@ +import { FolderTree, Landmark, Upload } from "lucide-react"; +import { Suspense, useCallback, useMemo, useState } from "react"; +import { Outlet, useNavigate, useOutletContext, useSearchParams } from "react-router-dom"; + +import { PeriodSelector } from "../../../components/PeriodSelector"; +import type { PeriodRange } from "../../../components/PeriodSelector"; +import { Button } from "../../../components/ui/Button"; +import { PageHeader } from "../../../components/ui/PageHeader"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { Tabs } from "../../../components/ui/Tabs"; +import { daysAgoIso, todayIso } from "../../../lib/dates"; +import { S } from "../strings"; +import { currentMonth, monthOf, monthSpan } from "../utils"; + +export interface FinanceOutletContext { + /** Page-level period (ux-pages §5.1) — inclusive ISO days, null = « Tout ». */ + range: PeriodRange; + /** Month "YYYY-MM" the KPIs and budgets refer to (end of the period). */ + month: string; + /** Number of months to request from the /stats endpoints to cover the period. */ + statsMonths: number; + /** Switch the period to « Tout » (empty-state action of ux-pages §16). */ + widenPeriod: () => void; +} + +/** Typed access to the finance page context from a tab. */ +export function useFinanceContext(): FinanceOutletContext { + return useOutletContext(); +} + +const DEFAULT_RANGE: PeriodRange = { + key: "30j", + from: daysAgoIso(29), + to: todayIso(), + label: "30 j", +}; + +/** + * Finances page shell: title, period selector, 4 URL-driven tabs + * (ux-pages §13) and links to the secondary pages Comptes / Catégories. + */ +export default function FinancePage() { + const navigate = useNavigate(); + const [, setSearchParams] = useSearchParams(); + const [periodSeed, setPeriodSeed] = useState(0); + const [range, setRange] = useState(DEFAULT_RANGE); + + const widenPeriod = useCallback(() => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set("periode", "tout"); + next.delete("du"); + next.delete("au"); + return next; + }, + { replace: true }, + ); + setPeriodSeed((seed) => seed + 1); + }, [setSearchParams]); + + const context = useMemo( + () => ({ + range, + month: range.to ? monthOf(range.to) : currentMonth(), + // The /stats endpoints take a number of months ending today: cover the + // whole period from its start up to now. + statsMonths: monthSpan(range.from, todayIso(), 24), + widenPeriod, + }), + [range, widenPeriod], + ); + + return ( +
+ + + + + + + } + /> + + + + }> + + +
+ ); +} diff --git a/apps/web/src/modules/finance/pages/OverviewPage.tsx b/apps/web/src/modules/finance/pages/OverviewPage.tsx new file mode 100644 index 0000000..66f4f4f --- /dev/null +++ b/apps/web/src/modules/finance/pages/OverviewPage.tsx @@ -0,0 +1,335 @@ +import { Landmark, TrendingDown, TrendingUp, Upload, Wallet } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; + +import { ChartCard } from "../../../components/charts/ChartCard"; +import { CHART_COLORS } from "../../../components/charts/theme"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { StatCard } from "../../../components/ui/StatCard"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { formatPercent } from "../../../lib/format"; +import { + useAccounts, + useBudgetProgress, + useCashflow, + useMonthlyByCategory, + useSankey, + useTransactions, + useUncategorizedCount, +} from "../api"; +import type { MonthlyByCategory } from "../api"; +import { + buildCashflowOption, + buildCategoryDonutOption, + buildMonthlyStackedOption, + buildSankeyOption, +} from "../charts/options"; +import { ChartStateCard, ErrorState } from "../components/StateViews"; +import { ACCOUNT_KIND_LABELS, S } from "../strings"; +import { + currentMonth, + formatAmount, + formatSignedAmount, + monthOf, + topCategories, +} from "../utils"; +import type { CategoryShare } from "../utils"; +import { useFinanceContext } from "./FinancePage"; + +/** Sum the monthly series over the months covered by the page period. */ +function sharesFromMonthly( + data: MonthlyByCategory, + fromMonth: string, + toMonth: string, +): { shares: CategoryShare[]; total: number } { + const indices = data.months + .map((month, index) => (month >= fromMonth && month <= toMonth ? index : -1)) + .filter((index) => index >= 0); + const entries: CategoryShare[] = data.series.map((serie, serieIndex) => ({ + id: serie.category_id, + name: serie.name, + color: serie.color ?? CHART_COLORS[serieIndex % CHART_COLORS.length], + value: indices.reduce((sum, index) => sum + (serie.data[index] ?? 0), 0), + })); + return { + shares: topCategories(entries, 7, S.charts.others), + total: entries.reduce((sum, entry) => sum + entry.value, 0), + }; +} + +export default function OverviewPage() { + const { range, month, statsMonths, widenPeriod } = useFinanceContext(); + const navigate = useNavigate(); + const [level, setLevel] = useState<"root" | "child">("root"); + + const accounts = useAccounts(); + const anyTransaction = useTransactions({ page: 1, page_size: 1 }); + const cashflow = useCashflow({ months: Math.max(statsMonths, 12) }); + const budget = useBudgetProgress(month); + const uncategorized = useUncategorizedCount({ date_from: range.from, date_to: range.to }); + const donutData = useMonthlyByCategory({ months: Math.max(statsMonths, 1), level: "root" }); + const stackedData = useMonthlyByCategory({ months: 12, level }); + const singleMonth = statsMonths <= 1; + const sankey = useSankey(singleMonth ? { month } : { months: Math.max(statsMonths, 1) }); + + const fromMonth = range.from ? monthOf(range.from) : "0000-00"; + const toMonth = range.to ? monthOf(range.to) : "9999-99"; + + const donut = useMemo( + () => (donutData.data ? sharesFromMonthly(donutData.data, fromMonth, toMonth) : null), + [donutData.data, fromMonth, toMonth], + ); + + const cashflowIndex = useMemo(() => { + const months = cashflow.data?.months ?? []; + const index = months.indexOf(month); + return index >= 0 ? index : months.length - 1; + }, [cashflow.data, month]); + + const monthExpenses = cashflow.data?.expenses[cashflowIndex] ?? 0; + const monthIncome = cashflow.data?.income[cashflowIndex] ?? 0; + const monthNet = cashflow.data?.net[cashflowIndex] ?? monthIncome - monthExpenses; + const averageExpenses = useMemo(() => { + const values = (cashflow.data?.expenses ?? []).slice(-6); + if (values.length === 0) return 0; + return values.reduce((sum, value) => sum + value, 0) / values.length; + }, [cashflow.data]); + + const totalBalance = (accounts.data ?? []).reduce((sum, account) => sum + (account.balance ?? 0), 0); + const budgetTotals = budget.data?.totals; + const budgetAmount = budgetTotals?.budget ?? 0; + const budgetActual = budgetTotals?.actual ?? 0; + const budgetPct = budgetAmount > 0 ? (budgetActual / budgetAmount) * 100 : 0; + const remainingToLive = budgetAmount > 0 ? budgetAmount - budgetActual : monthIncome - monthExpenses; + + const loading = accounts.isLoading || cashflow.isLoading || anyTransaction.isLoading; + const fatalError = accounts.error ?? cashflow.error; + + if (loading) return ; + if (fatalError) { + return ( + + { + void accounts.refetch(); + void cashflow.refetch(); + }} + /> + + ); + } + + if ((anyTransaction.data?.total ?? 0) === 0) { + return ( + + + + + + } + /> + + ); + } + + return ( +
+ {/* KPI (ux-pages §13.1) */} +
+ navigate("/finances/comptes")} + /> + averageExpenses ? TrendingUp : TrendingDown} + delta={formatSignedAmount(monthExpenses - averageExpenses)} + tone={monthExpenses > averageExpenses ? "negative" : "positive"} + /> + + = 0 ? "positive" : "negative"} + deltaIcon={monthNet >= 0 ? TrendingUp : TrendingDown} + delta={monthNet >= 0 ? "Épargne du mois" : "Déficit du mois"} + /> + 0 ? formatPercent(budgetPct) : S.common.none} + sub={ + budgetAmount > 0 + ? `${formatAmount(budgetActual)} / ${formatAmount(budgetAmount)}` + : S.kpi.noBudget + } + progress={budgetAmount > 0 ? budgetPct : undefined} + progressTone={budgetPct > 100 ? "negative" : budgetPct >= 80 ? "warning" : "positive"} + onClick={() => navigate("/finances/budgets")} + /> + = 0 ? "positive" : "negative"} + sub={budgetAmount > 0 ? S.kpi.globalBudget : S.kpi.monthCashflow} + /> + 0 ? "warning" : "neutral"} + onClick={() => navigate("/finances/transactions?categorie=none")} + /> +
+ + {/* Tuiles par compte (ux-pages §13.2 G1) */} + +
+ {(accounts.data ?? []).map((account) => ( + +

+ {ACCOUNT_KIND_LABELS[account.kind] ?? account.kind} + {account.institution ? ` · ${account.institution}` : ""} +

+

{account.name}

+

+ {formatAmount(account.balance ?? 0)} +

+

+ {S.kpi.transactionsCount(account.transaction_count ?? 0)} +

+ + ))} +
+
+ +
+ {/* G2 — donut */} + {donut && donut.shares.length > 0 ? ( + + {S.tabs.transactions} + + } + /> + ) : ( + void donutData.refetch()} + onWidenPeriod={widenPeriod} + height={300} + /> + )} + + {/* G3 — barres empilées */} + {stackedData.data && stackedData.data.series.length > 0 ? ( + 0 ? budgetAmount : null, + })} + actions={ + + } + /> + ) : ( + void stackedData.refetch()} + onWidenPeriod={widenPeriod} + height={300} + /> + )} +
+ + {/* G4 — cashflow */} + {cashflow.data && cashflow.data.months.length > 0 ? ( + + ) : ( + void cashflow.refetch()} + onWidenPeriod={widenPeriod} + height={320} + /> + )} + + {/* G5 — sankey */} + {sankey.data && sankey.data.links.length > 0 ? ( + + ) : ( + void sankey.refetch()} + onWidenPeriod={widenPeriod} + height={380} + /> + )} +
+ ); +} diff --git a/apps/web/src/modules/finance/pages/RecurringPage.tsx b/apps/web/src/modules/finance/pages/RecurringPage.tsx new file mode 100644 index 0000000..5a2da5b --- /dev/null +++ b/apps/web/src/modules/finance/pages/RecurringPage.tsx @@ -0,0 +1,203 @@ +import { Repeat } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { Badge } from "../../../components/ui/Badge"; +import { Card } from "../../../components/ui/Card"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { Select } from "../../../components/ui/Select"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { StatCard } from "../../../components/ui/StatCard"; +import { Table } from "../../../components/ui/Table"; +import type { TableColumn } from "../../../components/ui/Table"; +import { todayIso } from "../../../lib/dates"; +import { formatDate, formatNumber, formatPercent } from "../../../lib/format"; +import { useCashflow, useRecurring } from "../api"; +import type { Direction, RecurringItem } from "../api"; +import { CategoryPill } from "../components/CategoryPicker"; +import { RecurringTimeline } from "../components/RecurringTimeline"; +import { ErrorState } from "../components/StateViews"; +import { PERIODICITY_LABELS, S } from "../strings"; +import { currentMonth, formatAmount } from "../utils"; +import { useFinanceContext } from "./FinancePage"; + +/** Yearly cost of a series from its median amount and periodicity (§9.8). */ +function yearlyCost(item: RecurringItem): number { + switch (item.periodicity) { + case "weekly": + return item.expected_amount * 52; + case "monthly": + return item.expected_amount * 12; + case "quarterly": + return item.expected_amount * 4; + default: + return item.expected_amount; + } +} + +export default function RecurringPage() { + const { month } = useFinanceContext(); + const [direction, setDirection] = useState("debit"); + const [includeInactive, setIncludeInactive] = useState(false); + + const recurring = useRecurring({ direction, include_inactive: includeInactive }); + const cashflow = useCashflow({ months: 6 }); + + const items = useMemo( + () => [...(recurring.data?.items ?? [])].sort((a, b) => yearlyCost(b) - yearlyCost(a)), + [recurring.data], + ); + + const monthlyTotal = recurring.data?.monthly_total_estimate ?? 0; + const activeCount = items.filter((item) => item.is_active).length; + const monthExpenses = useMemo(() => { + const months = cashflow.data?.months ?? []; + const index = months.indexOf(month); + const resolved = index >= 0 ? index : months.length - 1; + return cashflow.data?.expenses[resolved] ?? 0; + }, [cashflow.data, month]); + const share = monthExpenses > 0 ? (monthlyTotal / monthExpenses) * 100 : null; + const today = todayIso(); + + const columns: TableColumn[] = [ + { + key: "label_display", + header: S.recurring.columns.label, + sortable: true, + sortValue: (row) => row.label_display, + render: (row) => ( + + {row.label_display} + {row.is_active ? null : {S.recurring.inactive}} + + ), + }, + { + key: "category_name", + header: S.recurring.columns.category, + render: (row) => , + }, + { + key: "periodicity", + header: S.recurring.columns.periodicity, + sortable: true, + sortValue: (row) => row.periodicity, + render: (row) => PERIODICITY_LABELS[row.periodicity] ?? row.periodicity, + }, + { + key: "expected_amount", + header: S.recurring.columns.amount, + align: "right", + sortable: true, + sortValue: (row) => row.expected_amount, + render: (row) => formatAmount(row.expected_amount), + }, + { + key: "occurrences", + header: S.recurring.columns.occurrences, + align: "right", + sortable: true, + sortValue: (row) => row.occurrences, + render: (row) => formatNumber(row.occurrences), + }, + { + key: "last_date", + header: S.recurring.columns.lastDate, + sortable: true, + sortValue: (row) => row.last_date, + render: (row) => formatDate(row.last_date), + }, + { + key: "next_date_predicted", + header: S.recurring.columns.nextDate, + sortable: true, + sortValue: (row) => row.next_date_predicted, + render: (row) => + row.next_date_predicted < today ? ( + + {formatDate(row.next_date_predicted)} · {S.recurring.overdue} + + ) : ( + formatDate(row.next_date_predicted) + ), + }, + { + key: "yearly", + header: S.recurring.columns.yearly, + align: "right", + sortable: true, + sortValue: (row) => yearlyCost(row), + render: (row) => formatAmount(yearlyCost(row)), + }, + ]; + + if (recurring.isLoading && !recurring.data) return ; + if (recurring.error) { + return ( + + void recurring.refetch()} /> + + ); + } + + return ( +
+
+ + + + +
+ + + + +
+ } + > +
`${row.merchant_key}-${row.periodicity}`} + className="p-2 md:p-3" + empty={ + + } + /> + + + {items.length > 0 ? ( + + + + ) : null} + + ); +} diff --git a/apps/web/src/modules/finance/pages/TransactionsPage.tsx b/apps/web/src/modules/finance/pages/TransactionsPage.tsx new file mode 100644 index 0000000..6364692 --- /dev/null +++ b/apps/web/src/modules/finance/pages/TransactionsPage.tsx @@ -0,0 +1,540 @@ +import clsx from "clsx"; +import { + ArrowLeftRight, + ChevronDown, + ChevronUp, + Pencil, + Plus, + RotateCcw, + Search, + Trash2, + Upload, + Wallet, + Zap, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; + +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { ConfirmDialog } from "../../../components/ui/ConfirmDialog"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { Input } from "../../../components/ui/Input"; +import { Select } from "../../../components/ui/Select"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { Table } from "../../../components/ui/Table"; +import type { TableColumn } from "../../../components/ui/Table"; +import { formatDate } from "../../../lib/format"; +import { + useAccounts, + useCategories, + useDeleteTransaction, + useTransactions, + useUpdateTransaction, +} from "../api"; +import type { Direction, Transaction, Uuid } from "../api"; +import { BulkCategorizeModal } from "../components/BulkCategorizeModal"; +import { InlineCategorySelect } from "../components/CategoryPicker"; +import { MultiSelect } from "../components/MultiSelect"; +import type { MultiSelectOption } from "../components/MultiSelect"; +import { Notice } from "../components/Notice"; +import { RuleModal } from "../components/RuleModal"; +import { ErrorState, errorMessage } from "../components/StateViews"; +import { TransactionModal } from "../components/TransactionModal"; +import { useDebouncedValue } from "../hooks"; +import { S } from "../strings"; +import { categoryOptions, formatSignedAmount, parseDecimalInput } from "../utils"; +import { useFinanceContext } from "./FinancePage"; + +const PAGE_SIZE = 50; +const UNCATEGORIZED = "none"; + +export default function TransactionsPage() { + const { range } = useFinanceContext(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + + const accountIds = searchParams.getAll("compte"); + const categoryIds = searchParams.getAll("categorie"); + const urlQuery = searchParams.get("q") ?? ""; + const direction = (searchParams.get("type") as Direction | null) ?? null; + const amountMin = searchParams.get("min") ?? ""; + const amountMax = searchParams.get("max") ?? ""; + const page = Math.max(Number(searchParams.get("page") ?? "1"), 1); + const sort = searchParams.get("tri") ?? "-booked_date"; + + const [search, setSearch] = useState(urlQuery); + const debouncedSearch = useDebouncedValue(search, 400); + const [selected, setSelected] = useState([]); + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + const [ruleFrom, setRuleFrom] = useState(null); + const [deleting, setDeleting] = useState(null); + const [bulkOpen, setBulkOpen] = useState(false); + + const updateParams = (mutate: (params: URLSearchParams) => void, resetPage = true) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + mutate(next); + if (resetPage) next.delete("page"); + return next; + }, + { replace: true }, + ); + }; + + useEffect(() => { + if (debouncedSearch === urlQuery) return; + updateParams((params) => { + if (debouncedSearch) params.set("q", debouncedSearch); + else params.delete("q"); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearch]); + + const accounts = useAccounts(); + const categories = useCategories(); + const deleteTransaction = useDeleteTransaction(); + const updateTransaction = useUpdateTransaction(); + + const transactions = useTransactions({ + date_from: range.from, + date_to: range.to, + account_id: accountIds, + category_id: categoryIds, + q: urlQuery || undefined, + direction, + amount_min: parseDecimalInput(amountMin), + amount_max: parseDecimalInput(amountMax), + sort, + page, + page_size: PAGE_SIZE, + }); + + const rows = transactions.data?.items ?? []; + const total = transactions.data?.total ?? 0; + + // Any filter/page change resets the selection (ids would no longer be visible). + const filterSignature = searchParams.toString(); + useEffect(() => { + setSelected([]); + }, [filterSignature]); + + const accountOptions: MultiSelectOption[] = (accounts.data ?? []).map((account) => ({ + value: account.id, + label: account.name, + })); + + const categoryFilterOptions: MultiSelectOption[] = useMemo(() => { + const options: MultiSelectOption[] = [ + { value: UNCATEGORIZED, label: S.transactions.uncategorizedOption }, + ]; + for (const option of categoryOptions(categories.data ?? [])) { + options.push({ + value: option.id, + label: option.isRoot ? option.name : `— ${option.name}`, + color: option.color, + group: option.groupName, + }); + } + return options; + }, [categories.data]); + + const hasFilters = + accountIds.length > 0 || + categoryIds.length > 0 || + urlQuery.length > 0 || + direction !== null || + amountMin !== "" || + amountMax !== ""; + + const resetFilters = () => { + setSearch(""); + updateParams((params) => { + for (const key of ["compte", "categorie", "q", "type", "min", "max"]) params.delete(key); + }); + }; + + const toggleSort = (field: string) => { + const next = sort === field ? `-${field}` : sort === `-${field}` ? field : `-${field}`; + updateParams((params) => params.set("tri", next), false); + }; + + const sortHeader = (label: string, field: string) => ( + + ); + + const allSelected = rows.length > 0 && selected.length === rows.length; + + const columns: TableColumn[] = [ + { + key: "select", + className: "w-8", + header: ( + setSelected(event.target.checked ? rows.map((row) => row.id) : [])} + className="h-4 w-4 rounded border bg-surface-2 accent-accent" + /> + ), + render: (row) => ( + + setSelected((prev) => + event.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id), + ) + } + className="h-4 w-4 rounded border bg-surface-2 accent-accent" + /> + ), + }, + { + key: "booked_date", + header: sortHeader(S.transactions.columns.date, "booked_date"), + className: "whitespace-nowrap", + render: (row) => formatDate(row.booked_date), + }, + { + key: "label_clean", + header: sortHeader(S.transactions.columns.label, "label_clean"), + render: (row) => ( +
+

+ {row.label_clean || row.label_raw} + {row.transfer_group_id ? ( + + + {S.transactions.transfer} + + ) : null} + {row.category_source === "user" ? ( + {S.transactions.manual} + ) : null} + {row.import_run_id === null ? ( + {S.transactions.manual} + ) : null} +

+ {row.label_raw && row.label_raw !== row.label_clean ? ( +

{row.label_raw}

+ ) : null} +
+ ), + }, + { + key: "account_name", + header: S.transactions.columns.account, + render: (row) => ( + {row.account_name ?? S.common.none} + ), + }, + { + key: "category_id", + header: S.transactions.columns.category, + render: (row) => ( + + updateTransaction.mutate({ id: row.id, payload: { category_id: value } }) + } + /> + ), + }, + { + key: "amount", + align: "right", + header: sortHeader(S.transactions.columns.amount, "amount"), + render: (row) => ( + 0 ? "text-semantic-positive" : "text-ink")}> + {formatSignedAmount(row.amount)} + + ), + }, + { + key: "actions", + align: "right", + header: {S.transactions.columns.actions}, + render: (row) => ( +
+ + + +
+ ), + }, + ]; + + if (transactions.isLoading && !transactions.data) return ; + if (transactions.error) { + return ( + + void transactions.refetch()} /> + + ); + } + + return ( +
+ +
+ setSearch(event.target.value)} + className="min-w-[14rem] flex-1" + /> + + updateParams((params) => { + params.delete("compte"); + for (const value of values) params.append("compte", value); + }) + } + className="w-52" + /> + + updateParams((params) => { + params.delete("categorie"); + for (const value of values) params.append("categorie", value); + }) + } + className="w-56" + /> + + + updateParams((params) => { + if (event.target.value) params.set("min", event.target.value); + else params.delete("min"); + }) + } + /> + + updateParams((params) => { + if (event.target.value) params.set("max", event.target.value); + else params.delete("max"); + }) + } + /> +
+ {hasFilters ? ( + + ) : null} + + +
+
+ + {hasFilters ? ( +
+ {S.transactions.activeFilters} : + {urlQuery ? ( + + + {urlQuery} + + ) : null} + {accountIds.map((id) => ( + + {accountOptions.find((option) => option.value === id)?.label ?? id} + + ))} + {categoryIds.map((id) => ( + + {categoryFilterOptions.find((option) => option.value === id)?.label ?? id} + + ))} + {direction ? ( + + {direction === "debit" + ? S.transactions.directionDebit + : S.transactions.directionCredit} + + ) : null} +
+ ) : null} +
+ + {selected.length > 0 ? ( + + {S.transactions.selectedCount(selected.length)} +
+ + +
+
+ ) : null} + + {deleteTransaction.error ? ( + {errorMessage(deleteTransaction.error)} + ) : null} + {updateTransaction.error ? ( + {errorMessage(updateTransaction.error)} + ) : null} + + +
row.id} + className="p-2 md:p-3" + pagination={{ + page, + pageSize: PAGE_SIZE, + total, + onPageChange: (next) => + updateParams((params) => params.set("page", String(next)), false), + }} + empty={ + hasFilters ? ( + + {S.transactions.resetFilters} + + } + /> + ) : ( + navigate("/imports")}> + {S.empty.financeAction} + + } + /> + ) + } + /> + + + { + setCreating(false); + setEditing(null); + }} + accounts={accounts.data ?? []} + categories={categories.data ?? []} + transaction={editing} + /> + + setRuleFrom(null)} + categories={categories.data ?? []} + accounts={accounts.data ?? []} + transaction={ruleFrom} + /> + + setBulkOpen(false)} + categories={categories.data ?? []} + transactionIds={selected} + onDone={() => setSelected([])} + /> + + setDeleting(null)} + onConfirm={() => { + if (!deleting) return; + deleteTransaction.mutate(deleting.id, { onSettled: () => setDeleting(null) }); + }} + /> + + ); +} diff --git a/apps/web/src/modules/finance/strings.ts b/apps/web/src/modules/finance/strings.ts new file mode 100644 index 0000000..683ee1f --- /dev/null +++ b/apps/web/src/modules/finance/strings.ts @@ -0,0 +1,352 @@ +/** + * French labels of the finance module (CONVENTIONS C5.3 — no scattered + * hard-coded French for reusable labels). Typography: « guillemets », + * numbers/dates formatted through src/lib/format.ts. + */ + +export const S = { + moduleTitle: "Finances", + + tabs: { + overview: "Vue d'ensemble", + transactions: "Transactions", + budgets: "Budgets", + recurring: "Récurrents", + }, + + nav: { + accounts: "Comptes", + categories: "Catégories", + backToFinance: "Retour aux finances", + importStatement: "Importer un relevé", + }, + + kpi: { + totalBalance: "Solde total", + monthExpenses: "Dépenses du mois", + monthIncome: "Revenus du mois", + monthCashflow: "Cashflow du mois", + globalBudget: "Budget global", + remainingToLive: "Reste à vivre", + toCategorize: "À catégoriser", + accountsCount: (n: number) => (n > 1 ? `${n} comptes` : `${n} compte`), + transactionsCount: (n: number) => (n > 1 ? `${n} transactions` : `${n} transaction`), + sixMonthAverage: "Moyenne 6 mois", + noBudget: "Aucun budget défini", + }, + + charts: { + categoryDonut: "Dépenses par catégorie", + monthlyByCategory: "Dépenses mensuelles par catégorie", + cashflow: "Cashflow mensuel", + sankey: "Flux du mois", + budgetVsActual: "Budget vs réalisé (6 mois)", + incomeSeries: "Revenus", + expenseSeries: "Dépenses", + netSeries: "Net", + budgetSeries: "Budget", + spentSeries: "Dépensé", + others: "Autres", + uncategorized: "Non catégorisé", + total: "Total", + level: "Niveau", + levelRoot: "Catégories racines", + levelChild: "Sous-catégories", + ariaDonut: "Graphique en anneau : répartition des dépenses par catégorie", + ariaMonthly: "Graphique en barres empilées : dépenses mensuelles par catégorie", + ariaCashflow: "Graphique en barres et ligne : revenus, dépenses et net par mois", + ariaSankey: "Diagramme de flux : des revenus vers les catégories de dépenses", + ariaBudget: "Graphique en barres : budget et dépenses réelles par mois", + }, + + accountsPage: { + title: "Comptes", + description: "Vos comptes bancaires, PayPal et espèces. Le solde est calculé à partir du solde initial et des transactions importées.", + create: "Créer un compte", + edit: "Modifier le compte", + columns: { + name: "Nom", + kind: "Type", + institution: "Banque", + currency: "Devise", + initialBalance: "Solde initial", + balance: "Solde", + transactions: "Transactions", + status: "Statut", + actions: "Actions", + }, + archived: "Archivé", + active: "Actif", + archive: "Archiver", + unarchive: "Réactiver", + showArchived: "Afficher les comptes archivés", + deleteTitle: "Supprimer ce compte ?", + deleteMessage: + "Cette action est irréversible. Un compte contenant des transactions ne peut pas être supprimé : archivez-le à la place.", + emptyTitle: "Aucun compte", + emptyHint: + "Créez un compte (courant, épargne, PayPal, espèces) avant d'importer un relevé bancaire.", + }, + + categoriesPage: { + title: "Catégories & règles", + description: + "Deux niveaux maximum : une catégorie racine et ses sous-catégories. Les règles catégorisent automatiquement les transactions importées.", + treeTitle: "Arbre des catégories", + createRoot: "Nouvelle catégorie", + createChild: "Sous-catégorie", + edit: "Modifier la catégorie", + system: "Système", + deleteTitle: "Supprimer cette catégorie ?", + deleteMessage: + "Les transactions concernées redeviendront « Non catégorisées », les sous-catégories et les budgets liés seront supprimés.", + emptyTitle: "Aucune catégorie", + emptyHint: "Créez vos premières catégories pour organiser vos dépenses.", + rulesTitle: "Règles de catégorisation", + rulesHint: "La première règle qui correspond gagne (priorité croissante).", + rulesEmptyTitle: "Aucune règle", + rulesEmptyHint: + "Créez une règle depuis une transaction pour catégoriser automatiquement les prochains imports.", + applyRules: "Appliquer les règles", + }, + + rules: { + columns: { + priority: "Priorité", + name: "Nom", + conditions: "Conditions", + action: "Action", + hits: "Applications", + enabled: "Active", + actions: "Actions", + }, + create: "Créer une règle", + edit: "Modifier la règle", + fromTransaction: "Créer une règle depuis cette transaction", + name: "Nom de la règle", + priorityLabel: "Priorité", + priorityHint: "Plus la valeur est basse, plus la règle est évaluée tôt.", + stop: "Arrêter l'évaluation après cette règle", + enabled: "Règle active", + labelCondition: "Si le libellé", + operatorContains: "contient", + operatorStartsWith: "commence par", + operatorRegex: "correspond à l'expression régulière", + addCondition: "+ Condition", + accountIs: "Compte est", + anyAccount: "Tous les comptes", + amountBetween: "Montant entre", + amountMin: "Minimum (€)", + amountMax: "Maximum (€)", + directionLabel: "Type", + thenCategorize: "Alors catégoriser en", + setLabel: "Renommer le libellé en", + setCounterparty: "Commerçant", + markTransfer: "Marquer comme virement interne", + applyExisting: "Appliquer aux transactions existantes non catégorisées manuellement", + previewCount: (n: number) => + n > 1 + ? `${n} transactions existantes correspondent à cette règle` + : `${n} transaction existante correspond à cette règle`, + previewNone: "Aucune transaction existante ne correspond à cette règle", + previewLoading: "Calcul de l'aperçu…", + previewSample: "Cinq premières correspondances", + submit: "Créer la règle", + save: "Enregistrer", + created: "Règle créée", + deleteTitle: "Supprimer cette règle ?", + deleteMessage: + "Les transactions déjà catégorisées gardent leur catégorie. Cette action est irréversible.", + noCondition: "Aucune condition", + noAction: "Aucune action", + applyTitle: "Appliquer les règles", + applyScope: "Portée", + scopeUncategorized: "Uniquement les non catégorisées", + scopeAllNonManual: "Tout sauf les catégorisations manuelles", + scopeAll: "Tout, y compris les catégorisations manuelles", + scopeAllWarning: + "Cette portée écrase les catégories choisies à la main. Cochez la confirmation pour continuer.", + forceConfirm: "Je confirme vouloir écraser les catégorisations manuelles", + dryRun: "Simuler (aperçu)", + apply: "Appliquer", + dryRunResult: (scanned: number, matched: number) => + `${scanned} transactions analysées · ${matched} correspondances`, + applyResult: (updated: number) => + updated > 1 ? `${updated} transactions mises à jour` : `${updated} transaction mise à jour`, + }, + + transactions: { + columns: { + date: "Date", + label: "Libellé", + account: "Compte", + category: "Catégorie", + amount: "Montant", + actions: "Actions", + }, + search: "Rechercher un libellé", + searchPlaceholder: "Carrefour, EDF, salaire…", + accountFilter: "Compte", + allAccounts: "Tous les comptes", + categoryFilter: "Catégorie", + allCategories: "Toutes les catégories", + uncategorizedOption: "Non catégorisées", + directionFilter: "Type", + directionAll: "Tout", + directionDebit: "Dépenses", + directionCredit: "Revenus", + amountMin: "Montant min.", + amountMax: "Montant max.", + reset: "Réinitialiser", + resetFilters: "Réinitialiser les filtres", + activeFilters: "Filtres actifs", + selectAll: "Tout sélectionner", + selectRow: "Sélectionner la transaction", + selectedCount: (n: number) => (n > 1 ? `${n} sélectionnées` : `${n} sélectionnée`), + bulkCategorize: (n: number) => `Catégoriser (${n})`, + bulkTitle: "Catégoriser les transactions sélectionnées", + bulkApply: "Catégoriser", + clearSelection: "Annuler la sélection", + noCategory: "Sans catégorie", + manual: "Manuel", + transfer: "Virement interne", + rule: "Règle", + edit: "Modifier", + editTitle: "Modifier la transaction", + createTitle: "Ajouter une transaction", + create: "Ajouter une transaction", + delete: "Supprimer", + deleteTitle: "Supprimer cette transaction ?", + deleteMessage: + "Seules les transactions saisies manuellement peuvent être supprimées. Cette action est irréversible.", + createRule: "Créer une règle", + perPage: "Lignes par page", + typeLabel: "Type", + typeExpense: "Dépense", + typeIncome: "Revenu", + amountField: "Montant (€)", + dateField: "Date", + labelField: "Libellé", + accountField: "Compte", + categoryField: "Catégorie", + notesField: "Note", + counterpartyField: "Commerçant", + amountLockedHint: "Le montant et la date d'une transaction importée ne sont pas modifiables.", + }, + + budgets: { + title: "Budgets mensuels", + create: "Définir un budget", + edit: "Modifier le budget", + previousMonth: "Mois précédent", + nextMonth: "Mois suivant", + global: "Budget global", + pace: (dayPct: number, spentPct: number) => + `Vous avez dépensé ${spentPct} % du budget pour ${dayPct} % du mois écoulé`, + remaining: (amount: string) => `Reste ${amount}`, + over: (amount: string) => `Dépassé de ${amount}`, + projected: (amount: string) => `Projection fin de mois : ${amount}`, + statusOk: "Dans le budget", + statusWarning: "Proche de la limite", + statusOver: "Dépassé", + categoryField: "Catégorie", + amountField: "Montant mensuel (€)", + startMonthField: "À partir du mois", + endMonthField: "Jusqu'au mois (optionnel)", + effectiveFromHint: + "La modification s'applique à partir du mois affiché : le budget précédent est clôturé automatiquement.", + deleteTitle: "Supprimer ce budget ?", + deleteMessage: "Le suivi budgétaire de cette catégorie sera retiré. Cette action est irréversible.", + emptyTitle: "Aucun budget défini", + emptyHint: + "Définissez un plafond mensuel par catégorie pour suivre vos dépenses en cours de mois.", + noExpenseCategory: "Créez d'abord une catégorie de dépense.", + }, + + recurring: { + title: "Dépenses récurrentes détectées", + monthlyTotal: "Total récurrent mensuel", + shareOfExpenses: "Part des dépenses", + yearlyCost: "Coût annuel", + activeSeries: "Séries actives", + columns: { + label: "Libellé", + category: "Catégorie", + periodicity: "Fréquence", + amount: "Montant médian", + occurrences: "Occurrences", + lastDate: "Dernière occurrence", + nextDate: "Prochaine échéance", + yearly: "Coût annuel", + }, + includeInactive: "Inclure les séries inactives", + directionLabel: "Sens", + inactive: "Inactive", + overdue: "Échéance dépassée", + timeline: "Échéancier du mois", + timelinePast: "Déjà passée", + timelineUpcoming: "À venir", + emptyTitle: "Pas encore de récurrents détectés", + emptyHint: + "La détection a besoin d'au moins 3 mois de transactions pour repérer vos abonnements et charges fixes.", + }, + + empty: { + financeTitle: "Aucune transaction", + financeHint: + "Importez un relevé bancaire (CSV ou OFX) ou un export PayPal pour démarrer. La catégorisation automatique fera le tri.", + financeAction: "Importer un relevé", + noResultTitle: "Aucun résultat", + noResultHint: "Aucune ligne ne correspond à ces filtres.", + chartTitle: "Pas de données sur cette période", + chartAction: "Élargir la période", + }, + + common: { + cancel: "Annuler", + save: "Enregistrer", + create: "Créer", + delete: "Supprimer", + close: "Fermer", + retry: "Réessayer", + errorTitle: "Impossible de charger ces données", + loading: "Chargement…", + none: "—", + yes: "Oui", + no: "Non", + color: "Couleur", + icon: "Icône", + name: "Nom", + parent: "Catégorie parente", + noParent: "Aucune (catégorie racine)", + kind: "Type", + optional: "optionnel", + }, +} as const; + +export const ACCOUNT_KIND_LABELS: Record = { + checking: "Compte courant", + savings: "Épargne", + paypal: "PayPal", + cash: "Espèces", + other: "Autre", +}; + +export const CATEGORY_KIND_LABELS: Record = { + expense: "Dépense", + income: "Revenu", + transfer: "Virement", +}; + +export const PERIODICITY_LABELS: Record = { + weekly: "Hebdomadaire", + monthly: "Mensuel", + quarterly: "Trimestriel", + yearly: "Annuel", +}; + +export const BUDGET_STATUS_LABELS: Record = { + ok: S.budgets.statusOk, + warning: S.budgets.statusWarning, + over: S.budgets.statusOver, +}; diff --git a/apps/web/src/modules/finance/utils.ts b/apps/web/src/modules/finance/utils.ts new file mode 100644 index 0000000..8d87375 --- /dev/null +++ b/apps/web/src/modules/finance/utils.ts @@ -0,0 +1,324 @@ +/** + * Finance helpers: month arithmetic on "YYYY-MM" keys, category tree + * normalization, amount display. Display formatting always goes through + * src/lib/format.ts (fr-FR, U+202F thousands, U+2212 minus). + */ + +import { formatEuroAmount, formatMonth, formatMonthShort } from "../../lib/format"; +import { CHART_COLORS } from "../../components/charts/theme"; +import type { Category, Uuid } from "./api"; + +export const UNCATEGORIZED_COLOR = "#9ca3af"; +export const OTHERS_COLOR = "#898781"; + +/* ------------------------------------------------------------------ */ +/* Amounts */ +/* ------------------------------------------------------------------ */ + +/** Amounts are decimal euros in the finance API (datamodel-finance §1.1). */ +export function formatAmount(euros: number): string { + return formatEuroAmount(euros); +} + +/** Signed euro amount: « +2 300,00 € » · « −45,90 € ». */ +export function formatSignedAmount(euros: number): string { + const abs = formatEuroAmount(Math.abs(euros)); + if (euros > 0) return `+${abs}`; + if (euros < 0) return `−${abs}`; + return abs; +} + +/** + * Parse a French decimal input: comma AND dot accepted, spaces ignored + * (ux-pages §2). Returns null when the field is empty or unparsable. + */ +export function parseDecimalInput(value: string): number | null { + const normalized = value.replace(/[\s ]/g, "").replace(",", "."); + if (normalized === "") return null; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +} + +/** Value of a decimal input field (comma separator, no unit). */ +export function decimalInputValue(value: number | null | undefined): string { + if (value === null || value === undefined) return ""; + return String(value).replace(".", ","); +} + +/* ------------------------------------------------------------------ */ +/* Months ("YYYY-MM") */ +/* ------------------------------------------------------------------ */ + +export function currentMonth(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; +} + +export function monthOf(isoDate: string): string { + return isoDate.slice(0, 7); +} + +/** First day of a month as an ISO date: "2026-08" → "2026-08-01". */ +export function monthStartIso(month: string): string { + return `${month}-01`; +} + +/** Last day of a month as an ISO date: "2026-08" → "2026-08-31". */ +export function monthEndIso(month: string): string { + const [year, m] = month.split("-").map(Number); + const last = new Date(year, m, 0).getDate(); + return `${month}-${String(last).padStart(2, "0")}`; +} + +export function addMonths(month: string, delta: number): string { + const [year, m] = month.split("-").map(Number); + const date = new Date(year, m - 1 + delta, 1); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +/** « août 2026 ». */ +export function monthLabel(month: string): string { + return formatMonth(monthStartIso(month)); +} + +/** Compact axis label: « août 26 ». */ +export function monthAxisLabel(month: string): string { + return formatMonthShort(monthStartIso(month)); +} + +/** The `count` months ending at `endMonth` (inclusive), chronological. */ +export function lastMonths(count: number, endMonth = currentMonth()): string[] { + const months: string[] = []; + for (let i = count - 1; i >= 0; i -= 1) months.push(addMonths(endMonth, -i)); + return months; +} + +/** Number of calendar months covered by an inclusive ISO date range. */ +export function monthSpan(from: string | null, to: string | null, fallback = 12): number { + if (!from || !to) return fallback; + const [fy, fm] = from.slice(0, 7).split("-").map(Number); + const [ty, tm] = to.slice(0, 7).split("-").map(Number); + const span = (ty - fy) * 12 + (tm - fm) + 1; + return Math.min(Math.max(span, 1), 60); +} + +/** Share of the month already elapsed, in percent (0–100). */ +export function monthElapsedPct(month: string): number { + const [year, m] = month.split("-").map(Number); + const daysInMonth = new Date(year, m, 0).getDate(); + const now = new Date(); + const isCurrent = now.getFullYear() === year && now.getMonth() + 1 === m; + if (!isCurrent) return now < new Date(year, m - 1, 1) ? 0 : 100; + return Math.round((now.getDate() / daysInMonth) * 100); +} + +export function isFutureOrCurrentMonth(month: string): boolean { + return month >= currentMonth(); +} + +/** + * Day of month for « today » when `month` is the running month, else null + * (past month → the whole month is over, future month → nothing elapsed). + */ +export function todayDay(month: string): number | null { + const current = currentMonth(); + if (month === current) return new Date().getDate(); + if (month < current) return Number(monthEndIso(month).slice(-2)); + return null; +} + +/* ------------------------------------------------------------------ */ +/* Categories */ +/* ------------------------------------------------------------------ */ + +export interface CategoryTreeNode { + category: Category; + children: Category[]; +} + +/** Collect every node of a (possibly nested) category payload. */ +export function collectCategories(list: Category[]): Category[] { + const all: Category[] = []; + const walk = (nodes: Category[]) => { + for (const node of nodes) { + all.push(node); + if (node.children && node.children.length > 0) walk(node.children); + } + }; + walk(list); + return all; +} + +const bySortOrder = (a: Category, b: Category): number => + a.sort_order - b.sort_order || a.name.localeCompare(b.name, "fr"); + +/** Roots + children, tolerant to a nested tree or a flat list response. */ +export function buildCategoryTree(list: Category[]): CategoryTreeNode[] { + const all = collectCategories(list); + const roots = all.filter((c) => !c.parent_id).sort(bySortOrder); + return roots.map((root) => ({ + category: root, + children: all.filter((c) => c.parent_id === root.id).sort(bySortOrder), + })); +} + +export interface CategoryOption { + id: Uuid; + name: string; + /** Root name for a child category (used as label). */ + groupName: string; + isRoot: boolean; + color: string | null; + kind: Category["kind"]; + isSystem: boolean; +} + +/** Flat option list for selects: root, then its children. */ +export function categoryOptions(list: Category[]): CategoryOption[] { + const tree = buildCategoryTree(list); + const options: CategoryOption[] = []; + for (const node of tree) { + options.push({ + id: node.category.id, + name: node.category.name, + groupName: node.category.name, + isRoot: true, + color: node.category.color, + kind: node.category.kind, + isSystem: node.category.is_system, + }); + for (const child of node.children) { + options.push({ + id: child.id, + name: child.name, + groupName: node.category.name, + isRoot: false, + color: child.color ?? node.category.color, + kind: child.kind, + isSystem: child.is_system, + }); + } + } + return options; +} + +/** Map id → category for quick lookups (badges, budget rows). */ +export function categoryById(list: Category[]): Map { + return new Map(collectCategories(list).map((c) => [c.id, c])); +} + +/** Stable color of a category: its own, its parent's, else a palette slot. */ +export function categoryColor( + category: Category | null | undefined, + index = 0, + parents?: Map, +): string { + if (!category) return UNCATEGORIZED_COLOR; + if (category.color) return category.color; + const parent = category.parent_id && parents ? parents.get(category.parent_id) : undefined; + if (parent?.color) return parent.color; + return CHART_COLORS[index % CHART_COLORS.length]; +} + +/* ------------------------------------------------------------------ */ +/* Rules */ +/* ------------------------------------------------------------------ */ + +const RULE_STOPWORDS = new Set([ + "CARTE", + "CB", + "PRLV", + "SEPA", + "VIR", + "VIREMENT", + "PAIEMENT", + "ACHAT", + "WEB", + "FACTURE", + "FACT", + "DU", + "DE", + "LA", + "LE", + "LES", + "ET", + "PAR", + "POUR", +]); + +/** + * Most significant word of a transaction label — pre-fills the + * « Si le libellé contient » field of the rule modal (ux-pages §13.3). + */ +export function significantLabelToken(label: string): string { + const normalized = label + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toUpperCase(); + const words = normalized + .split(/[^A-Z0-9]+/) + .filter((w) => w.length >= 3 && !RULE_STOPWORDS.has(w) && !/^\d+$/.test(w)); + if (words.length === 0) return normalized.trim().slice(0, 30); + return words.sort((a, b) => b.length - a.length)[0]; +} + +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Human-readable French summary of a rule's matchers (rules table). */ +export function describeMatchers( + matchers: { + label_contains?: string[] | null; + label_regex?: string | null; + amount_min?: number | null; + amount_max?: number | null; + direction?: string | null; + account_id?: string | null; + }, + accountName?: string | null, +): string[] { + const parts: string[] = []; + if (matchers.label_contains && matchers.label_contains.length > 0) { + parts.push(`Libellé contient « ${matchers.label_contains.join(" » ou « ")} »`); + } + if (matchers.label_regex) parts.push(`Libellé correspond à « ${matchers.label_regex} »`); + if (matchers.direction === "debit") parts.push("Dépenses uniquement"); + if (matchers.direction === "credit") parts.push("Revenus uniquement"); + if (matchers.amount_min !== null && matchers.amount_min !== undefined) { + parts.push(`Montant ≥ ${formatAmount(matchers.amount_min)}`); + } + if (matchers.amount_max !== null && matchers.amount_max !== undefined) { + parts.push(`Montant ≤ ${formatAmount(matchers.amount_max)}`); + } + if (matchers.account_id) parts.push(`Compte : ${accountName ?? matchers.account_id}`); + return parts; +} + +/* ------------------------------------------------------------------ */ +/* Aggregation */ +/* ------------------------------------------------------------------ */ + +export interface CategoryShare { + id: Uuid | null; + name: string; + color: string; + value: number; +} + +/** + * Top N categories + « Autres » (ux-pages §4.4: donut/sankey are limited to + * 7 categories + Autres so every pair stays distinguishable). + */ +export function topCategories( + entries: CategoryShare[], + limit = 7, + othersLabel = "Autres", +): CategoryShare[] { + const sorted = [...entries].filter((e) => e.value > 0).sort((a, b) => b.value - a.value); + if (sorted.length <= limit) return sorted; + const head = sorted.slice(0, limit); + const rest = sorted.slice(limit).reduce((sum, e) => sum + e.value, 0); + if (rest > 0) head.push({ id: null, name: othersLabel, color: OTHERS_COLOR, value: rest }); + return head; +} diff --git a/apps/web/src/modules/health/api.ts b/apps/web/src/modules/health/api.ts new file mode 100644 index 0000000..f29cda3 --- /dev/null +++ b/apps/web/src/modules/health/api.ts @@ -0,0 +1,1270 @@ +/** + * Typed React Query hooks of the health module (CONVENTIONS C5.4). + * Every call goes through the api() wrapper of src/lib/api.ts; query keys + * follow [moduleId, resource, params] and mutations invalidate [moduleId, resource]. + * Endpoints: docs/design/datamodel-health-vape.md §8 + docs/design/addendum-planning.md. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query"; + +import { ApiError, api, qs } from "../../lib/api"; +import type { Page } from "../../types/api"; + +export const MODULE_ID = "health"; + +/** Local-day timezone used by every daily aggregation (CONVENTIONS C6). */ +export const TZ = "Europe/Paris"; + +/** Every health resource lives under /api/health (nutrition included). */ +const BASE = "/health"; + +/** Server-side cap of `page_size` (app/core/pagination.py — le=200). */ +const MAX_PAGE_SIZE = 200; + +/* ------------------------------------------------------------------ */ +/* Shared response shapes */ +/* ------------------------------------------------------------------ */ + +/** [date ISO, value] — directly injectable in an ECharts dataset. */ +export type SeriesPoint = [string, number | null]; + +export interface StatsSeries { + name: string; + type?: string; + points: SeriesPoint[]; +} + +/** Standard chart-ready stats envelope (datamodel §8.1). */ +export interface StatsResponse { + from: string; + to: string; + unit?: string; + series: StatsSeries[]; + meta: M; +} + +export interface PeriodParams { + from?: string | null; + to?: string | null; +} + +/** Accepts both a paginated Page[T] and a bare array (defensive read). */ +function toItems(payload: Page | T[] | null | undefined): T[] { + if (Array.isArray(payload)) return payload; + if (payload && Array.isArray(payload.items)) return payload.items; + return []; +} + +/** Series lookup by stable backend name. */ +export function seriesPoints(res: { series: StatsSeries[] } | undefined, name: string): SeriesPoint[] { + return res?.series.find((s) => s.name === name)?.points ?? []; +} + +/** Raw `meta` of a stats envelope before it is mapped to the module's names. */ +type RawMeta = Record; + +/** Raw stats envelope as served by the API (`meta` is an untyped object). */ +interface RawStats { + from: string; + to: string; + unit?: string; + series?: StatsSeries[] | null; + meta?: RawMeta | null; +} + +function numberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** + * Rebuilds a `StatsResponse` from the raw envelope, mapping the backend + * `meta` keys onto the names used by the pages. + */ +function toStats(raw: RawStats, mapMeta: (meta: RawMeta) => M): StatsResponse { + return { + from: raw.from, + to: raw.to, + unit: raw.unit, + series: Array.isArray(raw.series) ? raw.series : [], + meta: mapMeta(raw.meta ?? {}), + }; +} + +/* ------------------------------------------------------------------ */ +/* Health — profile, goal */ +/* ------------------------------------------------------------------ */ + +export type Sex = "male" | "female" | "other"; +export type ActivityLevel = "sedentary" | "light" | "moderate" | "active" | "very_active"; + +export interface ProfileRead { + id?: number; + height_cm: number; + sex: Sex; + birthdate: string; + activity_level: ActivityLevel; + timezone: string; + water_goal_ml: number | null; + calorie_floor_kcal: number | null; + age?: number | null; + bmr_kcal?: number | null; + tdee_estimated_kcal?: number | null; + current_weight_kg?: number | null; + bmi?: number | null; +} + +export type GoalMode = "weekly_rate" | "target_date" | "maintain"; +export type GoalStatus = "active" | "completed" | "abandoned"; + +export type ProjectionStatus = "ok" | "reached" | "not_converging" | "insufficient_data"; + +export interface Projection { + status: ProjectionStatus; + date?: string | null; + days?: number | null; + weight_kg?: number | null; +} + +export interface GoalRead { + id: number; + mode: GoalMode; + start_date: string; + start_weight_kg: number; + target_weight_kg: number; + target_date?: string | null; + weekly_rate_kg?: number | null; + status: GoalStatus; + note?: string | null; +} + +/** + * GET /health/goals/active answers `{goal, budget, projection, …}`; the flat + * shape below is what the pages consume, built by `flattenActiveGoal()`. + */ +export interface ActiveGoalRead extends GoalRead { + daily_budget_kcal?: number | null; + daily_budget?: number | null; + deficit_target_kcal?: number | null; + floor_applied?: boolean; + tdee_kcal?: number | null; + tdee_method?: string | null; + projection?: Projection | null; + trend_weight_kg?: number | null; + done_kg?: number | null; + remaining_kg?: number | null; + pct?: number | null; +} + +/** Envelope served by GET /health/goals/active. */ +interface ActiveGoalEnvelope { + goal?: GoalRead | null; + budget?: { + kcal?: number | null; + deficit_target_kcal?: number | null; + floor_applied?: boolean; + rate_clamped?: boolean; + tdee_kcal?: number | null; + tdee_method?: string | null; + } | null; + projection?: Projection | null; + trend_weight_kg?: number | null; + done_kg?: number | null; + remaining_kg?: number | null; + progress_pct?: number | null; +} + +/** Flattens the envelope; `null` when no goal is active. */ +function flattenActiveGoal(res: ActiveGoalEnvelope | null): ActiveGoalRead | null { + if (!res?.goal) return null; + return { + ...res.goal, + daily_budget_kcal: res.budget?.kcal ?? null, + daily_budget: res.budget?.kcal ?? null, + deficit_target_kcal: res.budget?.deficit_target_kcal ?? null, + floor_applied: res.budget?.floor_applied ?? false, + tdee_kcal: res.budget?.tdee_kcal ?? null, + tdee_method: res.budget?.tdee_method ?? null, + projection: res.projection ?? null, + trend_weight_kg: res.trend_weight_kg ?? null, + done_kg: res.done_kg ?? null, + remaining_kg: res.remaining_kg ?? null, + pct: res.progress_pct ?? null, + }; +} + +/* ------------------------------------------------------------------ */ +/* Health — weights & measurements */ +/* ------------------------------------------------------------------ */ + +export interface WeightRead { + id: number; + measured_at: string; + weight_kg: number; + body_fat_pct?: number | null; + muscle_mass_kg?: number | null; + water_pct?: number | null; + source: string; + note?: string | null; +} + +export interface WeightWrite { + measured_at: string; + weight_kg: number; + body_fat_pct?: number | null; + note?: string | null; +} + +export interface WeightStatsMeta { + trend_now?: number | null; + slope_14d_kg_week?: number | null; + slope_30d_kg_week?: number | null; + total_change_kg?: number | null; + projection?: Projection | null; + target_weight_kg?: number | null; + weekly_rate_target_kg?: number | null; + bmi?: number | null; +} + +export interface MeasurementRead { + id: number; + measured_at: string; + neck_cm?: number | null; + chest_cm?: number | null; + waist_cm?: number | null; + hips_cm?: number | null; + biceps_left_cm?: number | null; + biceps_right_cm?: number | null; + thigh_left_cm?: number | null; + thigh_right_cm?: number | null; + calf_left_cm?: number | null; + calf_right_cm?: number | null; + source?: string; + note?: string | null; +} + +export type MeasurementWrite = Omit; + +export interface MeasurementStatsMeta { + body_fat_navy_pct?: number | null; +} + +/* ------------------------------------------------------------------ */ +/* Health — activity & workouts */ +/* ------------------------------------------------------------------ */ + +export interface ActivityDay { + date: string; + steps?: number | null; + active_kcal?: number | null; + total_kcal?: number | null; + distance_m?: number | null; + active_minutes?: number | null; + floors?: number | null; + /** field → source retained by the merge (datamodel §3.5). */ + field_sources?: Record | null; +} + +export interface ActivityStatsMeta { + steps_avg?: number | null; + active_kcal_avg?: number | null; + distance_total_m?: number | null; + steps_goal?: number | null; + days_goal_reached?: number | null; + days_count?: number | null; + steps_avg_previous?: number | null; +} + +export type SportType = + | "treadmill_walk" + | "treadmill_run" + | "walking" + | "running" + | "cycling" + | "swimming" + | "strength" + | "hiit" + | "yoga" + | "hiking" + | "other"; + +export interface WorkoutRead { + id: number; + started_at: string; + ended_at: string; + sport_type: SportType; + sport_label?: string | null; + kcal?: number | null; + distance_m?: number | null; + steps?: number | null; + avg_hr?: number | null; + max_hr?: number | null; + is_hidden?: boolean; + source: string; + note?: string | null; +} + +export interface WorkoutWrite { + started_at: string; + ended_at: string; + sport_type: SportType; + sport_label?: string | null; + kcal?: number | null; + distance_m?: number | null; + avg_hr?: number | null; + note?: string | null; +} + +export interface WorkoutStatsMeta { + sessions_total?: number | null; + duration_total_min?: number | null; + kcal_total?: number | null; + distance_total_m?: number | null; + sessions_this_week?: number | null; + duration_this_week_min?: number | null; + sessions_last_week?: number | null; + duration_last_week_min?: number | null; + by_sport_type?: Record | null; +} + +/* ------------------------------------------------------------------ */ +/* Health — energy balance */ +/* ------------------------------------------------------------------ */ + +export interface EnergyBalanceMeta { + intake_avg?: number | null; + tdee_avg?: number | null; + balance_avg_7d?: number | null; + balance_today?: number | null; + balance_cumulative?: number | null; + budget_kcal?: number | null; + deficit_target_kcal?: number | null; + bmr?: number | null; + activity_factor?: number | null; + tdee_mode?: "factor" | "measured" | null; + tdee_methods?: Record | null; + expected_change_kg?: number | null; + actual_change_kg?: number | null; + gap_kg?: number | null; + tdee_correction_kcal?: number | null; + tdee_adaptive_kcal?: number | null; + tracked_days?: number | null; + untracked_days?: number | null; + status?: string | null; +} + +/* ------------------------------------------------------------------ */ +/* Planning (addendum-planning.md) */ +/* ------------------------------------------------------------------ */ + +export type HabitKind = "weigh_in" | "workout" | "food_log"; + +export interface ScheduleRead { + kind: HabitKind; + /** 0 = monday … 6 = sunday. */ + weekdays: number[]; + enabled: boolean; +} + +export interface TodayItem { + kind: HabitKind; + planned: boolean; + done: boolean; + value?: number | null; +} + +export interface StreakInfo { + current?: number | null; + best?: number | null; +} + +export interface TodayResponse { + date: string; + items: TodayItem[]; + streaks?: Partial> | null; +} + +export interface AdherenceDay { + date: string; + planned: boolean; + done: boolean; + missed?: boolean; +} + +export interface AdherenceMeta { + adherence_pct?: number | null; + current_streak?: number | null; + best_streak?: number | null; + planned_days?: number | null; + done_days?: number | null; +} + +export interface AdherenceResponse { + from: string; + to: string; + kind: HabitKind; + days: AdherenceDay[]; + meta: AdherenceMeta; +} + +/** Reads a streak count out of the loosely-typed `streaks` map. */ +export function streakOf(res: TodayResponse | undefined, kind: HabitKind): number { + const raw = res?.streaks?.[kind]; + if (typeof raw === "number") return raw; + if (raw && typeof raw === "object" && typeof raw.current === "number") return raw.current; + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Nutrition */ +/* ------------------------------------------------------------------ */ + +export type MealType = "breakfast" | "lunch" | "dinner" | "snack"; + +export const MEAL_ORDER: MealType[] = ["breakfast", "lunch", "dinner", "snack"]; + +export interface FoodEntryRead { + id: number; + eaten_at: string; + meal: MealType; + name: string; + brand?: string | null; + quantity: number; + unit: string; + kcal: number; + protein_g?: number | null; + carbs_g?: number | null; + fat_g?: number | null; + fiber_g?: number | null; + source: string; +} + +export interface FoodEntryWrite { + eaten_at: string; + meal: MealType; + name: string; + brand?: string | null; + quantity: number; + unit: string; + kcal: number; + protein_g?: number | null; + carbs_g?: number | null; + fat_g?: number | null; +} + +export interface NutritionDay { + date: string; + kcal: number | null; + protein_g?: number | null; + carbs_g?: number | null; + fat_g?: number | null; + fiber_g?: number | null; + budget_kcal?: number | null; + vs_budget?: number | null; +} + +export interface NutritionDaysMeta { + kcal_avg?: number | null; + budget_avg?: number | null; + protein_avg_g?: number | null; + days_in_budget?: number | null; + days_count?: number | null; + vs_budget_avg?: number | null; + protein_pct?: number | null; + carbs_pct?: number | null; + fat_pct?: number | null; + protein_goal_g?: number | null; +} + +export interface NutritionDaysResponse { + days: NutritionDay[]; + meta: NutritionDaysMeta; +} + +export interface MealGroup { + meal: MealType; + kcal: number; + entries: FoodEntryRead[]; +} + +export interface NutritionDayDetail { + date: string; + meals: MealGroup[]; + kcal: number; + protein_g: number; + carbs_g: number; + fat_g: number; + budget_kcal?: number | null; +} + +export interface FoodFavoriteRead { + id: number; + name: string; + brand?: string | null; + default_quantity: number; + unit: string; + kcal: number; + protein_g?: number | null; + carbs_g?: number | null; + fat_g?: number | null; + default_meal?: MealType | null; + use_count?: number | null; +} + +export interface FoodFavoriteWrite { + name: string; + brand?: string | null; + default_quantity: number; + unit: string; + kcal: number; + protein_g?: number | null; + carbs_g?: number | null; + fat_g?: number | null; + default_meal?: MealType | null; +} + +/** A food catalog hit (GET /health/foods/search) — values for `quantity` `unit`. */ +export interface FoodSearchResult { + id?: number | string; + name: string; + brand?: string | null; + quantity?: number | null; + unit?: string | null; + kcal: number; + protein_g?: number | null; + carbs_g?: number | null; + fat_g?: number | null; +} + +/* ------------------------------------------------------------------ */ +/* Query keys */ +/* ------------------------------------------------------------------ */ + +export const healthKeys = { + profile: () => [MODULE_ID, "profile"] as const, + goalActive: () => [MODULE_ID, "goal-active"] as const, + weights: (p: object) => [MODULE_ID, "weights", p] as const, + weightStats: (p: object) => [MODULE_ID, "weight-stats", p] as const, + measurements: (p: object) => [MODULE_ID, "measurements", p] as const, + measurementStats: (p: object) => [MODULE_ID, "measurement-stats", p] as const, + activity: (p: object) => [MODULE_ID, "activity", p] as const, + activityStats: (p: object) => [MODULE_ID, "activity-stats", p] as const, + workouts: (p: object) => [MODULE_ID, "workouts", p] as const, + workoutStats: (p: object) => [MODULE_ID, "workout-stats", p] as const, + energyBalance: (p: object) => [MODULE_ID, "energy-balance", p] as const, + schedules: () => [MODULE_ID, "schedules"] as const, + today: (p: object) => [MODULE_ID, "today", p] as const, + adherence: (p: object) => [MODULE_ID, "adherence", p] as const, + nutritionEntries: (p: object) => [MODULE_ID, "nutrition-entries", p] as const, + nutritionDays: (p: object) => [MODULE_ID, "nutrition-days", p] as const, + nutritionDay: (p: object) => [MODULE_ID, "nutrition-day", p] as const, + favorites: (p: object) => [MODULE_ID, "favorites", p] as const, + recentFoods: () => [MODULE_ID, "recent-foods"] as const, + foodSearch: (p: object) => [MODULE_ID, "food-search", p] as const, +}; + +/** Resources invalidated together when a weight is written. */ +const WEIGHT_SCOPE = ["weights", "weight-stats", "goal-active", "today", "adherence", "energy-balance"]; +const MEASUREMENT_SCOPE = ["measurements", "measurement-stats"]; +const WORKOUT_SCOPE = ["workouts", "workout-stats", "activity", "activity-stats", "today", "adherence"]; +const FOOD_SCOPE = [ + "nutrition-entries", + "nutrition-days", + "nutrition-day", + "favorites", + "recent-foods", + "today", + "energy-balance", +]; + +function useInvalidate(): (resources: string[]) => void { + const queryClient = useQueryClient(); + return (resources: string[]) => { + for (const resource of resources) { + void queryClient.invalidateQueries({ queryKey: [MODULE_ID, resource] }); + } + }; +} + +/* ------------------------------------------------------------------ */ +/* Profile & goal */ +/* ------------------------------------------------------------------ */ + +/** GET that turns a documented 404 (« pas encore configuré ») into `null`. */ +async function getOrNull(path: string): Promise { + try { + return await api(path); + } catch (error) { + if (error instanceof ApiError && error.status === 404) return null; + throw error; + } +} + +/** `null` while the health profile has not been configured (404, ux §16). */ +export function useProfile(): UseQueryResult { + return useQuery({ + queryKey: healthKeys.profile(), + queryFn: () => getOrNull(`${BASE}/profile`), + staleTime: 300_000, + }); +} + +export function useActiveGoal(): UseQueryResult { + return useQuery({ + queryKey: healthKeys.goalActive(), + queryFn: () => + api(`${BASE}/goals/active?${qs({ tz: TZ })}`).then( + flattenActiveGoal, + ), + staleTime: 300_000, + }); +} + +/* ------------------------------------------------------------------ */ +/* Weights */ +/* ------------------------------------------------------------------ */ + +export function useWeights(params: PeriodParams): UseQueryResult { + return useQuery({ + queryKey: healthKeys.weights(params), + queryFn: () => + api | WeightRead[]>( + `${BASE}/weights?${qs({ page_size: MAX_PAGE_SIZE, tz: TZ, ...params })}`, + ).then(toItems), + }); +} + +export function useWeightStats( + params: PeriodParams, +): UseQueryResult> { + return useQuery({ + queryKey: healthKeys.weightStats(params), + queryFn: () => + api(`${BASE}/weights/stats?${qs({ tz: TZ, ...params })}`).then((raw) => + toStats(raw, (meta) => ({ + trend_now: numberOrNull(meta.trend_now_kg), + slope_14d_kg_week: numberOrNull(meta.slope_14d_kg_week), + slope_30d_kg_week: numberOrNull(meta.slope_30d_kg_week), + total_change_kg: numberOrNull(meta.total_change_kg), + projection: (meta.projection as Projection | undefined) ?? null, + target_weight_kg: numberOrNull(meta.target_weight_kg), + bmi: numberOrNull(meta.bmi), + })), + ), + }); +} + +export function useCreateWeight(): UseMutationResult { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (body: WeightWrite) => + api(`${BASE}/weights`, { method: "POST", body: JSON.stringify(body) }), + onSuccess: () => invalidate(WEIGHT_SCOPE), + }); +} + +export function useUpdateWeight(): UseMutationResult< + WeightRead, + Error, + { id: number; body: WeightWrite } +> { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, body }: { id: number; body: WeightWrite }) => + api(`${BASE}/weights/${id}`, { method: "PUT", body: JSON.stringify(body) }), + onSuccess: () => invalidate(WEIGHT_SCOPE), + }); +} + +export function useDeleteWeight(): UseMutationResult { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: number) => api(`${BASE}/weights/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate(WEIGHT_SCOPE), + }); +} + +/* ------------------------------------------------------------------ */ +/* Measurements */ +/* ------------------------------------------------------------------ */ + +export function useMeasurements(params: PeriodParams): UseQueryResult { + return useQuery({ + queryKey: healthKeys.measurements(params), + queryFn: () => + api | MeasurementRead[]>( + `${BASE}/measurements?${qs({ page_size: MAX_PAGE_SIZE, tz: TZ, ...params })}`, + ).then(toItems), + }); +} + +export function useMeasurementStats( + params: PeriodParams, +): UseQueryResult> { + return useQuery({ + queryKey: healthKeys.measurementStats(params), + queryFn: () => + api(`${BASE}/measurements/stats?${qs({ tz: TZ, ...params })}`).then((raw) => { + const stats = toStats(raw, () => ({})); + // The Navy body-fat estimate is served as a series, not as meta. + const navy = seriesPoints(stats, "body_fat_navy_pct").filter( + (point): point is [string, number] => typeof point[1] === "number", + ); + stats.meta = { body_fat_navy_pct: navy.length > 0 ? navy[navy.length - 1][1] : null }; + return stats; + }), + }); +} + +export function useCreateMeasurement(): UseMutationResult { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (body: MeasurementWrite) => + api(`${BASE}/measurements`, { method: "POST", body: JSON.stringify(body) }), + onSuccess: () => invalidate(MEASUREMENT_SCOPE), + }); +} + +export function useDeleteMeasurement(): UseMutationResult { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: number) => api(`${BASE}/measurements/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate(MEASUREMENT_SCOPE), + }); +} + +/* ------------------------------------------------------------------ */ +/* Activity & workouts */ +/* ------------------------------------------------------------------ */ + +export function useActivityDays(params: PeriodParams): UseQueryResult { + return useQuery({ + queryKey: healthKeys.activity(params), + queryFn: () => + api | ActivityDay[]>( + `${BASE}/activity?${qs({ tz: TZ, ...params })}`, + ).then(toItems), + }); +} + +export function useActivityStats( + params: PeriodParams, +): UseQueryResult> { + return useQuery({ + queryKey: healthKeys.activityStats(params), + queryFn: () => + api(`${BASE}/activity/stats?${qs({ tz: TZ, ...params })}`).then((raw) => + toStats(raw, (meta) => ({ + steps_avg: numberOrNull(meta.avg_steps), + active_kcal_avg: numberOrNull(meta.avg_active_kcal), + distance_total_m: numberOrNull(meta.total_distance_m), + days_count: numberOrNull(meta.days), + })), + ), + }); +} + +export function useWorkouts( + params: PeriodParams & { sport_type?: string | null }, +): UseQueryResult { + return useQuery({ + queryKey: healthKeys.workouts(params), + queryFn: () => + api | WorkoutRead[]>( + `${BASE}/workouts?${qs({ page_size: MAX_PAGE_SIZE, tz: TZ, ...params })}`, + ).then(toItems), + }); +} + +export function useWorkoutStats( + params: PeriodParams, +): UseQueryResult> { + return useQuery({ + queryKey: healthKeys.workoutStats(params), + queryFn: () => + api(`${BASE}/workouts/stats?${qs({ tz: TZ, ...params })}`).then((raw) => + toStats(raw, (meta) => { + const bySport = (meta.by_sport ?? {}) as Record>; + const durations: Record = {}; + for (const [sport, values] of Object.entries(bySport)) { + durations[sport] = numberOrNull(values?.duration_min) ?? 0; + } + return { + sessions_total: numberOrNull(meta.sessions), + duration_total_min: numberOrNull(meta.total_duration_min), + kcal_total: numberOrNull(meta.total_kcal), + distance_total_m: numberOrNull(meta.total_distance_m), + by_sport_type: durations, + }; + }), + ), + }); +} + +export function useCreateWorkout(): UseMutationResult { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (body: WorkoutWrite) => + api(`${BASE}/workouts`, { method: "POST", body: JSON.stringify(body) }), + onSuccess: () => invalidate(WORKOUT_SCOPE), + }); +} + +export function useUpdateWorkout(): UseMutationResult< + WorkoutRead, + Error, + { id: number; body: WorkoutWrite } +> { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, body }: { id: number; body: WorkoutWrite }) => + api(`${BASE}/workouts/${id}`, { method: "PUT", body: JSON.stringify(body) }), + onSuccess: () => invalidate(WORKOUT_SCOPE), + }); +} + +export function useDeleteWorkout(): UseMutationResult { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: number) => api(`${BASE}/workouts/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate(WORKOUT_SCOPE), + }); +} + +/* ------------------------------------------------------------------ */ +/* Energy balance */ +/* ------------------------------------------------------------------ */ + +/** Mean of the non-null values of a series (`null` when it is empty). */ +function seriesAverage(stats: StatsResponse, name: string): number | null { + const values = seriesPoints(stats, name) + .map(([, value]) => value) + .filter((value): value is number => typeof value === "number"); + if (values.length === 0) return null; + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +/** Last non-null value of a series. */ +function seriesLast(stats: StatsResponse, name: string): number | null { + const values = seriesPoints(stats, name) + .map(([, value]) => value) + .filter((value): value is number => typeof value === "number"); + return values.length > 0 ? values[values.length - 1] : null; +} + +export function useEnergyBalance( + params: PeriodParams, +): UseQueryResult> { + return useQuery({ + queryKey: healthKeys.energyBalance(params), + queryFn: () => + api(`${BASE}/energy-balance?${qs({ tz: TZ, ...params })}`).then((raw) => { + const meta = raw.meta ?? {}; + const stats = toStats(raw, () => ({})); + const days = numberOrNull(meta.days); + const trackedDays = numberOrNull(meta.tracked_days); + stats.meta = { + // Averages are not part of the server meta — derived from the series. + intake_avg: seriesAverage(stats, "intake_kcal"), + tdee_avg: seriesAverage(stats, "tdee_kcal"), + budget_kcal: seriesLast(stats, "budget_kcal"), + deficit_target_kcal: numberOrNull(meta.deficit_target_kcal), + balance_cumulative: numberOrNull(meta.cumulative_balance_kcal), + expected_change_kg: numberOrNull(meta.expected_change_kg), + actual_change_kg: numberOrNull(meta.actual_change_kg), + gap_kg: numberOrNull(meta.gap_kg), + tdee_correction_kcal: numberOrNull(meta.tdee_correction_kcal), + tdee_adaptive_kcal: numberOrNull(meta.tdee_adaptive_kcal), + tdee_methods: (meta.tdee_methods as Record | undefined) ?? null, + tracked_days: trackedDays, + untracked_days: days !== null && trackedDays !== null ? days - trackedDays : null, + status: typeof meta.calibration_status === "string" ? meta.calibration_status : null, + }; + return stats; + }), + }); +} + +/* ------------------------------------------------------------------ */ +/* Planning */ +/* ------------------------------------------------------------------ */ + +const DEFAULT_SCHEDULES: ScheduleRead[] = [ + { kind: "weigh_in", weekdays: [], enabled: false }, + { kind: "workout", weekdays: [], enabled: false }, + { kind: "food_log", weekdays: [], enabled: false }, +]; + +export function useSchedules(): UseQueryResult { + return useQuery({ + queryKey: healthKeys.schedules(), + queryFn: () => + api | ScheduleRead[]>(`${BASE}/schedules`).then((payload) => { + const items = toItems(payload); + return DEFAULT_SCHEDULES.map( + (fallback) => items.find((s) => s.kind === fallback.kind) ?? fallback, + ); + }), + staleTime: 300_000, + }); +} + +export function useSaveSchedule(): UseMutationResult< + ScheduleRead, + Error, + { kind: HabitKind; weekdays: number[]; enabled: boolean } +> { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ kind, ...body }: { kind: HabitKind; weekdays: number[]; enabled: boolean }) => + api(`${BASE}/schedules/${kind}`, { + method: "PUT", + body: JSON.stringify(body), + }), + onSuccess: () => invalidate(["schedules", "today", "adherence"]), + }); +} + +export function useToday(): UseQueryResult { + return useQuery({ + queryKey: healthKeys.today({ tz: TZ }), + queryFn: () => api(`${BASE}/today?${qs({ tz: TZ })}`), + }); +} + +/** One habit block of GET /health/stats/adherence (`kinds[]`). */ +interface AdherenceKindEnvelope { + kind: HabitKind; + weekdays?: number[]; + enabled?: boolean; + planned_days?: number | null; + done_days?: number | null; + missed_days?: number | null; + adherence_pct?: number | null; + streak?: StreakInfo | null; + days?: { date: string; planned: boolean; done: boolean; status?: string }[]; +} + +interface AdherenceEnvelope { + from: string; + to: string; + unit?: string; + kinds?: AdherenceKindEnvelope[]; +} + +/** + * GET /health/stats/adherence answers the §8.1 envelope with one `kinds[]` + * block per habit; the pages consume a single flattened habit. + */ +export function useAdherence( + params: PeriodParams & { kind: HabitKind }, +): UseQueryResult { + return useQuery({ + queryKey: healthKeys.adherence(params), + queryFn: () => + api(`${BASE}/stats/adherence?${qs({ tz: TZ, ...params })}`).then((res) => { + const block = (res.kinds ?? []).find((item) => item.kind === params.kind); + return { + from: res.from, + to: res.to, + kind: params.kind, + days: (block?.days ?? []).map((day) => ({ + date: day.date, + planned: day.planned, + done: day.done, + missed: day.status === "missed", + })), + meta: { + adherence_pct: block?.adherence_pct ?? null, + current_streak: block?.streak?.current ?? null, + best_streak: block?.streak?.best ?? null, + planned_days: block?.planned_days ?? null, + done_days: block?.done_days ?? null, + }, + } satisfies AdherenceResponse; + }), + }); +} + +/* ------------------------------------------------------------------ */ +/* Nutrition */ +/* ------------------------------------------------------------------ */ + +export function useFoodEntries( + params: PeriodParams & { meal?: MealType | null; q?: string | null }, +): UseQueryResult { + return useQuery({ + queryKey: healthKeys.nutritionEntries(params), + queryFn: () => + api | FoodEntryRead[]>( + `${BASE}/nutrition/entries?${qs({ page_size: MAX_PAGE_SIZE, tz: TZ, ...params })}`, + ).then(toItems), + }); +} + +/** One day as served by GET /health/nutrition/days (bare array, no meta). */ +interface NutritionDayRow extends Omit { + vs_budget_kcal?: number | null; +} + +/** Rebuilds the aggregates the page needs — the endpoint returns days only. */ +function nutritionDaysMeta(days: NutritionDay[]): NutritionDaysMeta { + const tracked = days.filter((day) => typeof day.kcal === "number"); + const withBudget = tracked.filter((day) => typeof day.budget_kcal === "number"); + const mean = (values: number[]): number | null => + values.length === 0 ? null : values.reduce((sum, value) => sum + value, 0) / values.length; + return { + kcal_avg: mean(tracked.map((day) => Number(day.kcal))), + budget_avg: mean(withBudget.map((day) => Number(day.budget_kcal))), + protein_avg_g: mean( + tracked + .filter((day) => typeof day.protein_g === "number") + .map((day) => Number(day.protein_g)), + ), + days_in_budget: withBudget.filter((day) => Number(day.kcal) <= Number(day.budget_kcal)).length, + days_count: tracked.length, + vs_budget_avg: mean( + withBudget + .map((day) => Number(day.vs_budget ?? Number(day.kcal) - Number(day.budget_kcal))) + .filter((value) => Number.isFinite(value)), + ), + }; +} + +export function useNutritionDays(params: PeriodParams): UseQueryResult { + return useQuery({ + queryKey: healthKeys.nutritionDays(params), + queryFn: () => + api(`${BASE}/nutrition/days?${qs({ tz: TZ, ...params })}`).then((rows) => { + const days: NutritionDay[] = (Array.isArray(rows) ? rows : []).map((row) => ({ + ...row, + vs_budget: row.vs_budget_kcal ?? null, + })); + return { days, meta: nutritionDaysMeta(days) }; + }), + }); +} + +/** Payload of GET /health/nutrition/days/{day} — totals live in `totals`. */ +interface NutritionDayDetailEnvelope { + date: string; + totals?: { + kcal?: number | null; + protein_g?: number | null; + carbs_g?: number | null; + fat_g?: number | null; + budget_kcal?: number | null; + } | null; + water_ml?: number; + water_goal_ml?: number | null; + meals?: { meal: MealType; kcal?: number; entries?: FoodEntryRead[] }[]; +} + +/** Journal of one local day, grouped by meal (GET /health/nutrition/days/{day}). */ +export function useNutritionDay(date: string): UseQueryResult { + return useQuery({ + queryKey: healthKeys.nutritionDay({ date, tz: TZ }), + queryFn: () => + api( + `${BASE}/nutrition/days/${date}?${qs({ tz: TZ })}`, + ).then((res) => normalizeDayDetail(date, res)), + }); +} + +/** Fills in every meal group (the API omits the empty ones). */ +function normalizeDayDetail(date: string, res: NutritionDayDetailEnvelope): NutritionDayDetail { + const meals: MealGroup[] = MEAL_ORDER.map((meal) => { + const fromServer = res.meals?.find((m) => m.meal === meal); + const entries = fromServer?.entries ?? []; + return { + meal, + entries, + kcal: fromServer?.kcal ?? entries.reduce((sum, e) => sum + Number(e.kcal ?? 0), 0), + }; + }); + const all = meals.flatMap((m) => m.entries); + const sum = (pick: (e: FoodEntryRead) => number | null | undefined): number => + all.reduce((acc, e) => acc + Number(pick(e) ?? 0), 0); + const totals = res.totals ?? {}; + return { + date: res.date ?? date, + meals, + kcal: totals.kcal ?? sum((e) => e.kcal), + protein_g: totals.protein_g ?? sum((e) => e.protein_g), + carbs_g: totals.carbs_g ?? sum((e) => e.carbs_g), + fat_g: totals.fat_g ?? sum((e) => e.fat_g), + budget_kcal: totals.budget_kcal ?? null, + }; +} + +export function useCreateFoodEntry(): UseMutationResult { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (body: FoodEntryWrite) => + api(`${BASE}/nutrition/entries`, { + method: "POST", + body: JSON.stringify(body), + }), + onSuccess: () => invalidate(FOOD_SCOPE), + }); +} + +export function useUpdateFoodEntry(): UseMutationResult< + FoodEntryRead, + Error, + { id: number; body: FoodEntryWrite } +> { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, body }: { id: number; body: FoodEntryWrite }) => + api(`${BASE}/nutrition/entries/${id}`, { + method: "PUT", + body: JSON.stringify(body), + }), + onSuccess: () => invalidate(FOOD_SCOPE), + }); +} + +export function useDeleteFoodEntry(): UseMutationResult { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: number) => api(`${BASE}/nutrition/entries/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate(FOOD_SCOPE), + }); +} + +/** + * Personal food library. The endpoint has no text filter, so the whole (small) + * library is fetched once and filtered client-side by the callers. + */ +export function useFavorites(): UseQueryResult { + return useQuery({ + queryKey: healthKeys.favorites({}), + queryFn: () => + api | FoodFavoriteRead[]>( + `${BASE}/nutrition/favorites?${qs({ page_size: MAX_PAGE_SIZE, sort: "-use_count" })}`, + ).then(toItems), + staleTime: 120_000, + }); +} + +export function useCreateFavorite(): UseMutationResult { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (body: FoodFavoriteWrite) => + api(`${BASE}/nutrition/favorites`, { + method: "POST", + body: JSON.stringify(body), + }), + onSuccess: () => invalidate(["favorites"]), + }); +} + +export function useDeleteFavorite(): UseMutationResult { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: number) => api(`${BASE}/nutrition/favorites/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate(["favorites"]), + }); +} + +/** One row of GET /health/nutrition/recent (a summary, not a stored entry). */ +interface RecentFoodRow { + name: string; + brand?: string | null; + unit?: string; + quantity?: number | null; + kcal?: number | null; + protein_g?: number | null; + carbs_g?: number | null; + fat_g?: number | null; + meal?: MealType | null; + last_eaten_at: string; +} + +/** + * 20 last distinct foods logged (GET /health/nutrition/recent). The endpoint + * returns aggregates, mapped here onto the entry shape used by the quick-add + * modal (negative synthetic ids — these rows are not stored entries). + */ +export function useRecentFoods(): UseQueryResult { + return useQuery({ + queryKey: healthKeys.recentFoods(), + queryFn: () => + api(`${BASE}/nutrition/recent`).then((rows) => + (Array.isArray(rows) ? rows : []).map((row, index) => ({ + id: -(index + 1), + eaten_at: row.last_eaten_at, + meal: row.meal ?? "snack", + name: row.name, + brand: row.brand ?? null, + quantity: row.quantity ?? 100, + unit: row.unit ?? "g", + kcal: row.kcal ?? 0, + protein_g: row.protein_g ?? null, + carbs_g: row.carbs_g ?? null, + fat_g: row.fat_g ?? null, + source: "manual", + })), + ), + staleTime: 120_000, + }); +} + +/** One catalog hit of GET /health/foods/search (values per 100 g). */ +interface FoodSearchItem { + id?: number | null; + source: string; + source_id?: string | null; + name: string; + brand?: string | null; + energy_kcal_100g?: number | null; + protein_g_100g?: number | null; + carbs_g_100g?: number | null; + fat_g_100g?: number | null; + serving_size_g?: number | null; +} + +interface FoodSearchEnvelope { + query: string; + items?: FoodSearchItem[]; + origin?: string; +} + +/** + * Food catalog autocomplete (GET /health/foods/search). Values are served per + * 100 g and rescaled here to the serving size (or 100 g by default) so the + * quick-add modal receives a ready-to-log quantity. A failure must not break + * the modal, so errors resolve to an empty list. + */ +export function useFoodSearch(q: string): UseQueryResult { + const query = q.trim(); + return useQuery({ + queryKey: healthKeys.foodSearch({ q: query }), + enabled: query.length >= 2, + queryFn: () => + api(`${BASE}/foods/search?${qs({ q: query, limit: 15 })}`) + .then((res) => + (res.items ?? []).map((item) => { + const quantity = item.serving_size_g ?? 100; + const ratio = quantity / 100; + const scale = (value: number | null | undefined): number | null => + typeof value === "number" ? Math.round(value * ratio * 10) / 10 : null; + return { + id: item.id ?? item.source_id ?? item.name, + name: item.name, + brand: item.brand ?? null, + quantity, + unit: "g", + kcal: scale(item.energy_kcal_100g) ?? 0, + protein_g: scale(item.protein_g_100g), + carbs_g: scale(item.carbs_g_100g), + fat_g: scale(item.fat_g_100g), + } satisfies FoodSearchResult; + }), + ) + .catch(() => [] as FoodSearchResult[]), + staleTime: 300_000, + }); +} + +export { toItems }; diff --git a/apps/web/src/modules/health/charts.ts b/apps/web/src/modules/health/charts.ts new file mode 100644 index 0000000..3fb7b02 --- /dev/null +++ b/apps/web/src/modules/health/charts.ts @@ -0,0 +1,251 @@ +/** + * Chart building blocks shared by the health pages. + * Every rule here comes from docs/design/ux-pages.md §6 (common ECharts config) + * and §4.4 (palette slots — fixed identity, never reordered). + */ +import type { EChartsOption } from "echarts"; + +import { CHART_SURFACE, SEMANTIC_COLORS } from "../../components/charts/theme"; +import { addDaysIso, isoWeek, parseIsoDate, toIsoDate } from "../../lib/dates"; +import { formatDateShort, formatNumberAuto } from "../../lib/format"; +import { S } from "./strings"; + +/* ------------------------------------------------------------------ */ +/* Tooltip helpers */ +/* ------------------------------------------------------------------ */ + +export interface TooltipParam { + axisValue?: unknown; + axisValueLabel?: string; + seriesName?: string; + seriesIndex?: number; + dataIndex?: number; + name?: string; + value?: unknown; + data?: unknown; + percent?: number; + marker?: string; + color?: string; +} + +/** Normalizes the untyped ECharts formatter argument into a param list. */ +export function tooltipParams(raw: unknown): TooltipParam[] { + return Array.isArray(raw) ? (raw as TooltipParam[]) : [raw as TooltipParam]; +} + +/** Numeric value of a tooltip param (handles [x, y] pairs and plain numbers). */ +export function paramValue(param: TooltipParam | undefined): number | null { + if (!param) return null; + const value = param.value; + if (typeof value === "number") return value; + if (Array.isArray(value)) { + const last = value[value.length - 1]; + return typeof last === "number" ? last : null; + } + return null; +} + +/** X value of a tooltip param, as an ISO date string when possible. */ +export function paramDate(param: TooltipParam | undefined): string { + if (!param) return ""; + const axis = param.axisValue; + if (typeof axis === "string") return axis; + if (typeof axis === "number") return toIsoDate(new Date(axis)); + if (axis instanceof Date) return toIsoDate(axis); + if (Array.isArray(param.value) && typeof param.value[0] === "string") return param.value[0]; + return param.name ?? ""; +} + +const DAY_LABEL_FORMAT = new Intl.DateTimeFormat("fr-FR", { + timeZone: "Europe/Paris", + weekday: "short", + day: "2-digit", + month: "2-digit", +}); + +/** Tooltip day header: « mar. 12/08 ». */ +export function formatDayLabel(iso: string): string { + if (!iso) return ""; + const date = /^\d{4}-\d{2}-\d{2}/.test(iso) ? parseIsoDate(iso.slice(0, 10)) : new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return DAY_LABEL_FORMAT.format(date); +} + +/** One tooltip row: « ● Série valeur ». */ +export function tooltipRow(marker: string | undefined, label: string, value: string): string { + return `
${ + marker ?? "" + }${label}${value}
`; +} + +export function tooltipTitle(text: string): string { + return `
${text}
`; +} + +/* ------------------------------------------------------------------ */ +/* Common option fragments */ +/* ------------------------------------------------------------------ */ + +export const GRID_DEFAULT = { left: 8, right: 16, top: 36, bottom: 8, containLabel: true }; +export const GRID_WITH_SLIDER = { left: 8, right: 16, top: 36, bottom: 44, containLabel: true }; + +/** Wheel/pinch zoom for every time series (ux-pages §6). */ +export const ZOOM_INSIDE: EChartsOption["dataZoom"] = [{ type: "inside" }]; + +/** Main page charts add a slider (height 20). */ +export const ZOOM_MAIN: EChartsOption["dataZoom"] = [ + { type: "inside" }, + { type: "slider", height: 20, bottom: 8 }, +]; + +export const AXIS_LABEL_DATE = { + formatter: (value: string | number): string => formatDateShort(value), +}; + +/** Category X axis of local days (labels dd/MM). */ +export function dayAxis(days: string[]): NonNullable { + return { + type: "category", + data: days, + boundaryGap: true, + axisTick: { alignWithLabel: true }, + axisLabel: { ...AXIS_LABEL_DATE, hideOverlap: true }, + }; +} + +/** Dashed markLine for budgets/targets (ux-pages §6). */ +export function targetMarkLine(value: number, label: string, color = CHART_SURFACE.inkSecondary) { + return { + silent: true, + symbol: "none", + label: { + formatter: label, + position: "insideEndTop" as const, + color: CHART_SURFACE.inkSecondary, + fontSize: 11, + }, + lineStyle: { color, type: [4, 4] as number[], width: 1 }, + data: [{ yAxis: value }], + }; +} + +/** Rounded bar tops (ux-pages §6). */ +export const BAR_RADIUS_UP: [number, number, number, number] = [4, 4, 0, 0]; +export const BAR_RADIUS_DOWN: [number, number, number, number] = [0, 0, 4, 4]; + +/** 2px gap between stacked segments. */ +export const STACK_BORDER = { borderColor: CHART_SURFACE.surface, borderWidth: 1 }; + +/** Vertical gradient area fill (series color at 25% → 0%). */ +export function areaGradient(color: string): { + color: { type: "linear"; x: number; y: number; x2: number; y2: number; colorStops: { offset: number; color: string }[] }; +} { + return { + color: { + type: "linear", + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: `${color}40` }, + { offset: 1, color: `${color}00` }, + ], + }, + }; +} + +/** In-canvas empty state (ux-pages §16). */ +export function emptyChartOption(): EChartsOption { + return { + title: { + text: S.common.noDataOnPeriod, + left: "center", + top: "middle", + textStyle: { color: CHART_SURFACE.inkMuted, fontSize: 13, fontWeight: "normal" }, + }, + xAxis: { show: false, type: "category", data: [] }, + yAxis: { show: false, type: "value" }, + series: [], + }; +} + +/** Direction-aware piecewise coloring for delta bars (ux-pages §3). */ +export function directionalVisualMap( + favorableBelowZero: boolean, +): NonNullable { + const belowColor = favorableBelowZero ? SEMANTIC_COLORS.positive : SEMANTIC_COLORS.negative; + const aboveColor = favorableBelowZero ? SEMANTIC_COLORS.negative : SEMANTIC_COLORS.positive; + return [ + { + show: false, + type: "piecewise", + seriesIndex: 0, + pieces: [ + { lt: 0, color: belowColor }, + { gte: 0, color: aboveColor }, + ], + outOfRange: { color: CHART_SURFACE.inkMuted }, + }, + ]; +} + +/* ------------------------------------------------------------------ */ +/* Day / week helpers */ +/* ------------------------------------------------------------------ */ + +/** Inclusive list of local days between two ISO dates (capped for safety). */ +export function daysRange(from: string, to: string, max = 800): string[] { + const out: string[] = []; + let cursor = from; + while (cursor <= to && out.length < max) { + out.push(cursor); + cursor = addDaysIso(cursor, 1); + } + return out; +} + +export interface WeekBucket { + key: string; + label: string; + start: string; + end: string; +} + +/** ISO week bucket (monday-based) of a local day. */ +export function weekBucket(iso: string): WeekBucket { + const date = parseIsoDate(iso); + const dow = (date.getDay() + 6) % 7; // 0 = monday + const start = addDaysIso(iso, -dow); + const end = addDaysIso(start, 6); + return { key: start, label: `Sem. ${isoWeek(date)}`, start, end }; +} + +export function weekRangeLabel(bucket: WeekBucket): string { + return `du ${formatDateShort(bucket.start)} au ${formatDateShort(bucket.end)}`; +} + +/** Groups day-indexed values into ISO weeks, preserving chronological order. */ +export function groupByWeek( + days: string[], + valueOf: (day: string, index: number) => T, +): { bucket: WeekBucket; values: T[] }[] { + const out: { bucket: WeekBucket; values: T[] }[] = []; + const index = new Map(); + days.forEach((day, i) => { + const bucket = weekBucket(day); + const at = index.get(bucket.key); + if (at === undefined) { + index.set(bucket.key, out.length); + out.push({ bucket, values: [valueOf(day, i)] }); + } else { + out[at].values.push(valueOf(day, i)); + } + }); + return out; +} + +/** kcal formatting used inside tooltips (no unit duplication in headers). */ +export function kcalValue(value: number | null): string { + return value === null ? S.common.none : `${formatNumberAuto(Math.round(value))} kcal`; +} diff --git a/apps/web/src/modules/health/components/AdherenceCalendar.tsx b/apps/web/src/modules/health/components/AdherenceCalendar.tsx new file mode 100644 index 0000000..3def88e --- /dev/null +++ b/apps/web/src/modules/health/components/AdherenceCalendar.tsx @@ -0,0 +1,141 @@ +import type { EChartsOption } from "echarts"; +import { useMemo } from "react"; + +import { ChartCard } from "../../../components/charts/ChartCard"; +import { CHART_SURFACE, SEMANTIC_COLORS } from "../../../components/charts/theme"; +import { todayIso } from "../../../lib/dates"; +import { formatDate } from "../../../lib/format"; +import type { AdherenceDay } from "../api"; +import { emptyChartOption, tooltipParams, tooltipTitle, weekBucket } from "../charts"; +import { S } from "../strings"; +import { weekdayIndex } from "../form"; + +type DayState = "done" | "missed" | "planned" | "off"; + +const STATE_LABEL: Record = { + done: S.planning.legend.done, + missed: S.planning.legend.missed, + planned: S.planning.legend.planned, + off: S.planning.legend.off, +}; + +const STATE_STYLE: Record = { + done: { color: SEMANTIC_COLORS.positive, borderColor: SEMANTIC_COLORS.positive, borderWidth: 0 }, + missed: { color: "transparent", borderColor: SEMANTIC_COLORS.negative, borderWidth: 1.5 }, + planned: { color: "transparent", borderColor: CHART_SURFACE.inkMuted, borderWidth: 1.5 }, + off: { color: CHART_SURFACE.surface2, borderColor: CHART_SURFACE.surface2, borderWidth: 0 }, +}; + +function stateOf(day: AdherenceDay, today: string): DayState { + if (day.done) return "done"; + if (!day.planned) return "off"; + if (day.missed ?? day.date < today) return "missed"; + return "planned"; +} + +export interface AdherenceCalendarProps { + title: string; + subtitle?: string; + days: AdherenceDay[]; + loading?: boolean; +} + +/** + * Calendar heatmap of a habit (addendum-planning « UX »): done = green, + * planned but missed = outlined. Weeks on X (monday-based), weekdays on Y. + */ +export function AdherenceCalendar({ title, subtitle, days, loading }: AdherenceCalendarProps) { + const option = useMemo(() => { + if (days.length === 0) return emptyChartOption(); + + const today = todayIso(); + const weekKeys: string[] = []; + const weekLabels: string[] = []; + for (const day of days) { + const bucket = weekBucket(day.date); + if (!weekKeys.includes(bucket.key)) { + weekKeys.push(bucket.key); + weekLabels.push(bucket.label); + } + } + + const data = days.map((day) => { + const state = stateOf(day, today); + const x = weekKeys.indexOf(weekBucket(day.date).key); + const y = 6 - weekdayIndex(day.date); + return { + value: [x, y, day.date, STATE_LABEL[state]], + itemStyle: STATE_STYLE[state], + }; + }); + + return { + grid: { left: 8, right: 16, top: 24, bottom: 8, containLabel: true }, + tooltip: { + trigger: "item", + formatter: (raw: unknown) => { + const [param] = tooltipParams(raw); + const value = Array.isArray(param?.value) ? (param.value as unknown[]) : []; + const date = typeof value[2] === "string" ? value[2] : ""; + const label = typeof value[3] === "string" ? value[3] : ""; + return `${tooltipTitle(formatDate(date))}${label}`; + }, + }, + xAxis: { + type: "category", + data: weekLabels, + splitLine: { show: false }, + axisTick: { show: false }, + axisLine: { show: false }, + axisLabel: { interval: "auto", hideOverlap: true }, + }, + yAxis: { + type: "category", + data: [...S.planning.weekdaysAbbr].reverse(), + splitLine: { show: false }, + axisTick: { show: false }, + axisLine: { show: false }, + }, + series: [ + { + type: "scatter", + name: title, + symbol: "roundRect", + symbolSize: 16, + data, + }, + ], + }; + }, [days, title]); + + return ( + +
  • + + {S.planning.legend.done} +
  • +
  • + + {S.planning.legend.missed} +
  • + + } + /> + ); +} diff --git a/apps/web/src/modules/health/components/FoodModal.tsx b/apps/web/src/modules/health/components/FoodModal.tsx new file mode 100644 index 0000000..b3d8f40 --- /dev/null +++ b/apps/web/src/modules/health/components/FoodModal.tsx @@ -0,0 +1,485 @@ +import clsx from "clsx"; +import { Search, Star, History, BookOpen } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { Select } from "../../../components/ui/Select"; +import { formatKcal } from "../../../lib/format"; +import { + MEAL_ORDER, + useCreateFavorite, + useCreateFoodEntry, + useFavorites, + useFoodSearch, + useRecentFoods, + useUpdateFoodEntry, +} from "../api"; +import type { FoodEntryRead, FoodEntryWrite, MealType } from "../api"; +import { defaultMealForNow, localDayOf, localTimeOf, nowTimeLocal, parseDecimal, toInputValue, toInstant } from "../form"; +import { S } from "../strings"; +import { ErrorNotice } from "./QueryBoundary"; + +type Origin = "favorite" | "recent" | "catalog"; + +interface Suggestion { + key: string; + name: string; + brand?: string | null; + quantity: number; + unit: string; + kcal: number; + protein_g?: number | null; + carbs_g?: number | null; + fat_g?: number | null; + meal?: MealType | null; + origin: Origin; +} + +const ORIGIN_META: Record = { + favorite: { label: S.nutrition.modal.favorites, icon: Star }, + recent: { label: S.nutrition.modal.recents, icon: History }, + catalog: { label: S.nutrition.modal.catalog, icon: BookOpen }, +}; + +const UNITS = ["g", "ml", "portion", "piece"] as const; + +export interface FoodModalProps { + open: boolean; + onClose: () => void; + /** Day shown in the journal — default date of a new entry. */ + day: string; + /** Pre-selected meal when opened from a meal group header. */ + meal?: MealType | null; + entry?: FoodEntryRead | null; + /** Prefill for a duplication (creation mode, values copied from an entry). */ + template?: FoodEntryRead | null; + onSaved?: (message: string) => void; +} + +/** Quick-add / edit food modal with autocomplete (ux-pages §9.5). */ +export function FoodModal({ open, onClose, day, meal, entry, template, onSaved }: FoodModalProps) { + const create = useCreateFoodEntry(); + const update = useUpdateFoodEntry(); + const createFavorite = useCreateFavorite(); + + const [query, setQuery] = useState(""); + const [name, setName] = useState(""); + const [brand, setBrand] = useState(null); + const [mealValue, setMealValue] = useState(meal ?? defaultMealForNow()); + const [date, setDate] = useState(day); + const [quantity, setQuantity] = useState("100"); + const [unit, setUnit] = useState("g"); + const [kcal, setKcal] = useState(""); + const [protein, setProtein] = useState(""); + const [carbs, setCarbs] = useState(""); + const [fat, setFat] = useState(""); + const [remember, setRemember] = useState(true); + const [error, setError] = useState(null); + /** Reference values of the selected library food, for proration. */ + const [base, setBase] = useState(null); + + const favorites = useFavorites(); + const recents = useRecentFoods(); + const catalog = useFoodSearch(query); + + const reset = (keepMeal: MealType) => { + setQuery(""); + setName(""); + setBrand(null); + setMealValue(keepMeal); + setQuantity("100"); + setUnit("g"); + setKcal(""); + setProtein(""); + setCarbs(""); + setFat(""); + setBase(null); + setError(null); + setRemember(true); + }; + + useEffect(() => { + if (!open) return; + if (entry) { + setQuery(""); + setName(entry.name); + setBrand(entry.brand ?? null); + setMealValue(entry.meal); + setDate(localDayOf(entry.eaten_at)); + setQuantity(toInputValue(Number(entry.quantity))); + setUnit(entry.unit); + setKcal(toInputValue(Number(entry.kcal))); + setProtein(toInputValue(entry.protein_g === null ? null : Number(entry.protein_g))); + setCarbs(toInputValue(entry.carbs_g === null ? null : Number(entry.carbs_g))); + setFat(toInputValue(entry.fat_g === null ? null : Number(entry.fat_g))); + setBase(null); + setRemember(false); + setError(null); + } else if (template) { + reset(template.meal); + setName(template.name); + setBrand(template.brand ?? null); + setQuantity(toInputValue(Number(template.quantity))); + setUnit(template.unit); + setKcal(toInputValue(Number(template.kcal))); + setProtein(toInputValue(template.protein_g === null ? null : Number(template.protein_g), 1)); + setCarbs(toInputValue(template.carbs_g === null ? null : Number(template.carbs_g), 1)); + setFat(toInputValue(template.fat_g === null ? null : Number(template.fat_g), 1)); + setRemember(false); + setDate(day); + } else { + reset(meal ?? defaultMealForNow()); + setDate(day); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, entry, template, day, meal]); + + const suggestions = useMemo(() => { + const q = query.trim().toLowerCase(); + if (q.length === 0) return []; + const matches = (label: string) => label.toLowerCase().includes(q); + + const fromFavorites: Suggestion[] = (favorites.data ?? []) + .filter((f) => matches(f.name)) + .slice(0, 6) + .map((f) => ({ + key: `fav-${f.id}`, + name: f.name, + brand: f.brand, + quantity: Number(f.default_quantity), + unit: f.unit, + kcal: Number(f.kcal), + protein_g: f.protein_g === null ? null : Number(f.protein_g), + carbs_g: f.carbs_g === null ? null : Number(f.carbs_g), + fat_g: f.fat_g === null ? null : Number(f.fat_g), + meal: f.default_meal ?? null, + origin: "favorite", + })); + + const seen = new Set(fromFavorites.map((s) => s.name.toLowerCase())); + const fromRecents: Suggestion[] = (recents.data ?? []) + .filter((r) => matches(r.name) && !seen.has(r.name.toLowerCase())) + .slice(0, 6) + .map((r) => ({ + key: `recent-${r.id}`, + name: r.name, + brand: r.brand, + quantity: Number(r.quantity), + unit: r.unit, + kcal: Number(r.kcal), + protein_g: r.protein_g === null ? null : Number(r.protein_g), + carbs_g: r.carbs_g === null ? null : Number(r.carbs_g), + fat_g: r.fat_g === null ? null : Number(r.fat_g), + meal: r.meal, + origin: "recent", + })); + + for (const s of fromRecents) seen.add(s.name.toLowerCase()); + + const fromCatalog: Suggestion[] = (catalog.data ?? []) + .filter((c) => !seen.has(c.name.toLowerCase())) + .slice(0, 8) + .map((c, index) => ({ + key: `catalog-${c.id ?? index}`, + name: c.name, + brand: c.brand, + quantity: c.quantity === null || c.quantity === undefined ? 100 : Number(c.quantity), + unit: c.unit ?? "g", + kcal: Number(c.kcal), + protein_g: c.protein_g === null ? null : Number(c.protein_g), + carbs_g: c.carbs_g === null ? null : Number(c.carbs_g), + fat_g: c.fat_g === null ? null : Number(c.fat_g), + origin: "catalog", + })); + + return [...fromFavorites, ...fromRecents, ...fromCatalog]; + }, [query, favorites.data, recents.data, catalog.data]); + + const applySuggestion = (suggestion: Suggestion) => { + setBase(suggestion); + setName(suggestion.name); + setBrand(suggestion.brand ?? null); + setQuantity(toInputValue(suggestion.quantity)); + setUnit(suggestion.unit); + setKcal(toInputValue(Math.round(suggestion.kcal))); + setProtein(toInputValue(suggestion.protein_g ?? null, 1)); + setCarbs(toInputValue(suggestion.carbs_g ?? null, 1)); + setFat(toInputValue(suggestion.fat_g ?? null, 1)); + if (suggestion.meal) setMealValue(suggestion.meal); + setRemember(false); + setQuery(""); + }; + + /** Library foods re-prorate kcal and macros when the quantity changes. */ + const onQuantityChange = (value: string) => { + setQuantity(value); + const parsed = parseDecimal(value); + if (!base || parsed === null || base.quantity <= 0) return; + const ratio = parsed / base.quantity; + setKcal(toInputValue(Math.round(base.kcal * ratio))); + setProtein(base.protein_g == null ? "" : toInputValue(base.protein_g * ratio, 1)); + setCarbs(base.carbs_g == null ? "" : toInputValue(base.carbs_g * ratio, 1)); + setFat(base.fat_g == null ? "" : toInputValue(base.fat_g * ratio, 1)); + }; + + const buildBody = (): FoodEntryWrite | null => { + const trimmedName = name.trim(); + const parsedQuantity = parseDecimal(quantity); + const parsedKcal = parseDecimal(kcal); + if (trimmedName === "" || parsedKcal === null) { + setError(S.errors.required); + return null; + } + if (parsedQuantity === null || parsedQuantity <= 0) { + setError(S.errors.positive); + return null; + } + setError(null); + return { + eaten_at: entry ? toInstant(date, localTimeOf(entry.eaten_at)) : toInstant(date, nowTimeLocal()), + meal: mealValue, + name: trimmedName, + brand: brand, + quantity: parsedQuantity, + unit, + kcal: parsedKcal, + protein_g: parseDecimal(protein), + carbs_g: parseDecimal(carbs), + fat_g: parseDecimal(fat), + }; + }; + + const memorize = (body: FoodEntryWrite) => { + if (!remember) return; + createFavorite.mutate({ + name: body.name, + brand: body.brand ?? null, + default_quantity: body.quantity, + unit: body.unit, + kcal: body.kcal, + protein_g: body.protein_g ?? null, + carbs_g: body.carbs_g ?? null, + fat_g: body.fat_g ?? null, + default_meal: body.meal, + }); + }; + + const submit = (keepOpen: boolean) => { + const body = buildBody(); + if (!body) return; + if (entry) { + update.mutate( + { id: entry.id, body }, + { + onSuccess: () => { + onSaved?.(S.nutrition.modal.saved); + onClose(); + }, + }, + ); + return; + } + create.mutate(body, { + onSuccess: () => { + memorize(body); + onSaved?.(S.nutrition.modal.saved); + if (keepOpen) reset(mealValue); + else onClose(); + }, + }); + }; + + const pending = create.isPending || update.isPending; + const mutationError = create.error ?? update.error ?? null; + + return ( + + + {entry ? null : ( + + )} + + + } + > +
    { + event.preventDefault(); + submit(false); + }} + > + {entry ? null : ( +
    + +
    + + setQuery(event.target.value)} + placeholder={S.nutrition.modal.searchPlaceholder} + className="h-10 w-full rounded-lg border bg-surface-2 pl-9 pr-3 text-sm text-ink placeholder:text-ink-muted focus:outline-none focus:ring-2 focus:ring-accent" + /> +
    + {query.trim().length > 0 ? ( +
      + {suggestions.length === 0 ? ( +
    • + {catalog.isFetching ? S.common.loading : S.nutrition.modal.noResult} +
    • + ) : ( + suggestions.map((suggestion) => { + const Icon = ORIGIN_META[suggestion.origin].icon; + return ( +
    • + +
    • + ); + }) + )} +
    + ) : null} +
    + )} + + { + setName(event.target.value); + setBase(null); + }} + error={error ?? undefined} + /> + +
    + + setDate(event.target.value)} + /> +
    + onQuantityChange(event.target.value)} + /> + +
    +
    + +
    + setKcal(event.target.value)} + /> + setProtein(event.target.value)} + /> + setCarbs(event.target.value)} + /> + setFat(event.target.value)} + /> +
    + + {brand ? {brand} : null} + + {entry ? null : ( + + )} + + {mutationError ? : null} + +
    + ); +} diff --git a/apps/web/src/modules/health/components/HabitPlanningSection.tsx b/apps/web/src/modules/health/components/HabitPlanningSection.tsx new file mode 100644 index 0000000..5d2d138 --- /dev/null +++ b/apps/web/src/modules/health/components/HabitPlanningSection.tsx @@ -0,0 +1,117 @@ +import { Flame, Target } from "lucide-react"; + +import { StatCard } from "../../../components/ui/StatCard"; +import { daysAgoIso, todayIso } from "../../../lib/dates"; +import { formatPercent } from "../../../lib/format"; +import { streakOf, useAdherence, useSchedules, useToday } from "../api"; +import type { HabitKind } from "../api"; +import { S } from "../strings"; +import { AdherenceCalendar } from "./AdherenceCalendar"; +import { QueryBoundary } from "./QueryBoundary"; +import { SchedulePlanner } from "./SchedulePlanner"; +import { TodayHabitCard } from "./TodayHabitCard"; + +export interface HabitPlanningSectionProps { + kind: HabitKind; + /** « Pesée du jour » / « Séance du jour ». */ + todayTitle: string; + /** Calendar heatmap title. */ + calendarTitle: string; + /** French CTA label of the today card. */ + actionLabel: string; + onAction: () => void; + formatValue?: (value: number) => string; + /** French empty-plan hint. */ + emptyHint: string; + /** KPI label, ex: « Assiduité 30 j ». */ + adherenceLabel: string; +} + +const CALENDAR_WEEKS = 12; + +/** + * Planning block shared by « Poids & Objectif » and « Activité & Sport » + * (docs/design/addendum-planning.md): today card, weekly planner, adherence + * KPI + streak and the calendar heatmap. + */ +export function HabitPlanningSection({ + kind, + todayTitle, + calendarTitle, + actionLabel, + onAction, + formatValue, + emptyHint, + adherenceLabel, +}: HabitPlanningSectionProps) { + const schedules = useSchedules(); + const today = useToday(); + const adherence30 = useAdherence({ from: daysAgoIso(29), to: todayIso(), kind }); + const calendar = useAdherence({ + from: daysAgoIso(CALENDAR_WEEKS * 7 - 1), + to: todayIso(), + kind, + }); + + const item = today.data?.items.find((i) => i.kind === kind); + const meta = adherence30.data?.meta; + const pct = meta?.adherence_pct ?? null; + const currentStreak = meta?.current_streak ?? streakOf(today.data, kind); + const bestStreak = meta?.best_streak ?? null; + + return ( +
    +

    + {S.planning.title} +

    + +
    + + = 80 ? "positive" : "accent"} + sub={ + meta?.planned_days + ? `${meta.done_days ?? 0} / ${meta.planned_days} jours planifiés` + : S.planning.status.notPlanned + } + deltaIcon={Target} + /> + +
    + + + {(list) => { + const schedule = list.find((s) => s.kind === kind) ?? { + kind, + weekdays: [], + enabled: false, + }; + return ; + }} + + + +
    + ); +} diff --git a/apps/web/src/modules/health/components/MeasurementModal.tsx b/apps/web/src/modules/health/components/MeasurementModal.tsx new file mode 100644 index 0000000..63c057d --- /dev/null +++ b/apps/web/src/modules/health/components/MeasurementModal.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { useCreateMeasurement } from "../api"; +import type { MeasurementWrite } from "../api"; +import { nowTimeLocal, parseDecimal, toInstant, todayLocal } from "../form"; +import { S } from "../strings"; +import { ErrorNotice } from "./QueryBoundary"; + +export const MEASUREMENT_FIELDS: { key: keyof MeasurementWrite; label: string }[] = [ + { key: "waist_cm", label: S.weight.measurements.waist }, + { key: "hips_cm", label: S.weight.measurements.hips }, + { key: "chest_cm", label: S.weight.measurements.chest }, + { key: "neck_cm", label: S.weight.measurements.neck }, + { key: "biceps_right_cm", label: S.weight.measurements.arm }, + { key: "thigh_right_cm", label: S.weight.measurements.thigh }, + { key: "calf_right_cm", label: S.weight.measurements.calf }, +]; + +export interface MeasurementModalProps { + open: boolean; + onClose: () => void; + onSaved?: (message: string) => void; +} + +/** « Mensurations » entry modal (ux-pages §8.5 — standalone variant). */ +export function MeasurementModal({ open, onClose, onSaved }: MeasurementModalProps) { + const create = useCreateMeasurement(); + const [day, setDay] = useState(todayLocal()); + const [values, setValues] = useState>({}); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setDay(todayLocal()); + setValues({}); + setError(null); + }, [open]); + + const submit = () => { + const payload: MeasurementWrite = { measured_at: toInstant(day, nowTimeLocal()) }; + let filled = false; + for (const field of MEASUREMENT_FIELDS) { + const parsed = parseDecimal(values[field.key as string] ?? ""); + if (parsed !== null) { + (payload as Record)[field.key as string] = parsed; + filled = true; + } + } + if (!filled) { + setError(S.errors.required); + return; + } + setError(null); + create.mutate(payload, { + onSuccess: () => { + onSaved?.(S.weight.measurements.title); + onClose(); + }, + }); + }; + + return ( + + + + + } + > +
    { + event.preventDefault(); + submit(); + }} + > + setDay(event.target.value)} + /> +
    + {MEASUREMENT_FIELDS.map((field) => ( + + setValues((prev) => ({ ...prev, [field.key as string]: event.target.value })) + } + /> + ))} +
    + {error ?

    {error}

    : null} + {create.isError ? : null} + +
    + ); +} diff --git a/apps/web/src/modules/health/components/QueryBoundary.tsx b/apps/web/src/modules/health/components/QueryBoundary.tsx new file mode 100644 index 0000000..cda2bec --- /dev/null +++ b/apps/web/src/modules/health/components/QueryBoundary.tsx @@ -0,0 +1,60 @@ +import { AlertTriangle } from "lucide-react"; +import type { ReactNode } from "react"; + +import { ApiError } from "../../../lib/api"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { S } from "../strings"; + +/** French message of any thrown error — ApiError.message is already French. */ +export function errorMessage(error: unknown): string { + if (error instanceof ApiError) return error.message; + if (error instanceof Error && error.message) return error.message; + return "Une erreur est survenue."; +} + +export interface ErrorNoticeProps { + error: unknown; + className?: string; +} + +/** Inline error block (ux-pages §5.4 tone, message from ApiError). */ +export function ErrorNotice({ error, className }: ErrorNoticeProps) { + return ( +
    + +
    +

    {S.common.errorTitle}

    +

    {errorMessage(error)}

    +
    +
    + ); +} + +interface QueryLike { + data: T | undefined; + isPending: boolean; + isError: boolean; + error: unknown; +} + +export interface QueryBoundaryProps { + query: QueryLike; + children: (data: T) => ReactNode; + /** Rendered instead of the spinner while loading (skeleton alternative). */ + fallback?: ReactNode; + className?: string; +} + +/** Loading / error / success switch used by every health page section. */ +export function QueryBoundary({ query, children, fallback, className }: QueryBoundaryProps) { + if (query.isPending) return <>{fallback ?? }; + if (query.isError || query.data === undefined) { + return ; + } + return <>{children(query.data)}; +} diff --git a/apps/web/src/modules/health/components/SchedulePlanner.tsx b/apps/web/src/modules/health/components/SchedulePlanner.tsx new file mode 100644 index 0000000..5877d8f --- /dev/null +++ b/apps/web/src/modules/health/components/SchedulePlanner.tsx @@ -0,0 +1,114 @@ +import clsx from "clsx"; +import { CalendarCheck } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { useSaveSchedule } from "../api"; +import type { HabitKind, ScheduleRead } from "../api"; +import { S } from "../strings"; +import { ErrorNotice } from "./QueryBoundary"; + +export interface SchedulePlannerProps { + kind: HabitKind; + schedule: ScheduleRead; + /** French empty-plan hint (addendum-planning « Empty states »). */ + emptyHint: string; +} + +/** + * Weekly habit planner (addendum-planning « UX ») — L M M J V S D checkboxes + * plus an enable switch, saved with PUT /api/health/schedules/{kind}. + */ +export function SchedulePlanner({ kind, schedule, emptyHint }: SchedulePlannerProps) { + const [weekdays, setWeekdays] = useState(schedule.weekdays); + const [enabled, setEnabled] = useState(schedule.enabled); + const [saved, setSaved] = useState(false); + const save = useSaveSchedule(); + + useEffect(() => { + setWeekdays(schedule.weekdays); + setEnabled(schedule.enabled); + }, [schedule]); + + const dirty = + enabled !== schedule.enabled || + weekdays.length !== schedule.weekdays.length || + weekdays.some((d) => !schedule.weekdays.includes(d)); + + const toggleDay = (day: number) => { + setSaved(false); + setWeekdays((prev) => + prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day].sort((a, b) => a - b), + ); + }; + + const submit = () => { + save.mutate( + { kind, weekdays, enabled }, + { onSuccess: () => setSaved(true) }, + ); + }; + + return ( + + { + setSaved(false); + setEnabled(event.target.checked); + }} + className="h-4 w-4 rounded border-border bg-surface-2 accent-accent" + /> + {S.planning.enabled} + + } + > +
    + {S.planning.weekdays.map((label, index) => { + const active = weekdays.includes(index); + return ( + + ); + })} +
    + {saved && !dirty ? ( + + + {S.planning.saved} + + ) : null} + +
    +
    + + {weekdays.length === 0 ? ( +

    {emptyHint}

    + ) : null} + + {save.isError ? : null} +
    + ); +} diff --git a/apps/web/src/modules/health/components/Toast.tsx b/apps/web/src/modules/health/components/Toast.tsx new file mode 100644 index 0000000..f6e2855 --- /dev/null +++ b/apps/web/src/modules/health/components/Toast.tsx @@ -0,0 +1,35 @@ +import { Check } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import type { ReactNode } from "react"; + +/** + * Minimal success toast (ux-pages §5.7): bottom-right on desktop, top on + * mobile, auto-dismissed after 4 s. + */ +export function useToast(): { showToast: (message: string) => void; toast: ReactNode } { + const [message, setMessage] = useState(null); + + useEffect(() => { + if (message === null) return; + const timer = window.setTimeout(() => setMessage(null), 4000); + return () => window.clearTimeout(timer); + }, [message]); + + const showToast = useCallback((next: string) => setMessage(next), []); + + const toast = + message === null ? null : ( +
    +
    + + {message} +
    +
    + ); + + return { showToast, toast }; +} diff --git a/apps/web/src/modules/health/components/TodayHabitCard.tsx b/apps/web/src/modules/health/components/TodayHabitCard.tsx new file mode 100644 index 0000000..797c753 --- /dev/null +++ b/apps/web/src/modules/health/components/TodayHabitCard.tsx @@ -0,0 +1,82 @@ +import { CalendarDays, Check, Flame } from "lucide-react"; + +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import type { TodayItem } from "../api"; +import { S } from "../strings"; + +export interface TodayHabitCardProps { + /** Card title, ex: « Pesée du jour ». */ + title: string; + item: TodayItem | undefined; + /** Current streak in days (planned days only). */ + streak: number; + /** French label of the CTA, ex: « Noter ma pesée ». */ + actionLabel: string; + onAction: () => void; + /** Formats the recorded value of the day (kg, sessions, kcal…). */ + formatValue?: (value: number) => string; +} + +/** + * « Pesée du jour » / « Séance du jour » card (addendum-planning « UX ») — + * planned / done / missed state derived from the existing data, plus the CTA + * opening the quick-add modal. + */ +export function TodayHabitCard({ + title, + item, + streak, + actionLabel, + onAction, + formatValue, +}: TodayHabitCardProps) { + const planned = item?.planned ?? false; + const done = item?.done ?? false; + const value = item?.value ?? null; + + const status = done + ? { tone: "positive" as const, label: S.planning.status.done } + : planned + ? { tone: "warning" as const, label: S.planning.status.planned } + : { tone: "muted" as const, label: S.planning.status.rest }; + + return ( + + + {title} + + } + actions={{status.label}} + > +
    +
    + {done ? ( +

    + + {value !== null && formatValue ? formatValue(value) : S.planning.status.done} +

    + ) : ( +

    + {planned ? S.planning.status.planned : S.planning.nothingToday} +

    + )} + {streak > 0 ? ( +

    + + {S.planning.streak(streak)} +

    + ) : null} +
    + {!done ? ( + + ) : null} +
    +
    + ); +} diff --git a/apps/web/src/modules/health/components/WeightModal.tsx b/apps/web/src/modules/health/components/WeightModal.tsx new file mode 100644 index 0000000..53bf539 --- /dev/null +++ b/apps/web/src/modules/health/components/WeightModal.tsx @@ -0,0 +1,249 @@ +import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { formatDate, formatWeight } from "../../../lib/format"; +import { useCreateMeasurement, useCreateWeight, useUpdateWeight } from "../api"; +import type { MeasurementWrite, WeightRead } from "../api"; +import { localDayOf, localTimeOf, nowTimeLocal, parseDecimal, toInputValue, toInstant, todayLocal } from "../form"; +import { S } from "../strings"; +import { ErrorNotice } from "./QueryBoundary"; + +const MIN_KG = 20; +const MAX_KG = 300; + +export interface WeightModalProps { + open: boolean; + onClose: () => void; + /** Entry being edited (creation mode when omitted). */ + entry?: WeightRead | null; + /** Existing entries — used for the prefill and the same-day warning. */ + entries: WeightRead[]; + onSaved?: (message: string) => void; +} + +type SameDayChoice = "replace" | "add"; + +/** Quick-add / edit weight modal (ux-pages §8.5). */ +export function WeightModal({ open, onClose, entry, entries, onSaved }: WeightModalProps) { + const create = useCreateWeight(); + const update = useUpdateWeight(); + const createMeasurement = useCreateMeasurement(); + + const lastWeight = entries.length > 0 ? entries[0].weight_kg : null; + + const [weight, setWeight] = useState(""); + const [day, setDay] = useState(todayLocal()); + const [time, setTime] = useState(nowTimeLocal()); + const [note, setNote] = useState(""); + const [error, setError] = useState(null); + const [showMeasurements, setShowMeasurements] = useState(false); + const [choice, setChoice] = useState("replace"); + const [measurements, setMeasurements] = useState>({}); + + useEffect(() => { + if (!open) return; + setError(null); + setChoice("replace"); + setMeasurements({}); + setShowMeasurements(false); + if (entry) { + setWeight(toInputValue(entry.weight_kg, 1)); + setDay(localDayOf(entry.measured_at)); + setTime(localTimeOf(entry.measured_at)); + setNote(entry.note ?? ""); + } else { + setWeight(lastWeight === null ? "" : toInputValue(lastWeight, 1)); + setDay(todayLocal()); + setTime(nowTimeLocal()); + setNote(""); + } + }, [open, entry, lastWeight]); + + /** Another entry already exists on the selected local day (ux-pages §8.5). */ + const sameDay = useMemo( + () => entries.find((e) => localDayOf(e.measured_at) === day && e.id !== entry?.id) ?? null, + [entries, day, entry?.id], + ); + + const measurementFields: { key: keyof MeasurementWrite; label: string }[] = [ + { key: "waist_cm", label: S.weight.measurements.waist }, + { key: "hips_cm", label: S.weight.measurements.hips }, + { key: "chest_cm", label: S.weight.measurements.chest }, + { key: "biceps_right_cm", label: S.weight.measurements.arm }, + { key: "thigh_right_cm", label: S.weight.measurements.thigh }, + ]; + + const pending = create.isPending || update.isPending || createMeasurement.isPending; + + const submit = () => { + const kg = parseDecimal(weight); + if (kg === null) { + setError(S.errors.required); + return; + } + if (kg < MIN_KG || kg > MAX_KG) { + setError(S.errors.weightRange); + return; + } + setError(null); + + const body = { + measured_at: toInstant(day, time), + weight_kg: kg, + note: note.trim() === "" ? null : note.trim(), + }; + + const saveMeasurements = () => { + const payload: MeasurementWrite = { measured_at: toInstant(day, time) }; + let filled = false; + for (const field of measurementFields) { + const parsed = parseDecimal(measurements[field.key as string] ?? ""); + if (parsed !== null) { + (payload as Record)[field.key as string] = parsed; + filled = true; + } + } + if (filled) createMeasurement.mutate(payload); + }; + + const done = () => { + saveMeasurements(); + onSaved?.(S.weight.modal.saved); + onClose(); + }; + + if (entry) { + update.mutate({ id: entry.id, body }, { onSuccess: done }); + return; + } + if (sameDay && choice === "replace") { + update.mutate({ id: sameDay.id, body }, { onSuccess: done }); + return; + } + create.mutate(body, { onSuccess: done }); + }; + + const mutationError = create.error ?? update.error ?? null; + + return ( + + + + + } + > +
    { + event.preventDefault(); + submit(); + }} + > + setWeight(event.target.value)} + inputMode="decimal" + autoFocus + error={error ?? undefined} + hint={`${MIN_KG} – ${MAX_KG} kg`} + /> + +
    + setDay(event.target.value)} + /> + setTime(event.target.value)} + /> +
    + + setNote(event.target.value)} + placeholder={S.weight.modal.notePlaceholder} + /> + + {sameDay && !entry ? ( +
    +

    + + + {`Une pesée existe déjà le ${formatDate(sameDay.measured_at)} (${formatWeight( + sameDay.weight_kg, + )}). Enregistrer remplacera cette valeur.`} + +

    +
    + + +
    +
    + ) : null} + +
    + + {showMeasurements ? ( +
    + {measurementFields.map((field) => ( + + setMeasurements((prev) => ({ + ...prev, + [field.key as string]: event.target.value, + })) + } + /> + ))} +
    + ) : null} +
    + + {mutationError ? : null} + +
    + ); +} diff --git a/apps/web/src/modules/health/components/WorkoutModal.tsx b/apps/web/src/modules/health/components/WorkoutModal.tsx new file mode 100644 index 0000000..c9bd4fa --- /dev/null +++ b/apps/web/src/modules/health/components/WorkoutModal.tsx @@ -0,0 +1,206 @@ +import { useEffect, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { Select } from "../../../components/ui/Select"; +import { useCreateWorkout, useUpdateWorkout } from "../api"; +import type { SportType, WorkoutRead } from "../api"; +import { + localDayOf, + localTimeOf, + nowTimeLocal, + parseDecimal, + parseDurationInput, + toDurationInput, + toInputValue, + toInstant, + todayLocal, + workoutMinutes, +} from "../form"; +import { S } from "../strings"; +import { ErrorNotice } from "./QueryBoundary"; + +const SPORT_TYPES: SportType[] = [ + "treadmill_walk", + "treadmill_run", + "walking", + "running", + "cycling", + "strength", + "swimming", + "hiit", + "yoga", + "hiking", + "other", +]; + +export interface WorkoutModalProps { + open: boolean; + onClose: () => void; + workout?: WorkoutRead | null; + onSaved?: (message: string) => void; +} + +/** « Ajouter une séance » modal (ux-pages §10.5). */ +export function WorkoutModal({ open, onClose, workout, onSaved }: WorkoutModalProps) { + const create = useCreateWorkout(); + const update = useUpdateWorkout(); + + const [sportType, setSportType] = useState("treadmill_walk"); + const [day, setDay] = useState(todayLocal()); + const [start, setStart] = useState(nowTimeLocal()); + const [duration, setDuration] = useState("00:30"); + const [distance, setDistance] = useState(""); + const [kcal, setKcal] = useState(""); + const [avgHr, setAvgHr] = useState(""); + const [note, setNote] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setError(null); + if (workout) { + setSportType(workout.sport_type); + setDay(localDayOf(workout.started_at)); + setStart(localTimeOf(workout.started_at)); + setDuration(toDurationInput(workoutMinutes(workout.started_at, workout.ended_at))); + setDistance( + workout.distance_m === null || workout.distance_m === undefined + ? "" + : toInputValue(Number(workout.distance_m) / 1000, 2), + ); + setKcal(workout.kcal === null || workout.kcal === undefined ? "" : toInputValue(Number(workout.kcal))); + setAvgHr(workout.avg_hr === null || workout.avg_hr === undefined ? "" : String(workout.avg_hr)); + setNote(workout.note ?? ""); + } else { + setSportType("treadmill_walk"); + setDay(todayLocal()); + setStart(nowTimeLocal()); + setDuration("00:30"); + setDistance(""); + setKcal(""); + setAvgHr(""); + setNote(""); + } + }, [open, workout]); + + const submit = () => { + const minutes = parseDurationInput(duration); + if (minutes === null || minutes <= 0) { + setError(S.errors.invalidDuration); + return; + } + setError(null); + const startedAt = toInstant(day, start); + const endedAt = new Date(new Date(startedAt).getTime() + minutes * 60000).toISOString(); + const km = parseDecimal(distance); + const body = { + started_at: startedAt, + ended_at: endedAt, + sport_type: sportType, + kcal: parseDecimal(kcal), + distance_m: km === null ? null : Math.round(km * 1000), + avg_hr: parseDecimal(avgHr) === null ? null : Math.round(parseDecimal(avgHr) as number), + note: note.trim() === "" ? null : note.trim(), + }; + const done = () => { + onSaved?.(S.activity.modal.saved); + onClose(); + }; + if (workout) update.mutate({ id: workout.id, body }, { onSuccess: done }); + else create.mutate(body, { onSuccess: done }); + }; + + const mutationError = create.error ?? update.error ?? null; + + return ( + + + + + } + > +
    { + event.preventDefault(); + submit(); + }} + > + + +
    + setDay(event.target.value)} + /> + setStart(event.target.value)} + /> + setDuration(event.target.value)} + error={error ?? undefined} + placeholder="00:45" + /> +
    + +
    + setDistance(event.target.value)} + /> + setKcal(event.target.value)} + placeholder={S.activity.modal.kcalPlaceholder} + /> + setAvgHr(event.target.value)} + /> +
    + + setNote(event.target.value)} + /> + + {mutationError ? : null} + +
    + ); +} diff --git a/apps/web/src/modules/health/form.ts b/apps/web/src/modules/health/form.ts new file mode 100644 index 0000000..6f37d1e --- /dev/null +++ b/apps/web/src/modules/health/form.ts @@ -0,0 +1,113 @@ +/** + * Form helpers: French decimal input (comma OR dot), local day ↔ UTC instant + * conversions (storage is UTC, local days are Europe/Paris — CONVENTIONS C6). + */ +import { TZ } from "./api"; +import type { MealType } from "./api"; + +const ISO_DAY_FORMAT = new Intl.DateTimeFormat("en-CA", { + timeZone: TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +const TIME_FORMAT = new Intl.DateTimeFormat("en-GB", { + timeZone: TZ, + hour: "2-digit", + minute: "2-digit", + hour12: false, +}); + +/** Local day (YYYY-MM-DD, Europe/Paris) of a UTC instant. */ +export function localDayOf(instant: string | Date): string { + const date = instant instanceof Date ? instant : new Date(instant); + return Number.isNaN(date.getTime()) ? "" : ISO_DAY_FORMAT.format(date); +} + +/** Local time (HH:mm, Europe/Paris) of a UTC instant. */ +export function localTimeOf(instant: string | Date): string { + const date = instant instanceof Date ? instant : new Date(instant); + return Number.isNaN(date.getTime()) ? "" : TIME_FORMAT.format(date); +} + +/** Today as a local day string. */ +export function todayLocal(): string { + return ISO_DAY_FORMAT.format(new Date()); +} + +/** Current local time HH:mm. */ +export function nowTimeLocal(): string { + return TIME_FORMAT.format(new Date()); +} + +/** + * Builds the UTC instant sent to the API from a local day + time. + * The browser runs in the user's timezone, so `new Date("YYYY-MM-DDTHH:mm")` + * already resolves the local offset. + */ +export function toInstant(day: string, time?: string | null): string { + const safeTime = time && /^\d{2}:\d{2}$/.test(time) ? time : "12:00"; + const date = new Date(`${day}T${safeTime}`); + return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); +} + +/** Parses a French decimal input: « 82,4 » and « 82.4 » both work. */ +export function parseDecimal(value: string): number | null { + const normalized = value.trim().replace(/\s| | /g, "").replace(",", "."); + if (normalized === "") return null; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +} + +/** Serializes a number back into an input value (comma decimals). */ +export function toInputValue(value: number | null | undefined, decimals?: number): string { + if (value === null || value === undefined) return ""; + const fixed = decimals === undefined ? String(value) : value.toFixed(decimals); + return fixed.replace(".", ","); +} + +/** hh:mm → minutes. */ +export function parseDurationInput(value: string): number | null { + const trimmed = value.trim(); + const match = /^(\d{1,3})\s*[:hH]\s*(\d{1,2})$/.exec(trimmed); + if (match) { + const hours = Number(match[1]); + const minutes = Number(match[2]); + if (minutes > 59) return null; + return hours * 60 + minutes; + } + const asNumber = parseDecimal(trimmed); + return asNumber !== null && asNumber > 0 ? Math.round(asNumber) : null; +} + +/** minutes → hh:mm input value. */ +export function toDurationInput(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = Math.round(minutes % 60); + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +} + +/** Duration of a workout in minutes. */ +export function workoutMinutes(startedAt: string, endedAt: string): number { + const start = new Date(startedAt).getTime(); + const end = new Date(endedAt).getTime(); + if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return 0; + return Math.round((end - start) / 60000); +} + +/** Default meal from the current hour (ux-pages §9.5). */ +export function defaultMealForNow(): MealType { + const hour = new Date().getHours(); + if (hour < 11) return "breakfast"; + if (hour < 15) return "lunch"; + if (hour < 18) return "snack"; + return "dinner"; +} + +/** Weekday index of a local day, monday = 0 (planning convention). */ +export function weekdayIndex(day: string): number { + const [y, m, d] = day.split("-").map(Number); + const date = new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1); + return (date.getDay() + 6) % 7; +} diff --git a/apps/web/src/modules/health/index.ts b/apps/web/src/modules/health/index.ts new file mode 100644 index 0000000..4bfccb5 --- /dev/null +++ b/apps/web/src/modules/health/index.ts @@ -0,0 +1,32 @@ +import { Activity, Flame, Scale, Utensils } from "lucide-react"; +import { createElement, lazy } from "react"; + +import type { ModuleManifest } from "../../types/module"; +import { S } from "./strings"; + +// Pages are lazy-loaded for code splitting (CONVENTIONS C5.7); the AppLayout +// provides the boundary. +const WeightPage = lazy(() => import("./pages/WeightPage")); +const NutritionPage = lazy(() => import("./pages/NutritionPage")); +const ActivityPage = lazy(() => import("./pages/ActivityPage")); +const EnergyBalancePage = lazy(() => import("./pages/EnergyBalancePage")); + +const manifest: ModuleManifest = { + id: "health", + title: S.module.title, + order: 10, + routes: [ + { path: "/sante/poids", element: createElement(WeightPage) }, + { path: "/sante/nutrition", element: createElement(NutritionPage) }, + { path: "/sante/activite", element: createElement(ActivityPage) }, + { path: "/sante/balance", element: createElement(EnergyBalancePage) }, + ], + nav: [ + { path: "/sante/poids", label: S.nav.weight, icon: Scale, order: 1 }, + { path: "/sante/nutrition", label: S.nav.nutrition, icon: Utensils, order: 2 }, + { path: "/sante/activite", label: S.nav.activity, icon: Activity, order: 3 }, + { path: "/sante/balance", label: S.nav.energy, icon: Flame, order: 4 }, + ], +}; + +export default manifest; diff --git a/apps/web/src/modules/health/pages/ActivityPage.tsx b/apps/web/src/modules/health/pages/ActivityPage.tsx new file mode 100644 index 0000000..5ce99fc --- /dev/null +++ b/apps/web/src/modules/health/pages/ActivityPage.tsx @@ -0,0 +1,879 @@ +import type { EChartsOption } from "echarts"; +import { Activity, Pencil, Plus, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; + +import { ChartCard } from "../../../components/charts/ChartCard"; +import { CHART_COLORS, CHART_SURFACE } from "../../../components/charts/theme"; +import { PeriodSelector } from "../../../components/PeriodSelector"; +import type { PeriodRange } from "../../../components/PeriodSelector"; +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { ConfirmDialog } from "../../../components/ui/ConfirmDialog"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { PageHeader } from "../../../components/ui/PageHeader"; +import { Select } from "../../../components/ui/Select"; +import { StatCard } from "../../../components/ui/StatCard"; +import { Table } from "../../../components/ui/Table"; +import type { TableColumn } from "../../../components/ui/Table"; +import { addDaysIso, daysAgoIso, todayIso } from "../../../lib/dates"; +import { + formatDate, + formatDistance, + formatDuration, + formatKcal, + formatKm, + formatNumber, + formatSigned, + formatSteps, +} from "../../../lib/format"; +import { + seriesPoints, + useActivityDays, + useActivityStats, + useDeleteWorkout, + useWorkoutStats, + useWorkouts, +} from "../api"; +import type { SeriesPoint, SportType, WorkoutRead } from "../api"; +import { + BAR_RADIUS_UP, + GRID_DEFAULT, + GRID_WITH_SLIDER, + STACK_BORDER, + ZOOM_INSIDE, + ZOOM_MAIN, + areaGradient, + dayAxis, + emptyChartOption, + formatDayLabel, + groupByWeek, + tooltipParams, + tooltipRow, + tooltipTitle, + targetMarkLine, + weekBucket, +} from "../charts"; +import { HabitPlanningSection } from "../components/HabitPlanningSection"; +import { ErrorNotice } from "../components/QueryBoundary"; +import { useToast } from "../components/Toast"; +import { WorkoutModal } from "../components/WorkoutModal"; +import { localDayOf, workoutMinutes } from "../form"; +import { S, sourceLabel, sportLabel } from "../strings"; + +const DEFAULT_STEPS_GOAL = 10_000; + +/** Workout buckets of « Entraînement par semaine » (ux-pages §10.2 G3). */ +const WORKOUT_BUCKETS: { label: string; color: string; types: SportType[] }[] = [ + { label: S.sportTypes.treadmill_run, color: CHART_COLORS[0], types: ["treadmill_walk", "treadmill_run"] }, + { label: S.sportTypes.walking, color: CHART_COLORS[1], types: ["walking", "hiking"] }, + { label: S.sportTypes.cycling, color: CHART_COLORS[2], types: ["cycling"] }, + { label: S.sportTypes.strength, color: CHART_COLORS[3], types: ["strength", "hiit"] }, + { label: S.sportTypes.other, color: CHART_SURFACE.inkMuted, types: ["running", "swimming", "yoga", "other"] }, +]; + +function bucketOf(type: SportType): number { + const index = WORKOUT_BUCKETS.findIndex((bucket) => bucket.types.includes(type)); + return index === -1 ? WORKOUT_BUCKETS.length - 1 : index; +} + +function valued(points: SeriesPoint[]): [string, number][] { + return points.filter((p): p is [string, number] => typeof p[1] === "number"); +} + +const PAGE_SIZE = 20; + +export default function ActivityPage() { + const [range, setRange] = useState({ + key: "30j", + from: daysAgoIso(29), + to: todayIso(), + label: "30 j", + }); + const params = { from: range.from, to: range.to }; + + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [toDelete, setToDelete] = useState(null); + const [typeFilter, setTypeFilter] = useState(""); + const [sourceFilter, setSourceFilter] = useState(""); + const [highlightDay, setHighlightDay] = useState(null); + const [page, setPage] = useState(1); + const { showToast, toast } = useToast(); + + const stats = useActivityStats(params); + const activityDays = useActivityDays(params); + const workouts = useWorkouts(params); + const workoutStats = useWorkoutStats(params); + const removeWorkout = useDeleteWorkout(); + + const meta = stats.data?.meta; + const stepsPoints = useMemo(() => valued(seriesPoints(stats.data, "steps")), [stats.data]); + const stepsMa7 = useMemo( + () => valued(seriesPoints(stats.data, "steps_ma7")), + [stats.data], + ); + const activeKcalPoints = useMemo( + () => valued(seriesPoints(stats.data, "active_kcal")), + [stats.data], + ); + const distancePoints = useMemo( + () => valued(seriesPoints(stats.data, "distance_m")), + [stats.data], + ); + + const stepsGoal = Number(meta?.steps_goal ?? DEFAULT_STEPS_GOAL); + const days = activityDays.data ?? []; + const today = todayIso(); + const todayRow = days.find((d) => d.date === today) ?? null; + + /* --- KPI --------------------------------------------------------- */ + + const stepsToday = todayRow?.steps ?? null; + const activeKcalToday = todayRow?.active_kcal ?? null; + + const mean = (values: number[]): number | null => + values.length === 0 ? null : values.reduce((sum, v) => sum + v, 0) / values.length; + + const stepsAvg7 = useMemo( + () => meta?.steps_avg ?? mean(stepsPoints.slice(-7).map((p) => p[1])), + [stepsPoints, meta], + ); + const stepsAvgPrevious7 = useMemo( + () => meta?.steps_avg_previous ?? mean(stepsPoints.slice(-14, -7).map((p) => p[1])), + [stepsPoints, meta], + ); + const stepsTrendPct = + stepsAvg7 !== null && stepsAvgPrevious7 !== null && Number(stepsAvgPrevious7) > 0 + ? ((Number(stepsAvg7) - Number(stepsAvgPrevious7)) / Number(stepsAvgPrevious7)) * 100 + : null; + + const activeKcalAvg7 = useMemo( + () => meta?.active_kcal_avg ?? mean(activeKcalPoints.slice(-7).map((p) => p[1])), + [activeKcalPoints, meta], + ); + + const distanceTotal = useMemo( + () => + meta?.distance_total_m ?? distancePoints.reduce((sum, [, value]) => sum + Number(value), 0), + [distancePoints, meta], + ); + const distancePerDay = + distancePoints.length > 0 ? Number(distanceTotal) / distancePoints.length : null; + + const goalReachedDays = + meta?.days_goal_reached ?? stepsPoints.filter(([, value]) => value >= stepsGoal).length; + const daysCount = meta?.days_count ?? stepsPoints.length; + + const thisWeek = weekBucket(today); + const workoutList = workouts.data ?? []; + const weekWorkouts = workoutList.filter((w) => weekBucket(localDayOf(w.started_at)).key === thisWeek.key); + const previousWeekKey = weekBucket(addDaysIso(thisWeek.start, -1)).key; + const lastWeekWorkouts = workoutList.filter( + (w) => weekBucket(localDayOf(w.started_at)).key === previousWeekKey, + ); + const sumMinutes = (list: WorkoutRead[]): number => + list.reduce((sum, w) => sum + workoutMinutes(w.started_at, w.ended_at), 0); + + /* --- G1 : steps per day ------------------------------------------ */ + + const stepsOption = useMemo(() => { + if (stepsPoints.length === 0) return emptyChartOption(); + const labels = stepsPoints.map((p) => p[0]); + const ma7ByDay = new Map(stepsMa7.map(([date, value]) => [date, value])); + const highlightIndex = highlightDay === null ? -1 : labels.indexOf(highlightDay); + return { + grid: GRID_WITH_SLIDER, + legend: { data: [S.activity.series.steps, S.activity.series.movingAvg] }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const list = tooltipParams(raw); + const index = list[0]?.dataIndex ?? 0; + const day = labels[index]; + const steps = stepsPoints[index]?.[1] ?? 0; + const ma = ma7ByDay.get(day); + return [ + tooltipTitle(formatDayLabel(day)), + tooltipRow(list[0]?.marker, S.activity.series.steps, formatSteps(steps)), + ma === undefined + ? "" + : tooltipRow(list[1]?.marker, S.activity.series.movingAvg, formatSteps(ma)), + ].join(""); + }, + }, + xAxis: dayAxis(labels), + yAxis: { type: "value" }, + dataZoom: ZOOM_MAIN, + series: [ + { + name: S.activity.series.steps, + type: "bar", + barMaxWidth: 28, + data: stepsPoints.map(([, value], index) => ({ + value, + itemStyle: { + color: CHART_COLORS[2], + opacity: value >= stepsGoal ? 1 : 0.55, + borderRadius: BAR_RADIUS_UP, + borderColor: index === highlightIndex ? CHART_SURFACE.ink : "transparent", + borderWidth: index === highlightIndex ? 2 : 0, + }, + })), + markLine: targetMarkLine(stepsGoal, `Objectif ${formatNumber(stepsGoal)}`), + }, + { + name: S.activity.series.movingAvg, + type: "line", + showSymbol: false, + smooth: 0.2, + lineStyle: { width: 2, color: "rgba(255,255,255,0.6)" }, + itemStyle: { color: "rgba(255,255,255,0.6)" }, + data: labels.map((day) => ma7ByDay.get(day) ?? null), + }, + ], + }; + }, [stepsPoints, stepsMa7, stepsGoal, highlightDay]); + + /* --- G2 : active kcal -------------------------------------------- */ + + const activeKcalOption = useMemo(() => { + if (activeKcalPoints.length === 0) return emptyChartOption(); + const labels = activeKcalPoints.map((p) => p[0]); + const average = mean(activeKcalPoints.map((p) => p[1])); + return { + grid: GRID_DEFAULT, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const list = tooltipParams(raw); + const index = list[0]?.dataIndex ?? 0; + return `${tooltipTitle(formatDayLabel(labels[index]))}${tooltipRow( + list[0]?.marker, + S.activity.series.activeKcal, + formatKcal(activeKcalPoints[index]?.[1] ?? 0), + )}`; + }, + }, + xAxis: dayAxis(labels), + yAxis: { type: "value" }, + dataZoom: ZOOM_INSIDE, + series: [ + { + name: S.activity.series.activeKcal, + type: "bar", + barMaxWidth: 28, + itemStyle: { color: CHART_COLORS[1], borderRadius: BAR_RADIUS_UP }, + data: activeKcalPoints.map(([, value]) => value), + markLine: + average === null + ? undefined + : targetMarkLine(average, `Moy. ${formatNumber(Math.round(average))}`), + }, + ], + }; + }, [activeKcalPoints]); + + /* --- G3 : training per week -------------------------------------- */ + + const weeklyOption = useMemo(() => { + if (workoutList.length === 0) return emptyChartOption(); + const byWeek = new Map(); + for (const workout of workoutList) { + const bucket = weekBucket(localDayOf(workout.started_at)); + const entry = byWeek.get(bucket.key) ?? { + label: bucket.label, + hours: WORKOUT_BUCKETS.map(() => 0), + }; + entry.hours[bucketOf(workout.sport_type)] += + workoutMinutes(workout.started_at, workout.ended_at) / 60; + byWeek.set(bucket.key, entry); + } + const keys = [...byWeek.keys()].sort(); + const labels = keys.map((key) => byWeek.get(key)?.label ?? key); + return { + grid: GRID_DEFAULT, + legend: { data: WORKOUT_BUCKETS.map((b) => b.label) }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const list = tooltipParams(raw); + const index = list[0]?.dataIndex ?? 0; + const entry = byWeek.get(keys[index]); + if (!entry) return ""; + const total = entry.hours.reduce((sum, h) => sum + h, 0); + return [ + tooltipTitle(entry.label), + ...WORKOUT_BUCKETS.map((bucket, i) => + entry.hours[i] > 0 + ? tooltipRow(list[i]?.marker, bucket.label, formatDuration(entry.hours[i] * 60)) + : "", + ), + tooltipRow("", S.common.total, formatDuration(total * 60)), + ].join(""); + }, + }, + xAxis: { type: "category", data: labels }, + yAxis: { type: "value", axisLabel: { formatter: "{value} h" } }, + series: WORKOUT_BUCKETS.map((bucket, index) => ({ + name: bucket.label, + type: "bar" as const, + stack: "workouts", + barMaxWidth: 28, + itemStyle: { + color: bucket.color, + ...STACK_BORDER, + ...(index === WORKOUT_BUCKETS.length - 1 ? { borderRadius: BAR_RADIUS_UP } : {}), + }, + data: keys.map((key) => Number((byWeek.get(key)?.hours[index] ?? 0).toFixed(2))), + })), + }; + }, [workoutList]); + + /* --- G4 : cumulative distance ------------------------------------ */ + + const distanceOption = useMemo(() => { + if (distancePoints.length === 0) return emptyChartOption(); + let cumulative = 0; + const data = distancePoints.map(([date, value]) => { + cumulative += Number(value) / 1000; + return [date, Number(cumulative.toFixed(2))] as [string, number]; + }); + return { + grid: GRID_DEFAULT, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const list = tooltipParams(raw); + const index = list[0]?.dataIndex ?? 0; + const daily = Number(distancePoints[index]?.[1] ?? 0) / 1000; + return `${tooltipTitle(formatDayLabel(data[index][0]))}${tooltipRow( + list[0]?.marker, + S.activity.series.distance, + `${formatKm(data[index][1])} (+${formatKm(daily)})`, + )}`; + }, + }, + xAxis: dayAxis(data.map((d) => d[0])), + yAxis: { type: "value", axisLabel: { formatter: "{value} km" } }, + dataZoom: ZOOM_INSIDE, + series: [ + { + name: S.activity.series.distance, + type: "line", + showSymbol: false, + smooth: 0.2, + lineStyle: { width: 2, color: CHART_COLORS[0] }, + itemStyle: { color: CHART_COLORS[0] }, + areaStyle: areaGradient(CHART_COLORS[0]), + endLabel: { + show: true, + color: CHART_SURFACE.inkSecondary, + fontSize: 11, + formatter: (param: { value?: unknown }) => + Array.isArray(param.value) && typeof param.value[1] === "number" + ? formatKm(param.value[1]) + : "", + }, + data: data.map(([, value]) => value), + }, + ], + }; + }, [distancePoints]); + + /* --- records ------------------------------------------------------ */ + + const records = useMemo(() => { + const bestSteps = stepsPoints.reduce<[string, number] | null>( + (best, point) => (best === null || point[1] > best[1] ? point : best), + null, + ); + const longest = workoutList.reduce((best, workout) => { + const duration = workoutMinutes(workout.started_at, workout.ended_at); + return best === null || duration > workoutMinutes(best.started_at, best.ended_at) + ? workout + : best; + }, null); + const farthest = workoutList.reduce((best, workout) => { + const distance = Number(workout.distance_m ?? 0); + return best === null || distance > Number(best.distance_m ?? 0) ? workout : best; + }, null); + const weeks = groupByWeek( + stepsPoints.map((p) => p[0]), + (_day, index) => stepsPoints[index][1], + ).map((group) => ({ + bucket: group.bucket, + total: group.values.reduce((sum, value) => sum + value, 0), + })); + const bestWeek = weeks.reduce<(typeof weeks)[number] | null>( + (best, week) => (best === null || week.total > best.total ? week : best), + null, + ); + return { bestSteps, longest, farthest, bestWeek }; + }, [stepsPoints, workoutList]); + + /* --- workouts table ----------------------------------------------- */ + + const filteredWorkouts = workoutList.filter( + (workout) => + (typeFilter === "" || workout.sport_type === typeFilter) && + (sourceFilter === "" || workout.source === sourceFilter), + ); + const sortedWorkouts = [...filteredWorkouts].sort((a, b) => + a.started_at < b.started_at ? 1 : -1, + ); + const pageWorkouts = sortedWorkouts.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + const columns: TableColumn[] = [ + { + key: "date", + header: S.common.date, + sortable: true, + sortValue: (row) => row.started_at, + render: (row) => formatDate(row.started_at), + }, + { + key: "type", + header: S.activity.table.type, + render: (row) => {sportLabel(row.sport_type, row.sport_label)}, + }, + { + key: "duration", + header: S.activity.table.duration, + align: "right", + sortable: true, + sortValue: (row) => workoutMinutes(row.started_at, row.ended_at), + render: (row) => formatDuration(workoutMinutes(row.started_at, row.ended_at)), + }, + { + key: "distance", + header: S.activity.table.distance, + align: "right", + render: (row) => + row.distance_m === null || row.distance_m === undefined + ? S.common.none + : formatDistance(Number(row.distance_m)), + }, + { + key: "kcal", + header: S.activity.table.kcal, + align: "right", + render: (row) => + row.kcal === null || row.kcal === undefined ? S.common.none : formatKcal(Number(row.kcal)), + }, + { + key: "avg_hr", + header: S.activity.table.avgHr, + align: "right", + render: (row) => (row.avg_hr ? `${formatNumber(row.avg_hr)} bpm` : S.common.none), + }, + { + key: "source", + header: S.common.source, + render: (row) => {sourceLabel(row.source)}, + }, + { + key: "actions", + header: S.common.actionsColumn, + align: "right", + render: (row) => ( +
    + + +
    + ), + }, + ]; + + const sources = [...new Set(workoutList.map((w) => w.source))]; + const isEmpty = + !stats.isPending && + !workouts.isPending && + stepsPoints.length === 0 && + activeKcalPoints.length === 0 && + workoutList.length === 0; + + const openAdd = () => { + setEditing(null); + setModalOpen(true); + }; + + return ( +
    + + + + + } + /> + + {stats.isError ? : null} + {workouts.isError ? : null} + + {isEmpty ? ( + + + + + + + + } + /> + + ) : ( + <> +
    + = stepsGoal ? "positive" : "accent" + } + /> + = 0 ? "positive" : "negative"} + /> + + + + 0 ? (goalReachedDays / daysCount) * 100 : undefined} + progressTone="positive" + /> +
    + + `${formatNumber(value)} séance${value > 1 ? "s" : ""}`} + emptyHint={S.planning.emptyPlanWorkout} + adherenceLabel={S.activity.kpi.adherence} + /> + + + +
    + + +
    + + + +
    + +
    + setHighlightDay(records.bestSteps?.[0] ?? null) : undefined + } + /> + setHighlightDay(localDayOf(records.longest?.started_at ?? "")) + : undefined + } + /> + + setHighlightDay(records.bestWeek?.bucket.start ?? null) + : undefined + } + /> +
    +
    + + + {todayRow?.field_sources && Object.keys(todayRow.field_sources).length > 0 ? ( +
      + {Object.entries(todayRow.field_sources).map(([field, source]) => ( +
    • + + {FIELD_LABELS[field] ?? field} : {sourceLabel(source)} + +
    • + ))} +
    + ) : ( +

    {S.common.noDataOnPeriod}

    + )} + {workoutStats.data?.meta.sessions_total ? ( +

    + {`${formatNumber(Number(workoutStats.data.meta.sessions_total))} séances · ${formatDuration( + Number(workoutStats.data.meta.duration_total_min ?? 0), + )} sur la période`} +

    + ) : null} +
    +
    + + + + + +
    + } + > +
    row.id} + className="px-1 pb-4" + empty={ + { + setTypeFilter(""); + setSourceFilter(""); + }} + > + {S.actions.resetFilters} + + } + /> + } + pagination={{ + page, + pageSize: PAGE_SIZE, + total: sortedWorkouts.length, + onPageChange: setPage, + }} + /> + + + )} + + setModalOpen(false)} + workout={editing} + onSaved={showToast} + /> + { + if (!toDelete) return; + removeWorkout.mutate(toDelete.id, { onSuccess: () => setToDelete(null) }); + }} + onClose={() => setToDelete(null)} + /> + {toast} + + ); +} + +const FIELD_LABELS: Record = { + steps: S.activity.series.steps, + active_kcal: S.activity.series.activeKcal, + total_kcal: "Dépense totale", + distance_m: "Distance", + active_minutes: "Minutes actives", + floors: "Étages", +}; + +interface RecordTileProps { + label: string; + value: string; + sub?: string; + onClick?: () => void; +} + +/** Record stat tile (ux-pages §10.3) — clicking highlights the day in G1. */ +function RecordTile({ label, value, sub, onClick }: RecordTileProps) { + const content = ( + <> +

    {label}

    +

    {value}

    + {sub ?

    {sub}

    : null} + + ); + if (!onClick) return
    {content}
    ; + return ( + + ); +} diff --git a/apps/web/src/modules/health/pages/EnergyBalancePage.tsx b/apps/web/src/modules/health/pages/EnergyBalancePage.tsx new file mode 100644 index 0000000..09dbbb1 --- /dev/null +++ b/apps/web/src/modules/health/pages/EnergyBalancePage.tsx @@ -0,0 +1,588 @@ +import type { EChartsOption } from "echarts"; +import { AlertTriangle, ChevronDown, ChevronRight, Flame } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; + +import { ChartCard } from "../../../components/charts/ChartCard"; +import { CHART_COLORS, CHART_SURFACE, SEMANTIC_COLORS } from "../../../components/charts/theme"; +import { PeriodSelector } from "../../../components/PeriodSelector"; +import type { PeriodRange } from "../../../components/PeriodSelector"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { PageHeader } from "../../../components/ui/PageHeader"; +import { StatCard } from "../../../components/ui/StatCard"; +import { daysAgoIso, todayIso } from "../../../lib/dates"; +import { formatKcal, formatNumber, formatSigned, formatWeight } from "../../../lib/format"; +import { seriesPoints, useEnergyBalance, useWeightStats } from "../api"; +import type { SeriesPoint } from "../api"; +import { + BAR_RADIUS_DOWN, + BAR_RADIUS_UP, + GRID_DEFAULT, + GRID_WITH_SLIDER, + ZOOM_INSIDE, + ZOOM_MAIN, + areaGradient, + dayAxis, + directionalVisualMap, + emptyChartOption, + formatDayLabel, + tooltipParams, + tooltipRow, + tooltipTitle, + targetMarkLine, +} from "../charts"; +import { ErrorNotice } from "../components/QueryBoundary"; +import { S } from "../strings"; + +/** kcal ↔ kg conversion (datamodel §5, KCAL_PER_KG_FAT). */ +const KCAL_PER_KG = 7700; + +const HATCH_DECAL = { + symbol: "rect", + dashArrayX: [1, 0], + dashArrayY: [2, 5], + rotation: Math.PI / 4, + color: "rgba(255,255,255,0.25)", +}; + +function pointsMap(points: SeriesPoint[]): Map { + return new Map(points.map(([date, value]) => [date, value])); +} + +export default function EnergyBalancePage() { + const [range, setRange] = useState({ + key: "30j", + from: daysAgoIso(29), + to: todayIso(), + label: "30 j", + }); + const params = { from: range.from, to: range.to }; + const [methodOpen, setMethodOpen] = useState(false); + + const balance = useEnergyBalance(params); + const weightStats = useWeightStats(params); + + const meta = balance.data?.meta; + const intake = useMemo(() => seriesPoints(balance.data, "intake_kcal"), [balance.data]); + const tdee = useMemo(() => seriesPoints(balance.data, "tdee_kcal"), [balance.data]); + const net = useMemo(() => seriesPoints(balance.data, "balance_kcal"), [balance.data]); + const budget = useMemo(() => seriesPoints(balance.data, "budget_kcal"), [balance.data]); + const weightTrend = useMemo( + () => seriesPoints(weightStats.data, "weight_trend"), + [weightStats.data], + ); + + const days = useMemo(() => { + const all = new Set(); + for (const [date] of intake) all.add(date); + for (const [date] of tdee) all.add(date); + return [...all].sort(); + }, [intake, tdee]); + + const intakeBy = useMemo(() => pointsMap(intake), [intake]); + const tdeeBy = useMemo(() => pointsMap(tdee), [tdee]); + const netBy = useMemo(() => pointsMap(net), [net]); + const budgetBy = useMemo(() => pointsMap(budget), [budget]); + + const untrackedDays = + meta?.untracked_days ?? days.filter((day) => (intakeBy.get(day) ?? null) === null).length; + + const targetDeficit = + meta?.deficit_target_kcal !== null && meta?.deficit_target_kcal !== undefined + ? -Math.abs(Number(meta.deficit_target_kcal)) + : null; + + /* --- KPI --------------------------------------------------------- */ + + const today = todayIso(); + const balanceToday = meta?.balance_today ?? netBy.get(today) ?? null; + const intakeToday = intakeBy.get(today) ?? null; + const tdeeToday = tdeeBy.get(today) ?? null; + + const avg7 = useMemo(() => { + if (meta?.balance_avg_7d !== null && meta?.balance_avg_7d !== undefined) { + return Number(meta.balance_avg_7d); + } + const values = days + .slice(-7) + .map((day) => netBy.get(day)) + .filter((value): value is number => typeof value === "number"); + if (values.length === 0) return null; + return values.reduce((sum, v) => sum + v, 0) / values.length; + }, [days, netBy, meta]); + + const cumulative = useMemo(() => { + if (meta?.balance_cumulative !== null && meta?.balance_cumulative !== undefined) { + return Number(meta.balance_cumulative); + } + return days.reduce((sum, day) => { + const value = netBy.get(day); + return typeof value === "number" ? sum + value : sum; + }, 0); + }, [days, netBy, meta]); + + const cumulativeKg = cumulative / KCAL_PER_KG; + + const budgetToday = meta?.budget_kcal ?? budgetBy.get(today) ?? null; + + /* --- G1 : in vs out ---------------------------------------------- */ + + const inOutOption = useMemo(() => { + if (days.length === 0) return emptyChartOption(); + return { + grid: GRID_WITH_SLIDER, + legend: { data: [S.energy.series.intake, S.energy.series.expenditure] }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const list = tooltipParams(raw); + const index = list[0]?.dataIndex ?? 0; + const day = days[index]; + const dayIntake = intakeBy.get(day) ?? null; + const dayTdee = tdeeBy.get(day) ?? null; + const dayNet = netBy.get(day) ?? null; + return [ + tooltipTitle(formatDayLabel(day)), + tooltipRow( + list[0]?.marker, + S.energy.series.intake, + dayIntake === null ? S.common.none : formatKcal(dayIntake), + ), + tooltipRow( + list[1]?.marker, + S.energy.series.expenditure, + dayTdee === null ? S.common.none : formatKcal(dayTdee), + ), + dayNet === null + ? `
    ${S.energy.noLog}
    ` + : tooltipRow("", S.energy.series.net, formatSigned(Math.round(dayNet), 0, "kcal")), + ].join(""); + }, + }, + xAxis: dayAxis(days), + yAxis: { + type: "value", + axisLabel: { + formatter: (value: number) => formatNumber(Math.abs(value)), + }, + }, + dataZoom: ZOOM_MAIN, + series: [ + { + name: S.energy.series.intake, + type: "bar", + stack: "energy", + barMaxWidth: 28, + data: days.map((day) => ({ + value: intakeBy.get(day) ?? null, + itemStyle: { color: CHART_COLORS[1], borderRadius: BAR_RADIUS_UP }, + })), + }, + { + name: S.energy.series.expenditure, + type: "bar", + stack: "energy", + barMaxWidth: 28, + data: days.map((day) => { + const value = tdeeBy.get(day); + const tracked = (intakeBy.get(day) ?? null) !== null; + return { + value: typeof value === "number" ? -value : null, + itemStyle: { + color: CHART_COLORS[0], + borderRadius: BAR_RADIUS_DOWN, + ...(tracked ? {} : { decal: HATCH_DECAL }), + }, + }; + }), + }, + ], + }; + }, [days, intakeBy, tdeeBy, netBy]); + + /* --- G2 : daily net ---------------------------------------------- */ + + const netOption = useMemo(() => { + const values = days.map((day) => netBy.get(day) ?? null); + if (values.every((value) => value === null)) return emptyChartOption(); + return { + grid: GRID_DEFAULT, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const list = tooltipParams(raw); + const index = list[0]?.dataIndex ?? 0; + const value = values[index]; + if (value === null) { + return `${tooltipTitle(formatDayLabel(days[index]))}${S.energy.noLog}`; + } + return `${tooltipTitle(formatDayLabel(days[index]))}${formatSigned( + Math.round(value), + 0, + "kcal", + )} (${value < 0 ? S.energy.deficit : S.energy.surplus})`; + }, + }, + xAxis: dayAxis(days), + yAxis: { type: "value" }, + dataZoom: ZOOM_INSIDE, + visualMap: directionalVisualMap(true), + series: [ + { + name: S.energy.series.net, + type: "bar", + barMaxWidth: 28, + data: values, + markLine: + targetDeficit === null + ? undefined + : targetMarkLine(targetDeficit, `${S.energy.series.targetDeficit} ${formatNumber(targetDeficit)}`), + }, + ], + }; + }, [days, netBy, targetDeficit]); + + /* --- G3 : cumulative deficit ------------------------------------- */ + + const cumulativeOption = useMemo(() => { + if (days.length === 0) return emptyChartOption(); + let running = 0; + const data = days.map((day) => { + const value = netBy.get(day); + if (typeof value === "number") running += value; + return Math.round(running); + }); + const color = running <= 0 ? SEMANTIC_COLORS.positive : SEMANTIC_COLORS.negative; + return { + grid: GRID_DEFAULT, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const list = tooltipParams(raw); + const index = list[0]?.dataIndex ?? 0; + const value = data[index]; + return `${tooltipTitle(formatDayLabel(days[index]))}${formatSigned( + value, + 0, + "kcal", + )} ≈ ${formatSigned(value / KCAL_PER_KG, 1, "kg")}`; + }, + }, + xAxis: dayAxis(days), + yAxis: { type: "value" }, + dataZoom: ZOOM_INSIDE, + series: [ + { + name: S.energy.series.cumulative, + type: "line", + showSymbol: false, + smooth: 0.2, + lineStyle: { width: 2, color }, + itemStyle: { color }, + areaStyle: areaGradient(color), + data, + }, + ], + }; + }, [days, netBy]); + + /* --- G4 : theoretical vs real weight ----------------------------- */ + + const weightModelOption = useMemo(() => { + const trend = weightTrend.filter((p): p is [string, number] => typeof p[1] === "number"); + if (trend.length === 0 || days.length === 0) return emptyChartOption(); + const anchor = trend[0][1]; + let running = 0; + const theoretical = days.map((day) => { + const value = netBy.get(day); + if (typeof value === "number") running += value; + return [day, Number((anchor + running / KCAL_PER_KG).toFixed(2))] as [string, number]; + }); + const trendByDay = new Map(trend.map(([date, value]) => [date.slice(0, 10), value])); + return { + grid: GRID_DEFAULT, + legend: { data: [S.energy.series.realWeight, S.energy.series.theoreticalWeight] }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const list = tooltipParams(raw); + const index = list[0]?.dataIndex ?? 0; + const day = days[index]; + const real = trendByDay.get(day) ?? null; + const model = theoretical[index]?.[1] ?? null; + const gap = real !== null && model !== null ? real - model : null; + return [ + tooltipTitle(formatDayLabel(day)), + tooltipRow( + list[0]?.marker, + S.energy.series.realWeight, + real === null ? S.common.none : formatWeight(real), + ), + tooltipRow( + list[1]?.marker, + S.energy.series.theoreticalWeight, + model === null ? S.common.none : formatWeight(model), + ), + gap === null ? "" : tooltipRow("", "Écart", formatSigned(gap, 1, "kg")), + ].join(""); + }, + }, + xAxis: dayAxis(days), + yAxis: { type: "value", scale: true, axisLabel: { formatter: "{value} kg" } }, + dataZoom: ZOOM_INSIDE, + series: [ + { + name: S.energy.series.realWeight, + type: "line", + showSymbol: false, + smooth: 0.2, + lineStyle: { width: 2, color: CHART_COLORS[0] }, + itemStyle: { color: CHART_COLORS[0] }, + endLabel: { + show: true, + formatter: S.energy.series.realWeight, + color: CHART_SURFACE.inkSecondary, + fontSize: 11, + }, + data: days.map((day) => trendByDay.get(day) ?? null), + }, + { + name: S.energy.series.theoreticalWeight, + type: "line", + showSymbol: false, + lineStyle: { width: 2, type: [6, 4], color: CHART_COLORS[6] }, + itemStyle: { color: CHART_COLORS[6] }, + endLabel: { + show: true, + formatter: S.energy.series.theoreticalWeight, + color: CHART_SURFACE.inkSecondary, + fontSize: 11, + }, + data: theoretical.map(([, value]) => value), + }, + ], + }; + }, [weightTrend, days, netBy]); + + /* --- degraded state ---------------------------------------------- */ + + const hasIntake = intake.some(([, value]) => typeof value === "number"); + const hasWeight = weightTrend.some(([, value]) => typeof value === "number"); + const isDegraded = + !balance.isPending && !weightStats.isPending && (!hasIntake || !hasWeight); + + return ( +
    + } + /> + + {balance.isError ? : null} + {weightStats.isError ? : null} + + {isDegraded ? ( + + + + + + + + + + } + /> + + ) : ( + <> + {untrackedDays > 0 ? ( +
    + +

    + {S.energy.incomplete} : + + {S.energy.incompleteHint(Number(untrackedDays))} + +

    +
    + ) : null} + +
    + + + + + + 0 + ? "le réel décroche au-dessus du modèle" + : "le réel devance le modèle" + } + /> +
    + + + +
    + + +
    + + + + + + {methodOpen ? ( +
    +

    {S.energy.method.text}

    + {meta?.tdee_adaptive_kcal ? ( +
    +

    + {`${S.energy.method.calibration} : votre dépense réelle estimée d'après la pesée est de ${formatNumber( + Math.round(Number(meta.tdee_adaptive_kcal)), + )} kcal/j (modèle : ${formatNumber( + Math.round(Number(meta.tdee_avg ?? 0)), + )} kcal/j).`} +

    + + + +
    + ) : ( +

    {S.energy.method.insufficient}

    + )} +
    + ) : null} +
    + + )} +
    + ); +} diff --git a/apps/web/src/modules/health/pages/NutritionPage.tsx b/apps/web/src/modules/health/pages/NutritionPage.tsx new file mode 100644 index 0000000..573354d --- /dev/null +++ b/apps/web/src/modules/health/pages/NutritionPage.tsx @@ -0,0 +1,943 @@ +import type { EChartsOption } from "echarts"; +import { + ChevronDown, + ChevronLeft, + ChevronRight, + Copy, + Pencil, + Plus, + Trash2, + Utensils, + X, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; + +import { ChartCard } from "../../../components/charts/ChartCard"; +import { CHART_COLORS, CHART_SURFACE, SEMANTIC_COLORS } from "../../../components/charts/theme"; +import { PeriodSelector } from "../../../components/PeriodSelector"; +import type { PeriodRange } from "../../../components/PeriodSelector"; +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { ConfirmDialog } from "../../../components/ui/ConfirmDialog"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { PageHeader } from "../../../components/ui/PageHeader"; +import { StatCard } from "../../../components/ui/StatCard"; +import { addDaysIso, daysAgoIso, todayIso } from "../../../lib/dates"; +import { formatDate, formatG, formatKcal, formatNumber, formatPercent, formatSigned } from "../../../lib/format"; +import { + MEAL_ORDER, + useDeleteFavorite, + useDeleteFoodEntry, + useFavorites, + useFoodEntries, + useNutritionDay, + useNutritionDays, +} from "../api"; +import type { FoodEntryRead, MealType, NutritionDay } from "../api"; +import { + BAR_RADIUS_UP, + GRID_DEFAULT, + GRID_WITH_SLIDER, + STACK_BORDER, + ZOOM_MAIN, + dayAxis, + emptyChartOption, + formatDayLabel, + tooltipParams, + tooltipRow, + tooltipTitle, +} from "../charts"; +import { FoodModal } from "../components/FoodModal"; +import { ErrorNotice } from "../components/QueryBoundary"; +import { useToast } from "../components/Toast"; +import { localDayOf, todayLocal } from "../form"; +import { S, sourceLabel, unitLabel } from "../strings"; + +const MEAL_COLORS: Record = { + breakfast: CHART_COLORS[0], + lunch: CHART_COLORS[1], + dinner: CHART_COLORS[2], + snack: CHART_COLORS[3], +}; + +const MACRO_COLORS = { + protein: CHART_COLORS[2], + carbs: CHART_COLORS[3], + fat: CHART_COLORS[4], +}; + +const KCAL_PER_G = { protein: 4, carbs: 4, fat: 9 }; + +function truncate(label: string, max = 24): string { + return label.length > max ? `${label.slice(0, max - 1)}…` : label; +} + +export default function NutritionPage() { + const [range, setRange] = useState({ + key: "30j", + from: daysAgoIso(29), + to: todayIso(), + label: "30 j", + }); + const params = { from: range.from, to: range.to }; + + const [journalDay, setJournalDay] = useState(todayLocal()); + const [collapsed, setCollapsed] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [modalMeal, setModalMeal] = useState(null); + const [editing, setEditing] = useState(null); + const [duplicating, setDuplicating] = useState(null); + const [toDelete, setToDelete] = useState(null); + const [foodFilter, setFoodFilter] = useState(null); + const [macroUnit, setMacroUnit] = useState<"kcal" | "g">("kcal"); + const [libraryOpen, setLibraryOpen] = useState(false); + const { showToast, toast } = useToast(); + + const daysQuery = useNutritionDays(params); + const entriesQuery = useFoodEntries(params); + const journal = useNutritionDay(journalDay); + const favorites = useFavorites(); + const removeEntry = useDeleteFoodEntry(); + const removeFavorite = useDeleteFavorite(); + + const days = useMemo( + () => [...(daysQuery.data?.days ?? [])].sort((a, b) => (a.date < b.date ? -1 : 1)), + [daysQuery.data], + ); + const meta = daysQuery.data?.meta; + const entries = entriesQuery.data ?? []; + + const dayLabels = days.map((d) => d.date); + const todayRow = days.find((d) => d.date === todayIso()) ?? null; + + /* --- KPI --------------------------------------------------------- */ + + const todayKcal = todayRow?.kcal ?? journal.data?.kcal ?? null; + const todayBudget = todayRow?.budget_kcal ?? journal.data?.budget_kcal ?? null; + const remaining = todayKcal !== null && todayBudget ? todayBudget - todayKcal : null; + + const avg7 = useMemo(() => { + const last7 = days.slice(-7).filter((d) => d.kcal !== null); + if (last7.length === 0) return null; + return last7.reduce((sum, d) => sum + Number(d.kcal ?? 0), 0) / last7.length; + }, [days]); + + const budgetAvg = useMemo(() => { + const withBudget = days.filter((d) => d.budget_kcal); + if (withBudget.length === 0) return meta?.budget_avg ?? null; + return withBudget.reduce((sum, d) => sum + Number(d.budget_kcal ?? 0), 0) / withBudget.length; + }, [days, meta]); + + const proteinToday = journal.data?.protein_g ?? todayRow?.protein_g ?? null; + const proteinGoal = meta?.protein_goal_g ?? null; + + const splitToday = useMemo(() => { + const p = Number(journal.data?.protein_g ?? 0) * KCAL_PER_G.protein; + const c = Number(journal.data?.carbs_g ?? 0) * KCAL_PER_G.carbs; + const f = Number(journal.data?.fat_g ?? 0) * KCAL_PER_G.fat; + const total = p + c + f; + if (total <= 0) return null; + return { p: (p / total) * 100, c: (c / total) * 100, f: (f / total) * 100, total }; + }, [journal.data]); + + const daysInBudget = + meta?.days_in_budget ?? + days.filter((d) => d.kcal !== null && d.budget_kcal && Number(d.kcal) <= Number(d.budget_kcal)) + .length; + const daysCount = meta?.days_count ?? days.filter((d) => d.kcal !== null).length; + + const avgGap = useMemo(() => { + if (meta?.vs_budget_avg !== null && meta?.vs_budget_avg !== undefined) { + return Number(meta.vs_budget_avg); + } + const withBoth = days.filter((d) => d.kcal !== null && d.budget_kcal); + if (withBoth.length === 0) return null; + return ( + withBoth.reduce((sum, d) => sum + (Number(d.kcal) - Number(d.budget_kcal)), 0) / + withBoth.length + ); + }, [days, meta]); + + /* --- G1 : kcal per day ------------------------------------------- */ + + const kcalOption = useMemo(() => { + if (days.length === 0) return emptyChartOption(); + return { + grid: GRID_WITH_SLIDER, + legend: { data: [S.nutrition.series.intake, S.nutrition.series.budget] }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const list = tooltipParams(raw); + const index = list[0]?.dataIndex ?? 0; + const day = days[index]; + if (!day) return ""; + const gap = + day.kcal !== null && day.budget_kcal + ? Number(day.kcal) - Number(day.budget_kcal) + : null; + return [ + tooltipTitle(formatDayLabel(day.date)), + tooltipRow(list[0]?.marker, S.nutrition.series.intake, formatKcal(Number(day.kcal ?? 0))), + day.budget_kcal + ? tooltipRow(list[1]?.marker, S.nutrition.series.budget, formatKcal(Number(day.budget_kcal))) + : "", + gap === null ? "" : tooltipRow("", "Écart", formatSigned(Math.round(gap), 0, "kcal")), + ].join(""); + }, + }, + xAxis: dayAxis(dayLabels), + yAxis: { type: "value", axisLabel: { formatter: "{value}" } }, + dataZoom: ZOOM_MAIN, + series: [ + { + name: S.nutrition.series.intake, + type: "bar", + barMaxWidth: 28, + data: days.map((day) => ({ + value: day.kcal === null ? null : Number(day.kcal), + itemStyle: { + borderRadius: BAR_RADIUS_UP, + color: + day.budget_kcal && day.kcal !== null && Number(day.kcal) > Number(day.budget_kcal) + ? SEMANTIC_COLORS.negative + : SEMANTIC_COLORS.positive, + }, + })), + }, + { + name: S.nutrition.series.budget, + type: "line", + step: "end", + showSymbol: false, + lineStyle: { type: [4, 4], width: 2, color: CHART_SURFACE.inkSecondary }, + itemStyle: { color: CHART_SURFACE.inkSecondary }, + data: days.map((day) => (day.budget_kcal ? Number(day.budget_kcal) : null)), + }, + ], + }; + }, [days, dayLabels]); + + /* --- G2 : macros per day ----------------------------------------- */ + + const macroOption = useMemo(() => { + if (days.length === 0) return emptyChartOption(); + const factor = (key: keyof typeof KCAL_PER_G) => (macroUnit === "kcal" ? KCAL_PER_G[key] : 1); + const build = (key: keyof typeof KCAL_PER_G, field: keyof NutritionDay) => + days.map((day) => { + const grams = day[field]; + return grams === null || grams === undefined ? null : Number(grams) * factor(key); + }); + return { + grid: GRID_DEFAULT, + legend: { + data: [S.nutrition.series.protein, S.nutrition.series.carbs, S.nutrition.series.fat], + }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const list = tooltipParams(raw); + const index = list[0]?.dataIndex ?? 0; + const day = days[index]; + if (!day) return ""; + const rows = [ + { label: S.nutrition.series.protein, grams: day.protein_g, kcal: KCAL_PER_G.protein }, + { label: S.nutrition.series.carbs, grams: day.carbs_g, kcal: KCAL_PER_G.carbs }, + { label: S.nutrition.series.fat, grams: day.fat_g, kcal: KCAL_PER_G.fat }, + ]; + return [ + tooltipTitle(formatDayLabel(day.date)), + ...rows.map((row, i) => + tooltipRow( + list[i]?.marker, + row.label, + `${formatG(Number(row.grams ?? 0))} (${formatKcal(Number(row.grams ?? 0) * row.kcal)})`, + ), + ), + ].join(""); + }, + }, + xAxis: dayAxis(dayLabels), + yAxis: { type: "value" }, + dataZoom: ZOOM_MAIN, + series: [ + { + name: S.nutrition.series.protein, + type: "bar", + stack: "macros", + barMaxWidth: 28, + itemStyle: { color: MACRO_COLORS.protein, ...STACK_BORDER }, + data: build("protein", "protein_g"), + }, + { + name: S.nutrition.series.carbs, + type: "bar", + stack: "macros", + itemStyle: { color: MACRO_COLORS.carbs, ...STACK_BORDER }, + data: build("carbs", "carbs_g"), + }, + { + name: S.nutrition.series.fat, + type: "bar", + stack: "macros", + itemStyle: { color: MACRO_COLORS.fat, ...STACK_BORDER, borderRadius: BAR_RADIUS_UP }, + data: build("fat", "fat_g"), + }, + ], + }; + }, [days, dayLabels, macroUnit]); + + /* --- G3 : today donut -------------------------------------------- */ + + const donutOption = useMemo(() => { + if (!splitToday) return emptyChartOption(); + const detail = journal.data; + const data = [ + { + name: S.nutrition.series.protein, + value: Math.round(Number(detail?.protein_g ?? 0) * KCAL_PER_G.protein), + grams: Number(detail?.protein_g ?? 0), + itemStyle: { color: MACRO_COLORS.protein }, + }, + { + name: S.nutrition.series.carbs, + value: Math.round(Number(detail?.carbs_g ?? 0) * KCAL_PER_G.carbs), + grams: Number(detail?.carbs_g ?? 0), + itemStyle: { color: MACRO_COLORS.carbs }, + }, + { + name: S.nutrition.series.fat, + value: Math.round(Number(detail?.fat_g ?? 0) * KCAL_PER_G.fat), + grams: Number(detail?.fat_g ?? 0), + itemStyle: { color: MACRO_COLORS.fat }, + }, + ]; + return { + tooltip: { + trigger: "item", + formatter: (raw: unknown) => { + const [param] = tooltipParams(raw); + const item = data.find((d) => d.name === param?.name); + if (!item) return ""; + return `${tooltipTitle(item.name)}${formatKcal(item.value)} (${formatPercent( + param?.percent ?? 0, + )}) · ${formatG(item.grams)}`; + }, + }, + graphic: [ + { + type: "text", + left: "center", + top: "middle", + style: { + text: formatKcal(Math.round(splitToday.total)), + fill: CHART_SURFACE.ink, + fontSize: 18, + fontWeight: 600, + }, + }, + ], + series: [ + { + type: "pie", + radius: ["55%", "80%"], + avoidLabelOverlap: true, + label: { + formatter: "{b} {d}%", + color: CHART_SURFACE.inkSecondary, + fontSize: 11, + }, + labelLine: { length: 8, length2: 8 }, + data, + }, + ], + }; + }, [splitToday, journal.data]); + + /* --- G4 : meal distribution -------------------------------------- */ + + const mealOption = useMemo(() => { + if (entries.length === 0) return emptyChartOption(); + const byDay = new Map>(); + for (const entry of entries) { + const day = localDayOf(entry.eaten_at); + const bucket = + byDay.get(day) ?? ({ breakfast: 0, lunch: 0, dinner: 0, snack: 0 } as Record); + bucket[entry.meal] += Number(entry.kcal ?? 0); + byDay.set(day, bucket); + } + const labels = [...byDay.keys()].sort(); + const totals = labels.map((day) => { + const bucket = byDay.get(day); + if (!bucket) return 0; + return MEAL_ORDER.reduce((sum, meal) => sum + bucket[meal], 0); + }); + return { + grid: GRID_DEFAULT, + legend: { data: MEAL_ORDER.map((meal) => S.nutrition.meals[meal]) }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const list = tooltipParams(raw); + const index = list[0]?.dataIndex ?? 0; + const day = labels[index]; + const bucket = byDay.get(day); + if (!bucket) return ""; + const total = totals[index] || 1; + return [ + tooltipTitle(formatDayLabel(day)), + ...MEAL_ORDER.map((meal, i) => + tooltipRow( + list[i]?.marker, + S.nutrition.meals[meal], + `${formatKcal(bucket[meal])} · ${formatPercent((bucket[meal] / total) * 100)}`, + ), + ), + ].join(""); + }, + }, + xAxis: dayAxis(labels), + yAxis: { type: "value", max: 100, axisLabel: { formatter: "{value} %" } }, + series: MEAL_ORDER.map((meal, index) => ({ + name: S.nutrition.meals[meal], + type: "bar" as const, + stack: "meals", + barMaxWidth: 28, + itemStyle: { + color: MEAL_COLORS[meal], + ...STACK_BORDER, + ...(index === MEAL_ORDER.length - 1 ? { borderRadius: BAR_RADIUS_UP } : {}), + }, + data: labels.map((day, i) => { + const bucket = byDay.get(day); + const total = totals[i]; + if (!bucket || total <= 0) return null; + return Number(((bucket[meal] / total) * 100).toFixed(1)); + }), + })), + }; + }, [entries]); + + /* --- G5 : top foods ---------------------------------------------- */ + + const topFoods = useMemo(() => { + const map = new Map(); + for (const entry of entries) { + const current = map.get(entry.name) ?? { kcal: 0, count: 0 }; + current.kcal += Number(entry.kcal ?? 0); + current.count += 1; + map.set(entry.name, current); + } + return [...map.entries()] + .map(([name, value]) => ({ name, ...value })) + .sort((a, b) => b.kcal - a.kcal) + .slice(0, 10) + .reverse(); + }, [entries]); + + const topFoodsOption = useMemo(() => { + if (topFoods.length === 0) return emptyChartOption(); + return { + grid: { left: 8, right: 72, top: 16, bottom: 8, containLabel: true }, + tooltip: { + trigger: "item", + formatter: (raw: unknown) => { + const [param] = tooltipParams(raw); + const item = topFoods[param?.dataIndex ?? 0]; + if (!item) return ""; + return `${tooltipTitle(item.name)}${formatKcal(item.kcal)} · ${ + item.count + } fois · ${formatKcal(item.kcal / item.count)}/fois`; + }, + }, + xAxis: { type: "value", axisLabel: { formatter: "{value}" } }, + yAxis: { + type: "category", + data: topFoods.map((f) => truncate(f.name)), + axisLabel: { width: 140, overflow: "truncate" }, + }, + series: [ + { + name: S.nutrition.series.kcal, + type: "bar", + barMaxWidth: 18, + itemStyle: { color: CHART_COLORS[1], borderRadius: [0, 4, 4, 0] }, + label: { + show: true, + position: "right", + color: CHART_SURFACE.inkSecondary, + fontSize: 11, + formatter: (param: { value?: unknown }) => + typeof param.value === "number" ? formatKcal(param.value) : "", + }, + data: topFoods.map((f) => Math.round(f.kcal)), + }, + ], + }; + }, [topFoods]); + + /* --- journal ------------------------------------------------------ */ + + const detail = journal.data; + const journalEntries = (detail?.meals ?? []).map((group) => ({ + ...group, + entries: foodFilter + ? group.entries.filter((e) => e.name.toLowerCase() === foodFilter.toLowerCase()) + : group.entries, + })); + const journalEmpty = journalEntries.every((group) => group.entries.length === 0); + + const openAdd = (meal: MealType | null) => { + setEditing(null); + setDuplicating(null); + setModalMeal(meal); + setModalOpen(true); + }; + + return ( +
    + + + + + + } + /> + + {daysQuery.isError ? : null} + {entriesQuery.isError ? : null} + +
    + Number(todayBudget) + ? "negative" + : "positive" + } + sub={ + remaining === null + ? undefined + : `${S.nutrition.journal.remaining} ${formatKcal(Math.max(remaining, 0))}` + } + /> + + + + + +
    + + {/* Journal du jour */} + + + + {formatDayLabel(journalDay)} + + + +
    + } + > + {journal.isError ? : null} + +
    + + {S.nutrition.journal.totals} : {formatKcal(Number(detail?.kcal ?? 0))} + {detail?.budget_kcal ? ` / ${formatKcal(Number(detail.budget_kcal))}` : ""} + + + P {formatG(Number(detail?.protein_g ?? 0))} · G {formatG(Number(detail?.carbs_g ?? 0))} · + L {formatG(Number(detail?.fat_g ?? 0))} + +
    + {detail?.budget_kcal ? ( +
    +
    +
    + ) : null} + + {foodFilter ? ( +
    + + {S.nutrition.journal.filteredOn} : {foodFilter} + + +
    + ) : null} + + {journal.isPending ? ( +

    {S.common.loading}

    + ) : journalEmpty ? ( + + + + + + + } + /> + ) : ( +
    + {journalEntries.map((group) => { + const isCollapsed = collapsed.includes(group.meal); + return ( +
    +
    + + +
    + {isCollapsed ? null : ( +
      + {group.entries.length === 0 ? ( +
    • {S.common.none}
    • + ) : ( + group.entries.map((item) => ( +
    • + {item.name} + + {formatNumber(Number(item.quantity))} {unitLabel(item.unit)} + + + {formatKcal(Number(item.kcal))} + + + P {formatG(Number(item.protein_g ?? 0))} · G{" "} + {formatG(Number(item.carbs_g ?? 0))} · L{" "} + {formatG(Number(item.fat_g ?? 0))} + + {item.source !== "manual" ? ( + {sourceLabel(item.source)} + ) : null} + + + + + +
    • + )) + )} +
    + )} +
    + ); + })} +
    + )} + + + + + setMacroUnit((u) => (u === "kcal" ? "g" : "kcal"))} + > + {macroUnit === "kcal" ? "Afficher en grammes" : "Afficher en kcal"} + + } + /> + +
    + + +
    + +
    + setFoodFilter(null)}> + {S.nutrition.journal.clearFilter} + + ) : null + } + /> + {topFoods.length > 0 ? ( +
    + {S.nutrition.journal.filteredOn} : + {[...topFoods] + .reverse() + .slice(0, 6) + .map((food) => ( + + ))} +
    + ) : null} +
    + + {libraryOpen ? ( +
    +
    setLibraryOpen(false)} + /> + +
    + ) : null} + + setModalOpen(false)} + day={journalDay} + meal={modalMeal} + entry={editing} + template={duplicating} + onSaved={showToast} + /> + + { + if (!toDelete) return; + removeEntry.mutate(toDelete.id, { onSuccess: () => setToDelete(null) }); + }} + onClose={() => setToDelete(null)} + /> + {toast} +
    + ); +} diff --git a/apps/web/src/modules/health/pages/WeightPage.tsx b/apps/web/src/modules/health/pages/WeightPage.tsx new file mode 100644 index 0000000..41c87db --- /dev/null +++ b/apps/web/src/modules/health/pages/WeightPage.tsx @@ -0,0 +1,816 @@ +import type { EChartsOption } from "echarts"; +import { Pencil, Plus, Scale, Trash2, TrendingDown, TrendingUp } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; + +import { ChartCard } from "../../../components/charts/ChartCard"; +import { CHART_BLUE_TINTS, CHART_COLORS, CHART_SURFACE } from "../../../components/charts/theme"; +import { PeriodSelector } from "../../../components/PeriodSelector"; +import type { PeriodRange } from "../../../components/PeriodSelector"; +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { ConfirmDialog } from "../../../components/ui/ConfirmDialog"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { PageHeader } from "../../../components/ui/PageHeader"; +import { StatCard } from "../../../components/ui/StatCard"; +import { Table } from "../../../components/ui/Table"; +import type { TableColumn } from "../../../components/ui/Table"; +import { daysAgoIso, diffDays, todayIso } from "../../../lib/dates"; +import { + deltaTone, + deltaToneName, + formatDate, + formatNumber, + formatPercent, + formatSigned, + formatWeight, +} from "../../../lib/format"; +import type { GoalDirection } from "../../../lib/format"; +import { + seriesPoints, + useActiveGoal, + useDeleteWeight, + useMeasurementStats, + useMeasurements, + useProfile, + useWeightStats, + useWeights, +} from "../api"; +import type { SeriesPoint, WeightRead } from "../api"; +import { + GRID_DEFAULT, + GRID_WITH_SLIDER, + ZOOM_INSIDE, + ZOOM_MAIN, + directionalVisualMap, + emptyChartOption, + groupByWeek, + paramValue, + targetMarkLine, + tooltipParams, + tooltipRow, + tooltipTitle, + weekRangeLabel, +} from "../charts"; +import { HabitPlanningSection } from "../components/HabitPlanningSection"; +import { MeasurementModal } from "../components/MeasurementModal"; +import { ErrorNotice } from "../components/QueryBoundary"; +import { useToast } from "../components/Toast"; +import { WeightModal } from "../components/WeightModal"; +import { localDayOf } from "../form"; +import { S } from "../strings"; + +/* ------------------------------------------------------------------ */ +/* BMI */ +/* ------------------------------------------------------------------ */ + +const BMI_BANDS = [ + { max: 18.5, label: S.weight.bmiCategories.underweight, color: CHART_COLORS[0] }, + { max: 25, label: S.weight.bmiCategories.normal, color: CHART_COLORS[2] }, + { max: 30, label: S.weight.bmiCategories.overweight, color: CHART_COLORS[3] }, + { max: 99, label: S.weight.bmiCategories.obese, color: CHART_COLORS[7] }, +]; + +function bmiCategory(bmi: number): string { + return BMI_BANDS.find((band) => bmi < band.max)?.label ?? S.weight.bmiCategories.obese; +} + +/** Filters out the null points of a stats series. */ +function valued(points: SeriesPoint[]): [string, number][] { + return points.filter((p): p is [string, number] => typeof p[1] === "number"); +} + +function lastOf(list: T[]): T | null { + return list.length > 0 ? list[list.length - 1] : null; +} + +const PAGE_SIZE = 20; + +export default function WeightPage() { + const [range, setRange] = useState({ + key: "90j", + from: daysAgoIso(89), + to: todayIso(), + label: "90 j", + }); + const params = { from: range.from, to: range.to }; + + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [measureOpen, setMeasureOpen] = useState(false); + const [toDelete, setToDelete] = useState(null); + const [page, setPage] = useState(1); + const { showToast, toast } = useToast(); + + const weights = useWeights(params); + const stats = useWeightStats(params); + const goal = useActiveGoal(); + const profile = useProfile(); + const measurements = useMeasurements(params); + const measurementStats = useMeasurementStats(params); + const removeWeight = useDeleteWeight(); + + const meta = stats.data?.meta; + const rawPoints = useMemo(() => valued(seriesPoints(stats.data, "weight_raw")), [stats.data]); + const trendPoints = useMemo(() => valued(seriesPoints(stats.data, "weight_trend")), [stats.data]); + + const entries = weights.data ?? []; + const latest = entries.length > 0 ? entries[0] : null; + const lastTrend = lastOf(trendPoints); + const trendNow = meta?.trend_now ?? lastTrend?.[1] ?? null; + + const heightM = profile.data?.height_cm ? Number(profile.data.height_cm) / 100 : null; + const bmi = + heightM && heightM > 0 && (trendNow ?? latest?.weight_kg) + ? (trendNow ?? Number(latest?.weight_kg)) / (heightM * heightM) + : null; + + const activeGoal = goal.data ?? null; + const targetWeight = activeGoal?.target_weight_kg ?? meta?.target_weight_kg ?? null; + const goalDirection: GoalDirection = + activeGoal && targetWeight !== null && Number(targetWeight) > Number(activeGoal.start_weight_kg) + ? "gain" + : "lose"; + /** Target rate in kg/week, signed for display (goals store positive = loss). */ + const targetRate = + activeGoal?.weekly_rate_kg !== null && activeGoal?.weekly_rate_kg !== undefined + ? -Number(activeGoal.weekly_rate_kg) + : null; + + /* --- KPI values ------------------------------------------------- */ + + const trendDelta7 = useMemo(() => { + if (trendPoints.length < 2 || trendNow === null) return null; + const last = trendPoints[trendPoints.length - 1][0]; + for (let i = trendPoints.length - 1; i >= 0; i -= 1) { + if (diffDays(trendPoints[i][0], last) >= 7) return trendNow - trendPoints[i][1]; + } + return null; + }, [trendPoints, trendNow]); + + const weeklyRates = useMemo(() => { + const groups = groupByWeek( + trendPoints.map((p) => p[0]), + (_day, index) => trendPoints[index][1], + ); + return groups.map((group, index) => { + const previous = index > 0 ? lastOf(groups[index - 1].values) : null; + const current = lastOf(group.values); + const rate = previous !== null && current !== null ? current - previous : null; + return { bucket: group.bucket, rate }; + }); + }, [trendPoints]); + + const currentRate = + meta?.slope_14d_kg_week ?? lastOf(weeklyRates.filter((w) => w.rate !== null))?.rate ?? null; + + const remainingKg = + activeGoal?.remaining_kg ?? + (targetWeight !== null && trendNow !== null ? trendNow - Number(targetWeight) : null); + + const progressPct = useMemo(() => { + if (activeGoal?.pct !== null && activeGoal?.pct !== undefined) return Number(activeGoal.pct); + if (!activeGoal || trendNow === null) return null; + const total = Number(activeGoal.start_weight_kg) - Number(activeGoal.target_weight_kg); + if (total === 0) return null; + return Math.max(0, Math.min(100, ((Number(activeGoal.start_weight_kg) - trendNow) / total) * 100)); + }, [activeGoal, trendNow]); + + const projection = meta?.projection ?? activeGoal?.projection ?? null; + const projectionWeeks = + projection?.status === "ok" && projection.date + ? Math.max(1, Math.round(diffDays(todayIso(), projection.date) / 7)) + : null; + + /* --- G1 : weight evolution -------------------------------------- */ + + const evolutionOption = useMemo(() => { + if (rawPoints.length === 0 && trendPoints.length === 0) return emptyChartOption(); + + const projectionSeries: [string, number][] = []; + if (lastTrend && projection?.status === "ok" && projection.date && targetWeight !== null) { + projectionSeries.push([lastTrend[0], lastTrend[1]], [projection.date, Number(targetWeight)]); + } + + return { + grid: GRID_WITH_SLIDER, + legend: { data: [S.weight.series.raw, S.weight.series.trend, S.weight.series.projection] }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const params_ = tooltipParams(raw); + const head = params_[0]; + const date = Array.isArray(head?.value) ? String(head.value[0]) : ""; + const rows = params_ + .filter((p) => paramValue(p) !== null) + .map((p) => tooltipRow(p.marker, p.seriesName ?? "", formatWeight(paramValue(p) ?? 0))) + .join(""); + return `${tooltipTitle(formatDate(date))}${rows}`; + }, + }, + xAxis: { type: "time" }, + yAxis: { type: "value", scale: true, axisLabel: { formatter: "{value} kg" } }, + dataZoom: ZOOM_MAIN, + series: [ + { + name: S.weight.series.raw, + type: "scatter", + symbolSize: 6, + itemStyle: { color: CHART_COLORS[0], opacity: 0.45 }, + data: rawPoints, + }, + { + name: S.weight.series.trend, + type: "line", + showSymbol: false, + smooth: 0.2, + lineStyle: { width: 2, color: CHART_COLORS[0] }, + itemStyle: { color: CHART_COLORS[0] }, + data: trendPoints, + markLine: + targetWeight === null + ? undefined + : targetMarkLine( + Number(targetWeight), + `${S.weight.series.goal} ${formatWeight(Number(targetWeight))}`, + ), + }, + { + name: S.weight.series.projection, + type: "line", + showSymbol: false, + lineStyle: { width: 2, type: [6, 4], color: CHART_BLUE_TINTS.light }, + itemStyle: { color: CHART_BLUE_TINTS.light }, + data: projectionSeries, + markPoint: + projectionSeries.length === 2 + ? { + symbol: "circle", + symbolSize: 8, + itemStyle: { color: CHART_BLUE_TINTS.light }, + label: { + formatter: formatDate(projectionSeries[1][0]), + position: "top", + color: CHART_SURFACE.inkSecondary, + fontSize: 11, + }, + data: [{ name: S.weight.series.projection, coord: projectionSeries[1] }], + } + : undefined, + }, + ], + }; + }, [rawPoints, trendPoints, lastTrend, projection, targetWeight]); + + /* --- G2 : weekly rate ------------------------------------------- */ + + const rateOption = useMemo(() => { + const points = weeklyRates.filter((w) => w.rate !== null); + if (points.length === 0) return emptyChartOption(); + return { + grid: GRID_DEFAULT, + tooltip: { + trigger: "item", + formatter: (raw: unknown) => { + const [param] = tooltipParams(raw); + const index = param?.dataIndex ?? 0; + const item = points[index]; + if (!item) return ""; + return `${tooltipTitle( + `${item.bucket.label} · ${weekRangeLabel(item.bucket)}`, + )}${formatSigned(item.rate ?? 0, 2, "kg")}`; + }, + }, + xAxis: { type: "category", data: points.map((w) => w.bucket.label) }, + yAxis: { type: "value", axisLabel: { formatter: "{value} kg" } }, + visualMap: directionalVisualMap(goalDirection === "lose"), + series: [ + { + name: S.weight.series.rate, + type: "bar", + barMaxWidth: 28, + data: points.map((w) => w.rate as number), + markLine: + targetRate === null + ? undefined + : targetMarkLine(targetRate, `${S.weight.kpi.weeklyRate} ${formatNumber(targetRate, 2)}`), + }, + ], + }; + }, [weeklyRates, goalDirection, targetRate]); + + /* --- G3 : BMI ---------------------------------------------------- */ + + const bmiOption = useMemo(() => { + if (!heightM || trendPoints.length === 0) return emptyChartOption(); + const data = trendPoints.map(([date, kg]) => [date, Number((kg / (heightM * heightM)).toFixed(2))]); + let floor = 0; + const bands: [ + { yAxis: number; itemStyle: { color: string }; name: string }, + { yAxis: number }, + ][] = BMI_BANDS.map((band) => { + const area: [ + { yAxis: number; itemStyle: { color: string }; name: string }, + { yAxis: number }, + ] = [ + { yAxis: floor, itemStyle: { color: `${band.color}10` }, name: band.label }, + { yAxis: Math.min(band.max, 35) }, + ]; + floor = band.max; + return area; + }); + return { + grid: GRID_DEFAULT, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const [param] = tooltipParams(raw); + const value = paramValue(param); + const date = Array.isArray(param?.value) ? String(param.value[0]) : ""; + if (value === null) return ""; + return `${tooltipTitle(formatDate(date))}IMC ${formatNumber(value, 1)} (${bmiCategory( + value, + )})`; + }, + }, + xAxis: { type: "time" }, + yAxis: { type: "value", min: 16, max: 35 }, + dataZoom: ZOOM_INSIDE, + series: [ + { + name: S.weight.kpi.bmi, + type: "line", + showSymbol: false, + smooth: 0.2, + lineStyle: { width: 2, color: CHART_COLORS[0] }, + itemStyle: { color: CHART_COLORS[0] }, + data, + markArea: { + silent: true, + label: { + position: "insideTopRight", + color: CHART_SURFACE.inkMuted, + fontSize: 10, + }, + data: bands, + }, + }, + ], + }; + }, [heightM, trendPoints]); + + /* --- G4 : measurements ------------------------------------------ */ + + const measurementSeriesLabels: Record = useMemo( + () => ({ + waist_cm: S.weight.measurements.waist, + hips_cm: S.weight.measurements.hips, + chest_cm: S.weight.measurements.chest, + biceps_right_cm: S.weight.measurements.arm, + biceps_left_cm: S.weight.measurements.arm, + thigh_right_cm: S.weight.measurements.thigh, + thigh_left_cm: S.weight.measurements.thigh, + neck_cm: S.weight.measurements.neck, + calf_right_cm: S.weight.measurements.calf, + calf_left_cm: S.weight.measurements.calf, + }), + [], + ); + + const measurementOption = useMemo(() => { + const series = (measurementStats.data?.series ?? []).filter( + (s) => measurementSeriesLabels[s.name] && valued(s.points).length > 0, + ); + if (series.length === 0) return emptyChartOption(); + const names = series.map((s) => measurementSeriesLabels[s.name]); + return { + grid: GRID_DEFAULT, + legend: { data: names }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (raw: unknown) => { + const params_ = tooltipParams(raw); + const date = Array.isArray(params_[0]?.value) ? String(params_[0].value[0]) : ""; + const rows = params_ + .filter((p) => paramValue(p) !== null) + .map((p) => + tooltipRow(p.marker, p.seriesName ?? "", `${formatNumber(paramValue(p) ?? 0, 1)} cm`), + ) + .join(""); + return `${tooltipTitle(formatDate(date))}${rows}`; + }, + }, + xAxis: { type: "time" }, + yAxis: { type: "value", scale: true, axisLabel: { formatter: "{value} cm" } }, + dataZoom: ZOOM_INSIDE, + series: series.map((s, index) => ({ + name: measurementSeriesLabels[s.name], + type: "line" as const, + showSymbol: false, + smooth: 0.2, + lineStyle: { width: 2, color: CHART_COLORS[index % CHART_COLORS.length] }, + itemStyle: { color: CHART_COLORS[index % CHART_COLORS.length] }, + endLabel: { + show: true, + formatter: measurementSeriesLabels[s.name], + color: CHART_SURFACE.inkSecondary, + fontSize: 11, + }, + data: valued(s.points), + })), + }; + }, [measurementStats.data, measurementSeriesLabels]); + + /* --- history table ---------------------------------------------- */ + + const trendByDay = useMemo(() => { + const map = new Map(); + for (const [date, value] of trendPoints) map.set(date.slice(0, 10), value); + return map; + }, [trendPoints]); + + const rows = useMemo(() => { + const sorted = [...entries].sort((a, b) => (a.measured_at < b.measured_at ? 1 : -1)); + return sorted.map((entry, index) => { + const previous = sorted[index + 1]; + return { + entry, + day: localDayOf(entry.measured_at), + trend: trendByDay.get(localDayOf(entry.measured_at)) ?? null, + variation: previous ? Number(entry.weight_kg) - Number(previous.weight_kg) : null, + }; + }); + }, [entries, trendByDay]); + + type Row = (typeof rows)[number]; + + const columns: TableColumn[] = [ + { + key: "date", + header: S.common.date, + sortable: true, + sortValue: (row) => row.entry.measured_at, + render: (row) => formatDate(row.entry.measured_at), + }, + { + key: "weight", + header: S.weight.table.weight, + align: "right", + sortable: true, + sortValue: (row) => Number(row.entry.weight_kg), + render: (row) => formatWeight(Number(row.entry.weight_kg)), + }, + { + key: "trend", + header: S.weight.table.trend, + align: "right", + render: (row) => (row.trend === null ? S.common.none : formatWeight(row.trend)), + }, + { + key: "variation", + header: S.weight.table.variation, + align: "right", + render: (row) => + row.variation === null ? ( + S.common.none + ) : ( + + {formatSigned(row.variation, 1, "kg")} + + ), + }, + { + key: "note", + header: S.common.note, + render: (row) => row.entry.note ?? S.common.none, + }, + { + key: "actions", + header: S.common.actionsColumn, + align: "right", + render: (row) => ( +
    + + +
    + ), + }, + ]; + + const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + const isEmpty = !weights.isPending && !weights.isError && entries.length === 0; + + const openAdd = () => { + setEditing(null); + setModalOpen(true); + }; + + return ( +
    + + + + + } + /> + + {weights.isError ? : null} + {stats.isError ? : null} + + {isEmpty ? ( + + + + + + + + } + /> + + ) : ( + <> +
    + + 0 ? TrendingUp : TrendingDown} + /> + + + + 1 ? "s" : ""}` + : S.weight.notConverging + } + /> +
    + + formatWeight(value)} + emptyHint={S.planning.emptyPlan} + adherenceLabel={S.weight.kpi.adherence} + /> + + + +
    + + +
    + + {(measurementStats.data?.series.length ?? 0) > 0 ? ( + + ) : null} + + setMeasureOpen(true)}> + {S.weight.measurements.title} + + } + > + {(measurements.data?.length ?? 0) === 0 ? ( +

    {S.weight.measurements.empty}

    + ) : ( +
      + {(measurements.data ?? []).slice(0, 8).map((row) => ( +
    • + + {formatDate(row.measured_at)} + + {row.waist_cm ? ( + + {S.weight.measurements.waist} {formatNumber(Number(row.waist_cm), 1)} cm + + ) : null} + {row.hips_cm ? ( + + {S.weight.measurements.hips} {formatNumber(Number(row.hips_cm), 1)} cm + + ) : null} + {row.chest_cm ? ( + + {S.weight.measurements.chest} {formatNumber(Number(row.chest_cm), 1)} cm + + ) : null} +
    • + ))} +
    + )} +
    + + + {S.actions.addWeight} + + } + > +
    row.entry.id} + className="px-1 pb-4" + pagination={{ + page, + pageSize: PAGE_SIZE, + total: rows.length, + onPageChange: setPage, + }} + /> + + + + {activeGoal ? ( +
    +

    + {`${S.weight.goalCard.start} ${formatWeight( + Number(activeGoal.start_weight_kg), + )} (${formatDate(activeGoal.start_date)}) → ${ + S.weight.goalCard.target + } ${formatWeight(Number(activeGoal.target_weight_kg))}`} +

    +
    +
    +
    +
    + {progressPct !== null ? ( + {formatPercent(progressPct)} + ) : null} + {activeGoal.daily_budget_kcal ?? activeGoal.daily_budget ? ( + + {`Budget ${formatNumber( + Number(activeGoal.daily_budget_kcal ?? activeGoal.daily_budget), + )} kcal/j`} + + ) : null} + + {S.actions.editGoal} + +
    +
    + ) : ( +

    + {S.weight.goalCard.none}{" "} + + {S.actions.editGoal} + +

    + )} + + + )} + + setModalOpen(false)} + entry={editing} + entries={entries} + onSaved={showToast} + /> + setMeasureOpen(false)} + onSaved={() => showToast(S.weight.measurements.title)} + /> + { + if (!toDelete) return; + removeWeight.mutate(toDelete.id, { onSuccess: () => setToDelete(null) }); + }} + onClose={() => setToDelete(null)} + /> + {toast} +
    + ); +} diff --git a/apps/web/src/modules/health/strings.ts b/apps/web/src/modules/health/strings.ts new file mode 100644 index 0000000..3a3bc83 --- /dev/null +++ b/apps/web/src/modules/health/strings.ts @@ -0,0 +1,431 @@ +/** + * French UI strings of the health module (CONVENTIONS C5.3). + * Code stays English, every user-facing label lives here. + */ + +export const S = { + module: { + title: "Santé", + }, + + nav: { + weight: "Poids & Objectif", + nutrition: "Nutrition", + activity: "Activité & Sport", + energy: "Balance énergétique", + }, + + actions: { + add: "Ajouter", + addWeight: "+ Ajouter une pesée", + addFood: "+ Ajouter un aliment", + addWorkout: "+ Ajouter une séance", + addMeasurement: "+ Ajouter des mensurations", + save: "Enregistrer", + cancel: "Annuler", + edit: "Modifier", + delete: "Supprimer", + close: "Fermer", + today: "Aujourd'hui", + replace: "Remplacer", + addAnyway: "Ajouter quand même", + addAndContinue: "Ajouter et continuer", + resetFilters: "Réinitialiser les filtres", + importCsv: "Importer un CSV", + importFoodvisor: "Importer Foodvisor", + seeImports: "Voir les imports", + goToWeight: "Aller à Poids", + goToNutrition: "Aller à Nutrition", + noteWeighIn: "Noter ma pesée", + noteWorkout: "Noter ma séance", + editGoal: "Modifier l'objectif", + useThisTdee: "Utiliser cette valeur comme TDEE", + }, + + common: { + loading: "Chargement…", + none: "—", + date: "Date", + time: "Heure", + note: "Note", + source: "Source", + total: "Total", + average: "Moyenne", + week: "Semaine", + day: "Jour", + kcal: "kcal", + actionsColumn: "Actions", + errorTitle: "Impossible de charger ces données", + retry: "Réessayer", + deleteConfirmTitle: "Supprimer cet élément ?", + deleteConfirmMessage: "Cette action est irréversible.", + noDataOnPeriod: "Pas de données sur cette période", + widenPeriod: "Élargir la période", + optional: "optionnel", + }, + + errors: { + weightRange: "Le poids doit être compris entre 20 et 300 kg.", + required: "Ce champ est obligatoire.", + invalidNumber: "Valeur numérique invalide.", + invalidDuration: "Durée invalide (format hh:mm).", + positive: "La valeur doit être positive.", + }, + + /* ---------------------------------------------------------------- */ + /* Poids & Objectif */ + /* ---------------------------------------------------------------- */ + weight: { + title: "Poids & Objectif", + kpi: { + current: "Poids actuel", + trend: "Tendance (EMA)", + weeklyRate: "Rythme hebdo", + bmi: "IMC", + goal: "Objectif", + projection: "Atteinte estimée", + adherence: "Assiduité 30 j", + }, + charts: { + evolution: "Évolution du poids", + weeklyRate: "Rythme hebdomadaire", + bmi: "IMC", + measurements: "Mensurations", + adherence: "Calendrier des pesées", + }, + series: { + raw: "Pesées", + trend: "Tendance", + projection: "Projection", + goal: "Objectif", + rate: "Rythme", + }, + table: { + title: "Historique des pesées", + weight: "Poids", + trend: "Tendance", + variation: "Variation", + }, + goalCard: { + title: "Objectif", + none: "Aucun objectif défini.", + progress: "Progression", + start: "Départ", + target: "Objectif", + remaining: "Reste", + }, + modal: { + title: "Ajouter une pesée", + editTitle: "Modifier la pesée", + weightLabel: "Poids (kg)", + dateLabel: "Date", + timeLabel: "Heure", + noteLabel: "Note", + notePlaceholder: "ex. après le sport", + measurementsSection: "Mensurations (optionnel)", + saved: "Pesée enregistrée", + }, + measurements: { + title: "Mensurations", + waist: "Tour de taille", + hips: "Hanches", + chest: "Poitrine", + neck: "Cou", + arm: "Bras", + thigh: "Cuisse", + calf: "Mollet", + bodyFat: "Masse grasse estimée", + empty: "Aucune mensuration enregistrée.", + }, + empty: { + title: "Aucune pesée pour l'instant", + hint: "Ajoutez votre première pesée ou importez un historique — la tendance et les projections apparaîtront dès 3 pesées.", + }, + bmiCategories: { + underweight: "Maigreur", + normal: "Corpulence normale", + overweight: "Surpoids", + obese: "Obésité", + }, + notConverging: "rythme insuffisant", + }, + + /* ---------------------------------------------------------------- */ + /* Nutrition */ + /* ---------------------------------------------------------------- */ + nutrition: { + title: "Nutrition", + kpi: { + today: "Aujourd'hui", + avg7: "Moyenne 7 j", + protein: "Protéines aujourd'hui", + split: "Répartition du jour", + daysInBudget: "Jours dans le budget", + avgGap: "Écart moyen au budget", + }, + charts: { + kcalPerDay: "Calories par jour", + macrosPerDay: "Macronutriments par jour", + todaySplit: "Répartition du jour", + perMeal: "Répartition par repas", + topFoods: "Top aliments", + }, + series: { + intake: "Apports", + budget: "Budget", + protein: "Protéines", + carbs: "Glucides", + fat: "Lipides", + kcal: "Calories", + }, + journal: { + title: "Journal du jour", + totals: "Total", + remaining: "Reste", + addToMeal: "+ Ajouter un aliment", + quantity: "Quantité", + duplicate: "Dupliquer", + filteredOn: "Filtré sur", + clearFilter: "Retirer le filtre", + }, + meals: { + breakfast: "Petit-déjeuner", + lunch: "Déjeuner", + dinner: "Dîner", + snack: "Collations", + }, + modal: { + title: "Ajouter un aliment", + editTitle: "Modifier l'aliment", + search: "Rechercher un aliment", + searchPlaceholder: "ex. pain complet", + favorites: "Favoris", + recents: "Récents", + catalog: "Catalogue", + noResult: "Aucun aliment trouvé — saisissez les valeurs manuellement.", + name: "Nom", + meal: "Repas", + quantity: "Quantité", + unit: "Unité", + kcal: "Calories (kcal)", + protein: "Protéines (g)", + carbs: "Glucides (g)", + fat: "Lipides (g)", + remember: "Mémoriser cet aliment dans ma bibliothèque", + saved: "Aliment ajouté", + }, + empty: { + title: "Rien dans le journal aujourd'hui", + hint: "Ajoutez votre premier aliment ou importez votre historique Foodvisor depuis la page Imports.", + }, + }, + + /* ---------------------------------------------------------------- */ + /* Activité & Sport */ + /* ---------------------------------------------------------------- */ + activity: { + title: "Activité & Sport", + kpi: { + stepsToday: "Pas aujourd'hui", + stepsAvg: "Moyenne pas (7 j)", + activeKcalToday: "Kcal actives aujourd'hui", + distance: "Distance (période)", + sessionsWeek: "Séances cette semaine", + goalReached: "Objectif atteint", + adherence: "Assiduité 30 j", + }, + charts: { + steps: "Pas par jour", + activeKcal: "Calories actives par jour", + weekly: "Entraînement par semaine", + distance: "Distance cumulée", + adherence: "Calendrier des séances", + }, + series: { + steps: "Pas", + movingAvg: "Moyenne 7 j", + activeKcal: "Calories actives", + distance: "Distance cumulée", + }, + records: { + title: "Records", + maxSteps: "Max pas en un jour", + longestWorkout: "Plus longue séance", + bestDistance: "Meilleure distance en séance", + bestWeek: "Meilleure semaine", + }, + table: { + title: "Séances", + type: "Type", + duration: "Durée", + distance: "Distance", + kcal: "Kcal", + avgHr: "FC moy.", + allTypes: "Tous les types", + allSources: "Toutes les sources", + }, + modal: { + title: "Ajouter une séance", + editTitle: "Modifier la séance", + type: "Type", + date: "Date", + startTime: "Heure de début", + duration: "Durée (hh:mm)", + distance: "Distance (km)", + kcal: "Calories (kcal)", + kcalPlaceholder: "estimées automatiquement si vide", + avgHr: "FC moyenne (bpm)", + saved: "Séance enregistrée", + }, + provenance: "Provenance des données du jour", + stepsGoal: "Objectif de pas", + empty: { + title: "Aucune activité enregistrée", + hint: "Connectez l'application compagnon Health Connect, importez un export FitShow, ou saisissez une séance manuellement.", + }, + }, + + /* ---------------------------------------------------------------- */ + /* Balance énergétique */ + /* ---------------------------------------------------------------- */ + energy: { + title: "Balance énergétique", + kpi: { + today: "Balance aujourd'hui", + avgDeficit: "Déficit moyen (7 j)", + cumulative: "Cumul (période)", + tdee: "TDEE estimé", + budget: "Budget quotidien", + realVsModel: "Réel vs théorique", + }, + charts: { + inOut: "Entrées vs sorties", + net: "Balance nette quotidienne", + cumulative: "Déficit cumulé", + weightModel: "Poids théorique vs poids réel", + }, + series: { + intake: "Apports", + expenditure: "Dépense énergétique", + net: "Balance nette", + cumulative: "Déficit cumulé", + realWeight: "Poids réel (tendance)", + theoreticalWeight: "Poids théorique", + targetDeficit: "Cible", + }, + method: { + title: "Méthode & calibration", + text: "Le métabolisme de base (BMR) est calculé avec la formule Mifflin-St Jeor à partir du poids de tendance du jour. La dépense totale (TDEE) applique ensuite votre facteur d'activité, ou ajoute vos calories actives mesurées. Les conversions énergie ↔ masse utilisent 7 700 kcal par kilogramme.", + calibration: "Calibration", + insufficient: "Pas encore assez de jours suivis pour calibrer le modèle (21 jours minimum).", + }, + incomplete: "Données incomplètes", + incompleteHint: (days: number) => + `${days} jour${days > 1 ? "s" : ""} sans journal alimentaire sur la période. Les cumuls excluent ces jours.`, + noLog: "journal incomplet", + deficit: "déficit", + surplus: "surplus", + empty: { + title: "Il manque des données", + hint: "La balance énergétique a besoin de vos pesées et de votre journal alimentaire. Complétez ces deux modules pour débloquer cette page.", + }, + }, + + /* ---------------------------------------------------------------- */ + /* Planning (addendum-planning.md) */ + /* ---------------------------------------------------------------- */ + planning: { + title: "Planning", + scheduleTitle: (habit: string) => `Planning — ${habit}`, + scheduleHint: "Choisis les jours où tu prévois cette habitude.", + enabled: "Planning activé", + weekdays: ["L", "M", "M", "J", "V", "S", "D"], + weekdaysAbbr: ["lun.", "mar.", "mer.", "jeu.", "ven.", "sam.", "dim."], + weekdaysFull: ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"], + saved: "Planning enregistré", + emptyPlan: "Aucun jour planifié. Choisis tes jours de pesée pour suivre ton assiduité.", + emptyPlanWorkout: "Aucun jour planifié. Choisis tes jours de séance pour suivre ton assiduité.", + nothingToday: "Rien de prévu aujourd'hui. Profites-en bien !", + todayCard: { + weighIn: "Pesée du jour", + workout: "Séance du jour", + }, + status: { + done: "Faite", + planned: "Planifiée", + missed: "Manquée", + rest: "Repos", + notPlanned: "Non planifiée", + }, + streak: (days: number) => `${days} jour${days > 1 ? "s" : ""} d'affilée`, + bestStreak: "Record", + adherence: "Assiduité", + streakLabel: "Série en cours", + legend: { + done: "Faite", + missed: "Planifiée manquée", + planned: "Planifiée", + off: "Non planifiée", + }, + habits: { + weigh_in: "Pesée", + workout: "Séance de sport", + food_log: "Journal alimentaire", + }, + }, + + /* ---------------------------------------------------------------- */ + /* Enum labels */ + /* ---------------------------------------------------------------- */ + sportTypes: { + treadmill_walk: "Marche sur tapis", + treadmill_run: "Course sur tapis", + walking: "Marche", + running: "Course à pied", + cycling: "Vélo", + swimming: "Natation", + strength: "Renforcement", + hiit: "Fractionné (HIIT)", + yoga: "Yoga", + hiking: "Randonnée", + other: "Autre", + }, + + sources: { + manual: "Manuel", + health_connect: "Health Connect", + fitshow: "FitShow", + foodvisor: "Foodvisor", + csv_import: "Import CSV", + api: "API", + }, + + units: { + g: "g", + ml: "ml", + portion: "portion", + piece: "pièce", + }, +} as const; + +export type MealKey = keyof typeof S.nutrition.meals; + +const UNIT_LABELS: Record = { ...S.units }; +const SOURCE_LABELS: Record = { ...S.sources }; +const SPORT_LABELS: Record = { ...S.sportTypes }; + +/** French label of a quantity unit (falls back to the raw value). */ +export function unitLabel(unit: string): string { + return UNIT_LABELS[unit] ?? unit; +} + +/** French label of a data source badge. */ +export function sourceLabel(source: string): string { + return SOURCE_LABELS[source] ?? source; +} + +/** French label of a sport type. */ +export function sportLabel(sportType: string, custom?: string | null): string { + if (sportType === "other" && custom) return custom; + return SPORT_LABELS[sportType] ?? sportType; +} diff --git a/apps/web/src/modules/home/api.ts b/apps/web/src/modules/home/api.ts new file mode 100644 index 0000000..c6ed1ce --- /dev/null +++ b/apps/web/src/modules/home/api.ts @@ -0,0 +1,501 @@ +/** + * Typed React Query hooks of the dashboard module (CONVENTIONS C5.4). + * Every widget owns its request so that one failing endpoint never blanks the + * page. Query keys follow [moduleId, resource, params]. + * + * Endpoints (datamodel-health-vape.md §8, datamodel-finance.md §9.8, + * addendum-planning.md): + * GET /health/today, /health/dashboard, /health/weights/stats, + * /health/energy-balance, /health/goals/active + * POST /health/weights + * GET /vape/dashboard, /vape/stats/savings + * GET /finance/stats/budget-progress, /finance/stats/monthly-by-category + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query"; + +import { ApiError, api, isNotFoundError, qs } from "../../lib/api"; +import { addDaysIso, daysAgoIso, todayIso } from "../../lib/dates"; + +export const MODULE_ID = "home"; + +/** Local timezone used for daily aggregations (CONVENTIONS C6). */ +export const TZ = "Europe/Paris"; + +/** kcal ↔ kg of fat conversion (datamodel-health-vape.md §10). */ +export const KCAL_PER_KG_FAT = 7700; + +/* ------------------------------------------------------------------ */ +/* Shared response shapes */ +/* ------------------------------------------------------------------ */ + +/** Chart-ready point [local day, value] (datamodel-health-vape.md §8.1). */ +export type SeriesPoint = [string, number | null]; + +export interface StatsSeries { + name: string; + type?: string; + points: SeriesPoint[]; +} + +export interface StatsResponse { + from: string; + to: string; + unit?: string; + series: StatsSeries[]; + meta?: M | null; +} + +/** Pick a named series out of a stats response (missing series → []). */ +export function seriesPoints( + response: StatsResponse | null | undefined, + name: string, +): SeriesPoint[] { + return response?.series?.find((s) => s.name === name)?.points ?? []; +} + +/** Points carrying an actual value, in chronological order. */ +export function valuedPoints(points: SeriesPoint[]): [string, number][] { + return points.filter((p): p is [string, number] => typeof p[1] === "number"); +} + +/** Last non-null point of a series. */ +export function lastValuedPoint(points: SeriesPoint[]): [string, number] | null { + const valued = valuedPoints(points); + return valued.length > 0 ? valued[valued.length - 1] : null; +} + +/** Last non-null value of a series. */ +export function lastValue(points: SeriesPoint[]): number | null { + return lastValuedPoint(points)?.[1] ?? null; +} + +/** Variation between the last point and the closest point `days` earlier. */ +export function deltaOverDays(points: SeriesPoint[], days: number): number | null { + const valued = valuedPoints(points); + if (valued.length < 2) return null; + const last = valued[valued.length - 1]; + const pivot = addDaysIso(last[0], -days); + const earlier = valued.filter((p) => p[0] <= pivot).pop() ?? valued[0]; + if (earlier[0] === last[0]) return null; + return last[1] - earlier[1]; +} + +/** Sum of the non-null values of a series. */ +export function sumValues(points: SeriesPoint[]): number { + return valuedPoints(points).reduce((total, p) => total + p[1], 0); +} + +/* ------------------------------------------------------------------ */ +/* Health — planning du jour (addendum-planning.md) */ +/* ------------------------------------------------------------------ */ + +export type HabitKind = "weigh_in" | "workout" | "food_log"; + +export interface TodayItem { + kind: HabitKind; + planned: boolean; + done: boolean; + /** Weight in kg · number of workouts · kcal logged, depending on `kind`. */ + value: number | null; +} + +export interface TodayStreak { + current: number; + best?: number | null; +} + +export interface TodayResponse { + date: string; + items: TodayItem[]; + streaks?: Partial> | null; +} + +/* ------------------------------------------------------------------ */ +/* Health — dashboard aggregate (datamodel-health-vape.md §8.2) */ +/* ------------------------------------------------------------------ */ + +/** GET /health/dashboard — flat aggregate of the day (datamodel §8.2). */ +export interface HealthDashboard { + date: string; + weight_kg?: number | null; + weight_measured_at?: string | null; + trend_weight_kg?: number | null; + trend_delta_7d_kg?: number | null; + bmi?: number | null; + intake_kcal?: number | null; + budget_kcal?: number | null; + remaining_kcal?: number | null; + tdee_kcal?: number | null; + balance_kcal?: number | null; + cumulative_balance_30d_kcal?: number | null; + steps?: number | null; + active_kcal?: number | null; + distance_m?: number | null; + water_ml?: number; + water_goal_ml?: number | null; + workouts_this_week?: number; +} + +export interface WeightStatsMeta { + trend_now_kg?: number | null; + last_weight_kg?: number | null; + slope_14d_kg_week?: number | null; + slope_30d_kg_week?: number | null; + total_change_kg?: number | null; +} + +export interface EnergyBalanceMeta { + cumulative_balance_kcal?: number | null; + expected_change_kg?: number | null; + actual_change_kg?: number | null; + gap_kg?: number | null; +} + +/** Flattened form of GET /health/goals/active (`{goal, budget, …}`). */ +export interface ActiveGoal { + id: number; + mode: "weekly_rate" | "target_date" | "maintain"; + start_date: string; + start_weight_kg: number; + target_weight_kg: number; + target_date?: string | null; + weekly_rate_kg?: number | null; + status: string; + daily_budget?: number | null; + pct?: number | null; +} + +interface ActiveGoalEnvelope { + goal?: Omit | null; + budget?: { kcal?: number | null } | null; + progress_pct?: number | null; +} + +/* ------------------------------------------------------------------ */ +/* Vape (datamodel-health-vape.md §8.4) */ +/* ------------------------------------------------------------------ */ + +/** GET /vape/dashboard — money in euro cents (CONVENTIONS C2.3). */ +export interface VapeDashboard { + quit_date: string; + days_since_quit: number; + ml_today?: number | null; + nicotine_today_mg?: number | null; + savings_display_cents?: number | null; + savings_theoretical_cents?: number | null; + savings_real_cents?: number | null; + current_coil_age_days?: number | null; + next_milestone?: { code: string; label_fr: string; progress_pct?: number | null } | null; +} + +/** + * `meta` of GET /vape/stats/savings. The API serves money in euro cents + * (`unit`: "cents"); `useVapeSavings()` converts the series and these amounts + * to euros so `formatEuroAmount` can be used directly. + */ +export interface SavingsMeta { + cig_cost_per_day?: number | null; + savings_per_day?: number | null; + days_since_quit?: number | null; + cigarettes_avoided?: number | null; +} + +/* ------------------------------------------------------------------ */ +/* Finance (datamodel-finance.md §9.8) */ +/* ------------------------------------------------------------------ */ + +export interface BudgetProgressItem { + budget_id?: string | number; + category_id?: string | number | null; + category_name: string; + budget: number; + actual: number; + remaining?: number | null; + progress_pct?: number | null; + status?: "ok" | "warning" | "over" | string; +} + +export interface BudgetProgressResponse { + month: string; + items: BudgetProgressItem[]; + totals?: { budget?: number | null; actual?: number | null; progress_pct?: number | null } | null; +} + +export interface MonthlyCategorySeries { + category_id?: string | number | null; + name: string; + color?: string | null; + data: (number | null)[]; +} + +export interface MonthlyByCategoryResponse { + months: string[]; + series: MonthlyCategorySeries[]; + totals?: (number | null)[]; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +/** + * GET that turns a documented 404 into `null` instead of an error. + * + * On a brand-new account several endpoints answer 404 by design — the vape + * ones with `details.setup_required = true` (`/vape/dashboard`, + * `/vape/stats/savings`), the health profile with a plain 404. The dashboard + * must render its French empty states in that case (ux-pages §16), never an + * error, so `null` here means « rien à afficher pour l'instant » and the + * widgets branch on it. + */ +async function getOrNull(path: string): Promise { + try { + return await api(path); + } catch (error) { + if (isNotFoundError(error)) return null; + throw error; + } +} + +export interface RangeParams { + from: string | null; + to: string | null; +} + +/** Inclusive range covering the last `days` local days (today included). */ +export function lastNDays(days: number): RangeParams { + return { from: daysAgoIso(days - 1), to: todayIso() }; +} + +/** Current local month, `YYYY-MM` (datamodel-finance.md §9.8). */ +export function currentMonth(): string { + return todayIso().slice(0, 7); +} + +/* ------------------------------------------------------------------ */ +/* Query keys */ +/* ------------------------------------------------------------------ */ + +export const homeKeys = { + all: [MODULE_ID] as const, + today: (tz: string) => [MODULE_ID, "today", { tz }] as const, + healthDashboard: (tz: string) => [MODULE_ID, "health-dashboard", { tz }] as const, + weightStats: (params: RangeParams) => [MODULE_ID, "weight-stats", params] as const, + energyBalance: (params: RangeParams) => [MODULE_ID, "energy-balance", params] as const, + activeGoal: () => [MODULE_ID, "active-goal", {}] as const, + vapeDashboard: (tz: string) => [MODULE_ID, "vape-dashboard", { tz }] as const, + vapeSavings: () => [MODULE_ID, "vape-savings", {}] as const, + budgetProgress: (month: string) => [MODULE_ID, "budget-progress", { month }] as const, + monthlyByCategory: (months: number) => [MODULE_ID, "monthly-by-category", { months }] as const, +}; + +/* ------------------------------------------------------------------ */ +/* Queries */ +/* ------------------------------------------------------------------ */ + +/** Checklist of the day (addendum-planning.md) — `null` when not configured. */ +export function useToday(): UseQueryResult { + return useQuery({ + queryKey: homeKeys.today(TZ), + queryFn: () => getOrNull(`/health/today?${qs({ tz: TZ })}`), + }); +} + +export function useHealthDashboard(): UseQueryResult { + return useQuery({ + queryKey: homeKeys.healthDashboard(TZ), + queryFn: () => getOrNull(`/health/dashboard?${qs({ tz: TZ })}`), + }); +} + +export function useWeightStats( + range: RangeParams, +): UseQueryResult | null, ApiError> { + return useQuery({ + queryKey: homeKeys.weightStats(range), + queryFn: () => + getOrNull>( + `/health/weights/stats?${qs({ from: range.from, to: range.to, tz: TZ })}`, + ), + }); +} + +export function useEnergyBalance( + range: RangeParams, +): UseQueryResult | null, ApiError> { + return useQuery({ + queryKey: homeKeys.energyBalance(range), + queryFn: () => + getOrNull>( + `/health/energy-balance?${qs({ from: range.from, to: range.to, tz: TZ })}`, + ), + }); +} + +/** Active weight goal — `null` when the user has not set one yet (404). */ +export function useActiveGoal(): UseQueryResult { + return useQuery({ + queryKey: homeKeys.activeGoal(), + queryFn: async () => { + const res = await getOrNull(`/health/goals/active?${qs({ tz: TZ })}`); + if (!res?.goal) return null; + return { + ...res.goal, + daily_budget: res.budget?.kcal ?? null, + pct: res.progress_pct ?? null, + } satisfies ActiveGoal; + }, + }); +} + +export function useVapeDashboard(): UseQueryResult { + return useQuery({ + queryKey: homeKeys.vapeDashboard(TZ), + queryFn: () => getOrNull(`/vape/dashboard?${qs({ tz: TZ })}`), + }); +} + +/** Raw stats envelope (`meta` is an untyped object). */ +interface RawStats { + from: string; + to: string; + unit?: string; + series?: StatsSeries[] | null; + meta?: Record | null; +} + +function euros(cents: unknown): number | null { + return typeof cents === "number" && Number.isFinite(cents) ? cents / 100 : null; +} + +/** Cumulative savings — converted from euro cents to euros (see SavingsMeta). */ +export function useVapeSavings(): UseQueryResult | null, ApiError> { + return useQuery({ + queryKey: homeKeys.vapeSavings(), + queryFn: async () => { + const raw = await getOrNull(`/vape/stats/savings?${qs({ tz: TZ })}`); + if (!raw) return null; + const meta = raw.meta ?? {}; + return { + from: raw.from, + to: raw.to, + unit: "euros", + series: (raw.series ?? []).map((serie) => ({ + ...serie, + points: (serie.points ?? []).map(([day, value]) => [day, euros(value)] as SeriesPoint), + })), + meta: { + cig_cost_per_day: euros(meta.cig_cost_per_day_cents), + savings_per_day: euros(meta.savings_per_day_cents), + days_since_quit: typeof meta.days_since_quit === "number" ? meta.days_since_quit : null, + cigarettes_avoided: + typeof meta.cigarettes_avoided === "number" ? meta.cigarettes_avoided : null, + }, + } satisfies StatsResponse; + }, + }); +} + +export function useBudgetProgress( + month: string, +): UseQueryResult { + return useQuery({ + queryKey: homeKeys.budgetProgress(month), + queryFn: () => + getOrNull(`/finance/stats/budget-progress?${qs({ month })}`), + }); +} + +export function useMonthlyByCategory( + months = 1, +): UseQueryResult { + return useQuery({ + queryKey: homeKeys.monthlyByCategory(months), + queryFn: () => + getOrNull( + `/finance/stats/monthly-by-category?${qs({ months, level: "root", direction: "debit" })}`, + ), + }); +} + +/* ------------------------------------------------------------------ */ +/* Derived state */ +/* ------------------------------------------------------------------ */ + +/** + * `true` once a `getOrNull()` query settled on `null`, i.e. the module answered + * « pas encore configuré » (vape: 404 + `setup_required`). Widgets use it to + * pick between the « non configuré » and the « configuré, sans données » empty + * states of ux-pages §16. + */ +export function isNotConfigured(query: { isSuccess: boolean; data: unknown }): boolean { + return query.isSuccess && query.data === null; +} + +export interface DashboardEmptiness { + /** Every dashboard query has settled. */ + ready: boolean; + /** Every query succeeded and no module holds any data yet. */ + empty: boolean; +} + +/** + * Detects the « première visite » case (ux-pages §7.4): the welcome screen + * replaces the widget grid only when every endpoint answered without data — + * a failing endpoint keeps the grid (each widget shows its own message). + */ +export function useDashboardEmptiness(range: RangeParams): DashboardEmptiness { + const today = useToday(); + const weight = useWeightStats(range); + const health = useHealthDashboard(); + const vape = useVapeDashboard(); + const budget = useBudgetProgress(currentMonth()); + const monthly = useMonthlyByCategory(1); + + const ready = ![today, weight, health, vape, budget, monthly].some((q) => q.isPending); + const allSucceeded = [today, weight, health, vape, budget, monthly].every((q) => q.isSuccess); + if (!ready || !allSucceeded) return { ready, empty: false }; + + const noToday = (today.data?.items ?? []).every((item) => !item.done); + const noWeight = + valuedPoints(seriesPoints(weight.data, "weight_raw")).length === 0 && + valuedPoints(seriesPoints(weight.data, "weight_trend")).length === 0; + const noHealth = !health.data?.intake_kcal && !health.data?.weight_kg; + const noVape = !vape.data?.ml_today && !vape.data?.savings_display_cents; + const noFinance = + (budget.data?.items?.length ?? 0) === 0 && + (monthly.data?.series ?? []).every((s) => (s.data ?? []).every((value) => !value)); + + return { ready: true, empty: noToday && noWeight && noHealth && noVape && noFinance }; +} + +/* ------------------------------------------------------------------ */ +/* Mutations */ +/* ------------------------------------------------------------------ */ + +export interface WeightCreate { + /** UTC ISO 8601 instant of the weigh-in. */ + measured_at: string; + weight_kg: number; + note?: string; +} + +export interface WeightEntry { + id: number; + measured_at: string; + weight_kg: number; + note?: string | null; +} + +/** POST /health/weights — invalidates every dashboard query (CONVENTIONS C5.4). */ +export function useCreateWeight(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => + api("/health/weights", { method: "POST", body: JSON.stringify(payload) }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: homeKeys.all }); + }, + }); +} diff --git a/apps/web/src/modules/home/components/ChartWidget.tsx b/apps/web/src/modules/home/components/ChartWidget.tsx new file mode 100644 index 0000000..708b93d --- /dev/null +++ b/apps/web/src/modules/home/components/ChartWidget.tsx @@ -0,0 +1,60 @@ +import type { EChartsOption } from "echarts"; +import type { ReactNode } from "react"; + +import { ChartCard } from "../../../components/charts/ChartCard"; +import { Card } from "../../../components/ui/Card"; +import type { ApiError } from "../../../lib/api"; +import { DetailLink, WidgetError, WidgetSpinner } from "./WidgetStates"; + +export interface ChartWidgetProps { + title: string; + subtitle?: string; + /** Chart height in px (ux-pages §5.2: 120 px for dashboard mini charts). */ + height?: number; + loading: boolean; + error: ApiError | null; + /** Rendered instead of the chart when there is nothing to plot. */ + empty?: ReactNode | null; + option: EChartsOption; + ariaLabel: string; + /** Module page the « Voir le détail → » link points to. */ + detailTo: string; +} + +/** + * One dashboard mini chart. Loading, error and empty states are handled + * locally so a failing module never blanks the dashboard; when data is + * present the shared ChartCard is used (accessible table + exports). + */ +export function ChartWidget({ + title, + subtitle, + height = 120, + loading, + error, + empty, + option, + ariaLabel, + detailTo, +}: ChartWidgetProps) { + if (loading || error || empty) { + return ( + }> + {loading ? : null} + {!loading && error ? : null} + {!loading && !error ? empty : null} + + ); + } + + return ( + } + /> + ); +} diff --git a/apps/web/src/modules/home/components/ChartWidgets.tsx b/apps/web/src/modules/home/components/ChartWidgets.tsx new file mode 100644 index 0000000..3df1638 --- /dev/null +++ b/apps/web/src/modules/home/components/ChartWidgets.tsx @@ -0,0 +1,407 @@ +import type { EChartsOption } from "echarts"; +import { useMemo } from "react"; + +import { CHART_COLORS, CHART_SURFACE, SEMANTIC_COLORS } from "../../../components/charts/theme"; +import { Button } from "../../../components/ui/Button"; +import { + formatDateShort, + formatEuroAmount, + formatKcal, + formatNumber, + formatWeight, +} from "../../../lib/format"; +import { + isNotConfigured, + lastNDays, + lastValue, + seriesPoints, + useActiveGoal, + useEnergyBalance, + useMonthlyByCategory, + useVapeSavings, + useWeightStats, + valuedPoints, +} from "../api"; +import type { RangeParams, SeriesPoint } from "../api"; +import { ROUTES, STRINGS } from "../strings"; +import { ChartWidget } from "./ChartWidget"; +import { WidgetEmpty } from "./WidgetStates"; + +/** Mini charts: no visible Y axis, short dates on X, no zoom (ux-pages §7.3). */ +const MINI_GRID = { left: 8, right: 16, top: 12, bottom: 8, containLabel: true } as const; +const MINI_HEIGHT = 120; + +/** Dashed markLine used for budgets and targets (ux-pages §6). */ +function markLine(value: number, label: string) { + return { + silent: true, + symbol: "none" as const, + lineStyle: { type: [4, 4] as number[], color: CHART_SURFACE.inkSecondary, width: 1 }, + label: { + formatter: label, + color: CHART_SURFACE.inkSecondary, + fontSize: 10, + position: "insideEndTop" as const, + }, + data: [{ yAxis: value }], + }; +} + +function WidenButton({ onWiden }: { onWiden: () => void }) { + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* 1 — Poids (mini-line tendance + markLine objectif) */ +/* ------------------------------------------------------------------ */ + +export function WeightChartWidget({ + range, + onWiden, +}: { + range: RangeParams; + onWiden: () => void; +}) { + const stats = useWeightStats(range); + const goal = useActiveGoal(); + + const trend = seriesPoints(stats.data, "weight_trend"); + const raw = seriesPoints(stats.data, "weight_raw"); + const points = valuedPoints(trend).length > 0 ? trend : raw; + const target = goal.data?.target_weight_kg ?? null; + // « Tout » is already selected → the module itself is empty, not the period. + const wholeHistory = range.from === null; + + const option = useMemo( + () => ({ + grid: { ...MINI_GRID }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + valueFormatter: (value) => (typeof value === "number" ? formatWeight(value) : "—"), + }, + xAxis: { + type: "time", + axisLabel: { formatter: (value: number) => formatDateShort(value) }, + }, + yAxis: { type: "value", scale: true, show: false }, + series: [ + { + name: STRINGS.charts.weightSeries, + type: "line", + smooth: 0.2, + showSymbol: false, + lineStyle: { width: 2, color: CHART_COLORS[0] }, + itemStyle: { color: CHART_COLORS[0] }, + data: points, + markLine: + target !== null + ? markLine(target, STRINGS.charts.weightTarget(formatWeight(target))) + : undefined, + }, + ], + }), + [points, target], + ); + + return ( + + ) : ( + } + /> + ) + ) : null + } + option={option} + ariaLabel={`${STRINGS.charts.weightTitle} — ${STRINGS.charts.weightSubtitle}`} + detailTo={ROUTES.weight} + /> + ); +} + +/* ------------------------------------------------------------------ */ +/* 2 — Calories entrées / sorties (7 j) */ +/* ------------------------------------------------------------------ */ + +/** Keep the last `count` points of a series. */ +function tail(points: SeriesPoint[], count: number): SeriesPoint[] { + return points.slice(Math.max(0, points.length - count)); +} + +export function KcalChartWidget() { + const balance = useEnergyBalance(lastNDays(30)); + const data = balance.data; + + const bars = useMemo(() => { + const intake = tail(seriesPoints(data, "intake_kcal"), 7); + const spent = tail(seriesPoints(data, "tdee_kcal"), 7); + const days = (intake.length > 0 ? intake : spent).map((point) => point[0]); + return { intake, spent, days }; + }, [data]); + const { intake, spent, days } = bars; + const budget = lastValue(seriesPoints(data, "budget_kcal")); + const hasData = valuedPoints(intake).length > 0 || valuedPoints(spent).length > 0; + + const option = useMemo( + () => ({ + grid: { ...MINI_GRID, top: 28 }, + legend: { show: true }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + valueFormatter: (value) => (typeof value === "number" ? formatKcal(value) : "—"), + }, + xAxis: { + type: "category", + data: days, + axisLabel: { formatter: (value: string) => formatDateShort(value) }, + }, + yAxis: { type: "value", show: false }, + series: [ + { + name: STRINGS.charts.kcalIn, + type: "bar", + barMaxWidth: 28, + itemStyle: { color: CHART_COLORS[1], borderRadius: [4, 4, 0, 0] }, + data: intake.map((p) => p[1]), + markLine: + budget !== null + ? markLine(budget, `Budget ${formatNumber(Math.round(budget))}`) + : undefined, + }, + { + name: STRINGS.charts.kcalOut, + type: "bar", + barMaxWidth: 28, + itemStyle: { color: CHART_COLORS[0], borderRadius: [4, 4, 0, 0] }, + data: spent.map((p) => p[1]), + }, + ], + }), + [bars, budget], + ); + + return ( + + ) + } + option={option} + ariaLabel={`${STRINGS.charts.kcalTitle} — ${STRINGS.charts.kcalSubtitle}`} + detailTo={ROUTES.nutrition} + /> + ); +} + +/* ------------------------------------------------------------------ */ +/* 3 — Économies vape (aire cumulée) */ +/* ------------------------------------------------------------------ */ + +export function SavingsChartWidget() { + const savings = useVapeSavings(); + // 404 + `setup_required` → `null`: le module n'est pas configuré (ux-pages §16). + const notConfigured = isNotConfigured(savings); + const real = seriesPoints(savings.data, "savings_real"); + const theoretical = seriesPoints(savings.data, "savings_theoretical"); + const points = valuedPoints(real).length > 0 ? real : theoretical; + const total = lastValue(points); + + const option = useMemo( + () => ({ + grid: { ...MINI_GRID, right: 56 }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + valueFormatter: (value) => (typeof value === "number" ? formatEuroAmount(value) : "—"), + }, + xAxis: { + type: "time", + axisLabel: { formatter: (value: number) => formatDateShort(value) }, + }, + yAxis: { type: "value", show: false }, + series: [ + { + name: STRINGS.charts.savingsSeries, + type: "line", + smooth: 0.2, + showSymbol: false, + lineStyle: { width: 2, color: SEMANTIC_COLORS.positive }, + itemStyle: { color: SEMANTIC_COLORS.positive }, + areaStyle: { + color: { + type: "linear", + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: "rgba(12,163,12,0.25)" }, + { offset: 1, color: "rgba(12,163,12,0)" }, + ], + }, + }, + endLabel: { + show: total !== null, + color: SEMANTIC_COLORS.positive, + fontSize: 11, + formatter: total !== null ? formatEuroAmount(total) : "", + }, + data: points, + }, + ], + }), + [points, total], + ); + + return ( + + ) : ( + + ) + ) : null + } + option={option} + ariaLabel={`${STRINGS.charts.savingsTitle} — ${STRINGS.charts.savingsSubtitle}`} + detailTo={ROUTES.vape} + /> + ); +} + +/* ------------------------------------------------------------------ */ +/* 4 — Dépenses du mois (donut) */ +/* ------------------------------------------------------------------ */ + +/** Top 7 categories in palette slot order + « Autres » in ink-muted (§4.4). */ +const MAX_SLICES = 7; + +export function ExpensesChartWidget() { + const monthly = useMonthlyByCategory(1); + + const slices = useMemo(() => { + const entries = (monthly.data?.series ?? []) + .map((serie) => ({ name: serie.name, value: Number(serie.data?.[0] ?? 0) })) + .filter((entry) => entry.value > 0) + .sort((a, b) => b.value - a.value); + const head = entries.slice(0, MAX_SLICES).map((entry, index) => ({ + ...entry, + itemStyle: { color: CHART_COLORS[index % CHART_COLORS.length] }, + })); + const rest = entries.slice(MAX_SLICES).reduce((total, entry) => total + entry.value, 0); + return rest > 0 + ? [ + ...head, + { + name: STRINGS.charts.expensesOther, + value: rest, + itemStyle: { color: CHART_SURFACE.inkMuted }, + }, + ] + : head; + }, [monthly.data]); + + const option = useMemo( + () => ({ + grid: { ...MINI_GRID }, + tooltip: { + trigger: "item", + valueFormatter: (value) => (typeof value === "number" ? formatEuroAmount(value) : "—"), + }, + legend: { + orient: "vertical", + right: 0, + top: "middle", + type: "scroll", + itemGap: 6, + textStyle: { fontSize: 11 }, + }, + series: [ + { + name: STRINGS.charts.expensesTitle, + type: "pie", + radius: ["52%", "78%"], + center: ["32%", "50%"], + avoidLabelOverlap: true, + label: { show: false }, + labelLine: { show: false }, + itemStyle: { borderColor: CHART_SURFACE.surface, borderWidth: 1 }, + data: slices, + }, + ], + }), + [slices], + ); + + return ( + + ) : null + } + option={option} + ariaLabel={`${STRINGS.charts.expensesTitle} — ${STRINGS.charts.expensesSubtitle}`} + detailTo={ROUTES.finance} + /> + ); +} diff --git a/apps/web/src/modules/home/components/KpiWidgets.tsx b/apps/web/src/modules/home/components/KpiWidgets.tsx new file mode 100644 index 0000000..6dfb795 --- /dev/null +++ b/apps/web/src/modules/home/components/KpiWidgets.tsx @@ -0,0 +1,328 @@ +import { Minus, TrendingDown, TrendingUp } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { Link } from "react-router-dom"; + +import { StatCard } from "../../../components/ui/StatCard"; +import { + deltaToneName, + formatDate, + formatEuroAmount, + formatKcal, + formatMg, + formatMl, + formatNumber, + formatPercent, + formatSigned, + formatWeight, +} from "../../../lib/format"; +import type { GoalDirection } from "../../../lib/format"; +import { + KCAL_PER_KG_FAT, + currentMonth, + deltaOverDays, + isNotConfigured, + lastNDays, + lastValue, + seriesPoints, + sumValues, + useActiveGoal, + useBudgetProgress, + useEnergyBalance, + useHealthDashboard, + useMonthlyByCategory, + useVapeDashboard, + useVapeSavings, + useWeightStats, + valuedPoints, +} from "../api"; +import type { ActiveGoal } from "../api"; +import { ROUTES, STRINGS } from "../strings"; +import { KpiFallback } from "./WidgetStates"; + +/** KPI windows are fixed (ux-pages §7.1): they ignore the page period. */ +const KPI_DAYS = 30; + +function trendIcon(value: number): LucideIcon { + if (value > 0) return TrendingUp; + if (value < 0) return TrendingDown; + return Minus; +} + +/** « Perdre » unless the active goal aims higher than the starting weight. */ +function goalDirection(goal: ActiveGoal | null | undefined): GoalDirection { + if (goal && goal.target_weight_kg > goal.start_weight_kg) return "gain"; + return "lose"; +} + +/* ------------------------------------------------------------------ */ +/* 1 — Poids actuel */ +/* ------------------------------------------------------------------ */ + +export function WeightKpi() { + const stats = useWeightStats(lastNDays(KPI_DAYS)); + const goal = useActiveGoal(); + + const raw = seriesPoints(stats.data, "weight_raw"); + const trend = seriesPoints(stats.data, "weight_trend"); + const latest = lastValue(raw) ?? lastValue(trend); + const delta = deltaOverDays(trend.length > 0 ? trend : raw, 7); + + if (stats.isPending || latest === null || stats.error) { + return ( + + ); + } + + const direction = goalDirection(goal.data); + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* 2 — Calories aujourd'hui */ +/* ------------------------------------------------------------------ */ + +export function CaloriesKpi() { + const dashboard = useHealthDashboard(); + const intake = dashboard.data?.intake_kcal ?? null; + const budget = dashboard.data?.budget_kcal ?? null; + + if (dashboard.isPending || dashboard.error || intake === null) { + return ( + + ); + } + + if (budget === null || budget <= 0) { + return ( + + {STRINGS.kpi.noBudget} + + } + /> + ); + } + + const remaining = dashboard.data?.remaining_kcal ?? budget - intake; + const over = remaining < 0; + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* 3 — Déficit cumulé (30 j) */ +/* ------------------------------------------------------------------ */ + +export function DeficitKpi() { + const balance = useEnergyBalance(lastNDays(KPI_DAYS)); + const goal = useActiveGoal(); + const points = seriesPoints(balance.data, "balance_kcal"); + const meta = balance.data?.meta; + const cumulated = + meta?.cumulative_balance_kcal ?? (valuedPoints(points).length > 0 ? sumValues(points) : null); + + if (balance.isPending || balance.error || cumulated === null) { + return ( + + ); + } + + const theoreticalKg = cumulated / KCAL_PER_KG_FAT; + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* 4 — Vape aujourd'hui */ +/* ------------------------------------------------------------------ */ + +export function VapeTodayKpi() { + const dashboard = useVapeDashboard(); + // `null` = 404 « setup_required » : le module n'est pas configuré (ux-pages §16). + const notConfigured = isNotConfigured(dashboard); + const ml = dashboard.data?.ml_today ?? null; + const nicotine = dashboard.data?.nicotine_today_mg ?? null; + + if (dashboard.isPending || dashboard.error || ml === null) { + return ( + + ); + } + + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* 5 — Économies vape */ +/* ------------------------------------------------------------------ */ + +export function SavingsKpi() { + const savings = useVapeSavings(); + const notConfigured = isNotConfigured(savings); + const real = seriesPoints(savings.data, "savings_real"); + const theoretical = seriesPoints(savings.data, "savings_theoretical"); + const points = valuedPoints(real).length > 0 ? real : theoretical; + const total = lastValue(points); + const start = valuedPoints(points)[0]?.[0] ?? null; + const days = savings.data?.meta?.days_since_quit ?? null; + + if (savings.isPending || savings.error || total === null) { + return ( + + ); + } + + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* 6 — Dépenses du mois */ +/* ------------------------------------------------------------------ */ + +export function ExpensesKpi() { + const month = currentMonth(); + const progress = useBudgetProgress(month); + const monthly = useMonthlyByCategory(1); + + const totals = progress.data?.totals; + const fallbackSpent = monthly.data?.totals?.[0] ?? null; + const spent = totals?.actual ?? fallbackSpent; + const budget = totals?.budget ?? null; + const loading = progress.isPending || monthly.isPending; + const error = progress.error ?? monthly.error; + + if (loading || (error && spent === null) || spent === null) { + return ( + + ); + } + + if (budget === null || budget <= 0) { + return ( + + {STRINGS.kpi.noBudget} + + } + /> + ); + } + + const pct = totals?.progress_pct ?? (spent / budget) * 100; + const tone = pct > 100 ? "negative" : pct >= 80 ? "warning" : "positive"; + return ( + + ); +} + +/** Fixed KPI row of the dashboard (ux-pages §7.1). */ +export function KpiRow() { + return ( +
    + + + + + + +
    + ); +} diff --git a/apps/web/src/modules/home/components/Toast.tsx b/apps/web/src/modules/home/components/Toast.tsx new file mode 100644 index 0000000..012b856 --- /dev/null +++ b/apps/web/src/modules/home/components/Toast.tsx @@ -0,0 +1,43 @@ +import clsx from "clsx"; +import { CheckCircle2, X } from "lucide-react"; +import { useEffect } from "react"; +import { createPortal } from "react-dom"; + +export interface ToastProps { + /** French message, ex: « Pesée enregistrée ✓ ». */ + message: string; + onClose: () => void; + /** Auto-dismiss delay in ms. */ + duration?: number; +} + +/** Success toast (ux-pages §5.7): bottom-right on desktop, top on mobile. */ +export function Toast({ message, onClose, duration = 4000 }: ToastProps) { + useEffect(() => { + const id = window.setTimeout(onClose, duration); + return () => window.clearTimeout(id); + }, [onClose, duration]); + + return createPortal( +
    + +

    {message}

    + +
    , + document.body, + ); +} diff --git a/apps/web/src/modules/home/components/TodayCard.tsx b/apps/web/src/modules/home/components/TodayCard.tsx new file mode 100644 index 0000000..c761593 --- /dev/null +++ b/apps/web/src/modules/home/components/TodayCard.tsx @@ -0,0 +1,187 @@ +import clsx from "clsx"; +import { CheckCircle2, Circle, Dumbbell, Scale, Utensils } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { Link } from "react-router-dom"; + +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { formatDate, formatKcal, formatNumber, formatWeight } from "../../../lib/format"; +import { useToday } from "../api"; +import type { HabitKind, TodayItem, TodayResponse } from "../api"; +import { ROUTES, STRINGS } from "../strings"; +import { WidgetError, WidgetSpinner } from "./WidgetStates"; + +const ORDER: HabitKind[] = ["weigh_in", "workout", "food_log"]; + +const META: Record = { + weigh_in: { + label: STRINGS.today.weighIn, + icon: Scale, + to: ROUTES.weight, + cta: STRINGS.today.logWeight, + }, + workout: { + label: STRINGS.today.workout, + icon: Dumbbell, + to: ROUTES.workouts, + cta: STRINGS.today.addWorkout, + }, + food_log: { + label: STRINGS.today.foodLog, + icon: Utensils, + to: ROUTES.nutrition, + cta: STRINGS.today.addFood, + }, +}; + +/** Human value of a done habit: weight, number of workouts, logged kcal. */ +function formatValue(kind: HabitKind, value: number | null): string | null { + if (value === null || value === undefined) return null; + if (kind === "weigh_in") return formatWeight(value); + if (kind === "food_log") return formatKcal(value); + const count = Math.round(value); + return `${formatNumber(count)} séance${count > 1 ? "s" : ""}`; +} + +/** Best current streak across habits (addendum-planning: badge « 🔥 n jours »). */ +function bestStreak(data: TodayResponse | null | undefined): number { + const streaks = data?.streaks; + if (!streaks) return 0; + return Object.values(streaks).reduce( + (best, streak) => Math.max(best, streak?.current ?? 0), + 0, + ); +} + +function HabitRow({ item, onLogWeight }: { item: TodayItem; onLogWeight: () => void }) { + const meta = META[item.kind]; + const Icon = meta.icon; + const value = formatValue(item.kind, item.value); + + return ( +
  • + {item.done ? ( + + ) : ( + + )} + + {meta.label} + + {item.done ? ( + + {value ?? STRINGS.today.done} + + ) : !item.planned ? ( + {STRINGS.today.rest} + ) : item.kind === "weigh_in" ? ( + + ) : ( + + {meta.cta} + + )} +
  • + ); +} + +export interface TodayCardProps { + /** Opens the quick weigh-in modal. */ + onLogWeight: () => void; +} + +/** + * « Aujourd'hui » card (docs/design/addendum-planning.md): checklist of the + * day derived from existing data + streak badge, with a graceful empty state + * when no schedule has been configured yet. + */ +export function TodayCard({ onLogWeight }: TodayCardProps) { + const { data, isPending, error } = useToday(); + + const items: TodayItem[] = ORDER.map( + (kind) => + data?.items?.find((item) => item.kind === kind) ?? { + kind, + planned: false, + done: false, + value: null, + }, + ); + const notConfigured = !isPending && !error && (data === null || (data?.items?.length ?? 0) === 0); + const nothingPlanned = items.every((item) => !item.planned); + const nothingDone = items.every((item) => !item.done); + const streak = bestStreak(data); + const weighIn = items.find((item) => item.kind === "weigh_in"); + + return ( + + {streak > 0 ? ( + + {STRINGS.today.streak(streak)} + — {STRINGS.today.streakHint} + + ) : null} + {!isPending && !weighIn?.done ? ( + + ) : null} + + } + > + {isPending ? : null} + {!isPending && error ? : null} + + {!isPending && !error && notConfigured ? ( + + + + {STRINGS.today.configureSchedule} + + + } + /> + ) : null} + + {!isPending && !error && !notConfigured ? ( + <> + {nothingPlanned && nothingDone ? ( +

    {STRINGS.today.nothingPlannedTitle}

    + ) : null} +
      + {items.map((item) => ( + + ))} +
    + + ) : null} +
    + ); +} diff --git a/apps/web/src/modules/home/components/WeightQuickAddModal.tsx b/apps/web/src/modules/home/components/WeightQuickAddModal.tsx new file mode 100644 index 0000000..197caab --- /dev/null +++ b/apps/web/src/modules/home/components/WeightQuickAddModal.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { todayIso } from "../../../lib/dates"; +import { useCreateWeight } from "../api"; +import { STRINGS } from "../strings"; + +const FORM_ID = "home-weight-quick-add"; + +function nowTime(): string { + const now = new Date(); + return `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; +} + +export interface WeightQuickAddModalProps { + open: boolean; + onClose: () => void; + /** Called after a successful POST (toast + refresh). */ + onSaved: () => void; + /** Last known weight, used to prefill the field (ux-pages §8.5). */ + lastWeightKg?: number | null; +} + +/** Quick weigh-in modal (ux-pages §8.5, addendum-planning « Noter ma pesée »). */ +export function WeightQuickAddModal({ + open, + onClose, + onSaved, + lastWeightKg, +}: WeightQuickAddModalProps) { + const createWeight = useCreateWeight(); + const [weight, setWeight] = useState(""); + const [date, setDate] = useState(todayIso()); + const [time, setTime] = useState(nowTime()); + const [note, setNote] = useState(""); + const [error, setError] = useState(null); + + // Reset the form each time the modal is opened. + useEffect(() => { + if (!open) return; + setWeight(lastWeightKg ? String(lastWeightKg) : ""); + setDate(todayIso()); + setTime(nowTime()); + setNote(""); + setError(null); + createWeight.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, lastWeightKg]); + + const submit = () => { + const parsed = Number(weight.replace(",", ".")); + if (!weight.trim() || Number.isNaN(parsed)) { + setError(STRINGS.weightModal.errorRequired); + return; + } + if (parsed < 20 || parsed > 300) { + setError(STRINGS.weightModal.errorRange); + return; + } + if (!date) { + setError(STRINGS.weightModal.errorDate); + return; + } + const measuredAt = new Date(`${date}T${time || "12:00"}`); + if (Number.isNaN(measuredAt.getTime())) { + setError(STRINGS.weightModal.errorDate); + return; + } + setError(null); + createWeight.mutate( + { + measured_at: measuredAt.toISOString(), + weight_kg: Math.round(parsed * 100) / 100, + ...(note.trim() ? { note: note.trim() } : {}), + }, + { + onSuccess: () => { + onSaved(); + onClose(); + }, + }, + ); + }; + + return ( + + + + + } + > +
    { + event.preventDefault(); + submit(); + }} + className="space-y-4" + > + setWeight(event.target.value)} + error={error ?? undefined} + /> +
    + setDate(event.target.value)} + className="flex-1" + /> + setTime(event.target.value)} + className="flex-1" + /> +
    + setNote(event.target.value)} + /> + {createWeight.error ? ( +

    {createWeight.error.message}

    + ) : null} + +
    + ); +} diff --git a/apps/web/src/modules/home/components/WidgetStates.tsx b/apps/web/src/modules/home/components/WidgetStates.tsx new file mode 100644 index 0000000..01f014d --- /dev/null +++ b/apps/web/src/modules/home/components/WidgetStates.tsx @@ -0,0 +1,111 @@ +import { AlertTriangle } from "lucide-react"; +import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; + +import { EmptyState } from "../../../components/ui/EmptyState"; +import { Spinner } from "../../../components/ui/Spinner"; +import { StatCard } from "../../../components/ui/StatCard"; +import type { ApiError } from "../../../lib/api"; +import { STRINGS } from "../strings"; + +/** Centered spinner filling the reserved height of a widget body. */ +export function WidgetSpinner({ height = 120 }: { height?: number }) { + return ( +
    + +
    + ); +} + +/** French error body of a widget — never blanks the rest of the dashboard. */ +export function WidgetError({ error, height = 120 }: { error: ApiError; height?: number }) { + return ( +
    + +

    {error.message}

    +
    + ); +} + +/** « Voir le détail → » link rendered in a widget header. */ +export function DetailLink({ to, label }: { to: string; label?: string }) { + return ( + + {label ?? STRINGS.charts.detail} + + ); +} + +export interface KpiFallbackProps { + label: string; + loading?: boolean; + /** French error message when the module endpoint failed. */ + error?: ApiError | null; + /** Where the « Configurer » call-to-action points to. */ + to: string; + cta?: string; +} + +/** + * Degraded KPI card (ux-pages §7.1): « — » plus a French call-to-action + * linking to the module that owns the missing data. + */ +export function KpiFallback({ label, loading, error, to, cta }: KpiFallbackProps) { + if (loading) { + return } />; + } + return ( + + {error ? {error.message} : null} + + {cta ?? STRINGS.kpi.configure} + + + } + /> + ); +} + +export interface WidgetEmptyProps { + title: string; + hint?: ReactNode; + /** Primary call-to-action linking to the module that owns the data. */ + actionLabel?: string; + actionTo?: string; + /** Optional secondary action (ex: « Élargir la période »). */ + secondary?: ReactNode; +} + +/** Empty state of a chart widget with its French call-to-action (ux-pages §16). */ +export function WidgetEmpty({ title, hint, actionLabel, actionTo, secondary }: WidgetEmptyProps) { + return ( + + {actionTo && actionLabel ? ( + + {actionLabel} + + ) : null} + {secondary} + + } + /> + ); +} diff --git a/apps/web/src/modules/home/index.ts b/apps/web/src/modules/home/index.ts new file mode 100644 index 0000000..b05a885 --- /dev/null +++ b/apps/web/src/modules/home/index.ts @@ -0,0 +1,18 @@ +import { LayoutDashboard } from "lucide-react"; +import { createElement, lazy } from "react"; + +import type { ModuleManifest } from "../../types/module"; + +// Pages are code-split (CONVENTIONS C5.7); AppLayout provides the Suspense boundary. +const HomePage = lazy(() => import("./pages/HomePage")); + +/** Dashboard module — reserved order 0, route « / » (CONVENTIONS C5.1). */ +const manifest: ModuleManifest = { + id: "home", + title: "Tableau de bord", + order: 0, + routes: [{ path: "/", element: createElement(HomePage) }], + nav: [{ path: "/", label: "Tableau de bord", icon: LayoutDashboard, order: 0 }], +}; + +export default manifest; diff --git a/apps/web/src/modules/home/pages/HomePage.tsx b/apps/web/src/modules/home/pages/HomePage.tsx new file mode 100644 index 0000000..79ba728 --- /dev/null +++ b/apps/web/src/modules/home/pages/HomePage.tsx @@ -0,0 +1,133 @@ +import { Settings, Upload } from "lucide-react"; +import { useCallback, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; + +import { PeriodSelector } from "../../../components/PeriodSelector"; +import type { PeriodRange } from "../../../components/PeriodSelector"; +import { Card } from "../../../components/ui/Card"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { PageHeader } from "../../../components/ui/PageHeader"; +import { + lastNDays, + lastValue, + seriesPoints, + useDashboardEmptiness, + useWeightStats, +} from "../api"; +import type { RangeParams } from "../api"; +import { + ExpensesChartWidget, + KcalChartWidget, + SavingsChartWidget, + WeightChartWidget, +} from "../components/ChartWidgets"; +import { KpiRow } from "../components/KpiWidgets"; +import { Toast } from "../components/Toast"; +import { TodayCard } from "../components/TodayCard"; +import { WeightQuickAddModal } from "../components/WeightQuickAddModal"; +import { ROUTES, STRINGS } from "../strings"; + +const linkClass = + "inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-accent px-4 text-sm font-medium text-white transition-colors hover:bg-accent/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"; +const secondaryLinkClass = + "inline-flex h-10 items-center justify-center gap-2 rounded-lg border bg-surface-2 px-4 text-sm font-medium text-ink transition-colors hover:bg-surface-2/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"; + +/** + * Cross-module dashboard (ux-pages §7 + addendum-planning): the « Aujourd'hui » + * checklist first, then the KPI row and the mini charts. Every widget owns its + * request and its own loading / error / empty state. + */ +export default function HomePage() { + const [range, setRange] = useState(() => lastNDays(30)); + const [selectorKey, setSelectorKey] = useState(0); + const [modalOpen, setModalOpen] = useState(false); + const [toast, setToast] = useState(null); + const [, setSearchParams] = useSearchParams(); + + const emptiness = useDashboardEmptiness(range); + const weightStats = useWeightStats(lastNDays(30)); + const lastWeightKg = lastValue(seriesPoints(weightStats.data, "weight_raw")); + + const handlePeriodChange = (period: PeriodRange) => { + setRange({ from: period.from, to: period.to }); + }; + + const hideToast = useCallback(() => setToast(null), []); + + /** « Élargir la période » (ux-pages §16): switch the page period to « Tout ». */ + const widenPeriod = () => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set("periode", "tout"); + next.delete("du"); + next.delete("au"); + return next; + }, + { replace: true }, + ); + setRange({ from: null, to: null }); + setSelectorKey((key) => key + 1); + }; + + const showWelcome = emptiness.ready && emptiness.empty; + + return ( + <> + + ) + } + /> + +
    + {showWelcome ? ( + + + + + {STRINGS.empty.welcomeProfile} + + + + {STRINGS.empty.welcomeImport} + + + } + /> + + ) : null} + + setModalOpen(true)} /> + + {showWelcome ? null : ( + <> + +
    + + + + +
    + + )} +
    + + setModalOpen(false)} + onSaved={() => setToast(STRINGS.weightModal.success)} + lastWeightKg={lastWeightKg} + /> + {toast ? : null} + + ); +} diff --git a/apps/web/src/modules/home/strings.ts b/apps/web/src/modules/home/strings.ts new file mode 100644 index 0000000..a02586f --- /dev/null +++ b/apps/web/src/modules/home/strings.ts @@ -0,0 +1,129 @@ +/** French labels of the « Tableau de bord » module (CONVENTIONS C5.3). */ + +/** Cross-module routes used by the dashboard call-to-actions. */ +export const ROUTES = { + weight: "/sante/poids", + nutrition: "/sante/nutrition", + workouts: "/sante/activite", + vape: "/vape", + finance: "/finances", + imports: "/imports", + settings: "/reglages", + settingsProfile: "/reglages?tab=profil", + settingsGoal: "/reglages?tab=objectif", + settingsVape: "/reglages?tab=vape", +} as const; + +export const STRINGS = { + pageTitle: "Tableau de bord", + pageDescription: "Vue croisée de tous vos modules.", + + // Carte « Aujourd'hui » (addendum-planning) + today: { + title: "Aujourd'hui", + weighIn: "Pesée", + workout: "Séance", + foodLog: "Journal alimentaire", + rest: "Repos", + done: "Fait", + todo: "À faire", + logWeight: "Noter ma pesée", + addWorkout: "Ajouter une séance", + addFood: "Ajouter un aliment", + streak: (days: number) => `🔥 ${days} ${days > 1 ? "jours" : "jour"}`, + streakHint: "Jours planifiés tenus d'affilée", + nothingPlannedTitle: "Rien de prévu aujourd'hui. Profites-en bien !", + noScheduleTitle: "Aucun jour planifié.", + noScheduleHint: "Choisis tes jours de pesée pour suivre ton assiduité.", + configureSchedule: "Configurer mon planning", + error: "Impossible de charger la checklist du jour.", + }, + + // Modale de pesée rapide (ux-pages §8.5) + weightModal: { + title: "Ajouter une pesée", + weight: "Poids (kg)", + date: "Date", + time: "Heure", + note: "Note", + notePlaceholder: "ex. après le sport", + cancel: "Annuler", + save: "Enregistrer", + success: "Pesée enregistrée ✓", + errorRequired: "Renseignez un poids.", + errorRange: "Le poids doit être compris entre 20 et 300 kg.", + errorDate: "Renseignez une date valide.", + }, + + // KPI (ux-pages §7.1) + kpi: { + weight: "Poids actuel", + weightDelta: (value: string) => `${value} sur 7 j`, + calories: "Calories aujourd'hui", + caloriesRemaining: (value: string) => `Reste ${value}`, + caloriesOver: (value: string) => `Dépassement de ${value}`, + deficit: "Déficit cumulé (30 j)", + deficitTheoretical: (value: string) => `≈ ${value} théoriques`, + vapeToday: "Vape aujourd'hui", + vapeNicotine: (value: string) => `≈ ${value} de nicotine`, + savings: "Économies vape", + savingsSince: (date: string) => `depuis le ${date}`, + savingsSinceDays: (days: number) => `depuis ${days} ${days > 1 ? "jours" : "jour"}`, + expenses: "Dépenses du mois", + expensesShare: (value: string) => `${value} du budget global`, + noBudget: "Aucun budget défini", + empty: "—", + configure: "Configurer", + }, + + // Mini-graphiques (ux-pages §7.3) + charts: { + weightTitle: "Poids", + weightSubtitle: "Tendance (moyenne lissée)", + weightSeries: "Tendance", + weightTarget: (value: string) => `Objectif ${value}`, + kcalTitle: "Calories entrées / sorties", + kcalSubtitle: "7 derniers jours", + kcalIn: "Apports", + kcalOut: "Dépense", + savingsTitle: "Économies vape", + savingsSubtitle: "Cumul depuis l'arrêt", + savingsSeries: "Économies cumulées", + expensesTitle: "Dépenses du mois", + expensesSubtitle: "Par catégorie", + expensesOther: "Autres", + detail: "Voir le détail →", + }, + + // États vides / erreurs (ux-pages §16) + empty: { + welcomeTitle: "Bienvenue sur LifeTrack 👋", + welcomeHint: + "Configurez votre profil et importez vos premières données pour voir vos tableaux de bord prendre vie.", + welcomeProfile: "Configurer mon profil", + welcomeImport: "Importer des données", + weightTitle: "Aucune pesée pour l'instant", + weightHint: + "Ajoutez votre première pesée ou importez un historique — la tendance et les projections apparaîtront dès 3 pesées.", + weightAction: "+ Ajouter une pesée", + nutritionTitle: "Rien dans le journal aujourd'hui", + nutritionHint: + "Ajoutez votre premier aliment ou importez votre historique Foodvisor depuis la page Imports.", + nutritionAction: "+ Ajouter un aliment", + vapeTitle: "Module vape non configuré", + vapeHint: + "Renseignez votre consommation de cigarettes avant l'arrêt et votre modèle de coût : LifeTrack calculera vos économies au centime près.", + vapeAction: "Configurer la vape", + // Vape configurée mais sans saisie (ux-pages §16, 2ᵉ ligne « Vape »). + vapeNoDataTitle: "Aucune recharge enregistrée", + vapeNoDataHint: "Enregistrez votre première recharge d'e-liquide — deux clics suffisent.", + vapeNoDataAction: "+ Recharge", + financeTitle: "Aucune transaction", + financeHint: + "Importez un relevé bancaire (CSV ou OFX) ou un export PayPal pour démarrer. La catégorisation automatique fera le tri.", + financeAction: "Importer un relevé", + noDataInPeriod: "Pas de données sur cette période", + widenPeriod: "Élargir la période", + loadError: "Ces données n'ont pas pu être chargées.", + }, +} as const; diff --git a/apps/web/src/modules/imports/api.ts b/apps/web/src/modules/imports/api.ts new file mode 100644 index 0000000..296dce3 --- /dev/null +++ b/apps/web/src/modules/imports/api.ts @@ -0,0 +1,466 @@ +/** + * React Query hooks of the « Imports » module. + * Endpoints: docs/design/architecture.md §5.4 (central /api/imports) and + * docs/design/datamodel-finance.md §9.6 (finance preview + source profiles). + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api, qs } from "../../lib/api"; +import type { Page } from "../../types/api"; +import { sourceLabels } from "./strings"; + +export const MODULE_ID = "imports"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +/** One importer, as exposed by GET /api/imports/sources. */ +export interface ImporterSource { + id: string; + label: string; + domain: string; + accepted_extensions: string[]; +} + +/** One failed row of an import run. */ +export interface ImportRunError { + row?: number | null; + message: string; + raw?: string | null; +} + +/** ImportRun as returned by /api/imports (architecture §5.3). */ +export interface ImportRun { + id: number | string; + importer_id: string; + domain: string; + filename: string; + file_size: number; + status: string; + rows_total: number; + rows_inserted: number; + rows_updated: number; + rows_duplicates: number; + rows_errors: number; + error_details: ImportRunError[]; + started_at: string; + finished_at: string | null; +} + +/** One normalized row of the finance preview (datamodel-finance §4.2). */ +export interface FinancePreviewRow { + booked_date: string; + value_date: string | null; + amount: number; + currency: string; + label_raw: string; + counterparty: string | null; + external_id: string | null; + source_row_index: number; +} + +/** Response of POST /api/finance/imports/preview (datamodel-finance §9.6). */ +export interface FinanceImportPreview { + rows_preview: FinancePreviewRow[]; + rows_total: number; + rows_error: number; + rows_skipped_filtered?: number; + would_skip_duplicates: number; + date_min: string | null; + date_max: string | null; + errors: ImportRunError[]; + duplicate_file_of?: number | null; +} + +/** Response of POST /api/finance/imports — `import_run_id` is the central run. */ +interface FinanceImportRun { + id: string; + import_run_id: number; + account_id: string; + filename: string; + status: string; + started_at: string; + finished_at: string | null; + error_message: string | null; +} + +/** Finance account (datamodel-finance §2.1) — only the fields the wizard needs. */ +export interface FinanceAccount { + id: string; + name: string; + kind: string; + currency: string; + is_archived?: boolean; +} + +/** Finance source profile (datamodel-finance §2.3). */ +export interface FinanceSourceProfile { + id: string; + name: string; + kind: string; // "csv" | "ofx" | "paypal_csv" + config: unknown; // shape depends on `kind` — see normalizeCsvConfig() + is_builtin: boolean; +} + +/** Column mapping of a `kind = "csv"` profile (datamodel-finance §3.1). */ +export interface CsvColumnsConfig { + booked_date: string | null; + value_date: string | null; + label: string | null; + amount: string | null; + debit: string | null; + credit: string | null; + currency: string | null; + external_id: string | null; +} + +/** `config` of a `kind = "csv"` profile (datamodel-finance §3.1). */ +export interface CsvProfileConfig { + encoding: string; + delimiter: string; + quote_char: string; + decimal_separator: string; + thousands_separator: string; + date_format: string; + has_header: boolean; + skip_rows_top: number; + skip_rows_bottom: number; + columns: CsvColumnsConfig; + amount_mode: string; // "signed" | "split" + invert_sign: boolean; +} + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +/** Upload limit of the import pipeline (ux-pages §14.1, datamodel-finance §4). */ +export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; + +/** Value of the « détection automatique » option (architecture §5.4). */ +export const AUTO_SOURCE_ID = "auto"; + +/** + * Importer ids whose domain is finance (preview + target account required). + * Ids must match those served by GET /api/imports/sources. + */ +export const FINANCE_SOURCE_IDS = ["bank_generic_csv", "bank_ofx", "paypal_csv"]; + +/** Documented importers — used when GET /imports/sources is unavailable. */ +export const FALLBACK_SOURCES: ImporterSource[] = [ + { + id: "foodvisor_csv", + label: sourceLabels.foodvisor_csv, + domain: "health", + accepted_extensions: [".csv", ".zip"], + }, + { + id: "health_sync_csv", + label: sourceLabels.health_sync_csv, + domain: "health", + accepted_extensions: [".csv", ".zip"], + }, + { + id: "weight_generic_csv", + label: sourceLabels.weight_generic_csv, + domain: "health", + accepted_extensions: [".csv"], + }, + { + id: "bank_generic_csv", + label: sourceLabels.bank_generic_csv, + domain: "finance", + accepted_extensions: [".csv"], + }, + { + id: "bank_ofx", + label: sourceLabels.bank_ofx, + domain: "finance", + accepted_extensions: [".ofx", ".qfx"], + }, + { + id: "paypal_csv", + label: sourceLabels.paypal_csv, + domain: "finance", + accepted_extensions: [".csv"], + }, +]; + +/** Default `config` of the generic CSV profile (datamodel-finance §3.1). */ +export const DEFAULT_CSV_CONFIG: CsvProfileConfig = { + encoding: "cp1252", + delimiter: ";", + quote_char: '"', + decimal_separator: ",", + thousands_separator: " ", + date_format: "%d/%m/%Y", + has_header: true, + skip_rows_top: 0, + skip_rows_bottom: 0, + columns: { + booked_date: null, + value_date: null, + label: null, + amount: null, + debit: null, + credit: null, + currency: null, + external_id: null, + }, + amount_mode: "signed", + invert_sign: false, +}; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +/** Accept both a bare array and a paginated Page (list endpoints drift). */ +export function listOf(response: Page | T[] | undefined | null): T[] { + if (!response) return []; + return Array.isArray(response) ? response : (response.items ?? []); +} + +function asString(value: unknown): string | null { + if (typeof value === "string") return value.length > 0 ? value : null; + if (typeof value === "number") return String(value); + if (Array.isArray(value) && value.length > 0) return asString(value[0]); + return null; +} + +function asBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function asInt(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : fallback; +} + +function asText(value: unknown, fallback: string): string { + return typeof value === "string" ? value : fallback; +} + +/** Merge an unknown profile `config` with the documented CSV defaults. */ +export function normalizeCsvConfig(config: unknown): CsvProfileConfig { + const raw = (config ?? {}) as Record; + const rawColumns = (raw.columns ?? {}) as Record; + return { + encoding: asText(raw.encoding, DEFAULT_CSV_CONFIG.encoding), + delimiter: asText(raw.delimiter, DEFAULT_CSV_CONFIG.delimiter), + quote_char: asText(raw.quote_char, DEFAULT_CSV_CONFIG.quote_char), + decimal_separator: asText(raw.decimal_separator, DEFAULT_CSV_CONFIG.decimal_separator), + thousands_separator: asText(raw.thousands_separator, DEFAULT_CSV_CONFIG.thousands_separator), + date_format: asText(raw.date_format, DEFAULT_CSV_CONFIG.date_format), + has_header: asBoolean(raw.has_header, DEFAULT_CSV_CONFIG.has_header), + skip_rows_top: asInt(raw.skip_rows_top, DEFAULT_CSV_CONFIG.skip_rows_top), + skip_rows_bottom: asInt(raw.skip_rows_bottom, DEFAULT_CSV_CONFIG.skip_rows_bottom), + columns: { + booked_date: asString(rawColumns.booked_date), + value_date: asString(rawColumns.value_date), + label: asString(rawColumns.label), + amount: asString(rawColumns.amount), + debit: asString(rawColumns.debit), + credit: asString(rawColumns.credit), + currency: asString(rawColumns.currency), + external_id: asString(rawColumns.external_id), + }, + amount_mode: asText(raw.amount_mode, DEFAULT_CSV_CONFIG.amount_mode), + invert_sign: asBoolean(raw.invert_sign, DEFAULT_CSV_CONFIG.invert_sign), + }; +} + +/* ------------------------------------------------------------------ */ +/* Queries */ +/* ------------------------------------------------------------------ */ + +// Module-level `select` functions: stable identity → memoized by React Query. +const selectSources = (data: ImporterSource[] | Page) => listOf(data); +const selectAccounts = (data: FinanceAccount[] | Page) => listOf(data); +const selectProfiles = (data: FinanceSourceProfile[] | Page) => listOf(data); + +/** GET /api/imports/sources — available importers (architecture §5.4). */ +export function useImportSources() { + return useQuery({ + queryKey: [MODULE_ID, "sources"], + queryFn: () => api>("/imports/sources"), + select: selectSources, + staleTime: 5 * 60_000, + retry: false, + }); +} + +export interface ImportRunsParams { + page: number; + page_size: number; + domain?: string | null; +} + +/** GET /api/imports — paginated history, newest first. */ +export function useImportRuns(params: ImportRunsParams) { + return useQuery({ + queryKey: [MODULE_ID, "runs", params], + queryFn: () => + api>(`/imports?${qs({ ...params, sort: "-started_at" })}`), + }); +} + +/** GET /api/imports/{id} — run detail (error_details included). */ +export function useImportRun(id: number | string | null) { + return useQuery({ + queryKey: [MODULE_ID, "runs", { id }], + queryFn: () => api(`/imports/${String(id)}`), + enabled: id !== null && id !== undefined, + }); +} + +/** GET /api/finance/accounts — target accounts of a bank import. */ +export function useFinanceAccounts(enabled: boolean) { + return useQuery({ + queryKey: [MODULE_ID, "finance-accounts"], + queryFn: () => + api>( + `/finance/accounts?${qs({ include_archived: false })}`, + ), + select: selectAccounts, + staleTime: 5 * 60_000, + enabled, + }); +} + +/** GET /api/finance/source-profiles — built-in presets + user profiles. */ +export function useFinanceSourceProfiles(enabled: boolean) { + return useQuery({ + queryKey: [MODULE_ID, "finance-source-profiles"], + queryFn: () => + api>("/finance/source-profiles"), + select: selectProfiles, + staleTime: 5 * 60_000, + enabled, + }); +} + +/* ------------------------------------------------------------------ */ +/* Mutations */ +/* ------------------------------------------------------------------ */ + +export interface ImportUploadVars { + file: File; + /** Importer id, or "auto" for sniffing. */ + source: string; + /** Finance only: target account. */ + accountId?: string | null; + /** Finance only: source profile (preset or user profile). */ + sourceProfileId?: string | null; +} + +/** Body of POST /api/imports — the central endpoint takes `file` + `source`. */ +function centralFormData(vars: ImportUploadVars): FormData { + const form = new FormData(); + form.append("file", vars.file); + form.append("source", vars.source); + return form; +} + +/** + * Body of the finance endpoints — they take the target account instead of the + * importer id (datamodel-finance §9.6). + */ +function financeFormData(vars: ImportUploadVars): FormData { + const form = new FormData(); + form.append("file", vars.file); + if (vars.accountId) form.append("account_id", vars.accountId); + if (vars.sourceProfileId) form.append("source_profile_id", vars.sourceProfileId); + return form; +} + +/** POST /api/finance/imports/preview — parse without writing (datamodel-finance §9.6). */ +export function useFinanceImportPreview() { + return useMutation({ + mutationFn: (vars: ImportUploadVars) => + api("/finance/imports/preview", { + method: "POST", + body: financeFormData(vars), + }), + }); +} + +/** + * Uploads the file and returns the resulting central `ImportRun`. + * Bank statements go through POST /api/finance/imports (it needs the target + * account); every other domain goes through the central POST /api/imports. + * The finance run carries `import_run_id`, the id of the central run that the + * result screen and the history display. + */ +export function useCreateImport() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (vars: ImportUploadVars): Promise => { + if (vars.accountId) { + const created = await api("/finance/imports", { + method: "POST", + body: financeFormData(vars), + }); + return api(`/imports/${created.import_run_id}`); + } + return api("/imports", { method: "POST", body: centralFormData(vars) }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [MODULE_ID, "runs"] }); + }, + }); +} + +export interface RollbackVars { + id: number | string; +} + +/** + * DELETE /api/imports/{id} — rollback of a whole batch. + * The central endpoint takes no parameter and never answers 409: the rollback + * always cascades (CONVENTIONS C8.5). Only the finance-specific + * DELETE /api/finance/imports/{uuid} supports `?force=`, and the central + * history does not expose it. + */ +export function useRollbackImport() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id }: RollbackVars) => + api(`/imports/${String(id)}`, { method: "DELETE" }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [MODULE_ID, "runs"] }); + }, + }); +} + +export interface SaveMappingVars { + profile: FinanceSourceProfile; + config: CsvProfileConfig; +} + +/** + * Persist an adjusted column mapping: a built-in preset is cloned first + * (datamodel-finance §3.4 — presets stay read-only), then patched. + */ +export function useSaveCsvMapping() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ profile, config }: SaveMappingVars) => { + let target = profile; + if (profile.is_builtin) { + // The endpoint takes no body: the server names the copy « … (copie) ». + target = await api( + `/finance/source-profiles/${profile.id}/clone`, + { method: "POST" }, + ); + } + return api(`/finance/source-profiles/${target.id}`, { + method: "PATCH", + body: JSON.stringify({ config }), + }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [MODULE_ID, "finance-source-profiles"] }); + }, + }); +} diff --git a/apps/web/src/modules/imports/components/ConnectorsSection.tsx b/apps/web/src/modules/imports/components/ConnectorsSection.tsx new file mode 100644 index 0000000..e1d2cfb --- /dev/null +++ b/apps/web/src/modules/imports/components/ConnectorsSection.tsx @@ -0,0 +1,118 @@ +import { Check, Copy, Landmark, Smartphone, Utensils } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; + +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { strings } from "../strings"; + +/** Ingest endpoint of the Android bridge (architecture §5.5). */ +function ingestUrl(): string { + const origin = typeof window === "undefined" ? "" : window.location.origin; + return `${origin}/api/ingest/health`; +} + +/** « Connecteurs & API » information cards (ux-pages §14.4). */ +export function ConnectorsSection() { + const [copied, setCopied] = useState(false); + const url = ingestUrl(); + + const copy = () => { + // navigator.clipboard is undefined outside secure contexts — fail silently. + const written = navigator.clipboard?.writeText(url); + if (!written) return; + void written.then( + () => { + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + }, + () => setCopied(false), + ); + }; + + return ( +
    +
    +

    {strings.connectors.title}

    +

    {strings.connectors.subtitle}

    +
    + +
    + + + {strings.connectors.healthTitle} + + } + > +

    {strings.connectors.healthText}

    + + +
    + event.target.select()} + className="h-10 w-full rounded-lg border bg-surface-2 px-3 font-mono text-xs text-ink focus:outline-none focus:ring-2 focus:ring-accent" + /> + +
    +

    {strings.connectors.healthHeaderHint}

    + + + {strings.connectors.healthKeys} + +

    {strings.connectors.healthFallback}

    +
    + + + + {strings.connectors.foodvisorTitle} + + } + > +

    {strings.connectors.foodvisorText}

    +
      + {strings.connectors.foodvisorSteps.map((step) => ( +
    1. {step}
    2. + ))} +
    +
    + + + + {strings.connectors.banksTitle} + + } + > +

    {strings.connectors.banksText}

    +
      + {strings.connectors.banksFormats.map((format) => ( +
    • {format}
    • + ))} +
    +

    {strings.connectors.banksHint}

    +
    +
    +
    + ); +} diff --git a/apps/web/src/modules/imports/components/CsvMappingForm.tsx b/apps/web/src/modules/imports/components/CsvMappingForm.tsx new file mode 100644 index 0000000..9d59691 --- /dev/null +++ b/apps/web/src/modules/imports/components/CsvMappingForm.tsx @@ -0,0 +1,191 @@ +import { useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Select } from "../../../components/ui/Select"; +import type { CsvColumnsConfig, CsvProfileConfig, FinanceSourceProfile } from "../api"; +import { normalizeCsvConfig } from "../api"; +import { strings } from "../strings"; +import { ErrorNotice } from "./ErrorNotice"; + +const ENCODINGS = ["auto", "utf-8", "utf-8-sig", "cp1252", "iso-8859-15"]; +const DELIMITERS: { value: string; label: string }[] = [ + { value: ";", label: "Point-virgule (;)" }, + { value: ",", label: "Virgule (,)" }, + { value: "\t", label: "Tabulation" }, + { value: "|", label: "Barre verticale (|)" }, +]; +const DECIMALS: { value: string; label: string }[] = [ + { value: ",", label: "Virgule (12,50)" }, + { value: ".", label: "Point (12.50)" }, +]; +const THOUSANDS: { value: string; label: string }[] = [ + { value: "", label: "Aucun" }, + { value: " ", label: "Espace" }, + { value: " ", label: "Espace insécable" }, + { value: ".", label: "Point" }, +]; + +export interface CsvMappingFormProps { + profile: FinanceSourceProfile; + onApply: (config: CsvProfileConfig) => void; + saving?: boolean; + error?: unknown; +} + +/** + * Column mapping of a generic bank CSV (ux-pages §14.1 step 2, + * datamodel-finance §3.1). Saving a built-in preset clones it first. + */ +export function CsvMappingForm({ profile, onApply, saving = false, error }: CsvMappingFormProps) { + const [config, setConfig] = useState(() => normalizeCsvConfig(profile.config)); + + const patch = (values: Partial) => + setConfig((prev) => ({ ...prev, ...values })); + const patchColumn = (key: keyof CsvColumnsConfig, value: string) => + setConfig((prev) => ({ + ...prev, + columns: { ...prev.columns, [key]: value.trim() === "" ? null : value }, + })); + + const columnField = (key: keyof CsvColumnsConfig, label: string) => ( + patchColumn(key, event.target.value)} + /> + ); + + return ( +
    +

    {strings.mapping.title}

    +

    {strings.mapping.subtitle}

    + {profile.is_builtin ? ( +

    {strings.mapping.builtinNotice}

    + ) : null} + +
    + + + + + patch({ date_format: event.target.value })} + /> + + patch({ skip_rows_top: Number(event.target.value) || 0 })} + /> + patch({ skip_rows_bottom: Number(event.target.value) || 0 })} + /> +
    + +
    + + +
    + +
    + {strings.mapping.columns} +
    +

    {strings.mapping.columnsHint}

    +
    + {columnField("booked_date", strings.mapping.colBookedDate)} + {columnField("value_date", strings.mapping.colValueDate)} + {columnField("label", strings.mapping.colLabel)} + {config.amount_mode === "split" ? ( + <> + {columnField("debit", strings.mapping.colDebit)} + {columnField("credit", strings.mapping.colCredit)} + + ) : ( + columnField("amount", strings.mapping.colAmount) + )} + {columnField("currency", strings.mapping.colCurrency)} +
    + + + +
    + +
    +
    + ); +} diff --git a/apps/web/src/modules/imports/components/ErrorNotice.tsx b/apps/web/src/modules/imports/components/ErrorNotice.tsx new file mode 100644 index 0000000..5fdf506 --- /dev/null +++ b/apps/web/src/modules/imports/components/ErrorNotice.tsx @@ -0,0 +1,30 @@ +import { AlertTriangle } from "lucide-react"; + +import { ApiError } from "../../../lib/api"; +import { strings } from "../strings"; + +export interface ErrorNoticeProps { + error: unknown; + className?: string; +} + +/** French message of an ApiError (already localized by the API), inline banner. */ +export function ErrorNotice({ error, className }: ErrorNoticeProps) { + if (!error) return null; + const message = + error instanceof ApiError + ? error.message + : error instanceof Error && error.message + ? error.message + : strings.common.genericError; + + return ( +

    + + {message} +

    + ); +} diff --git a/apps/web/src/modules/imports/components/ErrorsTable.tsx b/apps/web/src/modules/imports/components/ErrorsTable.tsx new file mode 100644 index 0000000..e5466d0 --- /dev/null +++ b/apps/web/src/modules/imports/components/ErrorsTable.tsx @@ -0,0 +1,64 @@ +import { Download } from "lucide-react"; + +import { Button } from "../../../components/ui/Button"; +import { Table } from "../../../components/ui/Table"; +import type { TableColumn } from "../../../components/ui/Table"; +import { formatNumber } from "../../../lib/format"; +import type { ImportRunError } from "../api"; +import { strings } from "../strings"; +import { downloadCsv } from "../utils/csv"; + +export interface ErrorsTableProps { + errors: ImportRunError[]; + /** Imported file name — used to build the CSV report name. */ + filename: string; +} + +const COLUMNS: TableColumn[] = [ + { + key: "row", + header: strings.errors.colRow, + align: "right", + className: "w-20", + render: (error) => (error.row != null ? formatNumber(error.row) : "—"), + }, + { + key: "message", + header: strings.errors.colReason, + render: (error) => ( +
    +

    {error.message}

    + {error.raw ? ( +

    {error.raw}

    + ) : null} +
    + ), + }, +]; + +/** Failed rows of a run + CSV report download (ux-pages §14.3). */ +export function ErrorsTable({ errors, filename }: ErrorsTableProps) { + if (errors.length === 0) { + return

    {strings.errors.none}

    ; + } + + const onDownload = () => { + const base = filename.replace(/\.[^.]+$/, "") || strings.errors.fileName; + downloadCsv( + `${strings.errors.fileName}-${base}.csv`, + [strings.errors.colRow, strings.errors.colReason, "Contenu"], + errors.map((error) => [error.row ?? null, error.message, error.raw ?? null]), + ); + }; + + return ( +
    +
    `${error.row ?? "-"}-${index}`} /> +
    + +
    + + ); +} diff --git a/apps/web/src/modules/imports/components/FileDropzone.tsx b/apps/web/src/modules/imports/components/FileDropzone.tsx new file mode 100644 index 0000000..556f9f2 --- /dev/null +++ b/apps/web/src/modules/imports/components/FileDropzone.tsx @@ -0,0 +1,101 @@ +import clsx from "clsx"; +import { FileText, UploadCloud, X } from "lucide-react"; +import { useState } from "react"; +import type { DragEvent } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { strings } from "../strings"; +import { formatFileSize, validateFile } from "../utils/file"; + +export interface FileDropzoneProps { + file: File | null; + onSelect: (file: File | null) => void; + /** `accept` attribute derived from the selected importer extensions. */ + accept?: string; + /** French validation message (size, extension…). */ + error?: string | null; + onError: (message: string | null) => void; +} + +/** Drag & drop + file picker (ux-pages §14.1). */ +export function FileDropzone({ file, onSelect, accept, error, onError }: FileDropzoneProps) { + const [dragging, setDragging] = useState(false); + + const handleFiles = (files: FileList | null) => { + const picked = files?.[0] ?? null; + if (!picked) return; + const message = validateFile(picked); + onError(message); + onSelect(message ? null : picked); + }; + + const onDrop = (event: DragEvent) => { + event.preventDefault(); + setDragging(false); + handleFiles(event.dataTransfer.files); + }; + + return ( +
    + + + {error ? ( +

    + {error} +

    + ) : null} + + {file ? ( +
    + + + {file.name} + {formatFileSize(file.size)} + + +
    + ) : null} +
    + ); +} diff --git a/apps/web/src/modules/imports/components/ImportHistory.tsx b/apps/web/src/modules/imports/components/ImportHistory.tsx new file mode 100644 index 0000000..afc4929 --- /dev/null +++ b/apps/web/src/modules/imports/components/ImportHistory.tsx @@ -0,0 +1,204 @@ +import { FileUp, RotateCcw } from "lucide-react"; +import { useState } from "react"; + +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { ConfirmDialog } from "../../../components/ui/ConfirmDialog"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { Table } from "../../../components/ui/Table"; +import type { TableColumn } from "../../../components/ui/Table"; +import { formatDateTime, formatNumber } from "../../../lib/format"; +import { useImportRuns, useRollbackImport } from "../api"; +import type { ImporterSource, ImportRun } from "../api"; +import { domainLabels, sourceLabels, strings } from "../strings"; +import { formatFileSize } from "../utils/file"; +import { runStatus, statusLabel, statusTone } from "../utils/status"; +import { ErrorNotice } from "./ErrorNotice"; +import { RunDetailModal } from "./RunDetailModal"; + +const PAGE_SIZE = 20; + +export interface ImportHistoryProps { + sources: ImporterSource[]; +} + +/** « Historique des imports » + rollback of a batch (ux-pages §14.2). */ +export function ImportHistory({ sources }: ImportHistoryProps) { + const [page, setPage] = useState(1); + const [detailRun, setDetailRun] = useState(null); + const [rollbackTarget, setRollbackTarget] = useState(null); + + const runs = useImportRuns({ page, page_size: PAGE_SIZE }); + const rollback = useRollbackImport(); + + const labelOf = (run: ImportRun): string => + sources.find((source) => source.id === run.importer_id)?.label ?? + sourceLabels[run.importer_id] ?? + run.importer_id; + + const closeRollback = () => { + setRollbackTarget(null); + rollback.reset(); + }; + + const columns: TableColumn[] = [ + { + key: "started_at", + header: strings.history.colDate, + render: (run) => formatDateTime(run.started_at), + }, + { + key: "importer_id", + header: strings.history.colSource, + render: (run) => ( + + {labelOf(run)} + {domainLabels[run.domain] ? ( + {domainLabels[run.domain]} + ) : null} + + ), + }, + { + key: "filename", + header: strings.history.colFile, + render: (run) => ( + + {run.filename} + {formatFileSize(run.file_size)} + + ), + }, + { + key: "rows_total", + header: strings.history.colRows, + align: "right", + render: (run) => formatNumber(run.rows_total), + }, + { + key: "rows_inserted", + header: strings.history.colInserted, + align: "right", + render: (run) => ( + {formatNumber(run.rows_inserted)} + ), + }, + { + key: "rows_duplicates", + header: strings.history.colDuplicates, + align: "right", + render: (run) => {formatNumber(run.rows_duplicates)}, + }, + { + key: "rows_errors", + header: strings.history.colErrors, + align: "right", + render: (run) => + run.rows_errors > 0 ? ( + + ) : ( + {formatNumber(0)} + ), + }, + { + key: "status", + header: strings.history.colStatus, + render: (run) => { + const status = runStatus(run); + return {statusLabel(status)}; + }, + }, + { + key: "actions", + header: strings.history.colActions, + align: "right", + render: (run) => ( + + + + + ), + }, + ]; + + const items = runs.data?.items ?? []; + + return ( + +
    + {runs.isLoading ? ( + + ) : runs.error ? ( + + ) : ( +
    String(run.id)} + empty={ + + } + pagination={{ + page, + pageSize: runs.data?.page_size ?? PAGE_SIZE, + total: runs.data?.total ?? 0, + onPageChange: setPage, + }} + /> + )} + + + {detailRun ? ( + setDetailRun(null)} /> + ) : null} + + { + if (!rollbackTarget) return; + rollback.mutate({ id: rollbackTarget.id }, { onSuccess: closeRollback }); + }} + message={ +
    +

    + {strings.rollback.consequence( + formatNumber(rollbackTarget?.rows_inserted ?? 0), + )} +

    +

    {strings.rollback.detail}

    +

    {strings.rollback.irreversible}

    + +
    + } + /> + + ); +} diff --git a/apps/web/src/modules/imports/components/RunDetailModal.tsx b/apps/web/src/modules/imports/components/RunDetailModal.tsx new file mode 100644 index 0000000..bfc6b09 --- /dev/null +++ b/apps/web/src/modules/imports/components/RunDetailModal.tsx @@ -0,0 +1,77 @@ +import { Badge } from "../../../components/ui/Badge"; +import { Modal } from "../../../components/ui/Modal"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { formatDateTime, formatNumber } from "../../../lib/format"; +import { useImportRun } from "../api"; +import type { ImportRun } from "../api"; +import { strings } from "../strings"; +import { formatFileSize } from "../utils/file"; +import { runStatus, statusLabel, statusTone } from "../utils/status"; +import { ErrorNotice } from "./ErrorNotice"; +import { ErrorsTable } from "./ErrorsTable"; + +export interface RunDetailModalProps { + /** Row of the history table (used while the detail request is in flight). */ + run: ImportRun; + onClose: () => void; +} + +/** « Détails » panel of an import run, error rows included (ux-pages §14.3). */ +export function RunDetailModal({ run, onClose }: RunDetailModalProps) { + const detail = useImportRun(run.id); + const current = detail.data ?? run; + const status = runStatus(current); + + return ( + +
    +
    + {statusLabel(status)} + {formatDateTime(current.started_at)} + {formatFileSize(current.file_size)} +
    + +
    +
    +
    + {strings.history.colRows} +
    +
    + {formatNumber(current.rows_total)} +
    +
    +
    +
    + {strings.history.colInserted} +
    +
    + {formatNumber(current.rows_inserted)} +
    +
    +
    +
    + {strings.history.colDuplicates} +
    +
    + {formatNumber(current.rows_duplicates)} +
    +
    +
    +
    + {strings.history.colErrors} +
    +
    + {formatNumber(current.rows_errors)} +
    +
    +
    + + {detail.isLoading ? : null} + + {!detail.isLoading ? ( + + ) : null} +
    +
    + ); +} diff --git a/apps/web/src/modules/imports/components/StepFile.tsx b/apps/web/src/modules/imports/components/StepFile.tsx new file mode 100644 index 0000000..8151516 --- /dev/null +++ b/apps/web/src/modules/imports/components/StepFile.tsx @@ -0,0 +1,156 @@ +import { Link } from "react-router-dom"; + +import { Badge } from "../../../components/ui/Badge"; +import { Select } from "../../../components/ui/Select"; +import { Spinner } from "../../../components/ui/Spinner"; +import type { FinanceAccount, FinanceSourceProfile, ImporterSource } from "../api"; +import { AUTO_SOURCE_ID } from "../api"; +import { domainLabels, strings } from "../strings"; +import { acceptAttribute } from "../utils/file"; +import { ErrorNotice } from "./ErrorNotice"; +import { FileDropzone } from "./FileDropzone"; + +export interface StepFileProps { + file: File | null; + onFileChange: (file: File | null) => void; + fileError: string | null; + onFileError: (message: string | null) => void; + + sources: ImporterSource[]; + sourcesLoading: boolean; + sourceId: string; + onSourceChange: (id: string) => void; + + /** The selected source targets the finance domain (account + profile needed). */ + isFinance: boolean; + accounts: FinanceAccount[]; + accountsLoading: boolean; + accountsError: unknown; + accountId: string; + onAccountChange: (id: string) => void; + + profiles: FinanceSourceProfile[]; + profilesLoading: boolean; + profilesError: unknown; + profileId: string; + onProfileChange: (id: string) => void; +} + +/** Step 1 — file + source profile (+ target account for bank statements). */ +export function StepFile({ + file, + onFileChange, + fileError, + onFileError, + sources, + sourcesLoading, + sourceId, + onSourceChange, + isFinance, + accounts, + accountsLoading, + accountsError, + accountId, + onAccountChange, + profiles, + profilesLoading, + profilesError, + profileId, + onProfileChange, +}: StepFileProps) { + const selected = sources.find((s) => s.id === sourceId); + + return ( +
    + + +
    +
    + + {sourceId === AUTO_SOURCE_ID ? ( + + {strings.file.autoDetected} + + ) : null} + {sourcesLoading ? ( +

    + + {strings.common.loading} +

    + ) : null} +
    + + {isFinance ? ( +
    +
    + + {!accountsLoading && accounts.length === 0 && !accountsError ? ( +

    + {strings.finance.accountMissing}{" "} + + {strings.finance.accountLink} + +

    + ) : null} + +
    + +
    + + {!profilesLoading && profiles.length === 0 && !profilesError ? ( +

    {strings.finance.profileMissing}

    + ) : null} + +
    +
    + ) : null} +
    +
    + ); +} diff --git a/apps/web/src/modules/imports/components/StepResult.tsx b/apps/web/src/modules/imports/components/StepResult.tsx new file mode 100644 index 0000000..e7f34ba --- /dev/null +++ b/apps/web/src/modules/imports/components/StepResult.tsx @@ -0,0 +1,89 @@ +import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react"; + +import { Badge } from "../../../components/ui/Badge"; +import { StatCard } from "../../../components/ui/StatCard"; +import { formatNumber } from "../../../lib/format"; +import type { ImportRun } from "../api"; +import { strings } from "../strings"; +import { formatFileSize } from "../utils/file"; +import { runStatus, statusLabel, statusTone } from "../utils/status"; +import { ErrorsTable } from "./ErrorsTable"; + +export interface StepResultProps { + run: ImportRun; +} + +/** Step 3 — summary of the completed ImportRun (ux-pages §14.1). */ +export function StepResult({ run }: StepResultProps) { + const status = runStatus(run); + const errors = run.error_details ?? []; + + const hint = + status === "failed" + ? strings.result.failedHint + : status === "partial" + ? strings.result.partialHint + : strings.result.successHint; + + const HintIcon = + status === "failed" ? XCircle : status === "partial" ? AlertTriangle : CheckCircle2; + const hintClass = + status === "failed" + ? "text-semantic-negative" + : status === "partial" + ? "text-semantic-warning" + : "text-semantic-positive"; + + return ( +
    +
    + {statusLabel(status)} + + {strings.result.fileLabel} : {run.filename}{" "} + ({formatFileSize(run.file_size)}) + +
    + +

    + + {hint} +

    + +
    + + 0 + ? `${formatNumber(run.rows_updated)} ${strings.result.updated.toLowerCase()}` + : undefined + } + /> + + 0 ? "negative" : "neutral"} + /> +
    + + {errors.length > 0 ? ( +
    +

    {strings.errors.title}

    + +
    + ) : null} +
    + ); +} diff --git a/apps/web/src/modules/imports/components/StepVerify.tsx b/apps/web/src/modules/imports/components/StepVerify.tsx new file mode 100644 index 0000000..5a142fd --- /dev/null +++ b/apps/web/src/modules/imports/components/StepVerify.tsx @@ -0,0 +1,202 @@ +import { AlertTriangle, CopyCheck, FileText } from "lucide-react"; +import { useState } from "react"; + +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { Table } from "../../../components/ui/Table"; +import type { TableColumn } from "../../../components/ui/Table"; +import { formatDate, formatEuroAmount, formatNumber } from "../../../lib/format"; +import type { + CsvProfileConfig, + FinanceImportPreview, + FinancePreviewRow, + FinanceSourceProfile, +} from "../api"; +import { strings } from "../strings"; +import { formatFileSize } from "../utils/file"; +import { CsvMappingForm } from "./CsvMappingForm"; +import { ErrorNotice } from "./ErrorNotice"; + +export interface StepVerifyProps { + file: File; + sourceLabel: string; + isFinance: boolean; + preview: FinanceImportPreview | null; + previewLoading: boolean; + previewError: unknown; + profile: FinanceSourceProfile | null; + onApplyMapping: (config: CsvProfileConfig) => void; + mappingSaving: boolean; + mappingError: unknown; +} + +const PREVIEW_COLUMNS: TableColumn[] = [ + { + key: "booked_date", + header: strings.preview.colDate, + render: (row) => formatDate(row.booked_date), + }, + { + key: "label_raw", + header: strings.preview.colLabel, + render: (row) => {row.label_raw}, + }, + { + key: "amount", + header: strings.preview.colAmount, + align: "right", + render: (row) => + row.amount > 0 ? ( + +{formatEuroAmount(row.amount)} + ) : ( + {formatEuroAmount(row.amount)} + ), + }, +]; + +/** Step 2 — preview of the parsed rows (finance) or file summary (other domains). */ +export function StepVerify({ + file, + sourceLabel, + isFinance, + preview, + previewLoading, + previewError, + profile, + onApplyMapping, + mappingSaving, + mappingError, +}: StepVerifyProps) { + const [mappingOpen, setMappingOpen] = useState(false); + + const summary = ( +
    +
    +
    +
    {strings.file.name}
    +
    + + {file.name} +
    +
    +
    +
    {strings.file.size}
    +
    {formatFileSize(file.size)}
    +
    +
    +
    {strings.file.source}
    +
    {sourceLabel}
    +
    +
    +
    + ); + + if (!isFinance) { + return ( +
    + {summary} +

    {strings.preview.noPreview}

    +

    + + {strings.preview.dedupe} +

    +
    + ); + } + + if (previewLoading) return ; + if (previewError) { + return ( +
    + {summary} + +
    + ); + } + if (!preview) return ; + + const errorRows = preview.errors ?? []; + + return ( +
    + {summary} + +
    + + {strings.preview.summary( + formatNumber(preview.rows_total), + formatNumber(preview.would_skip_duplicates), + formatNumber(preview.rows_error), + )} + + {preview.date_min && preview.date_max ? ( + + {strings.preview.period} : {formatDate(preview.date_min)} –{" "} + {formatDate(preview.date_max)} + + ) : null} +
    + + {preview.rows_preview.length === 0 ? ( +

    + {strings.preview.empty} +

    + ) : ( +
    +

    {strings.preview.title}

    +
    row.source_row_index} + /> + + )} + + {errorRows.length > 0 ? ( +
    +

    + + {strings.errors.title} +

    +
      + {errorRows.slice(0, 5).map((error, index) => ( +
    • + {error.row != null ? ( + + {strings.errors.colRow} {formatNumber(error.row)} —{" "} + + ) : null} + {error.message} +
    • + ))} +
    +
    + ) : null} + +

    + + {strings.preview.dedupe} +

    + + {profile && profile.kind === "csv" ? ( +
    + + {mappingOpen ? ( +
    + +
    + ) : null} +
    + ) : null} + + ); +} diff --git a/apps/web/src/modules/imports/components/StepperHeader.tsx b/apps/web/src/modules/imports/components/StepperHeader.tsx new file mode 100644 index 0000000..f4efd2e --- /dev/null +++ b/apps/web/src/modules/imports/components/StepperHeader.tsx @@ -0,0 +1,64 @@ +import clsx from "clsx"; +import { Check } from "lucide-react"; + +import { strings } from "../strings"; + +export type StepIndex = 1 | 2 | 3; + +const STEPS: { index: StepIndex; label: string }[] = [ + { index: 1, label: strings.steps.file }, + { index: 2, label: strings.steps.verify }, + { index: 3, label: strings.steps.run }, +]; + +export interface StepperHeaderProps { + current: StepIndex; +} + +/** « 1. Fichier → 2. Vérification → 3. Import » (ux-pages §14.1). */ +export function StepperHeader({ current }: StepperHeaderProps) { + return ( +
      + {STEPS.map((step, i) => { + const done = step.index < current; + const active = step.index === current; + return ( +
    1. + + + {done ? : step.index} + + {step.label} + + {i < STEPS.length - 1 ? ( + + ) : null} +
    2. + ); + })} +
    + ); +} diff --git a/apps/web/src/modules/imports/index.ts b/apps/web/src/modules/imports/index.ts new file mode 100644 index 0000000..7224286 --- /dev/null +++ b/apps/web/src/modules/imports/index.ts @@ -0,0 +1,18 @@ +import { Upload } from "lucide-react"; +import { createElement, lazy } from "react"; + +import type { ModuleManifest } from "../../types/module"; + +// Lazy page (CONVENTIONS C5.7) — the AppLayout provides the Suspense boundary. +const ImportsPage = lazy(() => import("./pages/ImportsPage")); + +/** Module « Imports » — connecteurs et imports de fichiers (ux-pages §14). */ +const manifest: ModuleManifest = { + id: "imports", + title: "Imports", + order: 80, + routes: [{ path: "/imports", element: createElement(ImportsPage) }], + nav: [{ path: "/imports", label: "Imports", icon: Upload, order: 80 }], +}; + +export default manifest; diff --git a/apps/web/src/modules/imports/pages/ImportsPage.tsx b/apps/web/src/modules/imports/pages/ImportsPage.tsx new file mode 100644 index 0000000..da11e0b --- /dev/null +++ b/apps/web/src/modules/imports/pages/ImportsPage.tsx @@ -0,0 +1,265 @@ +import { ArrowLeft, ArrowRight, RefreshCw, Upload } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { PageHeader } from "../../../components/ui/PageHeader"; +import { Spinner } from "../../../components/ui/Spinner"; +import { + AUTO_SOURCE_ID, + FALLBACK_SOURCES, + FINANCE_SOURCE_IDS, + useCreateImport, + useFinanceAccounts, + useFinanceImportPreview, + useFinanceSourceProfiles, + useImportSources, + useSaveCsvMapping, +} from "../api"; +import type { CsvProfileConfig, FinanceSourceProfile, ImportRun } from "../api"; +import { ConnectorsSection } from "../components/ConnectorsSection"; +import { ErrorNotice } from "../components/ErrorNotice"; +import { ImportHistory } from "../components/ImportHistory"; +import { StepFile } from "../components/StepFile"; +import { StepResult } from "../components/StepResult"; +import { StepVerify } from "../components/StepVerify"; +import { StepperHeader } from "../components/StepperHeader"; +import type { StepIndex } from "../components/StepperHeader"; +import { sourceLabels, strings } from "../strings"; + +/** Best source profile for the selected importer (datamodel-finance §3.4). */ +function pickProfile( + sourceId: string, + profiles: FinanceSourceProfile[], +): FinanceSourceProfile | null { + if (profiles.length === 0) return null; + // Importer id (GET /imports/sources) → `kind` of the source profile. + const wantedKind = + sourceId === "bank_ofx" ? "ofx" : sourceId === "paypal_csv" ? "paypal_csv" : "csv"; + const sameKind = profiles.filter((profile) => profile.kind === wantedKind); + if (sameKind.length === 0) return profiles[0]; + const generic = sameKind.find((profile) => + profile.name.toLowerCase().includes("générique"), + ); + return generic ?? sameKind[0]; +} + +/** Page « Imports » — 3-step wizard, history and connectors (ux-pages §14). */ +export default function ImportsPage() { + const [step, setStep] = useState(1); + const [file, setFile] = useState(null); + const [fileError, setFileError] = useState(null); + const [sourceId, setSourceId] = useState(AUTO_SOURCE_ID); + const [accountId, setAccountId] = useState(""); + const [profileId, setProfileId] = useState(""); + const [run, setRun] = useState(null); + + const sourcesQuery = useImportSources(); + const sources = + sourcesQuery.data && sourcesQuery.data.length > 0 ? sourcesQuery.data : FALLBACK_SOURCES; + + const selectedSource = sources.find((source) => source.id === sourceId) ?? null; + const isFinance = + selectedSource !== null && + (selectedSource.domain === "finance" || FINANCE_SOURCE_IDS.includes(selectedSource.id)); + + const accountsQuery = useFinanceAccounts(isFinance); + const profilesQuery = useFinanceSourceProfiles(isFinance); + const accounts = accountsQuery.data ?? []; + const profiles = profilesQuery.data ?? []; + const profile = profiles.find((item) => item.id === profileId) ?? null; + + const preview = useFinanceImportPreview(); + const createImport = useCreateImport(); + const saveMapping = useSaveCsvMapping(); + + // Preselect the target account and the source profile of a bank import. + const firstAccountId = accounts[0]?.id ?? ""; + const preferredProfileId = isFinance ? (pickProfile(sourceId, profiles)?.id ?? "") : ""; + + useEffect(() => { + if (!isFinance || accountId !== "" || firstAccountId === "") return; + setAccountId(firstAccountId); + }, [isFinance, firstAccountId, accountId]); + + useEffect(() => { + if (!isFinance || profileId !== "" || preferredProfileId === "") return; + setProfileId(preferredProfileId); + }, [isFinance, preferredProfileId, profileId]); + + const sourceLabel = + selectedSource?.label ?? sourceLabels[sourceId] ?? strings.source.unknown; + + const runPreview = (targetProfileId: string) => { + if (!file) return; + preview.mutate({ + file, + source: sourceId, + accountId, + sourceProfileId: targetProfileId, + }); + }; + + const onSourceChange = (id: string) => { + setSourceId(id); + setProfileId(""); + preview.reset(); + }; + + const onFileChange = (next: File | null) => { + setFile(next); + preview.reset(); + }; + + const canContinue = file !== null && (!isFinance || (accountId !== "" && profileId !== "")); + + const goToVerify = () => { + if (!file) return; + setStep(2); + if (isFinance) runPreview(profileId); + }; + + const startImport = () => { + if (!file) return; + createImport.mutate( + { + file, + source: sourceId, + // Account and profile only make sense for a bank statement. + accountId: isFinance ? accountId : null, + sourceProfileId: isFinance ? profileId : null, + }, + { + onSuccess: (created) => { + setRun(created); + setStep(3); + }, + }, + ); + }; + + const onApplyMapping = (config: CsvProfileConfig) => { + if (!profile) return; + saveMapping.mutate( + { profile, config }, + { + onSuccess: (updated) => { + setProfileId(updated.id); + runPreview(updated.id); + }, + }, + ); + }; + + const reset = () => { + setStep(1); + setFile(null); + setFileError(null); + setRun(null); + preview.reset(); + createImport.reset(); + saveMapping.reset(); + }; + + return ( +
    + + + + + +
    + {step === 1 ? ( + + ) : null} + + {step === 2 && file ? ( + + ) : null} + + {step === 3 && run ? : null} +
    + + {step === 2 && createImport.isPending ? ( +

    + + {strings.result.running} +

    + ) : null} + + {step === 2 ? : null} + +
    + {step === 1 ? ( + <> + {strings.steps.stepOf(1, 3)} + + + ) : null} + + {step === 2 ? ( + <> + + + + ) : null} + + {step === 3 ? ( + <> + {strings.result.title} + + + ) : null} +
    +
    + + + + +
    + ); +} diff --git a/apps/web/src/modules/imports/strings.ts b/apps/web/src/modules/imports/strings.ts new file mode 100644 index 0000000..725e7ae --- /dev/null +++ b/apps/web/src/modules/imports/strings.ts @@ -0,0 +1,238 @@ +/** + * French strings of the « Imports » module (CONVENTIONS C5.3). + * Texts of the empty states are the normative ones of docs/design/ux-pages.md §16. + */ + +export const strings = { + page: { + title: "Imports", + description: + "Déposez un relevé bancaire, un export d'application santé ou nutrition : LifeTrack détecte le format, vous montre un aperçu, puis importe sans jamais créer de doublon.", + }, + + steps: { + file: "Fichier", + verify: "Vérification", + run: "Import", + stepOf: (index: number, total: number) => `Étape ${index} sur ${total}`, + }, + + dropzone: { + title: "Glissez un fichier ici", + titleSuffix: "ou cliquez pour parcourir", + hint: "CSV, OFX, XLSX ou ZIP — 20 Mo max", + remove: "Retirer le fichier", + tooLarge: "Fichier trop volumineux : 20 Mo maximum.", + empty: "Fichier vide : sélectionnez un autre fichier.", + dropHere: "Déposez le fichier pour le sélectionner", + }, + + file: { + name: "Nom du fichier", + size: "Taille", + source: "Profil de source", + autoDetected: "détecté automatiquement", + }, + + source: { + label: "Profil de source", + hint: "Laissez « Détection automatique » : LifeTrack reconnaît la plupart des formats à partir de l'en-tête du fichier.", + auto: "Détection automatique", + unknown: "Source inconnue", + }, + + finance: { + account: "Compte de destination", + accountHint: "Les transactions seront rattachées à ce compte.", + accountMissing: + "Aucun compte n'est encore créé. Créez d'abord un compte dans Réglages → Finances.", + accountLink: "Créer un compte…", + profile: "Profil bancaire", + profileHint: + "Les mises en page des banques changent régulièrement : vérifiez toujours l'aperçu avant d'importer.", + profileMissing: "Aucun profil de source disponible.", + }, + + mapping: { + title: "Ajuster le mappage des colonnes", + subtitle: + "Ces réglages sont enregistrés dans un profil personnel : le preset d'origine n'est pas modifié.", + toggleOpen: "Ajuster le mappage", + toggleClose: "Masquer le mappage", + delimiter: "Séparateur de colonnes", + encoding: "Encodage", + decimalSeparator: "Séparateur décimal", + thousandsSeparator: "Séparateur de milliers", + dateFormat: "Format de date", + dateFormatHint: "Format Python, ex. %d/%m/%Y pour 13/08/2026.", + hasHeader: "Le fichier contient une ligne d'en-tête", + skipRowsTop: "Lignes à ignorer en tête", + skipRowsBottom: "Lignes à ignorer en pied", + amountMode: "Format des montants", + amountModeSigned: "Une colonne signée", + amountModeSplit: "Colonnes Débit / Crédit séparées", + invertSign: "Inverser le signe des montants", + columns: "Colonnes source", + columnsHint: + "Indiquez le nom de la colonne dans le fichier (ou son numéro, à partir de 0, si le fichier n'a pas d'en-tête).", + colBookedDate: "Date comptable", + colValueDate: "Date de valeur", + colLabel: "Libellé", + colAmount: "Montant", + colDebit: "Débit", + colCredit: "Crédit", + colCurrency: "Devise", + apply: "Appliquer et prévisualiser", + saved: "Mappage enregistré.", + builtinNotice: + "Ce profil est un preset intégré : l'enregistrement crée une copie personnalisable.", + }, + + preview: { + title: "Aperçu des 20 premières lignes", + summary: (read: string, duplicates: string, errors: string) => + `${read} lignes lues · ${duplicates} doublons ignorés (déjà importés) · ${errors} lignes en erreur`, + period: "Période couverte", + colDate: "Date", + colLabel: "Libellé", + colAmount: "Montant", + empty: "Aucune ligne exploitable — vérifiez le profil de source.", + dedupe: + "La déduplication est automatique : ré-importer le même fichier n'ajoute rien deux fois.", + noPreview: + "Ce format ne propose pas d'aperçu détaillé : le fichier sera analysé au moment de l'import, ligne par ligne.", + }, + + result: { + title: "Import terminé", + inserted: "Lignes insérées", + duplicates: "Doublons ignorés", + errors: "Lignes en erreur", + updated: "Lignes mises à jour", + total: "Lignes lues", + fileLabel: "Fichier importé", + newImport: "Importer un autre fichier", + running: "Import en cours…", + successHint: "Les données sont disponibles immédiatement dans les pages concernées.", + partialHint: + "Certaines lignes n'ont pas pu être importées : consultez le détail des erreurs ci-dessous.", + failedHint: "L'import a échoué : aucune donnée n'a été enregistrée.", + }, + + errors: { + title: "Lignes en erreur", + colRow: "Ligne", + colReason: "Motif", + download: "Télécharger le rapport d'erreurs (CSV)", + fileName: "rapport-erreurs", + none: "Aucune erreur signalée pour cet import.", + truncated: "Seules les 100 premières erreurs sont conservées.", + }, + + history: { + title: "Historique des imports", + subtitle: "Chaque import peut être annulé : les lignes créées sont alors supprimées.", + colDate: "Date", + colSource: "Source", + colFile: "Fichier", + colRows: "Lignes", + colInserted: "Importées", + colDuplicates: "Doublons", + colErrors: "Erreurs", + colStatus: "Statut", + colActions: "Actions", + details: "Détails", + rollback: "Annuler cet import", + emptyTitle: "Aucun import pour l'instant", + emptyHint: + "Déposez un fichier ci-dessus : LifeTrack détecte le format et vous montre un aperçu avant d'importer quoi que ce soit.", + }, + + status: { + pending: "En attente", + running: "En cours", + completed: "Terminé", + partial: "Partiel", + failed: "Échec", + cancelled: "Annulé", + }, + + rollback: { + title: "Annuler cet import ?", + consequence: (rows: string) => + `Cette action supprimera les ${rows} entrées créées par cet import.`, + detail: + "Les données correspondantes disparaîtront des tableaux de bord. Le fichier pourra être ré-importé ensuite.", + irreversible: "Cette action est irréversible.", + confirm: "Annuler l'import", + cancel: "Conserver", + done: "Import annulé.", + }, + + connectors: { + title: "Connecteurs & API", + subtitle: "Automatisez vos entrées de données quand le format le permet.", + healthTitle: "Health Connect (Android)", + healthText: + "Votre téléphone peut envoyer les données Health Connect automatiquement : installez l'application passerelle, puis collez l'URL ci-dessous comme webhook. Chaque envoi est authentifié par une clé d'appareil.", + healthUrlLabel: "URL de réception (webhook)", + healthHeaderHint: + "En-tête à renseigner dans l'application : X-API-Key avec votre clé d'appareil (ltk_…).", + healthKeys: "Gérer les clés d'appareil", + healthFallback: + "Sans passerelle, l'export CSV de Health Sync s'importe ici (profil « Health Sync — export CSV »).", + copy: "Copier", + copied: "Copié ✓", + foodvisorTitle: "Foodvisor (nutrition)", + foodvisorText: + "Foodvisor n'expose aucune API : l'historique s'obtient par un export de vos données, puis s'importe ici.", + foodvisorSteps: [ + "Dans l'application : Réglages → Compte → « Demander mes données ».", + "Si l'option est absente ou l'export incomplet, écrivez à data@foodvisor.io en invoquant le RGPD (articles 15 et 20) et demandez le journal alimentaire complet au format CSV ou JSON.", + "Réponse sous 24 à 72 h en général, un mois au maximum.", + "Déposez l'archive reçue ci-dessus avec le profil « Foodvisor — export ».", + ], + banksTitle: "Banques & PayPal", + banksText: "Formats acceptés pour les relevés :", + banksFormats: [ + "CSV générique avec mappage de colonnes ajustable (toutes banques).", + "Presets intégrés : BoursoBank, Crédit Agricole, La Banque Postale, Société Générale, Fortuneo.", + "OFX (toutes banques) — à privilégier : la déduplication utilise l'identifiant FITID.", + "PayPal — rapport d'activité CSV (export FR ou EN).", + ], + banksHint: "Un aperçu des lignes est toujours affiché avant l'écriture en base.", + }, + + actions: { + back: "Retour", + continue: "Continuer", + startImport: "Lancer l'import", + retry: "Réessayer", + close: "Fermer", + reset: "Recommencer", + }, + + common: { + loading: "Chargement…", + genericError: "Une erreur est survenue.", + required: "Champ obligatoire", + }, +} as const; + +/** French labels of the documented importers — fallback when GET /imports/sources fails. */ +export const sourceLabels: Record = { + auto: "Détection automatique", + foodvisor_csv: "Foodvisor — export (CSV)", + health_sync_csv: "Health Sync — export (CSV)", + weight_generic_csv: "Pesées — CSV générique", + bank_generic_csv: "Relevé bancaire — CSV générique", + bank_ofx: "Relevé bancaire — OFX", + paypal_csv: "PayPal — activité (CSV)", +}; + +/** French labels of the import domains. */ +export const domainLabels: Record = { + health: "Santé", + vape: "Vape", + finance: "Finances", +}; diff --git a/apps/web/src/modules/imports/utils/csv.ts b/apps/web/src/modules/imports/utils/csv.ts new file mode 100644 index 0000000..cc7e4c6 --- /dev/null +++ b/apps/web/src/modules/imports/utils/csv.ts @@ -0,0 +1,30 @@ +/** CSV export of the error report (ux-pages §14.3) — fr-FR: « ; » separator, BOM, CRLF. */ + +function escapeCell(cell: string | number | null | undefined): string { + if (cell === null || cell === undefined) return ""; + if (typeof cell === "number") return String(cell).replace(".", ","); + return /[;"\n\r]/.test(cell) ? `"${cell.replace(/"/g, '""')}"` : cell; +} + +export function toCsv(columns: string[], rows: (string | number | null)[][]): string { + const lines = [columns.map(escapeCell).join(";")]; + for (const row of rows) lines.push(row.map(escapeCell).join(";")); + return `${lines.join("\r\n")}`; +} + +/** Trigger a client-side download of a CSV built from `columns` + `rows`. */ +export function downloadCsv( + filename: string, + columns: string[], + rows: (string | number | null)[][], +): void { + const blob = new Blob([toCsv(columns, rows)], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename.endsWith(".csv") ? filename : `${filename}.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/apps/web/src/modules/imports/utils/file.ts b/apps/web/src/modules/imports/utils/file.ts new file mode 100644 index 0000000..dbc7f68 --- /dev/null +++ b/apps/web/src/modules/imports/utils/file.ts @@ -0,0 +1,25 @@ +import { formatNumber } from "../../../lib/format"; +import { MAX_UPLOAD_BYTES } from "../api"; +import { strings } from "../strings"; + +const NNBSP = " "; // narrow no-break space, like lib/format + +/** File size in fr-FR: « 842 ko » · « 3,4 Mo ». */ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${formatNumber(bytes)}${NNBSP}o`; + if (bytes < 1024 * 1024) return `${formatNumber(bytes / 1024)}${NNBSP}ko`; + return `${formatNumber(bytes / (1024 * 1024), 1)}${NNBSP}Mo`; +} + +/** French validation of a picked file — null when the file is acceptable. */ +export function validateFile(file: File): string | null { + if (file.size === 0) return strings.dropzone.empty; + if (file.size > MAX_UPLOAD_BYTES) return strings.dropzone.tooLarge; + return null; +} + +/** `accept` attribute of the file input, from the selected importer. */ +export function acceptAttribute(extensions: string[] | undefined): string | undefined { + if (!extensions || extensions.length === 0) return undefined; + return extensions.join(","); +} diff --git a/apps/web/src/modules/imports/utils/status.ts b/apps/web/src/modules/imports/utils/status.ts new file mode 100644 index 0000000..810d20a --- /dev/null +++ b/apps/web/src/modules/imports/utils/status.ts @@ -0,0 +1,36 @@ +import type { BadgeTone } from "../../../components/ui/Badge"; +import type { ImportRun } from "../api"; +import { strings } from "../strings"; + +export type RunStatus = "pending" | "running" | "completed" | "partial" | "failed" | "cancelled"; + +const KNOWN: RunStatus[] = ["pending", "running", "completed", "partial", "failed", "cancelled"]; + +/** + * Displayed status of a run: the API knows « completed » / « failed », the UI + * additionally distinguishes « partiel » when some rows were rejected + * (ux-pages §14.2). + */ +export function runStatus(run: ImportRun): RunStatus { + const raw = (run.status ?? "").toLowerCase(); + const known = KNOWN.find((s) => s === raw) ?? "completed"; + if (known === "completed" && run.rows_errors > 0) return "partial"; + return known; +} + +export function statusLabel(status: RunStatus): string { + return strings.status[status]; +} + +const TONES: Record = { + pending: "muted", + running: "accent", + completed: "positive", + partial: "warning", + failed: "negative", + cancelled: "muted", +}; + +export function statusTone(status: RunStatus): BadgeTone { + return TONES[status]; +} diff --git a/apps/web/src/modules/settings/api.ts b/apps/web/src/modules/settings/api.ts new file mode 100644 index 0000000..0973bf5 --- /dev/null +++ b/apps/web/src/modules/settings/api.ts @@ -0,0 +1,351 @@ +/** + * Typed React Query hooks of the « Réglages » module (CONVENTIONS C5.4). + * Endpoints: /health/profile, /health/goals*, /health/weights/stats, + * /vape/settings (datamodel-health-vape.md §8) and /auth/device-keys + * (architecture.md §4.4). + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query"; + +import { ApiError, api, qs } from "../../lib/api"; +import { daysAgoIso, todayIso } from "../../lib/dates"; +import type { Page } from "../../types/api"; + +export const MODULE_ID = "settings"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type Sex = "male" | "female" | "other"; + +export type ActivityLevel = "sedentary" | "light" | "moderate" | "active" | "very_active"; + +/** + * GET /health/profile — `age`, `bmr_kcal` and `tdee_estimated_kcal` are + * computed server-side. + */ +export interface Profile { + id?: number; + height_cm: number; + sex: Sex; + birthdate: string; + activity_level: ActivityLevel; + timezone?: string | null; + water_goal_ml?: number | null; + calorie_floor_kcal?: number | null; + age?: number | null; + bmr_kcal?: number | null; + tdee_estimated_kcal?: number | null; + current_weight_kg?: number | null; + bmi?: number | null; +} + +/** PUT /health/profile is a singleton write: omitted fields are reset. */ +export interface ProfileUpdate { + height_cm: number; + sex: Sex; + birthdate: string; + activity_level: ActivityLevel; + timezone?: string | null; + water_goal_ml?: number | null; + calorie_floor_kcal?: number | null; +} + +export type GoalMode = "weekly_rate" | "target_date" | "maintain"; + +/** + * Flattened form of GET /health/goals/active, which answers + * `{goal, budget, projection, done_kg, remaining_kg, progress_pct}`. + */ +export interface Goal { + id: number; + mode: GoalMode; + start_date: string; + start_weight_kg: number; + target_weight_kg: number; + target_date?: string | null; + weekly_rate_kg?: number | null; + status: string; + note?: string | null; + daily_budget?: number | null; + projection?: { status?: string; date?: string | null } | null; + done_kg?: number | null; + remaining_kg?: number | null; + pct?: number | null; +} + +/** Envelope served by GET /health/goals/active. */ +interface ActiveGoalEnvelope { + goal?: Omit | null; + budget?: { kcal?: number | null } | null; + projection?: { status?: string; date?: string | null } | null; + done_kg?: number | null; + remaining_kg?: number | null; + progress_pct?: number | null; +} + +export interface GoalPayload { + mode: GoalMode; + start_date: string; + start_weight_kg: number; + target_weight_kg: number; + target_date?: string | null; + weekly_rate_kg?: number | null; +} + +/** + * GET/PUT /vape/settings (404 → configuration assistant). The API stores the + * pack price in euro cents (CONVENTIONS C2.3); the form works in euros, so the + * hooks below convert on both directions. + */ +export interface VapeSettings { + quit_date: string; + cigs_per_day_before: number; + cig_pack_price: number; + cigs_per_pack: number; + default_nicotine_mg_ml: number; + currency?: string | null; +} + +/** Wire shape of /vape/settings. */ +interface VapeSettingsWire { + id?: number; + quit_date: string; + cigs_per_day_before: number; + cig_pack_price_cents: number; + cigs_per_pack: number; + default_nicotine_mg_ml: number; + currency?: string | null; +} + +function fromVapeWire(wire: VapeSettingsWire): VapeSettings { + return { + quit_date: wire.quit_date, + cigs_per_day_before: Number(wire.cigs_per_day_before), + cig_pack_price: Number(wire.cig_pack_price_cents ?? 0) / 100, + cigs_per_pack: wire.cigs_per_pack, + default_nicotine_mg_ml: Number(wire.default_nicotine_mg_ml), + currency: wire.currency ?? null, + }; +} + +export interface DeviceKey { + id: number; + name: string; + key_prefix: string; + scopes: string[]; + created_at?: string | null; + last_used_at?: string | null; + revoked_at?: string | null; +} + +/** POST /auth/device-keys — the plaintext key is returned only once. */ +export interface DeviceKeyCreated extends Partial { + key: string; +} + +export interface DeviceKeyCreate { + name: string; + scopes: string[]; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +/** GET that turns a documented 404 (« pas encore configuré ») into `null`. */ +async function getOrNull(path: string): Promise { + try { + return await api(path); + } catch (error) { + if (error instanceof ApiError && error.status === 404) return null; + throw error; + } +} + +/** Accepts both a bare array and a paginated Page[T] envelope. */ +function toItems(payload: T[] | Page | null): T[] { + if (!payload) return []; + return Array.isArray(payload) ? payload : (payload.items ?? []); +} + +/* ------------------------------------------------------------------ */ +/* Query keys */ +/* ------------------------------------------------------------------ */ + +export const settingsKeys = { + profile: () => [MODULE_ID, "profile", {}] as const, + goal: () => [MODULE_ID, "goal", {}] as const, + latestWeight: () => [MODULE_ID, "latest-weight", {}] as const, + vape: () => [MODULE_ID, "vape-settings", {}] as const, + deviceKeys: () => [MODULE_ID, "device-keys", {}] as const, +}; + +/* ------------------------------------------------------------------ */ +/* Profil */ +/* ------------------------------------------------------------------ */ + +export function useProfile(): UseQueryResult { + return useQuery({ + queryKey: settingsKeys.profile(), + queryFn: () => getOrNull("/health/profile"), + }); +} + +export function useUpdateProfile(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => + api("/health/profile", { method: "PUT", body: JSON.stringify(payload) }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [MODULE_ID, "profile"] }); + void queryClient.invalidateQueries({ queryKey: [MODULE_ID, "goal"] }); + }, + }); +} + +/* ------------------------------------------------------------------ */ +/* Objectif */ +/* ------------------------------------------------------------------ */ + +export function useActiveGoal(): UseQueryResult { + return useQuery({ + queryKey: settingsKeys.goal(), + queryFn: async () => { + const res = await getOrNull("/health/goals/active"); + if (!res?.goal) return null; + return { + ...res.goal, + daily_budget: res.budget?.kcal ?? null, + projection: res.projection ?? null, + done_kg: res.done_kg ?? null, + remaining_kg: res.remaining_kg ?? null, + pct: res.progress_pct ?? null, + } satisfies Goal; + }, + }); +} + +interface WeightStatsMeta { + trend_now_kg?: number | null; +} + +interface WeightStats { + series: { name: string; points: [string, number | null][] }[]; + meta?: WeightStatsMeta | null; +} + +/** Current trend weight (kg) — prefills the goal starting weight (ux §15.2). */ +export function useLatestWeight(): UseQueryResult { + return useQuery({ + queryKey: settingsKeys.latestWeight(), + queryFn: async () => { + const stats = await getOrNull( + `/health/weights/stats?${qs({ from: daysAgoIso(89), to: todayIso() })}`, + ); + if (!stats) return null; + if (typeof stats.meta?.trend_now_kg === "number") return stats.meta.trend_now_kg; + const raw = stats.series?.find((s) => s.name === "weight_raw")?.points ?? []; + const valued = raw.filter((point): point is [string, number] => typeof point[1] === "number"); + return valued.length > 0 ? valued[valued.length - 1][1] : null; + }, + }); +} + +export interface SaveGoalInput extends GoalPayload { + /** Existing active goal to update (PUT) instead of creating a new one. */ + id?: number; +} + +export function useSaveGoal(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...payload }) => + id + ? api(`/health/goals/${id}`, { method: "PUT", body: JSON.stringify(payload) }) + : api(`/health/goals?${qs({ replace_active: true })}`, { + method: "POST", + body: JSON.stringify(payload), + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [MODULE_ID, "goal"] }); + }, + }); +} + +/* ------------------------------------------------------------------ */ +/* Vape */ +/* ------------------------------------------------------------------ */ + +export function useVapeSettings(): UseQueryResult { + return useQuery({ + queryKey: settingsKeys.vape(), + queryFn: async () => { + const wire = await getOrNull("/vape/settings"); + return wire ? fromVapeWire(wire) : null; + }, + }); +} + +export function useUpdateVapeSettings(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (payload) => { + const wire = await api("/vape/settings", { + method: "PUT", + body: JSON.stringify({ + quit_date: payload.quit_date, + cigs_per_day_before: payload.cigs_per_day_before, + cig_pack_price_cents: Math.round(payload.cig_pack_price * 100), + cigs_per_pack: payload.cigs_per_pack, + default_nicotine_mg_ml: payload.default_nicotine_mg_ml, + currency: payload.currency ?? "EUR", + }), + }); + return fromVapeWire(wire); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [MODULE_ID, "vape-settings"] }); + }, + }); +} + +/* ------------------------------------------------------------------ */ +/* Appareils & API */ +/* ------------------------------------------------------------------ */ + +export function useDeviceKeys(): UseQueryResult { + return useQuery({ + queryKey: settingsKeys.deviceKeys(), + queryFn: async () => toItems(await api>("/auth/device-keys")), + }); +} + +export function useCreateDeviceKey(): UseMutationResult< + DeviceKeyCreated, + ApiError, + DeviceKeyCreate +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload) => + api("/auth/device-keys", { + method: "POST", + body: JSON.stringify(payload), + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [MODULE_ID, "device-keys"] }); + }, + }); +} + +export function useRevokeDeviceKey(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id) => api(`/auth/device-keys/${id}`, { method: "DELETE" }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [MODULE_ID, "device-keys"] }); + }, + }); +} diff --git a/apps/web/src/modules/settings/components/AppTab.tsx b/apps/web/src/modules/settings/components/AppTab.tsx new file mode 100644 index 0000000..1e308c0 --- /dev/null +++ b/apps/web/src/modules/settings/components/AppTab.tsx @@ -0,0 +1,129 @@ +import { RotateCcw } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { Select } from "../../../components/ui/Select"; +import { PERIOD_OPTIONS, STRINGS } from "../strings"; + +/** Local-only preferences (no API): stored per browser. */ +const PERIOD_KEY = "lifetrack.settings.defaultPeriod"; +const THEME_KEY = "lifetrack.settings.theme"; +const PERIOD_PREFIX = "period:"; + +export type ThemePreference = "dark" | "light" | "auto"; + +const THEME_OPTIONS: { value: ThemePreference; label: string }[] = [ + { value: "dark", label: STRINGS.app.themeDark }, + { value: "light", label: STRINGS.app.themeLight }, + { value: "auto", label: STRINGS.app.themeAuto }, +]; + +function readPeriod(): string { + const stored = localStorage.getItem(PERIOD_KEY); + return PERIOD_OPTIONS.some((option) => option.value === stored) ? (stored as string) : "30j"; +} + +function readTheme(): ThemePreference { + const stored = localStorage.getItem(THEME_KEY); + return stored === "light" || stored === "auto" || stored === "dark" ? stored : "dark"; +} + +/** Mirrors the preference on (ux-pages §1.3: attribut `data-theme`). */ +function applyTheme(theme: ThemePreference): void { + const root = document.documentElement; + const effective = + theme === "auto" + ? window.matchMedia("(prefers-color-scheme: light)").matches + ? "light" + : "dark" + : theme; + root.dataset.theme = effective; + root.classList.toggle("dark", effective === "dark"); +} + +/** Onglet « Application » — préférences locales (ux-pages §15.7). */ +export function AppTab({ onToast }: { onToast: (message: string) => void }) { + const [period, setPeriod] = useState(() => readPeriod()); + const [theme, setTheme] = useState(() => readTheme()); + + const save = () => { + localStorage.setItem(PERIOD_KEY, period); + localStorage.setItem(THEME_KEY, theme); + applyTheme(theme); + onToast(STRINGS.app.savedToast); + }; + + const resetPeriods = () => { + const keys: string[] = []; + for (let index = 0; index < localStorage.length; index += 1) { + const key = localStorage.key(index); + if (key && key.startsWith(PERIOD_PREFIX)) keys.push(key); + } + keys.forEach((key) => localStorage.removeItem(key)); + onToast(STRINGS.app.resetPeriodsDone); + }; + + return ( +
    + +
    + + + +
    + + +
    +
    +
    + + +
    +
    +
    + {STRINGS.app.language} +
    +
    {STRINGS.app.languageValue}
    +
    +
    +
    + {STRINGS.app.timezone} +
    +
    {STRINGS.app.timezoneValue}
    +
    +
    +
    + {STRINGS.app.weekStart} +
    +
    {STRINGS.app.weekStartValue}
    +
    +
    +
    +
    + ); +} diff --git a/apps/web/src/modules/settings/components/DevicesTab.tsx b/apps/web/src/modules/settings/components/DevicesTab.tsx new file mode 100644 index 0000000..ddd39b9 --- /dev/null +++ b/apps/web/src/modules/settings/components/DevicesTab.tsx @@ -0,0 +1,308 @@ +import { Copy, KeyRound, Trash2 } from "lucide-react"; +import { useState } from "react"; + +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { ConfirmDialog } from "../../../components/ui/ConfirmDialog"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { Table } from "../../../components/ui/Table"; +import type { TableColumn } from "../../../components/ui/Table"; +import { formatDate } from "../../../lib/format"; +import { useCreateDeviceKey, useDeviceKeys, useRevokeDeviceKey } from "../api"; +import type { DeviceKey } from "../api"; +import { AVAILABLE_SCOPES, SCOPE_LABELS, STRINGS } from "../strings"; + +/** French relative time (« il y a 2 h »), green when used less than 24 h ago. */ +function relativeUse(iso: string | null | undefined): { label: string; recent: boolean } { + if (!iso) return { label: STRINGS.common.never, recent: false }; + const timestamp = new Date(iso).getTime(); + if (Number.isNaN(timestamp)) return { label: STRINGS.common.never, recent: false }; + const minutes = Math.floor((Date.now() - timestamp) / 60_000); + if (minutes < 1) return { label: STRINGS.devices.relativeNow, recent: true }; + if (minutes < 60) return { label: STRINGS.devices.relativeMinutes(minutes), recent: true }; + const hours = Math.floor(minutes / 60); + if (hours < 24) return { label: STRINGS.devices.relativeHours(hours), recent: true }; + const days = Math.floor(hours / 24); + if (days < 30) return { label: STRINGS.devices.relativeDays(days), recent: false }; + return { label: formatDate(iso), recent: false }; +} + +/** Onglet « Appareils & API » — /auth/device-keys (ux-pages §15.6). */ +export function DevicesTab({ + onToast, +}: { + onToast: (message: string, tone?: "success" | "error") => void; +}) { + const keys = useDeviceKeys(); + const createKey = useCreateDeviceKey(); + const revokeKey = useRevokeDeviceKey(); + + const [createOpen, setCreateOpen] = useState(false); + const [name, setName] = useState(""); + const [scopes, setScopes] = useState(["ingest:health"]); + const [formError, setFormError] = useState(null); + const [plaintextKey, setPlaintextKey] = useState(null); + const [toRevoke, setToRevoke] = useState(null); + + const openCreate = () => { + setName(""); + setScopes(["ingest:health"]); + setFormError(null); + createKey.reset(); + setCreateOpen(true); + }; + + const toggleScope = (scope: string) => { + setScopes((prev) => + prev.includes(scope) ? prev.filter((value) => value !== scope) : [...prev, scope], + ); + }; + + const submitCreate = () => { + if (!name.trim()) { + setFormError(STRINGS.devices.errorName); + return; + } + if (scopes.length === 0) { + setFormError(STRINGS.devices.errorScopes); + return; + } + setFormError(null); + createKey.mutate( + { name: name.trim(), scopes }, + { + onSuccess: (created) => { + setCreateOpen(false); + setPlaintextKey(created.key); + onToast(STRINGS.devices.createdToast); + }, + }, + ); + }; + + const copyKey = async () => { + if (!plaintextKey) return; + try { + await navigator.clipboard.writeText(plaintextKey); + onToast(STRINGS.devices.copied); + } catch { + onToast(STRINGS.devices.keyHint, "error"); + } + }; + + const columns: TableColumn[] = [ + { + key: "name", + header: STRINGS.devices.name, + sortable: true, + render: (row) => ( + + {row.name} + {row.revoked_at ? {STRINGS.devices.revoked} : null} + + ), + }, + { + key: "key_prefix", + header: STRINGS.devices.prefix, + render: (row) => {row.key_prefix}…, + }, + { + key: "scopes", + header: STRINGS.devices.scopes, + render: (row) => ( + + {(row.scopes ?? []).map((scope) => ( + + {SCOPE_LABELS[scope] ?? scope} + + ))} + + ), + }, + { + key: "created_at", + header: STRINGS.devices.createdAt, + sortable: true, + render: (row) => ( + {row.created_at ? formatDate(row.created_at) : "—"} + ), + }, + { + key: "last_used_at", + header: STRINGS.devices.lastUsed, + render: (row) => { + const used = relativeUse(row.last_used_at); + return ( + + {used.label} + + ); + }, + }, + { + key: "actions", + header: STRINGS.devices.actions, + align: "right", + render: (row) => + row.revoked_at ? null : ( + + ), + }, + ]; + + return ( +
    + + {STRINGS.devices.create} + + } + noPadding + > + {keys.isPending ? : null} + {!keys.isPending && keys.error ? ( +

    {keys.error.message}

    + ) : null} + {!keys.isPending && !keys.error ? ( +
    row.id} + empty={ + + {STRINGS.devices.create} + + } + /> + } + /> + ) : null} + + + +

    {STRINGS.devices.endpointHint}

    +
    +          {`POST /api/ingest/health
    +X-API-Key: ltk_…
    +Content-Type: application/json
    +
    +{"records": [{"type": "weight", "measured_at": "2026-08-13T06:30:00Z", "weight_kg": 82.4}]}`}
    +        
    +
    + + setCreateOpen(false)} + title={STRINGS.devices.createTitle} + footer={ + <> + + + + } + > +
    + setName(event.target.value)} + /> +
    + + {STRINGS.devices.scopes} + +

    {STRINGS.devices.scopesHint}

    +
    + {AVAILABLE_SCOPES.map((scope) => ( + + ))} +
    +
    + {formError ?

    {formError}

    : null} + {createKey.error ? ( +

    {createKey.error.message}

    + ) : null} +
    +
    + + setPlaintextKey(null)} + title={STRINGS.devices.keyTitle} + footer={ + + } + > +
    +

    + {STRINGS.devices.keyWarning} +

    +

    {STRINGS.devices.keyHint}

    +
    + + {plaintextKey} + + +
    +
    +
    + + setToRevoke(null)} + onConfirm={() => { + if (!toRevoke) return; + revokeKey.mutate(toRevoke.id, { + onSuccess: () => { + setToRevoke(null); + onToast(STRINGS.devices.revokedToast); + }, + }); + }} + /> + + ); +} diff --git a/apps/web/src/modules/settings/components/GoalTab.tsx b/apps/web/src/modules/settings/components/GoalTab.tsx new file mode 100644 index 0000000..3a3470c --- /dev/null +++ b/apps/web/src/modules/settings/components/GoalTab.tsx @@ -0,0 +1,267 @@ +import { AlertTriangle, CalendarCheck } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; + +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { Input } from "../../../components/ui/Input"; +import { Select } from "../../../components/ui/Select"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { todayIso } from "../../../lib/dates"; +import { formatDate, formatKcal, formatPercent, formatWeight } from "../../../lib/format"; +import { useActiveGoal, useLatestWeight, useProfile, useSaveGoal } from "../api"; +import type { GoalMode } from "../api"; +import { GOAL_MODE_LABELS, ROUTES, STRINGS } from "../strings"; + +/** Calorie floors (datamodel-health-vape.md §10). */ +const FLOOR_FEMALE = 1200; +const FLOOR_MALE = 1500; + +interface FormState { + startWeight: string; + startDate: string; + targetWeight: string; + mode: GoalMode; + weeklyRate: string; + targetDate: string; +} + +const EMPTY_FORM: FormState = { + startWeight: "", + startDate: todayIso(), + targetWeight: "", + mode: "weekly_rate", + weeklyRate: "0.5", + targetDate: "", +}; + +type FieldErrors = Partial< + Record<"startWeight" | "targetWeight" | "weeklyRate" | "targetDate", string> +>; + +function parseNumber(value: string): number { + return Number(value.replace(",", ".")); +} + +/** Onglet « Objectif » — /health/goals (ux-pages §15.2 + addendum-planning). */ +export function GoalTab({ onToast }: { onToast: (message: string) => void }) { + const goal = useActiveGoal(); + const profile = useProfile(); + const latestWeight = useLatestWeight(); + const save = useSaveGoal(); + const [form, setForm] = useState(EMPTY_FORM); + const [errors, setErrors] = useState({}); + + useEffect(() => { + const data = goal.data; + if (data) { + setForm({ + startWeight: String(data.start_weight_kg ?? ""), + startDate: data.start_date ?? todayIso(), + targetWeight: String(data.target_weight_kg ?? ""), + mode: data.mode ?? "weekly_rate", + weeklyRate: data.weekly_rate_kg !== null && data.weekly_rate_kg !== undefined + ? String(data.weekly_rate_kg) + : "0.5", + targetDate: data.target_date ?? "", + }); + return; + } + if (latestWeight.data) { + setForm((prev) => (prev.startWeight ? prev : { ...prev, startWeight: String(latestWeight.data) })); + } + }, [goal.data, latestWeight.data]); + + if (goal.isPending) return ; + if (goal.error) return

    {goal.error.message}

    ; + + const submit = () => { + const startWeight = parseNumber(form.startWeight); + const targetWeight = parseNumber(form.targetWeight); + const rate = parseNumber(form.weeklyRate); + const nextErrors: FieldErrors = {}; + + if (!form.startWeight.trim() || Number.isNaN(startWeight) || startWeight < 20 || startWeight > 300) { + nextErrors.startWeight = STRINGS.goal.errorStartWeight; + } + if ( + !form.targetWeight.trim() || + Number.isNaN(targetWeight) || + targetWeight < 20 || + targetWeight > 300 + ) { + nextErrors.targetWeight = STRINGS.goal.errorTargetWeight; + } + if (form.mode === "weekly_rate" && (Number.isNaN(rate) || rate < 0.25 || rate > 1)) { + nextErrors.weeklyRate = STRINGS.goal.errorRate; + } + if (form.mode === "target_date" && (!form.targetDate || form.targetDate <= form.startDate)) { + nextErrors.targetDate = STRINGS.goal.errorTargetDate; + } + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) return; + + save.mutate( + { + id: goal.data?.id, + mode: form.mode, + start_date: form.startDate || todayIso(), + start_weight_kg: Math.round(startWeight * 100) / 100, + target_weight_kg: Math.round(targetWeight * 100) / 100, + target_date: form.mode === "target_date" ? form.targetDate : null, + weekly_rate_kg: form.mode === "weekly_rate" ? Math.round(rate * 100) / 100 : null, + }, + { onSuccess: () => onToast(STRINGS.common.saved) }, + ); + }; + + const budget = goal.data?.daily_budget ?? null; + const floor = profile.data?.sex === "female" ? FLOOR_FEMALE : FLOOR_MALE; + const projectedDate = goal.data?.projection?.date ?? goal.data?.target_date ?? null; + const pct = goal.data?.pct ?? null; + const remaining = goal.data?.remaining_kg ?? null; + + return ( +
    + + {goal.data === null ? ( +

    {STRINGS.goal.emptyHint}

    + ) : null} + +
    { + event.preventDefault(); + submit(); + }} + className="grid gap-4 sm:grid-cols-2" + > + setForm({ ...form, startWeight: event.target.value })} + error={errors.startWeight} + /> + setForm({ ...form, startDate: event.target.value })} + /> + setForm({ ...form, targetWeight: event.target.value })} + error={errors.targetWeight} + /> + + + {form.mode === "weekly_rate" ? ( + setForm({ ...form, weeklyRate: event.target.value })} + hint={STRINGS.goal.weeklyRateHint} + error={errors.weeklyRate} + /> + ) : null} + {form.mode === "target_date" ? ( + setForm({ ...form, targetDate: event.target.value })} + error={errors.targetDate} + /> + ) : null} + + {save.error ? ( +

    {save.error.message}

    + ) : null} + +
    + +
    + + + {budget !== null || projectedDate || pct !== null ? ( +
    +
    +
    + {STRINGS.goal.dailyBudget} +
    +
    + {budget !== null ? `${formatKcal(budget)}${STRINGS.profile.perDay}` : "—"} +
    +
    +
    +
    + {STRINGS.goal.projection} +
    +
    + {projectedDate ? formatDate(projectedDate) : "—"} +
    +
    +
    +
    + {STRINGS.goal.progress} +
    +
    + {pct !== null ? formatPercent(pct) : "—"} + {remaining !== null ? ( + + {STRINGS.goal.remaining(formatWeight(Math.abs(remaining)))} + + ) : null} +
    +
    +
    + ) : null} + + {budget !== null && budget < floor ? ( +

    + + {STRINGS.goal.floorWarning} +

    + ) : null} +
    + + + + + {STRINGS.goal.planningLink} + + +
    + ); +} diff --git a/apps/web/src/modules/settings/components/ProfileTab.tsx b/apps/web/src/modules/settings/components/ProfileTab.tsx new file mode 100644 index 0000000..e9060b8 --- /dev/null +++ b/apps/web/src/modules/settings/components/ProfileTab.tsx @@ -0,0 +1,186 @@ +import { useEffect, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { Input } from "../../../components/ui/Input"; +import { Select } from "../../../components/ui/Select"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { formatKcal } from "../../../lib/format"; +import { useProfile, useUpdateProfile } from "../api"; +import type { ActivityLevel, Sex } from "../api"; +import { ACTIVITY_LABELS, SEX_LABELS, STRINGS } from "../strings"; + +interface FormState { + height: string; + sex: Sex; + birthdate: string; + activity: ActivityLevel; +} + +const EMPTY_FORM: FormState = { + height: "", + sex: "male", + birthdate: "", + activity: "sedentary", +}; + +type FieldErrors = Partial>; + +/** Onglet « Profil » — GET/PUT /health/profile (ux-pages §15.1). */ +export function ProfileTab({ onToast }: { onToast: (message: string) => void }) { + const profile = useProfile(); + const update = useUpdateProfile(); + const [form, setForm] = useState(EMPTY_FORM); + const [errors, setErrors] = useState({}); + + useEffect(() => { + const data = profile.data; + if (!data) return; + setForm({ + height: data.height_cm ? String(data.height_cm) : "", + sex: data.sex ?? "male", + birthdate: data.birthdate ?? "", + activity: data.activity_level ?? "sedentary", + }); + }, [profile.data]); + + if (profile.isPending) return ; + if (profile.error) { + return

    {profile.error.message}

    ; + } + + const submit = () => { + const height = Number(form.height.replace(",", ".")); + const nextErrors: FieldErrors = {}; + if (!form.height.trim() || Number.isNaN(height) || height < 100 || height > 250) { + nextErrors.height = STRINGS.profile.errorHeight; + } + if (!form.birthdate || Number.isNaN(new Date(form.birthdate).getTime())) { + nextErrors.birthdate = STRINGS.profile.errorBirthdate; + } + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) return; + + update.mutate( + { + height_cm: Math.round(height * 10) / 10, + sex: form.sex, + birthdate: form.birthdate, + activity_level: form.activity, + // PUT is a singleton write: carry the untouched preferences over. + timezone: profile.data?.timezone ?? undefined, + water_goal_ml: profile.data?.water_goal_ml ?? null, + calorie_floor_kcal: profile.data?.calorie_floor_kcal ?? null, + }, + { onSuccess: () => onToast(STRINGS.common.saved) }, + ); + }; + + const data = profile.data; + + return ( +
    + + {data === null ? ( +

    {STRINGS.profile.emptyHint}

    + ) : null} + +
    { + event.preventDefault(); + submit(); + }} + className="grid gap-4 sm:grid-cols-2" + > + setForm({ ...form, height: event.target.value })} + error={errors.height} + /> + + setForm({ ...form, birthdate: event.target.value })} + error={errors.birthdate} + /> + + + {update.error ? ( +

    {update.error.message}

    + ) : null} + +
    + +
    + +
    + + + {data?.bmr_kcal || data?.tdee_estimated_kcal || data?.age ? ( +
    +
    +
    + {STRINGS.profile.age} +
    +
    + {data.age ? STRINGS.profile.ageValue(data.age) : "—"} +
    +
    +
    +
    + {STRINGS.profile.bmr} +
    +
    + {data.bmr_kcal ? formatKcal(data.bmr_kcal) : "—"} +
    +
    +
    +
    + {STRINGS.profile.tdee} +
    +
    + {data.tdee_estimated_kcal + ? `${formatKcal(data.tdee_estimated_kcal)}${STRINGS.profile.perDay}` + : "—"} +
    +
    +
    + ) : ( +

    {STRINGS.profile.noComputed}

    + )} +
    +
    + ); +} diff --git a/apps/web/src/modules/settings/components/Toast.tsx b/apps/web/src/modules/settings/components/Toast.tsx new file mode 100644 index 0000000..dd39a8c --- /dev/null +++ b/apps/web/src/modules/settings/components/Toast.tsx @@ -0,0 +1,70 @@ +import clsx from "clsx"; +import { AlertCircle, CheckCircle2, X } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +export type ToastTone = "success" | "error"; + +export interface ToastState { + message: string; + tone: ToastTone; +} + +/** Single-slot toast controller shared by the settings tabs. */ +export function useToast() { + const [toast, setToast] = useState(null); + const showToast = useCallback((message: string, tone: ToastTone = "success") => { + setToast({ message, tone }); + }, []); + const hideToast = useCallback(() => setToast(null), []); + return { toast, showToast, hideToast }; +} + +export interface ToastProps { + /** French message, ex: « Modifications enregistrées ✓ ». */ + message: string; + tone?: ToastTone; + onClose: () => void; + duration?: number; +} + +/** Toast (ux-pages §5.7): bottom-right on desktop, top on mobile. */ +export function Toast({ message, tone = "success", onClose, duration = 4000 }: ToastProps) { + useEffect(() => { + const id = window.setTimeout(onClose, duration); + return () => window.clearTimeout(id); + }, [onClose, duration, message]); + + const Icon = tone === "error" ? AlertCircle : CheckCircle2; + + return createPortal( +
    + +

    {message}

    + +
    , + document.body, + ); +} diff --git a/apps/web/src/modules/settings/components/VapeTab.tsx b/apps/web/src/modules/settings/components/VapeTab.tsx new file mode 100644 index 0000000..a66a446 --- /dev/null +++ b/apps/web/src/modules/settings/components/VapeTab.tsx @@ -0,0 +1,200 @@ +import { useEffect, useState } from "react"; + +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { Input } from "../../../components/ui/Input"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { todayIso } from "../../../lib/dates"; +import { formatEuroAmount } from "../../../lib/format"; +import { useUpdateVapeSettings, useVapeSettings } from "../api"; +import { STRINGS } from "../strings"; + +interface FormState { + quitDate: string; + cigsPerDay: string; + cigsPerPack: string; + packPrice: string; + nicotine: string; +} + +/** Defaults of ux-pages §15.4. */ +const DEFAULT_FORM: FormState = { + quitDate: todayIso(), + cigsPerDay: "15", + cigsPerPack: "20", + packPrice: "12.50", + nicotine: "3", +}; + +type FieldErrors = Partial< + Record<"quitDate" | "cigsPerDay" | "cigsPerPack" | "packPrice" | "nicotine", string> +>; + +function parseNumber(value: string): number { + return Number(value.replace(",", ".")); +} + +/** Onglet « Vape » — GET/PUT /vape/settings (ux-pages §15.4). */ +export function VapeTab({ onToast }: { onToast: (message: string) => void }) { + const settings = useVapeSettings(); + const update = useUpdateVapeSettings(); + const [form, setForm] = useState(DEFAULT_FORM); + const [errors, setErrors] = useState({}); + const [configuring, setConfiguring] = useState(false); + + useEffect(() => { + const data = settings.data; + if (!data) return; + setForm({ + quitDate: data.quit_date ?? todayIso(), + cigsPerDay: String(data.cigs_per_day_before ?? DEFAULT_FORM.cigsPerDay), + cigsPerPack: String(data.cigs_per_pack ?? DEFAULT_FORM.cigsPerPack), + packPrice: String(data.cig_pack_price ?? DEFAULT_FORM.packPrice), + nicotine: String(data.default_nicotine_mg_ml ?? DEFAULT_FORM.nicotine), + }); + }, [settings.data]); + + if (settings.isPending) return ; + if (settings.error) { + return

    {settings.error.message}

    ; + } + + const notConfigured = settings.data === null; + if (notConfigured && !configuring) { + return ( + + setConfiguring(true)}>{STRINGS.vape.emptyAction}} + /> + + ); + } + + const submit = () => { + const cigsPerDay = parseNumber(form.cigsPerDay); + const cigsPerPack = parseNumber(form.cigsPerPack); + const packPrice = parseNumber(form.packPrice); + const nicotine = parseNumber(form.nicotine); + const nextErrors: FieldErrors = {}; + + if (!form.quitDate) nextErrors.quitDate = STRINGS.vape.errorQuitDate; + if (Number.isNaN(cigsPerDay) || cigsPerDay < 0 || cigsPerDay > 100) { + nextErrors.cigsPerDay = STRINGS.vape.errorCigsPerDay; + } + if (Number.isNaN(cigsPerPack) || cigsPerPack < 1) { + nextErrors.cigsPerPack = STRINGS.vape.errorCigsPerPack; + } + if (Number.isNaN(packPrice) || packPrice < 0) { + nextErrors.packPrice = STRINGS.vape.errorPackPrice; + } + if (Number.isNaN(nicotine) || nicotine < 0 || nicotine > 50) { + nextErrors.nicotine = STRINGS.vape.errorNicotine; + } + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) return; + + update.mutate( + { + quit_date: form.quitDate, + cigs_per_day_before: Math.round(cigsPerDay * 10) / 10, + cigs_per_pack: Math.round(cigsPerPack), + cig_pack_price: Math.round(packPrice * 100) / 100, + default_nicotine_mg_ml: Math.round(nicotine * 10) / 10, + }, + { onSuccess: () => onToast(STRINGS.common.saved) }, + ); + }; + + const cigsPerDay = parseNumber(form.cigsPerDay); + const cigsPerPack = parseNumber(form.cigsPerPack); + const packPrice = parseNumber(form.packPrice); + const referenceCost = + !Number.isNaN(cigsPerDay) && !Number.isNaN(cigsPerPack) && !Number.isNaN(packPrice) && cigsPerPack > 0 + ? (packPrice / cigsPerPack) * cigsPerDay + : null; + + return ( + +
    { + event.preventDefault(); + submit(); + }} + className="grid gap-4 sm:grid-cols-2" + > + setForm({ ...form, quitDate: event.target.value })} + error={errors.quitDate} + /> + setForm({ ...form, cigsPerDay: event.target.value })} + error={errors.cigsPerDay} + /> + setForm({ ...form, packPrice: event.target.value })} + error={errors.packPrice} + hint={STRINGS.vape.priceNote} + /> + setForm({ ...form, cigsPerPack: event.target.value })} + error={errors.cigsPerPack} + /> + setForm({ ...form, nicotine: event.target.value })} + error={errors.nicotine} + /> + + {update.error ? ( +

    {update.error.message}

    + ) : null} + +
    +

    + {STRINGS.vape.reference} :{" "} + + {referenceCost === null + ? "—" + : `${formatEuroAmount(referenceCost)}${STRINGS.vape.perDay}`} + +

    + +
    + +
    + ); +} diff --git a/apps/web/src/modules/settings/index.ts b/apps/web/src/modules/settings/index.ts new file mode 100644 index 0000000..0541010 --- /dev/null +++ b/apps/web/src/modules/settings/index.ts @@ -0,0 +1,18 @@ +import { Settings } from "lucide-react"; +import { createElement, lazy } from "react"; + +import type { ModuleManifest } from "../../types/module"; + +// Pages are code-split (CONVENTIONS C5.7); AppLayout provides the Suspense boundary. +const SettingsPage = lazy(() => import("./pages/SettingsPage")); + +/** Settings module — reserved order 90, route « /reglages » (CONVENTIONS C5.1). */ +const manifest: ModuleManifest = { + id: "settings", + title: "Réglages", + order: 90, + routes: [{ path: "/reglages", element: createElement(SettingsPage) }], + nav: [{ path: "/reglages", label: "Réglages", icon: Settings, order: 0 }], +}; + +export default manifest; diff --git a/apps/web/src/modules/settings/pages/SettingsPage.tsx b/apps/web/src/modules/settings/pages/SettingsPage.tsx new file mode 100644 index 0000000..ffc3bc2 --- /dev/null +++ b/apps/web/src/modules/settings/pages/SettingsPage.tsx @@ -0,0 +1,62 @@ +import { useSearchParams } from "react-router-dom"; + +import { PageHeader } from "../../../components/ui/PageHeader"; +import { Tabs } from "../../../components/ui/Tabs"; +import type { TabItem } from "../../../components/ui/Tabs"; +import { AppTab } from "../components/AppTab"; +import { DevicesTab } from "../components/DevicesTab"; +import { GoalTab } from "../components/GoalTab"; +import { ProfileTab } from "../components/ProfileTab"; +import { Toast, useToast } from "../components/Toast"; +import { VapeTab } from "../components/VapeTab"; +import { STRINGS } from "../strings"; + +/** Tab ids are French slugs — they appear in the URL (`/reglages?tab=objectif`). */ +const TAB_IDS = ["profil", "objectif", "vape", "appareils", "application"] as const; +type TabId = (typeof TAB_IDS)[number]; + +const TABS: TabItem[] = [ + { id: "profil", label: STRINGS.tabs.profile }, + { id: "objectif", label: STRINGS.tabs.goal }, + { id: "vape", label: STRINGS.tabs.vape }, + { id: "appareils", label: STRINGS.tabs.devices }, + { id: "application", label: STRINGS.tabs.app }, +]; + +function isTabId(value: string | null): value is TabId { + return value !== null && (TAB_IDS as readonly string[]).includes(value); +} + +/** Page « Réglages » (ux-pages §15) — onglets pilotés par l'URL. */ +export default function SettingsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const requested = searchParams.get("tab"); + const tab: TabId = isTabId(requested) ? requested : "profil"; + const { toast, showToast, hideToast } = useToast(); + + const selectTab = (id: string) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set("tab", id); + return next; + }, + { replace: true }, + ); + }; + + return ( + <> + + + + {tab === "profil" ? : null} + {tab === "objectif" ? : null} + {tab === "vape" ? : null} + {tab === "appareils" ? : null} + {tab === "application" ? : null} + + {toast ? : null} + + ); +} diff --git a/apps/web/src/modules/settings/strings.ts b/apps/web/src/modules/settings/strings.ts new file mode 100644 index 0000000..682c9b2 --- /dev/null +++ b/apps/web/src/modules/settings/strings.ts @@ -0,0 +1,215 @@ +/** French labels of the « Réglages » module (CONVENTIONS C5.3). */ + +import type { ActivityLevel, GoalMode, Sex } from "./api"; + +/** Cross-module routes referenced by the settings shortcuts. */ +export const ROUTES = { + weight: "/sante/poids", + vape: "/vape", + imports: "/imports", +} as const; + +export const SEX_LABELS: Record = { + male: "Homme", + female: "Femme", + other: "Autre", +}; + +export const ACTIVITY_LABELS: Record = { + sedentary: "Sédentaire (×1,2)", + light: "Légèrement actif (×1,375)", + moderate: "Modérément actif (×1,55)", + active: "Très actif (×1,725)", + very_active: "Extrêmement actif (×1,9)", +}; + +export const GOAL_MODE_LABELS: Record = { + weekly_rate: "Rythme hebdomadaire", + target_date: "Date cible", + maintain: "Maintien du poids", +}; + +/** + * Libellés des portées. `ingest:vape` et `ingest:finance` restent listés pour + * afficher correctement d'anciennes clés, mais aucun handler d'ingestion ne les + * dessert (seul le domaine « health » en expose un) : ils ne sont donc pas + * proposés à la création — voir AVAILABLE_SCOPES. + */ +export const SCOPE_LABELS: Record = { + "ingest:health": "Santé (pesées, activité, séances)", + "ingest:vape": "Vape (aucun connecteur disponible)", + "ingest:finance": "Finances (aucun connecteur disponible)", + "ingest:*": "Tous les domaines disponibles", +}; + +/** + * Portées proposées à la création d'une clé. Restreintes aux domaines qui ont + * réellement un handler `POST /api/ingest/{domain}` (aujourd'hui « health »), + * plus le joker `ingest:*` qui couvrira automatiquement les futurs domaines. + */ +export const AVAILABLE_SCOPES = ["ingest:health", "ingest:*"] as const; + +export const STRINGS = { + pageTitle: "Réglages", + pageDescription: "Profil, objectif, vape, appareils et préférences de l'application.", + + tabs: { + profile: "Profil", + goal: "Objectif", + vape: "Vape", + devices: "Appareils & API", + app: "Application", + }, + + common: { + save: "Enregistrer", + saved: "Modifications enregistrées ✓", + cancel: "Annuler", + required: "Ce champ est obligatoire.", + loadError: "Ces réglages n'ont pas pu être chargés.", + never: "Jamais", + }, + + profile: { + cardTitle: "Profil corporel", + cardSubtitle: "Utilisé pour calculer votre métabolisme de base et votre dépense énergétique.", + height: "Taille (cm)", + sex: "Sexe", + birthdate: "Date de naissance", + activity: "Niveau d'activité", + computed: "Estimations", + bmr: "Métabolisme de base", + tdee: "Dépense énergétique estimée", + age: "Âge", + ageValue: (years: number) => `${years} ans`, + perDay: "/j", + noComputed: "Enregistrez votre profil pour voir vos estimations.", + errorHeight: "La taille doit être comprise entre 100 et 250 cm.", + errorBirthdate: "Renseignez une date de naissance valide.", + emptyTitle: "Profil non renseigné", + emptyHint: + "Renseignez votre taille, votre sexe et votre date de naissance : LifeTrack pourra alors calculer votre budget calorique.", + }, + + goal: { + cardTitle: "Objectif de poids", + cardSubtitle: "Détermine votre budget calorique quotidien et vos projections.", + startWeight: "Poids de départ (kg)", + startDate: "Date de départ", + targetWeight: "Poids cible (kg)", + mode: "Mode de calcul du budget", + weeklyRate: "Rythme visé (kg/semaine)", + weeklyRateHint: "Recommandé : 0,5 kg/semaine.", + targetDate: "Date cible", + dailyBudget: "Budget calorique quotidien", + projection: "Date d'atteinte estimée", + progress: "Progression", + remaining: (value: string) => `Reste ${value}`, + floorWarning: + "Ce rythme impose un budget très bas. Envisagez un objectif plus progressif.", + planningTitle: "Planning de suivi", + planningHint: + "Choisissez vos jours de pesée et de séance pour suivre votre assiduité et vos séries.", + planningLink: "Modifier mon planning", + errorTargetWeight: "Le poids cible doit être compris entre 20 et 300 kg.", + errorStartWeight: "Le poids de départ doit être compris entre 20 et 300 kg.", + errorRate: "Le rythme doit être compris entre 0,25 et 1 kg/semaine.", + errorTargetDate: "Renseignez une date cible postérieure à la date de départ.", + emptyTitle: "Aucun objectif défini", + emptyHint: + "Fixez un poids cible et un rythme : LifeTrack en déduit votre budget calorique et la date d'atteinte estimée.", + }, + + vape: { + cardTitle: "Référence tabac (avant l'arrêt)", + cardSubtitle: "Ancre le calcul de vos économies et des jalons santé.", + quitDate: "Date d'arrêt du tabac", + cigsPerDay: "Cigarettes par jour", + cigsPerPack: "Cigarettes par paquet", + packPrice: "Prix du paquet (€)", + nicotine: "Taux de nicotine par défaut (mg/ml)", + reference: "Coût tabac de référence", + perDay: "/j", + priceNote: + "Le prix du paquet retenu est le prix actuel : les économies théoriques sont donc légèrement optimistes.", + errorQuitDate: "Renseignez la date d'arrêt du tabac.", + errorCigsPerDay: "Indiquez un nombre de cigarettes par jour (0 à 100).", + errorPackPrice: "Indiquez un prix de paquet valide.", + errorCigsPerPack: "Le paquet doit contenir au moins une cigarette.", + errorNicotine: "Le taux de nicotine doit être compris entre 0 et 50 mg/ml.", + emptyTitle: "Module vape non configuré", + emptyHint: + "Renseignez votre consommation de cigarettes avant l'arrêt et votre modèle de coût : LifeTrack calculera vos économies au centime près.", + emptyAction: "Configurer la vape", + }, + + devices: { + cardTitle: "Clés d'appareil", + intro: + "Créez une clé pour permettre à l'application compagnon Android (Health Connect) d'envoyer ses données à LifeTrack.", + create: "Créer une clé", + name: "Nom", + namePlaceholder: "ex. Pixel de Julien", + prefix: "Préfixe", + scopes: "Portées", + scopesHint: + "Seule l'ingestion du domaine « santé » dispose d'un connecteur pour l'instant : les autres domaines (vape, finances) passent par la page Imports.", + createdAt: "Créée le", + lastUsed: "Dernière utilisation", + actions: "Actions", + revoke: "Révoquer", + revoked: "Révoquée", + revokeTitle: "Révoquer cette clé ?", + revokeMessage: + "L'appareil qui utilise cette clé ne pourra plus envoyer de données. Cette action est irréversible.", + createTitle: "Créer une clé d'appareil", + createSubmit: "Créer la clé", + keyTitle: "Votre nouvelle clé", + keyWarning: "Cette clé ne sera plus jamais affichée.", + keyHint: "Copiez-la maintenant et collez-la dans l'application compagnon.", + copy: "Copier", + copied: "Clé copiée ✓", + close: "J'ai copié la clé", + errorName: "Donnez un nom à cette clé.", + errorScopes: "Sélectionnez au moins une portée.", + emptyTitle: "Aucune clé d'appareil", + endpointTitle: "Envoyer des données (développeurs)", + endpointHint: "Authentification par en-tête :", + relativeNow: "à l'instant", + relativeMinutes: (value: number) => `il y a ${value} min`, + relativeHours: (value: number) => `il y a ${value} h`, + relativeDays: (value: number) => `il y a ${value} j`, + revokedToast: "Clé révoquée ✓", + createdToast: "Clé créée ✓", + }, + + app: { + cardTitle: "Préférences d'affichage", + cardSubtitle: "Ces réglages sont enregistrés sur cet appareil uniquement.", + defaultPeriod: "Période par défaut", + defaultPeriodHint: + "Chaque page mémorise la dernière période choisie ; ce réglage sert de valeur de départ.", + theme: "Thème", + themeHint: "Le thème clair arrive dans une prochaine version : l'interface reste sombre.", + themeDark: "Sombre", + themeLight: "Clair", + themeAuto: "Automatique", + language: "Langue", + languageValue: "Français", + timezone: "Fuseau horaire", + timezoneValue: "Europe/Paris", + weekStart: "Premier jour de la semaine", + weekStartValue: "Lundi", + resetPeriods: "Oublier les périodes mémorisées", + resetPeriodsDone: "Périodes mémorisées effacées ✓", + savedToast: "Préférences enregistrées ✓", + }, +} as const; + +export const PERIOD_OPTIONS: { value: string; label: string }[] = [ + { value: "7j", label: "7 jours" }, + { value: "30j", label: "30 jours" }, + { value: "90j", label: "90 jours" }, + { value: "1an", label: "1 an" }, + { value: "tout", label: "Tout" }, +]; diff --git a/apps/web/src/modules/vape/api.ts b/apps/web/src/modules/vape/api.ts new file mode 100644 index 0000000..cee8637 --- /dev/null +++ b/apps/web/src/modules/vape/api.ts @@ -0,0 +1,894 @@ +/** + * Vape module data layer — typed React Query hooks over the api() wrapper + * (CONVENTIONS C5.4). Endpoints: docs/design/datamodel-health-vape.md §8.4. + * Query keys are [moduleId, resource, params]; mutations invalidate + * [moduleId, resource] (plus the derived stats resources). + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { QueryClient } from "@tanstack/react-query"; + +import { api, qs } from "../../lib/api"; +import type { Page } from "../../types/api"; + +export const MODULE_ID = "vape"; +const BASE = "/vape"; + +/** Local-day timezone of every daily aggregation (CONVENTIONS C6). */ +const TZ = "Europe/Paris"; + +/** Server-side cap of `page_size` (app/core/pagination.py — le=200). */ +const MAX_PAGE_SIZE = 200; + +/** + * The API stores and serves money in euro **cents** (CONVENTIONS C2.3: + * `price_cents`, `cost_per_ml_cents`, `savings_*_cents`, `unit`: "cents"). + * The module works in euros so `formatEuroAmount` can be used directly, so + * every amount is converted here at the boundary — and back on writes. + */ +function eurosOf(cents: Decimalish | null | undefined): number | null { + const value = num(cents); + return value === null ? null : value / 100; +} + +function centsOf(euros: number | null | undefined): number | null { + return euros === null || euros === undefined ? null : Math.round(euros * 100); +} + +/** Converts a cents-denominated series envelope to euros. */ +function seriesToEuros(series: StatsSeries[] | undefined | null): StatsSeries[] { + return (series ?? []).map((item) => ({ + ...item, + points: (item.points ?? []).map(([day, value]) => [day, eurosOf(value)] as [string, number | null]), + })); +} + +/* ------------------------------------------------------------------ */ +/* Numeric helpers — the API serializes Numeric columns as number or */ +/* string depending on the serializer, so every numeric read is */ +/* normalized here. */ +/* ------------------------------------------------------------------ */ + +export type Decimalish = number | string; + +/** Normalize an API numeric (number | string | null) to a finite number or null. */ +export function num(value: Decimalish | null | undefined): number | null { + if (value === null || value === undefined || value === "") return null; + const parsed = typeof value === "number" ? value : Number(String(value).replace(",", ".")); + return Number.isFinite(parsed) ? parsed : null; +} + +/** Same as num() with a fallback for the null case. */ +export function numOr(value: Decimalish | null | undefined, fallback: number): number { + return num(value) ?? fallback; +} + +/** Parse a user-typed decimal accepting both "," and "." (ux-pages §2). */ +export function parseDecimalInput(value: string): number | null { + const trimmed = value.trim().replace(/\s/g, ""); + if (trimmed === "") return null; + const parsed = Number(trimmed.replace(",", ".")); + return Number.isFinite(parsed) ? parsed : null; +} + +/* ------------------------------------------------------------------ */ +/* API types (docs/design/datamodel-health-vape.md §6) */ +/* ------------------------------------------------------------------ */ + +export type ProductKind = "coil" | "base" | "booster" | "aroma" | "hardware" | "pod"; +export type SizeUnit = "ml" | "unit" | "g"; +export type LiquidKind = "refill" | "daily_total"; + +export interface VapeSettings { + id?: number; + quit_date: string; + cigs_per_day_before: Decimalish; + /** Euros — derived from `cig_pack_price_cents`. */ + cig_pack_price: number | null; + cigs_per_pack: number; + default_nicotine_mg_ml: Decimalish; + currency?: string | null; +} + +interface VapeSettingsWire { + id?: number; + quit_date: string; + cigs_per_day_before: Decimalish; + cig_pack_price_cents: number; + cigs_per_pack: number; + default_nicotine_mg_ml: Decimalish; + currency?: string | null; +} + +function toSettings(wire: VapeSettingsWire): VapeSettings { + return { + id: wire.id, + quit_date: wire.quit_date, + cigs_per_day_before: wire.cigs_per_day_before, + cig_pack_price: eurosOf(wire.cig_pack_price_cents), + cigs_per_pack: wire.cigs_per_pack, + default_nicotine_mg_ml: wire.default_nicotine_mg_ml, + currency: wire.currency ?? null, + }; +} + +export interface Product { + id: number; + kind: ProductKind; + name: string; + brand?: string | null; + /** Euros — derived from `price_cents`. */ + price: number | null; + size_value: Decimalish; + size_unit: SizeUnit; + nicotine_mg_ml?: Decimalish | null; + vg_pct?: Decimalish | null; + ohm?: Decimalish | null; + is_archived: boolean; + note?: string | null; + /** Euros per size unit — derived from `unit_price_cents`. */ + unit_price?: number | null; +} + +interface ProductWire extends Omit { + price_cents: number; + unit_price_cents?: number | null; +} + +function toProduct(wire: ProductWire): Product { + const { price_cents, unit_price_cents, ...rest } = wire; + return { ...rest, price: eurosOf(price_cents), unit_price: eurosOf(unit_price_cents) }; +} + +export type ProductInput = { + kind: ProductKind; + name: string; + brand?: string | null; + price: number; + size_value: number; + size_unit: SizeUnit; + nicotine_mg_ml?: number | null; + vg_pct?: number | null; + ohm?: number | null; + is_archived?: boolean; + note?: string | null; +}; + +export interface MixComponent { + id?: number; + product_id: number; + quantity: Decimalish; +} + +export interface Mix { + id: number; + name: string; + total_ml: Decimalish; + target_nicotine_mg_ml: Decimalish; + is_active: boolean; + is_archived: boolean; + note?: string | null; + components: MixComponent[]; + /** Euros — derived from `cost_total_cents`. */ + cost_total?: number | null; + /** Euros per ml — derived from `cost_per_ml_cents`. */ + cost_per_ml?: number | null; + nicotine_check_mg_ml?: Decimalish | null; + vg_pct_mix?: Decimalish | null; + warning?: string | null; +} + +interface MixWire extends Omit { + cost_total_cents?: number | null; + cost_per_ml_cents?: number | null; +} + +function toMix(wire: MixWire): Mix { + const { cost_total_cents, cost_per_ml_cents, ...rest } = wire; + return { + ...rest, + cost_total: eurosOf(cost_total_cents), + cost_per_ml: eurosOf(cost_per_ml_cents), + }; +} + +export type MixInput = { + name: string; + total_ml: number; + target_nicotine_mg_ml: number; + note?: string | null; + components: { product_id: number; quantity: number }[]; +}; + +export type MixCalculatorInput = { + total_ml: number; + target_nicotine_mg_ml: number; + booster_product_id: number; + base_product_id: number; + aroma_pct: number; + aroma_product_id?: number | null; +}; + +export interface MixCalculatorResult { + booster_ml?: Decimalish | null; + aroma_ml?: Decimalish | null; + base_ml?: Decimalish | null; + /** Euros. */ + cost_total?: number | null; + /** Euros per ml. */ + cost_per_ml?: number | null; + nicotine_check_mg_ml?: Decimalish | null; + warning?: string | null; +} + +interface MixCalculatorWire extends Omit { + cost_total_cents?: number | null; + cost_per_ml_cents?: number | null; +} + +export interface LiquidEntry { + id: number; + entry_date: string; + kind: LiquidKind; + ml: Decimalish; + nicotine_mg_ml?: Decimalish | null; + mix_id?: number | null; + source?: string; + note?: string | null; +} + +export type LiquidEntryInput = { + entry_date: string; + kind: LiquidKind; + ml: number; + nicotine_mg_ml?: number | null; + note?: string | null; +}; + +export interface CoilChange { + id: number; + changed_at: string; + product_id?: number | null; + reason?: string | null; + note?: string | null; + /** Derived cycle values (§7.3) — the last coil is still running. */ + lifespan_days?: Decimalish | null; + ml_through?: Decimalish | null; + removed_at?: string | null; + is_current?: boolean; +} + +export type CoilChangeInput = { + changed_at: string; + product_id?: number | null; + reason?: string | null; + note?: string | null; +}; + +/** POST /vape/coils may echo the closed cycle length — otherwise computed client-side. */ +export interface CoilChangeCreated extends CoilChange { + previous_lifespan_days?: Decimalish | null; +} + +export interface Purchase { + id: number; + purchased_on: string; + product_id: number; + product_name?: string | null; + qty: Decimalish; + /** Euros — derived from `unit_price_cents`. */ + unit_price?: number | null; + note?: string | null; + /** Euros — derived from `total_cents`. */ + total?: number | null; +} + +interface PurchaseWire extends Omit { + unit_price_cents?: number | null; + total_cents?: number | null; +} + +function toPurchase(wire: PurchaseWire): Purchase { + const { unit_price_cents, total_cents, ...rest } = wire; + return { ...rest, unit_price: eurosOf(unit_price_cents), total: eurosOf(total_cents) }; +} + +export type PurchaseInput = { + purchased_on: string; + product_id: number; + qty: number; + unit_price?: number | null; + note?: string | null; +}; + +export interface Milestone { + code: string; + label_fr?: string | null; + reached_at: string; + achieved: boolean; + progress_pct?: Decimalish | null; +} + +/* ------------------------------------------------------------------ */ +/* Chart-ready stats envelope (§8.1) */ +/* ------------------------------------------------------------------ */ + +export type SeriesPoint = [string, number | null]; + +export interface StatsSeries { + name: string; + type?: string; + points: [string, Decimalish | null][]; +} + +export interface StatsResponse { + from?: string | null; + to?: string | null; + unit?: string | null; + series: StatsSeries[]; + meta: M; +} + +/** Normalized points of a named series ("ml", "ml_ma7"…) — [] when absent. */ +export function pointsOf(response: StatsResponse | undefined, name: string): SeriesPoint[] { + const series = response?.series?.find((s) => s.name === name); + if (!series || !Array.isArray(series.points)) return []; + return series.points.map(([day, value]) => [day, num(value)] as SeriesPoint); +} + +export interface ConsumptionMeta { + ml_per_day_7?: Decimalish | null; + ml_per_day_30?: Decimalish | null; + tracked_days_ratio?: Decimalish | null; +} + +export type NicotineTrend = "down" | "stable" | "up"; + +export interface NicotineMeta { + slope_30d?: Decimalish | null; + status?: NicotineTrend | string | null; + cig_equivalent?: Decimalish | null; +} + +export interface CostsMeta { + cost_per_ml?: Decimalish | null; + vape_cost_per_day?: Decimalish | null; + coil_cost_per_day?: Decimalish | null; + real_cost_per_day?: Decimalish | null; +} + +export interface SavingsMeta { + cig_cost_per_day?: Decimalish | null; + savings_per_day?: Decimalish | null; + days_since_quit?: Decimalish | null; + cigarettes_avoided?: Decimalish | null; + packs_avoided?: Decimalish | null; + time_regained_minutes?: Decimalish | null; +} + +export interface CoilStatsMeta { + status?: string | null; + avg_lifespan_days?: Decimalish | null; + avg_ml_through_coil?: Decimalish | null; + current_coil_age_days?: Decimalish | null; + current_coil_product_id?: number | null; +} + +/** GET /vape/dashboard, money already converted to euros. */ +export interface VapeDashboard { + quit_date?: string | null; + days_since_quit?: number | null; + ml_today?: Decimalish | null; + ml_per_day_7?: Decimalish | null; + ml_per_day_30?: Decimalish | null; + nicotine_mg_today?: Decimalish | null; + cumulative_savings?: number | null; + savings_theoretical?: number | null; + savings_real?: number | null; + has_purchases?: boolean; + cigarettes_avoided?: number | null; + packs_avoided?: Decimalish | null; + current_coil_age_days?: Decimalish | null; + coil_avg_lifespan_days?: Decimalish | null; + next_milestone?: Milestone | null; +} + +/** Wire shape of GET /vape/dashboard (money in cents). */ +interface VapeDashboardWire { + quit_date: string; + days_since_quit: number; + ml_today?: Decimalish | null; + ml_per_day_7?: Decimalish | null; + ml_per_day_30?: Decimalish | null; + nicotine_today_mg?: Decimalish | null; + savings_theoretical_cents?: number | null; + savings_real_cents?: number | null; + savings_display_cents?: number | null; + has_purchases?: boolean; + cigarettes_avoided?: number | null; + packs_avoided?: Decimalish | null; + current_coil_age_days?: Decimalish | null; + coil_avg_lifespan_days?: Decimalish | null; + next_milestone?: Milestone | null; +} + +function toDashboard(wire: VapeDashboardWire): VapeDashboard { + return { + quit_date: wire.quit_date, + days_since_quit: wire.days_since_quit, + ml_today: wire.ml_today ?? null, + ml_per_day_7: wire.ml_per_day_7 ?? null, + ml_per_day_30: wire.ml_per_day_30 ?? null, + nicotine_mg_today: wire.nicotine_today_mg ?? null, + cumulative_savings: eurosOf(wire.savings_display_cents), + savings_theoretical: eurosOf(wire.savings_theoretical_cents), + savings_real: eurosOf(wire.savings_real_cents), + has_purchases: wire.has_purchases ?? false, + cigarettes_avoided: wire.cigarettes_avoided ?? null, + packs_avoided: wire.packs_avoided ?? null, + current_coil_age_days: wire.current_coil_age_days ?? null, + coil_avg_lifespan_days: wire.coil_avg_lifespan_days ?? null, + next_milestone: wire.next_milestone ?? null, + }; +} + +/** Raw `meta` of a stats envelope, before it is mapped to the module names. */ +type RawMeta = Record; + +interface RawStats { + from?: string | null; + to?: string | null; + unit?: string | null; + series?: StatsSeries[] | null; + meta?: RawMeta | null; +} + +/* ------------------------------------------------------------------ */ +/* List responses — tolerate both Page[T] and a bare array */ +/* ------------------------------------------------------------------ */ + +export type ListResponse = Page | T[]; + +export function itemsOf(response: ListResponse | undefined): T[] { + if (!response) return []; + return Array.isArray(response) ? response : (response.items ?? []); +} + +export function totalOf(response: ListResponse | undefined): number { + if (!response) return 0; + return Array.isArray(response) ? response.length : (response.total ?? response.items.length); +} + +/* ------------------------------------------------------------------ */ +/* Query keys + invalidation */ +/* ------------------------------------------------------------------ */ + +export type VapeResource = + | "settings" + | "liquids" + | "products" + | "mixes" + | "coils" + | "purchases" + | "stats" + | "milestones" + | "dashboard"; + +export const vapeKey = (resource: VapeResource, params?: unknown): unknown[] => + params === undefined ? [MODULE_ID, resource] : [MODULE_ID, resource, params]; + +function invalidate(client: QueryClient, resources: VapeResource[]): void { + for (const resource of resources) { + void client.invalidateQueries({ queryKey: [MODULE_ID, resource] }); + } +} + +export type PeriodParams = { from?: string | null; to?: string | null }; + +/** Converts the module's `limit`/`offset` paging to the API `page`/`page_size`. */ +function pageParams(params: { limit?: number; offset?: number }): Record { + const { limit, offset, ...rest } = params; + if (limit === undefined && offset === undefined) return rest; + const pageSize = Math.min(limit ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE); + return { ...rest, page: Math.floor((offset ?? 0) / pageSize) + 1, page_size: pageSize }; +} + +/** Rebuilds a Page envelope by mapping each item through `map`. */ +function mapPage(response: ListResponse, map: (item: W) => T): Page { + const items = itemsOf(response).map(map); + return Array.isArray(response) + ? { items, total: items.length, page: 1, page_size: items.length } + : { items, total: response.total ?? items.length, page: response.page, page_size: response.page_size }; +} + +/* ------------------------------------------------------------------ */ +/* Settings */ +/* ------------------------------------------------------------------ */ + +/** GET /vape/settings — a 404 means « module non configuré » (ux-pages §16). */ +export function useVapeSettings() { + return useQuery({ + queryKey: vapeKey("settings"), + queryFn: () => api(`${BASE}/settings`).then(toSettings), + retry: false, + }); +} + +/* ------------------------------------------------------------------ */ +/* Liquid entries */ +/* ------------------------------------------------------------------ */ + +export type LiquidListParams = PeriodParams & { + kind?: LiquidKind | null; + limit?: number; + offset?: number; +}; + +export function useLiquidEntries(params: LiquidListParams) { + return useQuery({ + queryKey: vapeKey("liquids", params), + queryFn: () => api>(`${BASE}/liquids?${qs(pageParams(params))}`), + }); +} + +export function useCreateLiquidEntry() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (body: LiquidEntryInput) => + api(`${BASE}/liquids`, { method: "POST", body: JSON.stringify(body) }), + onSuccess: () => invalidate(client, ["liquids", "stats", "dashboard"]), + }); +} + +export function useUpdateLiquidEntry() { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: number; body: Partial }) => + api(`${BASE}/liquids/${id}`, { method: "PATCH", body: JSON.stringify(body) }), + onSuccess: () => invalidate(client, ["liquids", "stats", "dashboard"]), + }); +} + +export function useDeleteLiquidEntry() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => api(`${BASE}/liquids/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate(client, ["liquids", "stats", "dashboard"]), + }); +} + +/* ------------------------------------------------------------------ */ +/* Products */ +/* ------------------------------------------------------------------ */ + +export type ProductListParams = { kind?: ProductKind | null; include_archived?: boolean }; + +export function useProducts(params: ProductListParams = {}) { + return useQuery({ + queryKey: vapeKey("products", params), + queryFn: () => + api>( + `${BASE}/products?${qs({ page_size: MAX_PAGE_SIZE, ...params })}`, + ).then((res) => mapPage(res, toProduct)), + }); +} + +/** Money leaves the module in cents (the API stores `price_cents`). */ +function productBody(body: Partial): Record { + const { price, ...rest } = body; + return price === undefined ? rest : { ...rest, price_cents: centsOf(price) }; +} + +export function useCreateProduct() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (body: ProductInput) => + api(`${BASE}/products`, { + method: "POST", + body: JSON.stringify(productBody(body)), + }).then(toProduct), + onSuccess: () => invalidate(client, ["products", "mixes", "stats"]), + }); +} + +export function useUpdateProduct() { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: number; body: Partial }) => + api(`${BASE}/products/${id}`, { + method: "PATCH", + body: JSON.stringify(productBody(body)), + }).then(toProduct), + onSuccess: () => invalidate(client, ["products", "mixes", "stats"]), + }); +} + +export function useDeleteProduct() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => api(`${BASE}/products/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate(client, ["products", "mixes", "stats"]), + }); +} + +/* ------------------------------------------------------------------ */ +/* Mixes */ +/* ------------------------------------------------------------------ */ + +export function useMixes() { + return useQuery({ + queryKey: vapeKey("mixes"), + queryFn: () => + api>(`${BASE}/mixes?${qs({ page_size: MAX_PAGE_SIZE })}`).then((res) => + mapPage(res, toMix), + ), + }); +} + +export function useCreateMix() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (body: MixInput) => + api(`${BASE}/mixes`, { method: "POST", body: JSON.stringify(body) }).then(toMix), + onSuccess: () => invalidate(client, ["mixes", "stats", "dashboard"]), + }); +} + +export function useUpdateMix() { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: number; body: MixInput }) => + api(`${BASE}/mixes/${id}`, { method: "PATCH", body: JSON.stringify(body) }).then( + toMix, + ), + onSuccess: () => invalidate(client, ["mixes", "stats", "dashboard"]), + }); +} + +export function useDeleteMix() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => api(`${BASE}/mixes/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate(client, ["mixes", "stats", "dashboard"]), + }); +} + +export function useActivateMix() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + api(`${BASE}/mixes/${id}/activate`, { method: "POST" }).then(toMix), + onSuccess: () => invalidate(client, ["mixes", "stats", "dashboard"]), + }); +} + +/** POST /vape/mixes/calculator — stateless recipe assistant (§6.3). */ +export function useMixCalculator() { + return useMutation({ + mutationFn: (body: MixCalculatorInput) => + api(`${BASE}/mixes/calculator`, { + method: "POST", + body: JSON.stringify(body), + }).then(({ cost_total_cents, cost_per_ml_cents, ...rest }) => ({ + ...rest, + cost_total: eurosOf(cost_total_cents), + cost_per_ml: eurosOf(cost_per_ml_cents), + })), + }); +} + +/* ------------------------------------------------------------------ */ +/* Coil changes */ +/* ------------------------------------------------------------------ */ + +export type CoilListParams = PeriodParams & { limit?: number; offset?: number }; + +export function useCoilChanges(params: CoilListParams = {}) { + return useQuery({ + queryKey: vapeKey("coils", params), + queryFn: () => + api>(`${BASE}/coils?${qs({ tz: TZ, ...pageParams(params) })}`), + }); +} + +/** GET /vape/coils/stats — takes `tz` only (no period filter). */ +export function useCoilStats(params: PeriodParams = {}) { + return useQuery({ + queryKey: vapeKey("stats", ["coils", params]), + queryFn: () => + api(`${BASE}/coils/stats?${qs({ tz: TZ })}`).then((raw) => ({ + from: raw.from ?? null, + to: raw.to ?? null, + unit: raw.unit ?? null, + series: raw.series ?? [], + meta: { + // The API exposes the confidence through `avg_lifespan_is_default`. + status: raw.meta?.avg_lifespan_is_default === true ? "insufficient_data" : "ok", + avg_lifespan_days: (raw.meta?.avg_lifespan_days as Decimalish | null) ?? null, + avg_ml_through_coil: (raw.meta?.avg_ml_through_coil as Decimalish | null) ?? null, + current_coil_age_days: (raw.meta?.current_coil_age_days as Decimalish | null) ?? null, + current_coil_product_id: (raw.meta?.current_coil_product_id as number | null) ?? null, + } satisfies CoilStatsMeta, + })), + }); +} + +export function useCreateCoilChange() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (body: CoilChangeInput) => + api(`${BASE}/coils`, { method: "POST", body: JSON.stringify(body) }), + onSuccess: () => invalidate(client, ["coils", "stats", "dashboard"]), + }); +} + +export function useUpdateCoilChange() { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: number; body: Partial }) => + api(`${BASE}/coils/${id}`, { method: "PATCH", body: JSON.stringify(body) }), + onSuccess: () => invalidate(client, ["coils", "stats", "dashboard"]), + }); +} + +export function useDeleteCoilChange() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => api(`${BASE}/coils/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate(client, ["coils", "stats", "dashboard"]), + }); +} + +/* ------------------------------------------------------------------ */ +/* Purchases */ +/* ------------------------------------------------------------------ */ + +export type PurchaseListParams = PeriodParams & { + product_id?: number | null; + limit?: number; + offset?: number; +}; + +export function usePurchases(params: PurchaseListParams = {}) { + return useQuery({ + queryKey: vapeKey("purchases", params), + queryFn: () => + api>(`${BASE}/purchases?${qs(pageParams(params))}`).then((res) => + mapPage(res, toPurchase), + ), + }); +} + +/** `unit_price` is sent back in cents (the API stores `unit_price_cents`). */ +function purchaseBody(body: Partial): Record { + const { unit_price, ...rest } = body; + return unit_price === undefined ? rest : { ...rest, unit_price_cents: centsOf(unit_price) }; +} + +export function useCreatePurchase() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (body: PurchaseInput) => + api(`${BASE}/purchases`, { + method: "POST", + body: JSON.stringify(purchaseBody(body)), + }).then(toPurchase), + onSuccess: () => invalidate(client, ["purchases", "stats", "dashboard"]), + }); +} + +export function useUpdatePurchase() { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: number; body: Partial }) => + api(`${BASE}/purchases/${id}`, { + method: "PATCH", + body: JSON.stringify(purchaseBody(body)), + }).then(toPurchase), + onSuccess: () => invalidate(client, ["purchases", "stats", "dashboard"]), + }); +} + +export function useDeletePurchase() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => api(`${BASE}/purchases/${id}`, { method: "DELETE" }), + onSuccess: () => invalidate(client, ["purchases", "stats", "dashboard"]), + }); +} + +/* ------------------------------------------------------------------ */ +/* Stats */ +/* ------------------------------------------------------------------ */ + +function statsEnvelope(raw: RawStats, meta: M, series?: StatsSeries[]): StatsResponse { + return { + from: raw.from ?? null, + to: raw.to ?? null, + unit: raw.unit ?? null, + series: series ?? raw.series ?? [], + meta, + }; +} + +export function useConsumptionStats(params: PeriodParams) { + return useQuery({ + queryKey: vapeKey("stats", ["consumption", params]), + queryFn: () => + api(`${BASE}/stats/consumption?${qs({ tz: TZ, ...params })}`).then((raw) => + statsEnvelope(raw, { + ml_per_day_7: (raw.meta?.ml_per_day_7 as Decimalish | null) ?? null, + ml_per_day_30: (raw.meta?.ml_per_day_30 as Decimalish | null) ?? null, + tracked_days_ratio: (raw.meta?.tracked_days_ratio as Decimalish | null) ?? null, + }), + ), + }); +} + +export function useNicotineStats(params: PeriodParams) { + return useQuery({ + queryKey: vapeKey("stats", ["nicotine", params]), + queryFn: () => + api(`${BASE}/stats/nicotine?${qs({ tz: TZ, ...params })}`).then((raw) => + statsEnvelope(raw, { + slope_30d: (raw.meta?.slope_30d_mg_per_day as Decimalish | null) ?? null, + status: (raw.meta?.trend_status as string | null) ?? null, + cig_equivalent: (raw.meta?.cig_equivalent_per_day as Decimalish | null) ?? null, + }), + ), + }); +} + +/** Costs are served in cents (`unit`: "cents") — series and meta go to euros. */ +export function useCostStats(params: PeriodParams) { + return useQuery({ + queryKey: vapeKey("stats", ["costs", params]), + queryFn: () => + api(`${BASE}/stats/costs?${qs({ tz: TZ, ...params })}`).then((raw) => + statsEnvelope( + raw, + { + cost_per_ml: eurosOf(raw.meta?.cost_per_ml_cents as Decimalish | null), + vape_cost_per_day: eurosOf(raw.meta?.vape_cost_per_day_cents as Decimalish | null), + coil_cost_per_day: eurosOf(raw.meta?.coil_cost_per_day_cents as Decimalish | null), + real_cost_per_day: eurosOf(raw.meta?.real_cost_per_day_cents as Decimalish | null), + }, + seriesToEuros(raw.series), + ), + ), + }); +} + +export function useSavingsStats(params: PeriodParams) { + return useQuery({ + queryKey: vapeKey("stats", ["savings", params]), + queryFn: () => + api(`${BASE}/stats/savings?${qs({ tz: TZ, ...params })}`).then((raw) => + statsEnvelope( + raw, + { + cig_cost_per_day: eurosOf(raw.meta?.cig_cost_per_day_cents as Decimalish | null), + savings_per_day: eurosOf(raw.meta?.savings_per_day_cents as Decimalish | null), + days_since_quit: (raw.meta?.days_since_quit as Decimalish | null) ?? null, + cigarettes_avoided: (raw.meta?.cigarettes_avoided as Decimalish | null) ?? null, + packs_avoided: (raw.meta?.packs_avoided as Decimalish | null) ?? null, + time_regained_minutes: (raw.meta?.time_regained_minutes as Decimalish | null) ?? null, + }, + seriesToEuros(raw.series), + ), + ), + }); +} + +export function useMilestones() { + return useQuery({ + queryKey: vapeKey("milestones"), + queryFn: () => api>(`${BASE}/milestones?${qs({ tz: TZ })}`), + }); +} + +export function useVapeDashboard() { + return useQuery({ + queryKey: vapeKey("dashboard"), + queryFn: () => api(`${BASE}/dashboard?${qs({ tz: TZ })}`).then(toDashboard), + }); +} diff --git a/apps/web/src/modules/vape/charts.ts b/apps/web/src/modules/vape/charts.ts new file mode 100644 index 0000000..6f37dd7 --- /dev/null +++ b/apps/web/src/modules/vape/charts.ts @@ -0,0 +1,617 @@ +/** + * ECharts option builders of the vape module. + * Palette slots are normative (ux-pages §4.4): ml = chart-7 violet, + * nicotine = chart-5 magenta, cost = chart-4 yellow, savings = semantic + * positive. Never a dual Y axis, legend as soon as 2 series, dashed + * markLines for budgets/targets (ux-pages §6). + */ +import type { EChartsOption } from "echarts"; + +import { CHART_COLORS, CHART_SURFACE, SEMANTIC_COLORS } from "../../components/charts/theme"; +import { + formatDate, + formatDateShort, + formatEuroAmount, + formatMg, + formatMl, + formatMonthShort, + formatNumber, +} from "../../lib/format"; +import type { SeriesPoint } from "./api"; +import { S } from "./strings"; + +/** Fixed identity colors of the module (ux-pages §4.4). */ +export const VAPE_COLORS = { + ml: CHART_COLORS[6], // #9085E9 violet — vape volume + nicotine: CHART_COLORS[4], // #D55181 magenta — nicotine + cost: CHART_COLORS[3], // #C98500 yellow — vape cost + costLight: "#E8B24D", // sequential tint of the cost yellow (variant of the same entity) + savings: SEMANTIC_COLORS.positive, // #0CA30C — savings are semantically favorable + savingsLight: "#6FCF6F", // lighter tint for the theoretical variant + reference: CHART_SURFACE.inkSecondary, // #C3C2B7 — tobacco reference / markLines + muted: CHART_SURFACE.inkMuted, +} as const; + +const GRID_WITH_SLIDER = { left: 8, right: 16, top: 36, bottom: 44, containLabel: true } as const; +const GRID_DEFAULT = { left: 8, right: 16, top: 36, bottom: 8, containLabel: true } as const; + +/* ------------------------------------------------------------------ */ +/* Small helpers */ +/* ------------------------------------------------------------------ */ + +function hexToRgba(hex: string, alpha: number): string { + const value = hex.replace("#", ""); + const r = parseInt(value.slice(0, 2), 16); + const g = parseInt(value.slice(2, 4), 16); + const b = parseInt(value.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** Vertical gradient of a series color: 25 % → 0 % opacity (ux-pages §6). */ +function areaGradient(hex: string) { + return { + type: "linear" as const, + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: hexToRgba(hex, 0.25) }, + { offset: 1, color: hexToRgba(hex, 0) }, + ], + }; +} + +/** In-canvas « Pas de données sur cette période » (ux-pages §16). */ +export function emptyGraphic(hasData: boolean): EChartsOption["graphic"] { + if (hasData) return undefined; + return { + type: "text", + left: "center", + top: "middle", + silent: true, + style: { + text: S.empty.chartNoData, + fill: CHART_SURFACE.inkMuted, + font: '14px system-ui, "Segoe UI", sans-serif', + }, + }; +} + +/** True when at least one point carries a value. */ +export function hasPoints(points: SeriesPoint[]): boolean { + return points.some(([, value]) => value !== null && value !== undefined); +} + +interface TooltipItem { + axisValue?: string | number; + axisValueLabel?: string; + seriesName?: string; + marker?: string; + value?: unknown; + dataIndex?: number; + name?: string; +} + +function asItems(params: unknown): TooltipItem[] { + if (Array.isArray(params)) return params as TooltipItem[]; + return params ? [params as TooltipItem] : []; +} + +/** Numeric value of a tooltip item ([date, value] on time axes, value on category axes). */ +function itemValue(item: TooltipItem): number | null { + const raw = item.value; + if (Array.isArray(raw)) { + const last = raw[raw.length - 1]; + return typeof last === "number" ? last : null; + } + return typeof raw === "number" ? raw : null; +} + +function itemDate(item: TooltipItem): string { + if (Array.isArray(item.value) && typeof item.value[0] === "string") return item.value[0]; + if (typeof item.axisValue === "string") return item.axisValue; + if (typeof item.axisValue === "number") return new Date(item.axisValue).toISOString(); + return item.axisValueLabel ?? ""; +} + +function tooltipHead(iso: string): string { + return `
    ${iso ? formatDate(iso) : ""}
    `; +} + +function tooltipLine(item: TooltipItem, text: string): string { + return `
    ${item.marker ?? ""} ${item.seriesName ?? ""}${text}
    `; +} + +const TIME_AXIS = { + type: "time" as const, + axisLabel: { formatter: (value: number) => formatDateShort(value) }, +}; + +function categoryAxis(labels: string[], formatter: (value: string) => string) { + return { + type: "category" as const, + data: labels, + axisLabel: { formatter, interval: "auto" as const, hideOverlap: true }, + }; +} + +/** Dashed reference line (budgets/targets — ux-pages §6). */ +function dashedMarkLine(value: number, label: string) { + return { + silent: true, + symbol: "none" as const, + lineStyle: { type: [4, 4] as [number, number], color: VAPE_COLORS.reference, width: 1 }, + label: { + formatter: label, + color: CHART_SURFACE.inkSecondary, + position: "insideEndTop" as const, + fontSize: 11, + }, + data: [{ yAxis: value }], + }; +} + +/* ------------------------------------------------------------------ */ +/* G1 — Consommation d'e-liquide (ux-pages §12.2) */ +/* ------------------------------------------------------------------ */ + +export interface ConsumptionOptionInput { + raw: SeriesPoint[]; + ma7: SeriesPoint[]; + /** Local days (YYYY-MM-DD) with a coil change — discrete markPoints. */ + coilDays: string[]; + targetMlPerDay: number | null; +} + +export function buildConsumptionOption({ + raw, + ma7, + coilDays, + targetMlPerDay, +}: ConsumptionOptionInput): EChartsOption { + const ma7ByDay = new Map(ma7.map(([day, value]) => [day, value])); + return { + grid: GRID_WITH_SLIDER, + legend: { data: [S.charts.consumptionMl, S.charts.consumptionMa7] }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (params: unknown): string => { + const items = asItems(params); + if (items.length === 0) return ""; + const iso = itemDate(items[0]); + const lines = items.map((item) => { + const value = itemValue(item); + return tooltipLine(item, value === null ? "—" : formatMl(value)); + }); + const ma = ma7ByDay.get(iso.slice(0, 10)); + const extra = + items.length === 1 && ma !== undefined && ma !== null + ? `
    ${S.charts.consumptionMa7} : ${formatMl(ma)}
    ` + : ""; + return `${tooltipHead(iso)}${lines.join("")}${extra}`; + }, + }, + xAxis: TIME_AXIS, + yAxis: { type: "value", min: 0 }, + dataZoom: [{ type: "inside" }, { type: "slider", height: 20, bottom: 8 }], + graphic: emptyGraphic(hasPoints(raw) || hasPoints(ma7)), + series: [ + { + name: S.charts.consumptionMl, + type: "scatter", + symbolSize: 6, + itemStyle: { color: VAPE_COLORS.ml, opacity: 0.45 }, + data: raw, + markPoint: { + symbol: "diamond", + symbolSize: 9, + itemStyle: { color: VAPE_COLORS.muted }, + label: { show: false }, + data: coilDays.map((day) => ({ name: S.charts.coilChangedMark, coord: [day, 0] })), + }, + }, + { + name: S.charts.consumptionMa7, + type: "line", + smooth: 0.2, + showSymbol: false, + lineStyle: { width: 2, color: VAPE_COLORS.ml }, + itemStyle: { color: VAPE_COLORS.ml }, + data: ma7, + markLine: + targetMlPerDay !== null + ? dashedMarkLine(targetMlPerDay, `${S.charts.consumptionTarget} ${formatMl(targetMlPerDay)}`) + : undefined, + }, + ], + }; +} + +/* ------------------------------------------------------------------ */ +/* G2 — Nicotine absorbée */ +/* ------------------------------------------------------------------ */ + +export function buildNicotineOption(points: SeriesPoint[], mlByDay: Map): EChartsOption { + const labels = points.map(([day]) => day); + const values = points.map(([, value]) => value); + return { + grid: GRID_DEFAULT, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (params: unknown): string => { + const items = asItems(params); + if (items.length === 0) return ""; + const iso = itemDate(items[0]); + const value = itemValue(items[0]); + const ml = mlByDay.get(iso.slice(0, 10)); + const detail = ml !== undefined ? ` (${formatMl(ml)})` : ""; + return `${tooltipHead(iso)}${tooltipLine(items[0], value === null ? "—" : `${formatMg(value)}${detail}`)}`; + }, + }, + xAxis: categoryAxis(labels, (value) => formatDateShort(value)), + yAxis: { type: "value", min: 0 }, + dataZoom: [{ type: "inside" }], + graphic: emptyGraphic(hasPoints(points)), + series: [ + { + name: S.charts.nicotineSeries, + type: "bar", + barMaxWidth: 28, + itemStyle: { color: VAPE_COLORS.nicotine, borderRadius: [4, 4, 0, 0] }, + data: values, + markLine: { + silent: true, + symbol: "none", + lineStyle: { type: [4, 4], color: VAPE_COLORS.reference, width: 1 }, + label: { color: CHART_SURFACE.inkSecondary, fontSize: 11, formatter: S.charts.average }, + data: [{ type: "average" }], + }, + }, + ], + }; +} + +/* ------------------------------------------------------------------ */ +/* G3 — Coût quotidien (vape vs référence tabac) */ +/* ------------------------------------------------------------------ */ + +export interface DailyCostOptionInput { + raw: SeriesPoint[]; + ma7: SeriesPoint[]; + tobaccoPerDay: number | null; +} + +export function buildDailyCostOption({ + raw, + ma7, + tobaccoPerDay, +}: DailyCostOptionInput): EChartsOption { + return { + grid: GRID_DEFAULT, + legend: { data: [S.charts.dailyCostSeries, S.charts.dailyCostMa7] }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (params: unknown): string => { + const items = asItems(params); + if (items.length === 0) return ""; + const iso = itemDate(items[0]); + const lines = items.map((item) => { + const value = itemValue(item); + return tooltipLine(item, value === null ? "—" : formatEuroAmount(value)); + }); + const vape = itemValue(items[0]); + const gain = + tobaccoPerDay !== null && vape !== null + ? `
    ${S.charts.tobaccoReference} ${formatEuroAmount(tobaccoPerDay)} — gain ${formatEuroAmount(tobaccoPerDay - vape)}
    ` + : ""; + return `${tooltipHead(iso)}${lines.join("")}${gain}`; + }, + }, + xAxis: TIME_AXIS, + yAxis: { type: "value", min: 0 }, + dataZoom: [{ type: "inside" }], + graphic: emptyGraphic(hasPoints(raw) || hasPoints(ma7)), + series: [ + { + name: S.charts.dailyCostSeries, + type: "scatter", + symbolSize: 6, + itemStyle: { color: VAPE_COLORS.cost, opacity: 0.45 }, + data: raw, + }, + { + name: S.charts.dailyCostMa7, + type: "line", + smooth: 0.2, + showSymbol: false, + lineStyle: { width: 2, color: VAPE_COLORS.cost }, + itemStyle: { color: VAPE_COLORS.cost }, + data: ma7, + markLine: + tobaccoPerDay !== null + ? dashedMarkLine( + tobaccoPerDay, + `${S.charts.tobaccoReference} ${formatEuroAmount(tobaccoPerDay)}`, + ) + : undefined, + }, + ], + }; +} + +/* ------------------------------------------------------------------ */ +/* Coût mensuel — théorique vs dépenses réelles */ +/* ------------------------------------------------------------------ */ + +export function buildMonthlyCostOption( + theoretical: SeriesPoint[], + real: SeriesPoint[], +): EChartsOption { + const labels = Array.from( + new Set([...theoretical.map(([day]) => day), ...real.map(([day]) => day)]), + ).sort(); + const byDay = (points: SeriesPoint[]) => { + const map = new Map(points); + return labels.map((label) => map.get(label) ?? null); + }; + return { + grid: GRID_DEFAULT, + legend: { data: [S.charts.theoreticalCost, S.charts.realSpend] }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (params: unknown): string => { + const items = asItems(params); + if (items.length === 0) return ""; + const label = String(items[0].axisValue ?? ""); + const lines = items.map((item) => { + const value = itemValue(item); + return tooltipLine(item, value === null ? "—" : formatEuroAmount(value)); + }); + return `
    ${label ? formatMonthShort(label) : ""}
    ${lines.join("")}`; + }, + }, + xAxis: categoryAxis(labels, (value) => formatMonthShort(value)), + yAxis: { type: "value", min: 0 }, + graphic: emptyGraphic(hasPoints(theoretical) || hasPoints(real)), + series: [ + { + name: S.charts.theoreticalCost, + type: "bar", + barMaxWidth: 28, + itemStyle: { + color: VAPE_COLORS.costLight, + borderRadius: [4, 4, 0, 0], + }, + data: byDay(theoretical), + }, + { + name: S.charts.realSpend, + type: "bar", + barMaxWidth: 28, + itemStyle: { color: VAPE_COLORS.cost, borderRadius: [4, 4, 0, 0] }, + data: byDay(real), + }, + ], + }; +} + +/* ------------------------------------------------------------------ */ +/* G5 — Durée de vie des résistances */ +/* ------------------------------------------------------------------ */ + +export interface CoilBar { + /** ISO day the coil was fitted. */ + day: string; + days: number | null; + ml: number | null; + productLabel: string | null; + current: boolean; +} + +export function buildCoilLifespanOption(bars: CoilBar[], average: number | null): EChartsOption { + const hatch = { + symbol: "rect", + dashArrayX: [1, 0] as [number, number], + dashArrayY: [2, 5] as [number, number], + rotation: Math.PI / 4, + color: "rgba(255,255,255,0.25)", + }; + return { + grid: GRID_DEFAULT, + tooltip: { + trigger: "item", + formatter: (params: unknown): string => { + const item = asItems(params)[0]; + const index = item?.dataIndex ?? 0; + const bar = bars[index]; + if (!bar) return ""; + const parts = [ + bar.days === null ? "—" : `${formatNumber(bar.days, 1)} j`, + bar.ml === null ? null : `≈ ${formatMl(bar.ml)}`, + bar.productLabel, + bar.current ? S.charts.coilCurrent : null, + ].filter((part): part is string => Boolean(part)); + return `${tooltipHead(bar.day)}${parts.join(" · ")}`; + }, + }, + xAxis: categoryAxis( + bars.map((bar) => bar.day), + (value) => formatDateShort(value), + ), + yAxis: { type: "value", min: 0 }, + graphic: emptyGraphic(bars.length > 0), + series: [ + { + name: S.charts.coilLifespanSeries, + type: "bar", + barMaxWidth: 28, + data: bars.map((bar) => ({ + value: bar.days, + itemStyle: { + color: VAPE_COLORS.ml, + borderRadius: [4, 4, 0, 0] as [number, number, number, number], + opacity: bar.current ? 0.75 : 1, + decal: bar.current ? hatch : undefined, + }, + })), + markLine: + average !== null + ? dashedMarkLine(average, `${S.charts.average} ${formatNumber(average, 0)} j`) + : undefined, + }, + ], + }; +} + +/* ------------------------------------------------------------------ */ +/* G4 — Économies cumulées */ +/* ------------------------------------------------------------------ */ + +const SAVINGS_MILESTONES = [100, 250, 500, 1000, 2000, 5000]; + +function thresholdMarkPoints(points: SeriesPoint[]) { + const marks: { name: string; coord: [string, number]; value: string }[] = []; + for (const threshold of SAVINGS_MILESTONES) { + const hit = points.find(([, value]) => value !== null && value >= threshold); + if (!hit || hit[1] === null) continue; + marks.push({ + name: formatEuroAmount(threshold), + coord: [hit[0], hit[1]], + value: formatEuroAmount(threshold), + }); + } + return marks; +} + +export function buildSavingsOption( + theoretical: SeriesPoint[], + real: SeriesPoint[], +): EChartsOption { + const emphasized = hasPoints(real) ? real : theoretical; + const last = [...emphasized].reverse().find(([, value]) => value !== null); + return { + grid: GRID_WITH_SLIDER, + legend: { data: [S.charts.savingsTheoretical, S.charts.savingsReal] }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line" }, + formatter: (params: unknown): string => { + const items = asItems(params); + if (items.length === 0) return ""; + const lines = items.map((item) => { + const value = itemValue(item); + return tooltipLine(item, value === null ? "—" : formatEuroAmount(value)); + }); + return `${tooltipHead(itemDate(items[0]))}${lines.join("")}`; + }, + }, + xAxis: TIME_AXIS, + yAxis: { type: "value" }, + dataZoom: [{ type: "inside" }, { type: "slider", height: 20, bottom: 8 }], + graphic: emptyGraphic(hasPoints(theoretical) || hasPoints(real)), + series: [ + { + name: S.charts.savingsTheoretical, + type: "line", + smooth: 0.2, + showSymbol: false, + lineStyle: { width: 2, color: VAPE_COLORS.savingsLight, type: "dashed" }, + itemStyle: { color: VAPE_COLORS.savingsLight }, + data: theoretical, + }, + { + name: S.charts.savingsReal, + type: "line", + smooth: 0.2, + showSymbol: false, + lineStyle: { width: 2, color: VAPE_COLORS.savings }, + itemStyle: { color: VAPE_COLORS.savings }, + areaStyle: { color: areaGradient(VAPE_COLORS.savings) }, + data: real, + markPoint: { + symbol: "pin", + symbolSize: 34, + itemStyle: { color: hexToRgba(VAPE_COLORS.savings, 0.85) }, + label: { color: "#FFFFFF", fontSize: 10 }, + data: thresholdMarkPoints(emphasized), + }, + endLabel: last + ? { + show: true, + color: CHART_SURFACE.ink, + fontSize: 12, + formatter: () => formatEuroAmount(last[1] ?? 0), + } + : undefined, + }, + ], + }; +} + +/* ------------------------------------------------------------------ */ +/* Comparaison coût vape / coût tabac */ +/* ------------------------------------------------------------------ */ + +export function buildCostComparisonOption( + vapePerDay: number | null, + tobaccoPerDay: number | null, +): EChartsOption { + const values = [vapePerDay, tobaccoPerDay]; + return { + grid: GRID_DEFAULT, + tooltip: { + trigger: "item", + formatter: (params: unknown): string => { + const item = asItems(params)[0]; + const value = itemValue(item); + return `${item?.name ?? ""}${ + value === null ? "—" : formatEuroAmount(value) + }`; + }, + }, + xAxis: { + type: "category", + data: [S.charts.comparisonVape, S.charts.comparisonTobacco], + }, + yAxis: { type: "value", min: 0 }, + graphic: emptyGraphic(values.some((value) => value !== null)), + series: [ + { + name: S.charts.comparisonSeries, + type: "bar", + barMaxWidth: 64, + label: { + show: true, + position: "top", + color: CHART_SURFACE.inkSecondary, + fontSize: 12, + formatter: (params: { dataIndex: number }) => { + const value = values[params.dataIndex]; + return value === null ? "—" : formatEuroAmount(value); + }, + }, + data: [ + { + value: vapePerDay, + itemStyle: { + color: VAPE_COLORS.cost, + borderRadius: [4, 4, 0, 0] as [number, number, number, number], + }, + }, + { + value: tobaccoPerDay, + itemStyle: { + color: VAPE_COLORS.reference, + borderRadius: [4, 4, 0, 0] as [number, number, number, number], + }, + }, + ], + }, + ], + }; +} diff --git a/apps/web/src/modules/vape/components/CoilChangeButton.tsx b/apps/web/src/modules/vape/components/CoilChangeButton.tsx new file mode 100644 index 0000000..5a6c809 --- /dev/null +++ b/apps/web/src/modules/vape/components/CoilChangeButton.tsx @@ -0,0 +1,81 @@ +import { Wrench } from "lucide-react"; +import { useState } from "react"; + +import { ApiError } from "../../../lib/api"; +import { Button } from "../../../components/ui/Button"; +import { num, useCreateCoilChange } from "../api"; +import type { CoilChange, Product } from "../api"; +import { daysLabel, S } from "../strings"; +import { CoilDetailModal } from "./CoilDetailModal"; +import { useToast } from "./Toast"; + +export interface CoilChangeButtonProps { + /** ISO datetime of the most recent coil change — closes that cycle. */ + lastChangedAt?: string | null; + /** Coil / pod products offered in the « Ajouter un détail » modal. */ + products: Product[]; + size?: "sm" | "md"; + variant?: "primary" | "secondary"; + className?: string; +} + +/** One-click « Résistance changée » (ux-pages §12.6) + toast with the closed lifespan. */ +export function CoilChangeButton({ + lastChangedAt, + products, + size = "md", + variant = "secondary", + className, +}: CoilChangeButtonProps) { + const create = useCreateCoilChange(); + const toast = useToast(); + const [detailCoil, setDetailCoil] = useState(null); + + const previousLifespanDays = (created: { previous_lifespan_days?: number | string | null }) => { + const echoed = num(created.previous_lifespan_days); + if (echoed !== null) return echoed; + if (!lastChangedAt) return null; + const elapsed = Date.now() - new Date(lastChangedAt).getTime(); + return Number.isFinite(elapsed) && elapsed > 0 ? elapsed / 86_400_000 : null; + }; + + const onClick = () => { + create.mutate( + { changed_at: new Date().toISOString() }, + { + onSuccess: (created) => { + const days = previousLifespanDays(created); + toast.success( + days === null + ? S.coils.changedToast + : S.coils.changedToastWithLifespan(daysLabel(days)), + { action: { label: S.actions.addDetail, onClick: () => setDetailCoil(created) } }, + ); + }, + onError: (error: unknown) => + toast.error(error instanceof ApiError ? error.message : S.misc.loadingError), + }, + ); + }; + + return ( + <> + + setDetailCoil(null)} + coil={detailCoil} + products={products} + /> + + ); +} diff --git a/apps/web/src/modules/vape/components/CoilDetailModal.tsx b/apps/web/src/modules/vape/components/CoilDetailModal.tsx new file mode 100644 index 0000000..4cdbb54 --- /dev/null +++ b/apps/web/src/modules/vape/components/CoilDetailModal.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from "react"; + +import { ApiError } from "../../../lib/api"; +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { Select } from "../../../components/ui/Select"; +import { useUpdateCoilChange } from "../api"; +import type { CoilChange, Product } from "../api"; +import { S } from "../strings"; +import { useToast } from "./Toast"; + +/** ISO (UTC) → value of an in the browser timezone. */ +export function toLocalInputValue(iso: string): string { + const date = new Date(iso); + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad( + date.getHours(), + )}:${pad(date.getMinutes())}`; +} + +/** Local datetime input value → ISO 8601 UTC. */ +export function fromLocalInputValue(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); +} + +export interface CoilDetailModalProps { + open: boolean; + onClose: () => void; + coil: CoilChange | null; + /** Coil / pod products offered in the model select. */ + products: Product[]; +} + +/** « Ajouter un détail » mini-modal of the one-click coil change (ux-pages §12.6). */ +export function CoilDetailModal({ open, onClose, coil, products }: CoilDetailModalProps) { + const update = useUpdateCoilChange(); + const toast = useToast(); + + const [productId, setProductId] = useState(""); + const [reason, setReason] = useState(""); + const [note, setNote] = useState(""); + const [changedAt, setChangedAt] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !coil) return; + setProductId(coil.product_id ? String(coil.product_id) : ""); + setReason(coil.reason ?? ""); + setNote(coil.note ?? ""); + setChangedAt(toLocalInputValue(coil.changed_at)); + setError(null); + }, [open, coil]); + + const submit = () => { + if (!coil) return; + setError(null); + update.mutate( + { + id: coil.id, + body: { + changed_at: fromLocalInputValue(changedAt), + product_id: productId === "" ? null : Number(productId), + reason: reason.trim() === "" ? null : reason.trim(), + note: note.trim() === "" ? null : note.trim(), + }, + }, + { + onSuccess: () => { + toast.success(S.coils.detailSaved); + onClose(); + }, + onError: (err: unknown) => + setError(err instanceof ApiError ? err.message : S.misc.loadingError), + }, + ); + }; + + return ( + + + + + } + > +
    + + + setChangedAt(event.target.value)} + /> + + setReason(event.target.value)} + /> + + setNote(event.target.value)} + /> + + {error ?

    {error}

    : null} +
    +
    + ); +} diff --git a/apps/web/src/modules/vape/components/MilestoneTimeline.tsx b/apps/web/src/modules/vape/components/MilestoneTimeline.tsx new file mode 100644 index 0000000..e1e160f --- /dev/null +++ b/apps/web/src/modules/vape/components/MilestoneTimeline.tsx @@ -0,0 +1,86 @@ +import clsx from "clsx"; +import { Check } from "lucide-react"; +import { useMemo } from "react"; + +import { formatDate, formatPercent } from "../../../lib/format"; +import { num } from "../api"; +import type { Milestone } from "../api"; +import { S } from "../strings"; + +export interface MilestoneTimelineProps { + milestones: Milestone[]; +} + +/** Vertical « Jalons santé » timeline (ux-pages §12.3) — list, not a chart. */ +export function MilestoneTimeline({ milestones }: MilestoneTimelineProps) { + const ordered = useMemo( + () => + [...milestones].sort( + (a, b) => new Date(a.reached_at).getTime() - new Date(b.reached_at).getTime(), + ), + [milestones], + ); + const nextIndex = ordered.findIndex((milestone) => !milestone.achieved); + + if (ordered.length === 0) { + return

    {S.empty.noMilestones}

    ; + } + + return ( +
      + {ordered.map((milestone, index) => { + const label = milestone.label_fr || S.milestones.labels[milestone.code] || milestone.code; + const progress = num(milestone.progress_pct); + const isNext = index === nextIndex; + return ( +
    1. + + {milestone.achieved ? : null} + +

      + {label} +

      +

      + {milestone.achieved + ? S.milestones.reachedOn(formatDate(milestone.reached_at)) + : S.milestones.expectedOn(formatDate(milestone.reached_at))} +

      + {isNext && progress !== null ? ( +
      +
      +
      +
      +

      + {S.milestones.next} — {formatPercent(progress)} +

      +
      + ) : null} +
    2. + ); + })} +
    + ); +} diff --git a/apps/web/src/modules/vape/components/MixModal.tsx b/apps/web/src/modules/vape/components/MixModal.tsx new file mode 100644 index 0000000..48d3393 --- /dev/null +++ b/apps/web/src/modules/vape/components/MixModal.tsx @@ -0,0 +1,432 @@ +import { Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { ApiError } from "../../../lib/api"; +import { formatEuroAmount, formatMg, formatMl, formatNumber } from "../../../lib/format"; +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { Select } from "../../../components/ui/Select"; +import { + num, + parseDecimalInput, + useCreateMix, + useMixCalculator, + useUpdateMix, +} from "../api"; +import type { Mix, Product } from "../api"; +import { formatEuroPerMl, recipeCostPerMl, recipeCostTotal, recipeNicotine } from "../costs"; +import { S } from "../strings"; +import { useToast } from "./Toast"; + +interface ComponentRow { + key: number; + productId: string; + quantity: string; +} + +export interface MixModalProps { + open: boolean; + onClose: () => void; + mix?: Mix | null; + products: Product[]; +} + +let rowKey = 1; +const newRow = (productId = "", quantity = ""): ComponentRow => ({ + key: rowKey++, + productId, + quantity, +}); + +/** Recipe editor (§6.3): components, total ml, target nicotine + live calculator. */ +export function MixModal({ open, onClose, mix, products }: MixModalProps) { + const create = useCreateMix(); + const update = useUpdateMix(); + const calculator = useMixCalculator(); + const toast = useToast(); + + const [name, setName] = useState(""); + const [totalMl, setTotalMl] = useState(""); + const [targetNicotine, setTargetNicotine] = useState(""); + const [note, setNote] = useState(""); + const [rows, setRows] = useState([newRow()]); + const [baseId, setBaseId] = useState(""); + const [boosterId, setBoosterId] = useState(""); + const [aromaId, setAromaId] = useState(""); + const [aromaPct, setAromaPct] = useState("10"); + const [error, setError] = useState(null); + const [formError, setFormError] = useState(null); + + const productsById = useMemo( + () => new Map(products.map((product) => [product.id, product])), + [products], + ); + const liquidProducts = useMemo( + () => products.filter((product) => ["base", "booster", "aroma"].includes(product.kind)), + [products], + ); + const byKind = (kind: string) => products.filter((product) => product.kind === kind); + + useEffect(() => { + if (!open) return; + setName(mix?.name ?? ""); + setTotalMl(mix ? String(num(mix.total_ml) ?? "") : ""); + setTargetNicotine(mix ? String(num(mix.target_nicotine_mg_ml) ?? "") : ""); + setNote(mix?.note ?? ""); + setRows( + mix && mix.components.length > 0 + ? mix.components.map((component) => + newRow(String(component.product_id), String(num(component.quantity) ?? "")), + ) + : [newRow()], + ); + setBaseId(String(byKind("base")[0]?.id ?? "")); + setBoosterId(String(byKind("booster")[0]?.id ?? "")); + setAromaId(String(byKind("aroma")[0]?.id ?? "")); + setAromaPct("10"); + setError(null); + setFormError(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, mix]); + + const totalMlValue = parseDecimalInput(totalMl); + const targetNicotineValue = parseDecimalInput(targetNicotine); + + const lines = useMemo( + () => + rows + .map((row) => ({ + product_id: Number(row.productId), + quantity: parseDecimalInput(row.quantity) ?? 0, + })) + .filter((line) => Number.isFinite(line.product_id) && line.product_id > 0 && line.quantity > 0), + [rows], + ); + + const localCostPerMl = recipeCostPerMl(lines, productsById, totalMlValue); + const localCostTotal = recipeCostTotal(lines, productsById); + const localNicotine = recipeNicotine(lines, productsById, totalMlValue); + + /* Live recipe assistant — POST /vape/mixes/calculator (debounced, stateless). */ + const { mutate: runCalculator, data: calculation, reset: resetCalculator } = calculator; + useEffect(() => { + if (!open) return; + if (totalMlValue === null || targetNicotineValue === null || baseId === "" || boosterId === "") { + return; + } + const timer = window.setTimeout(() => { + runCalculator({ + total_ml: totalMlValue, + target_nicotine_mg_ml: targetNicotineValue, + base_product_id: Number(baseId), + booster_product_id: Number(boosterId), + aroma_pct: parseDecimalInput(aromaPct) ?? 0, + aroma_product_id: aromaId === "" ? null : Number(aromaId), + }); + }, 400); + return () => window.clearTimeout(timer); + }, [open, totalMlValue, targetNicotineValue, baseId, boosterId, aromaId, aromaPct, runCalculator]); + + useEffect(() => { + if (!open) resetCalculator(); + }, [open, resetCalculator]); + + const applyCalculation = () => { + const baseMl = num(calculation?.base_ml); + const boosterMl = num(calculation?.booster_ml); + const aromaMl = num(calculation?.aroma_ml); + const next: ComponentRow[] = []; + if (baseId !== "" && baseMl !== null) next.push(newRow(baseId, formatQuantity(baseMl))); + if (boosterId !== "" && boosterMl !== null) { + next.push(newRow(boosterId, formatQuantity(boosterMl))); + } + if (aromaId !== "" && aromaMl !== null) next.push(newRow(aromaId, formatQuantity(aromaMl))); + if (next.length > 0) setRows(next); + }; + + const calculatedCostPerMl = num(calculation?.cost_per_ml); + const calculatedNicotine = num(calculation?.nicotine_check_mg_ml); + const nicotineForCheck = calculatedNicotine ?? localNicotine; + const mismatch = + targetNicotineValue !== null && + targetNicotineValue > 0 && + nicotineForCheck !== null && + Math.abs(nicotineForCheck - targetNicotineValue) / targetNicotineValue > 0.1; + + const pending = create.isPending || update.isPending; + + const submit = () => { + if (name.trim() === "") { + setFormError(S.misc.required); + return; + } + if (totalMlValue === null || totalMlValue <= 0 || targetNicotineValue === null) { + setFormError(S.misc.positiveNumber); + return; + } + if (lines.length === 0) { + setFormError(S.mixes.componentsRequired); + return; + } + setFormError(null); + setError(null); + const body = { + name: name.trim(), + total_ml: totalMlValue, + target_nicotine_mg_ml: targetNicotineValue, + note: note.trim() === "" ? null : note.trim(), + components: lines, + }; + const onError = (err: unknown) => + setError(err instanceof ApiError ? err.message : S.misc.loadingError); + const onSuccess = () => { + toast.success(S.mixes.saved); + onClose(); + }; + if (mix) update.mutate({ id: mix.id, body }, { onSuccess, onError }); + else create.mutate(body, { onSuccess, onError }); + }; + + return ( + + + + + } + > + {products.length === 0 ? ( +

    {S.mixes.noProductsHint}

    + ) : ( +
    +
    + setName(event.target.value)} + className="sm:col-span-3" + /> + setTotalMl(event.target.value)} + /> + setTargetNicotine(event.target.value)} + /> + setNote(event.target.value)} + /> +
    + + {/* Assistant de recette — calculateur serveur, ne persiste rien. */} +
    +

    {S.mixes.assistantTitle}

    +

    {S.mixes.assistantHelp}

    +
    + + + + setAromaPct(event.target.value)} + /> +
    + +
    +
    +
    {S.mixes.resultBase}
    +
    {formatOrDash(num(calculation?.base_ml))}
    +
    +
    +
    {S.mixes.resultBooster}
    +
    + {formatOrDash(num(calculation?.booster_ml))} +
    +
    +
    +
    {S.mixes.resultAroma}
    +
    {formatOrDash(num(calculation?.aroma_ml))}
    +
    +
    +
    {S.mixes.costPerMl}
    +
    + {calculatedCostPerMl === null + ? S.model.unknown + : `${formatEuroPerMl(calculatedCostPerMl)} / ml`} +
    +
    +
    + + {calculator.isError ? ( +

    + {calculator.error instanceof ApiError + ? calculator.error.message + : S.misc.loadingError} +

    + ) : null} + +
    + +
    +
    + + {/* Composants de la recette */} +
    +

    {S.mixes.componentsTitle}

    +
    + {rows.map((row, index) => ( +
    + + + setRows((prev) => + prev.map((item) => + item.key === row.key ? { ...item, quantity: event.target.value } : item, + ), + ) + } + className="w-28" + /> +
    + ))} +
    + + +
    +
    +
    {S.mixes.costTotal}
    +
    + {localCostTotal === null ? S.model.unknown : formatEuroAmount(localCostTotal)} +
    +
    +
    +
    {S.mixes.costPerMl}
    +
    + {localCostPerMl === null + ? S.model.unknown + : `${formatEuroPerMl(localCostPerMl)} / ml`} +
    +
    +
    +
    {S.mixes.nicotineCheck}
    +
    + {nicotineForCheck === null + ? S.model.unknown + : `${formatMg(nicotineForCheck)} / ml`} +
    +
    +
    + {mismatch ? ( +

    {S.mixes.nicotineMismatch}

    + ) : null} +
    + + {formError ?

    {formError}

    : null} + {error ?

    {error}

    : null} +
    + )} +
    + ); +} + +function formatQuantity(value: number): string { + return formatNumber(value, 1); +} + +function formatOrDash(value: number | null): string { + return value === null ? S.model.unknown : formatMl(value); +} diff --git a/apps/web/src/modules/vape/components/Odometer.tsx b/apps/web/src/modules/vape/components/Odometer.tsx new file mode 100644 index 0000000..faca766 --- /dev/null +++ b/apps/web/src/modules/vape/components/Odometer.tsx @@ -0,0 +1,46 @@ +import { useEffect, useRef, useState } from "react"; + +import { formatNumber } from "../../../lib/format"; + +export interface OdometerProps { + value: number | null; + /** Animation duration in ms (ignored when prefers-reduced-motion is set). */ + duration?: number; + decimals?: number; +} + +function prefersReducedMotion(): boolean { + return ( + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +/** Animated counter (ux-pages §12.1 « compteur animé ») — static when reduced motion. */ +export function Odometer({ value, duration = 900, decimals = 0 }: OdometerProps) { + const [display, setDisplay] = useState(value ?? 0); + const frameRef = useRef(null); + + useEffect(() => { + if (value === null) return; + if (prefersReducedMotion()) { + setDisplay(value); + return; + } + const start = performance.now(); + const from = 0; + const step = (now: number) => { + const ratio = Math.min(1, (now - start) / duration); + const eased = 1 - (1 - ratio) ** 3; + setDisplay(from + (value - from) * eased); + if (ratio < 1) frameRef.current = requestAnimationFrame(step); + }; + frameRef.current = requestAnimationFrame(step); + return () => { + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + }; + }, [value, duration]); + + if (value === null) return <>—; + return <>{formatNumber(display, decimals)}; +} diff --git a/apps/web/src/modules/vape/components/PeriodBar.tsx b/apps/web/src/modules/vape/components/PeriodBar.tsx new file mode 100644 index 0000000..8e13fc7 --- /dev/null +++ b/apps/web/src/modules/vape/components/PeriodBar.tsx @@ -0,0 +1,76 @@ +import { useCallback, useState } from "react"; +import type { ReactNode } from "react"; +import { useSearchParams } from "react-router-dom"; + +import { PeriodSelector } from "../../../components/PeriodSelector"; +import type { PeriodKey, PeriodRange } from "../../../components/PeriodSelector"; +import { daysAgoIso, todayIso } from "../../../lib/dates"; + +const PRESET_DAYS: Record, number> = { + "7j": 7, + "30j": 30, + "90j": 90, + "1an": 365, +}; + +const PRESET_LABELS: Record = { + "7j": "7 j", + "30j": "30 j", + "90j": "90 j", + "1an": "1 an", + tout: "Tout", + perso: "Personnalisé", +}; + +function initialRange(key: PeriodKey): PeriodRange { + if (key === "tout" || key === "perso") { + return { key: "tout", from: null, to: null, label: PRESET_LABELS.tout }; + } + return { + key, + from: daysAgoIso(PRESET_DAYS[key] - 1), + to: todayIso(), + label: PRESET_LABELS[key], + }; +} + +export interface PeriodControl { + range: PeriodRange; + /** Rendered segmented control (mount it in the page toolbar). */ + selector: ReactNode; + /** « Élargir la période » action of the empty chart state (ux-pages §16). */ + widen: () => void; +} + +/** + * Page period state on top of the shared PeriodSelector: keeps the resolved + * range and exposes a « Tout » widening action (remounts the selector so it + * re-reads the URL). + */ +export function usePeriodControl(defaultKey: PeriodKey = "30j"): PeriodControl { + const [range, setRange] = useState(() => initialRange(defaultKey)); + const [nonce, setNonce] = useState(0); + const [, setSearchParams] = useSearchParams(); + + const widen = useCallback(() => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set("periode", "tout"); + next.delete("du"); + next.delete("au"); + next.delete("period"); + return next; + }, + { replace: true }, + ); + setRange(initialRange("tout")); + setNonce((value) => value + 1); + }, [setSearchParams]); + + const selector = ( + + ); + + return { range, selector, widen }; +} diff --git a/apps/web/src/modules/vape/components/ProductModal.tsx b/apps/web/src/modules/vape/components/ProductModal.tsx new file mode 100644 index 0000000..c2a8526 --- /dev/null +++ b/apps/web/src/modules/vape/components/ProductModal.tsx @@ -0,0 +1,219 @@ +import { useEffect, useState } from "react"; + +import { ApiError } from "../../../lib/api"; +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { Select } from "../../../components/ui/Select"; +import { num, parseDecimalInput, useCreateProduct, useUpdateProduct } from "../api"; +import type { Product, ProductKind, SizeUnit } from "../api"; +import { S } from "../strings"; +import { useToast } from "./Toast"; + +const KINDS: ProductKind[] = ["coil", "base", "booster", "aroma", "pod", "hardware"]; +const UNITS: SizeUnit[] = ["ml", "unit", "g"]; + +export interface ProductModalProps { + open: boolean; + onClose: () => void; + product?: Product | null; +} + +/** Product catalogue CRUD form (résistances, bases, boosters, arômes — §6.2). */ +export function ProductModal({ open, onClose, product }: ProductModalProps) { + const create = useCreateProduct(); + const update = useUpdateProduct(); + const toast = useToast(); + + const [kind, setKind] = useState("base"); + const [name, setName] = useState(""); + const [brand, setBrand] = useState(""); + const [price, setPrice] = useState(""); + const [sizeValue, setSizeValue] = useState(""); + const [sizeUnit, setSizeUnit] = useState("ml"); + const [nicotine, setNicotine] = useState(""); + const [vg, setVg] = useState(""); + const [ohm, setOhm] = useState(""); + const [note, setNote] = useState(""); + const [archived, setArchived] = useState(false); + const [errors, setErrors] = useState>({}); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setKind(product?.kind ?? "base"); + setName(product?.name ?? ""); + setBrand(product?.brand ?? ""); + setPrice(product ? String(num(product.price) ?? "") : ""); + setSizeValue(product ? String(num(product.size_value) ?? "") : ""); + setSizeUnit(product?.size_unit ?? "ml"); + setNicotine(product ? String(num(product.nicotine_mg_ml) ?? "") : ""); + setVg(product ? String(num(product.vg_pct) ?? "") : ""); + setOhm(product ? String(num(product.ohm) ?? "") : ""); + setNote(product?.note ?? ""); + setArchived(product?.is_archived ?? false); + setErrors({}); + setError(null); + }, [open, product]); + + const pending = create.isPending || update.isPending; + const isCoil = kind === "coil" || kind === "pod"; + const isLiquid = kind === "base" || kind === "booster" || kind === "aroma"; + + const submit = () => { + const nextErrors: Record = {}; + if (name.trim() === "") nextErrors.name = S.misc.required; + const priceValue = parseDecimalInput(price); + if (priceValue === null || priceValue < 0) nextErrors.price = S.misc.invalidNumber; + const sizeValueNumber = parseDecimalInput(sizeValue); + if (sizeValueNumber === null || sizeValueNumber <= 0) nextErrors.size = S.misc.positiveNumber; + const nicotineValue = parseDecimalInput(nicotine); + if (kind === "booster" && nicotineValue === null) nextErrors.nicotine = S.misc.required; + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0 || priceValue === null || sizeValueNumber === null) return; + + setError(null); + const body = { + kind, + name: name.trim(), + brand: brand.trim() === "" ? null : brand.trim(), + price: priceValue, + size_value: sizeValueNumber, + size_unit: sizeUnit, + nicotine_mg_ml: kind === "booster" ? nicotineValue : parseDecimalInput(nicotine), + vg_pct: parseDecimalInput(vg), + ohm: parseDecimalInput(ohm), + is_archived: archived, + note: note.trim() === "" ? null : note.trim(), + }; + const onError = (err: unknown) => + setError(err instanceof ApiError ? err.message : S.misc.loadingError); + const onSuccess = () => { + toast.success(S.products.saved); + onClose(); + }; + if (product) update.mutate({ id: product.id, body }, { onSuccess, onError }); + else create.mutate(body, { onSuccess, onError }); + }; + + return ( + + + + + } + > +
    + + + setName(event.target.value)} + error={errors.name} + /> + + setBrand(event.target.value)} + /> + + setPrice(event.target.value)} + error={errors.price} + /> + + setSizeValue(event.target.value)} + error={errors.size} + /> + + + + {kind === "booster" ? ( + setNicotine(event.target.value)} + error={errors.nicotine} + /> + ) : null} + + {isLiquid ? ( + setVg(event.target.value)} + /> + ) : null} + + {isCoil ? ( + setOhm(event.target.value)} + /> + ) : null} + + setNote(event.target.value)} + className="sm:col-span-2" + /> + + + + {error ?

    {error}

    : null} +
    +
    + ); +} diff --git a/apps/web/src/modules/vape/components/PurchaseModal.tsx b/apps/web/src/modules/vape/components/PurchaseModal.tsx new file mode 100644 index 0000000..3366b50 --- /dev/null +++ b/apps/web/src/modules/vape/components/PurchaseModal.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from "react"; + +import { ApiError } from "../../../lib/api"; +import { todayIso } from "../../../lib/dates"; +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { Select } from "../../../components/ui/Select"; +import { num, parseDecimalInput, useCreatePurchase, useUpdatePurchase } from "../api"; +import type { Product, Purchase } from "../api"; +import { S } from "../strings"; +import { useToast } from "./Toast"; + +export interface PurchaseModalProps { + open: boolean; + onClose: () => void; + purchase?: Purchase | null; + products: Product[]; +} + +/** Real purchases log (§6.6) — feeds « économies réelles » and « coût réel/jour ». */ +export function PurchaseModal({ open, onClose, purchase, products }: PurchaseModalProps) { + const create = useCreatePurchase(); + const update = useUpdatePurchase(); + const toast = useToast(); + + const [date, setDate] = useState(todayIso()); + const [productId, setProductId] = useState(""); + const [qty, setQty] = useState("1"); + const [unitPriceValue, setUnitPriceValue] = useState(""); + const [note, setNote] = useState(""); + const [errors, setErrors] = useState>({}); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setDate(purchase?.purchased_on ?? todayIso()); + setProductId(purchase ? String(purchase.product_id) : String(products[0]?.id ?? "")); + setQty(purchase ? String(num(purchase.qty) ?? 1) : "1"); + setUnitPriceValue(purchase ? String(num(purchase.unit_price) ?? "") : ""); + setNote(purchase?.note ?? ""); + setErrors({}); + setError(null); + }, [open, purchase, products]); + + const pending = create.isPending || update.isPending; + + const submit = () => { + const nextErrors: Record = {}; + if (productId === "") nextErrors.product = S.misc.required; + const qtyValue = parseDecimalInput(qty); + if (qtyValue === null || qtyValue <= 0) nextErrors.qty = S.misc.positiveNumber; + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0 || qtyValue === null) return; + + setError(null); + const body = { + purchased_on: date, + product_id: Number(productId), + qty: qtyValue, + unit_price: parseDecimalInput(unitPriceValue), + note: note.trim() === "" ? null : note.trim(), + }; + const onError = (err: unknown) => + setError(err instanceof ApiError ? err.message : S.misc.loadingError); + const onSuccess = () => { + toast.success(S.purchases.saved); + onClose(); + }; + if (purchase) update.mutate({ id: purchase.id, body }, { onSuccess, onError }); + else create.mutate(body, { onSuccess, onError }); + }; + + return ( + + + + + } + > +
    + setDate(event.target.value)} + /> + + setQty(event.target.value)} + error={errors.qty} + /> + setUnitPriceValue(event.target.value)} + /> + setNote(event.target.value)} + /> + {error ?

    {error}

    : null} +
    +
    + ); +} diff --git a/apps/web/src/modules/vape/components/RefillModal.tsx b/apps/web/src/modules/vape/components/RefillModal.tsx new file mode 100644 index 0000000..30a1fcc --- /dev/null +++ b/apps/web/src/modules/vape/components/RefillModal.tsx @@ -0,0 +1,200 @@ +import clsx from "clsx"; +import { useEffect, useState } from "react"; + +import { ApiError } from "../../../lib/api"; +import { todayIso } from "../../../lib/dates"; +import { formatMl } from "../../../lib/format"; +import { Button } from "../../../components/ui/Button"; +import { Input } from "../../../components/ui/Input"; +import { Modal } from "../../../components/ui/Modal"; +import { num, parseDecimalInput, useCreateLiquidEntry, useUpdateLiquidEntry } from "../api"; +import type { LiquidEntry, LiquidKind, Mix, VapeSettings } from "../api"; +import { S } from "../strings"; +import { useToast } from "./Toast"; + +const QUICK_ML = [10, 30, 50]; +const MAX_ML = 100; + +export interface RefillModalProps { + open: boolean; + onClose: () => void; + settings?: VapeSettings; + activeMix?: Mix; + /** Editing an existing entry (otherwise quick-add). */ + entry?: LiquidEntry | null; +} + +/** Quick-add « Recharge » (ux-pages §12.5) with the refill / daily-total variants. */ +export function RefillModal({ open, onClose, settings, activeMix, entry }: RefillModalProps) { + const create = useCreateLiquidEntry(); + const update = useUpdateLiquidEntry(); + const toast = useToast(); + + // No bottle-size preference exists in the data model: fall back to the + // active recipe's batch size, then to the usual 10 ml bottle. + const defaultMl = num(activeMix?.total_ml) ?? 10; + const defaultNicotine = + num(activeMix?.target_nicotine_mg_ml) ?? num(settings?.default_nicotine_mg_ml); + + const [kind, setKind] = useState("refill"); + const [ml, setMl] = useState(""); + const [nicotine, setNicotine] = useState(""); + const [date, setDate] = useState(todayIso()); + const [note, setNote] = useState(""); + const [error, setError] = useState(null); + const [mlError, setMlError] = useState(null); + + useEffect(() => { + if (!open) return; + setKind(entry?.kind ?? "refill"); + setMl(String(entry ? (num(entry.ml) ?? defaultMl) : defaultMl).replace(".", ",")); + const nic = entry ? num(entry.nicotine_mg_ml) : null; + setNicotine(String(nic ?? defaultNicotine ?? "").replace(".", ",")); + setDate(entry?.entry_date ?? todayIso()); + setNote(entry?.note ?? ""); + setError(null); + setMlError(null); + }, [open, entry, defaultMl, defaultNicotine]); + + const pending = create.isPending || update.isPending; + + const submit = () => { + const mlValue = parseDecimalInput(ml); + if (mlValue === null || mlValue <= 0) { + setMlError(S.misc.positiveNumber); + return; + } + if (mlValue > MAX_ML) { + setMlError(S.misc.maxMl(formatMl(MAX_ML))); + return; + } + setMlError(null); + setError(null); + const body = { + entry_date: date, + kind, + ml: mlValue, + nicotine_mg_ml: parseDecimalInput(nicotine), + note: note.trim() === "" ? null : note.trim(), + }; + const onError = (err: unknown) => { + setError(err instanceof ApiError ? err.message : S.misc.loadingError); + }; + if (entry) { + update.mutate( + { id: entry.id, body }, + { + onSuccess: () => { + toast.success(S.refill.updated); + onClose(); + }, + onError, + }, + ); + return; + } + create.mutate(body, { + onSuccess: () => { + toast.success( + kind === "refill" + ? S.refill.saved(formatMl(mlValue)) + : S.refill.savedDaily(formatMl(mlValue)), + ); + onClose(); + }, + onError, + }); + }; + + const kindButton = (value: LiquidKind, label: string) => ( + + ); + + return ( + + + + + } + > +
    +
    +

    {S.refill.kindLabel}

    +
    + {kindButton("refill", S.refill.kindRefill)} + {kindButton("daily_total", S.refill.kindDailyTotal)} +
    +

    + {kind === "refill" ? S.refill.kindRefillHelp : S.refill.kindDailyTotalHelp} +

    +
    + +
    + setMl(event.target.value)} + error={mlError ?? undefined} + /> +
    + {QUICK_ML.map((value) => ( + + ))} +
    +
    + + setNicotine(event.target.value)} + /> + + setDate(event.target.value)} + /> + + setNote(event.target.value)} + /> + + {error ?

    {error}

    : null} +
    +
    + ); +} diff --git a/apps/web/src/modules/vape/components/Toast.tsx b/apps/web/src/modules/vape/components/Toast.tsx new file mode 100644 index 0000000..17ac7d7 --- /dev/null +++ b/apps/web/src/modules/vape/components/Toast.tsx @@ -0,0 +1,117 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { createPortal } from "react-dom"; + +/** Toast (ux-pages §5.7): bottom-right on desktop, top on mobile, auto-dismiss. */ +export interface ToastAction { + label: string; + onClick: () => void; +} + +export interface ToastOptions { + tone?: "success" | "error"; + action?: ToastAction; + /** Auto-dismiss delay in ms (default 5000, matching the undo window). */ + duration?: number; +} + +interface ToastItem extends ToastOptions { + id: number; + message: string; +} + +interface ToastApi { + success: (message: string, options?: Omit) => void; + error: (message: string, options?: Omit) => void; +} + +const ToastContext = createContext(null); + +/** Module-scoped toast provider — mounted once by the vape layout. */ +export function ToastProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState([]); + const nextId = useRef(1); + const timers = useRef([]); + + const dismiss = useCallback((id: number) => { + setItems((prev) => prev.filter((item) => item.id !== id)); + }, []); + + const push = useCallback( + (message: string, options: ToastOptions) => { + const id = nextId.current++; + setItems((prev) => [...prev, { ...options, id, message }]); + const timer = window.setTimeout(() => dismiss(id), options.duration ?? 5000); + timers.current.push(timer); + }, + [dismiss], + ); + + useEffect( + () => () => { + for (const timer of timers.current) window.clearTimeout(timer); + }, + [], + ); + + const api = useMemo( + () => ({ + success: (message, options) => push(message, { ...options, tone: "success" }), + error: (message, options) => push(message, { ...options, tone: "error" }), + }), + [push], + ); + + return ( + + {children} + {createPortal( +
    + {items.map((item) => ( +
    + {item.message} +
    + {item.action ? ( + + ) : null} + +
    +
    + ))} +
    , + document.body, + )} +
    + ); +} + +/** Access the vape toasts (no-op outside the provider). */ +export function useToast(): ToastApi { + const context = useContext(ToastContext); + return context ?? { success: () => undefined, error: () => undefined }; +} diff --git a/apps/web/src/modules/vape/costs.ts b/apps/web/src/modules/vape/costs.ts new file mode 100644 index 0000000..bc1a95e --- /dev/null +++ b/apps/web/src/modules/vape/costs.ts @@ -0,0 +1,128 @@ +/** + * Pure cost helpers of the vape module (docs/design/datamodel-health-vape.md + * §7.1–§7.5). They mirror the server formulas so the recipe editor and the + * « Modèle de coût » card can display live values without a round-trip. + */ +import { formatNumber } from "../../lib/format"; +import { num } from "./api"; +import type { Product, ProductKind } from "./api"; + +const NNBSP = " "; // espace fine insécable, avant le symbole monétaire + +/** + * Prix unitaire d'un liquide, en € avec 3 décimales : une recette DIY revient à + * ~0,069 €/ml, que `formatEuroAmount` (2 décimales) écraserait en « 0,07 € » — + * et chaque poste du détail (base, booster, arôme) en « 0,01 € ». + */ +export function formatEuroPerMl(euros: number): string { + return `${formatNumber(euros, 3)}${NNBSP}€`; +} + +/** €/ml (liquids) or €/unit (coils) — hybrid property price / size_value. */ +export function unitPrice(product: Product | undefined): number | null { + if (!product) return null; + const echoed = num(product.unit_price); + if (echoed !== null) return echoed; + const price = num(product.price); + const size = num(product.size_value); + if (price === null || size === null || size === 0) return null; + return price / size; +} + +export interface RecipeLine { + product_id: number; + quantity: number; +} + +/** cost_total = Σ quantity × (price / size_value). */ +export function recipeCostTotal( + lines: RecipeLine[], + productsById: Map, +): number | null { + let total = 0; + let counted = 0; + for (const line of lines) { + const price = unitPrice(productsById.get(line.product_id)); + if (price === null) continue; + total += line.quantity * price; + counted += 1; + } + return counted === 0 ? null : total; +} + +/** cost_per_ml = cost_total / total_ml. */ +export function recipeCostPerMl( + lines: RecipeLine[], + productsById: Map, + totalMl: number | null, +): number | null { + const total = recipeCostTotal(lines, productsById); + if (total === null || totalMl === null || totalMl <= 0) return null; + return total / totalMl; +} + +/** nicotine_check = Σ (qty_booster × booster.nicotine_mg_ml) / total_ml. */ +export function recipeNicotine( + lines: RecipeLine[], + productsById: Map, + totalMl: number | null, +): number | null { + if (totalMl === null || totalMl <= 0) return null; + let mg = 0; + for (const line of lines) { + const product = productsById.get(line.product_id); + const rate = num(product?.nicotine_mg_ml); + if (product?.kind !== "booster" || rate === null) continue; + mg += line.quantity * rate; + } + return mg / totalMl; +} + +/** €/ml split by product kind — feeds the « Modèle de coût » breakdown (§12.4). */ +export function costBreakdownPerMl( + lines: RecipeLine[], + productsById: Map, + totalMl: number | null, +): Partial> { + if (totalMl === null || totalMl <= 0) return {}; + const out: Partial> = {}; + for (const line of lines) { + const product = productsById.get(line.product_id); + const price = unitPrice(product); + if (!product || price === null) continue; + out[product.kind] = (out[product.kind] ?? 0) + (line.quantity * price) / totalMl; + } + return out; +} + +/** cig_cost_per_day = cigs_per_day_before / cigs_per_pack × cig_pack_price (§7.5). */ +export function tobaccoCostPerDay( + cigsPerDay: number | null, + cigsPerPack: number | null, + packPrice: number | null, +): number | null { + if (cigsPerDay === null || cigsPerPack === null || packPrice === null || cigsPerPack <= 0) { + return null; + } + return (cigsPerDay / cigsPerPack) * packPrice; +} + +/** Simple trailing moving average over a value series (nulls are skipped). */ +export function movingAverage( + points: [string, number | null][], + window: number, +): [string, number | null][] { + const out: [string, number | null][] = []; + const buffer: number[] = []; + for (const [day, value] of points) { + if (value !== null) { + buffer.push(value); + if (buffer.length > window) buffer.shift(); + } + out.push([ + day, + buffer.length === 0 ? null : buffer.reduce((sum, item) => sum + item, 0) / buffer.length, + ]); + } + return out; +} diff --git a/apps/web/src/modules/vape/index.ts b/apps/web/src/modules/vape/index.ts new file mode 100644 index 0000000..14c5c0c --- /dev/null +++ b/apps/web/src/modules/vape/index.ts @@ -0,0 +1,34 @@ +import { CigaretteOff } from "lucide-react"; +import { createElement, lazy } from "react"; + +import type { ModuleManifest } from "../../types/module"; +import { S } from "./strings"; + +// Pages are lazy-loaded (CONVENTIONS C5.1/C5.7); index.ts stays JSX-free. +const VapeLayout = lazy(() => import("./pages/VapeLayout")); +const ConsumptionPage = lazy(() => import("./pages/ConsumptionPage")); +const CostsPage = lazy(() => import("./pages/CostsPage")); +const CoilsPage = lazy(() => import("./pages/CoilsPage")); +const SavingsPage = lazy(() => import("./pages/SavingsPage")); + +/** Vape / sevrage tabac module — auto-discovered by src/app/modules.ts. */ +const manifest: ModuleManifest = { + id: "vape", + title: S.title, + order: 20, + routes: [ + { + path: "/vape", + element: createElement(VapeLayout), + children: [ + { index: true, element: createElement(ConsumptionPage) }, + { path: "couts", element: createElement(CostsPage) }, + { path: "resistances", element: createElement(CoilsPage) }, + { path: "economies", element: createElement(SavingsPage) }, + ], + }, + ], + nav: [{ path: "/vape", label: S.title, icon: CigaretteOff, order: 20 }], +}; + +export default manifest; diff --git a/apps/web/src/modules/vape/pages/CoilsPage.tsx b/apps/web/src/modules/vape/pages/CoilsPage.tsx new file mode 100644 index 0000000..ec6d784 --- /dev/null +++ b/apps/web/src/modules/vape/pages/CoilsPage.tsx @@ -0,0 +1,296 @@ +import { Pencil, Trash2, Wrench } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { ChartCard } from "../../../components/charts/ChartCard"; +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { ConfirmDialog } from "../../../components/ui/ConfirmDialog"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { StatCard } from "../../../components/ui/StatCard"; +import { Table } from "../../../components/ui/Table"; +import type { TableColumn } from "../../../components/ui/Table"; +import { ApiError } from "../../../lib/api"; +import { toIsoDate } from "../../../lib/dates"; +import { formatDateTime, formatMl, formatNumber } from "../../../lib/format"; +import { + itemsOf, + num, + pointsOf, + useCoilChanges, + useCoilStats, + useDeleteCoilChange, + useProducts, +} from "../api"; +import type { CoilChange, Product } from "../api"; +import { buildCoilLifespanOption } from "../charts"; +import type { CoilBar } from "../charts"; +import { CoilChangeButton } from "../components/CoilChangeButton"; +import { CoilDetailModal } from "../components/CoilDetailModal"; +import { useToast } from "../components/Toast"; +import { daysLabel, S } from "../strings"; + +const DAY_MS = 86_400_000; + +interface CoilRow { + coil: CoilChange; + days: number | null; + ml: number | null; + removedAt: string | null; + productLabel: string | null; + current: boolean; +} + +function productLabelOf(product: Product | undefined): string | null { + if (!product) return null; + const ohm = num(product.ohm); + const base = product.brand ? `${product.brand} — ${product.name}` : product.name; + return ohm === null ? base : `${base} (${formatNumber(ohm, 2)} Ω)`; +} + +/** « Résistances » tab: one-click change, lifespan chart and cycle history. */ +export default function CoilsPage() { + const toast = useToast(); + const [edited, setEdited] = useState(null); + const [deleted, setDeleted] = useState(null); + + const coils = useCoilChanges({ limit: 200 }); + const stats = useCoilStats({}); + const products = useProducts({}); + const remove = useDeleteCoilChange(); + + const productsById = useMemo( + () => new Map(itemsOf(products.data).map((product) => [product.id, product])), + [products.data], + ); + const coilProducts = itemsOf(products.data).filter( + (product) => product.kind === "coil" || product.kind === "pod", + ); + + const statsByDay = useMemo( + () => new Map(pointsOf(stats.data, "lifespan_days")), + [stats.data], + ); + + /** Cycles sorted by fitting date (ascending) with their derived lifespan. */ + const rows = useMemo(() => { + const sorted = [...itemsOf(coils.data)].sort( + (a, b) => new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime(), + ); + return sorted.map((coil, index) => { + const next = sorted[index + 1]; + const day = toIsoDate(new Date(coil.changed_at)); + const computed = next + ? (new Date(next.changed_at).getTime() - new Date(coil.changed_at).getTime()) / DAY_MS + : (Date.now() - new Date(coil.changed_at).getTime()) / DAY_MS; + const days = num(coil.lifespan_days) ?? statsByDay.get(day) ?? round1(computed); + return { + coil, + days, + ml: num(coil.ml_through), + removedAt: coil.removed_at ?? next?.changed_at ?? null, + productLabel: productLabelOf( + coil.product_id ? productsById.get(coil.product_id) : undefined, + ), + current: !next, + }; + }); + }, [coils.data, productsById, statsByDay]); + + const lastRow: CoilRow | undefined = rows[rows.length - 1]; + const finished = rows.filter((row) => !row.current); + const metaAvg = num(stats.data?.meta?.avg_lifespan_days); + const computedAvg = + finished.length > 0 + ? finished.reduce((sum, row) => sum + (row.days ?? 0), 0) / finished.length + : null; + const avgLifespan = metaAvg ?? computedAvg; + const avgMl = num(stats.data?.meta?.avg_ml_through_coil); + const currentAge = num(stats.data?.meta?.current_coil_age_days) ?? lastRow?.days ?? null; + const insufficient = stats.data?.meta?.status === "insufficient_data" || finished.length < 1; + + const bars = useMemo( + () => + rows.map((row) => ({ + day: toIsoDate(new Date(row.coil.changed_at)), + days: row.days, + ml: row.ml, + productLabel: row.productLabel, + current: row.current, + })), + [rows], + ); + const option = useMemo( + () => buildCoilLifespanOption(bars, avgLifespan), + [bars, avgLifespan], + ); + + const columns: TableColumn[] = [ + { + key: "changed_at", + header: S.tables.placedOn, + sortable: true, + sortValue: (row) => row.coil.changed_at, + render: (row) => formatDateTime(row.coil.changed_at), + }, + { + key: "removed_at", + header: S.tables.removedOn, + render: (row) => + row.current ? ( + {S.coils.inProgress} + ) : row.removedAt ? ( + formatDateTime(row.removedAt) + ) : ( + S.model.unknown + ), + }, + { + key: "days", + header: S.tables.duration, + align: "right", + sortable: true, + sortValue: (row) => row.days, + render: (row) => (row.days === null ? S.model.unknown : daysLabel(row.days)), + }, + { + key: "ml", + header: S.tables.consumedMl, + align: "right", + render: (row) => (row.ml === null ? S.model.unknown : formatMl(row.ml)), + }, + { + key: "model", + header: S.tables.model, + render: (row) => row.productLabel ?? row.coil.reason ?? "", + }, + { + key: "actions", + header: S.tables.actions, + align: "right", + render: (row) => ( +
    +
    + ), + }, + ]; + + if (coils.isLoading) return ; + if (coils.isError) { + return ( +

    + {coils.error instanceof ApiError ? coils.error.message : S.misc.loadingError} +

    + ); + } + + const lastChangedAt = lastRow?.coil.changed_at ?? null; + + return ( +
    + +
    +
    +

    {S.coils.bigButtonTitle}

    +

    {S.coils.bigButtonHelp}

    +
    + +
    +
    + +
    + + + +
    + + + + +
    row.coil.id} + className="px-1 pb-4 pt-2" + empty={ + } + /> + } + /> + + + setEdited(null)} + coil={edited} + products={coilProducts} + /> + setDeleted(null)} + onConfirm={() => { + if (!deleted) return; + remove.mutate(deleted.id, { + onSuccess: () => { + toast.success(S.coils.deleted); + setDeleted(null); + }, + onError: (error: unknown) => + toast.error(error instanceof ApiError ? error.message : S.misc.loadingError), + }); + }} + /> + + ); +} + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} diff --git a/apps/web/src/modules/vape/pages/ConsumptionPage.tsx b/apps/web/src/modules/vape/pages/ConsumptionPage.tsx new file mode 100644 index 0000000..f26ec44 --- /dev/null +++ b/apps/web/src/modules/vape/pages/ConsumptionPage.tsx @@ -0,0 +1,524 @@ +import { Droplets, Pencil, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { ChartCard } from "../../../components/charts/ChartCard"; +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { ConfirmDialog } from "../../../components/ui/ConfirmDialog"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { StatCard } from "../../../components/ui/StatCard"; +import { Table } from "../../../components/ui/Table"; +import type { TableColumn } from "../../../components/ui/Table"; +import { ApiError } from "../../../lib/api"; +import { diffDays, toIsoDate, todayIso } from "../../../lib/dates"; +import { + formatDate, + formatEuroAmount, + formatMg, + formatMl, + formatNumber, + formatPercent, +} from "../../../lib/format"; +import { + itemsOf, + num, + pointsOf, + totalOf, + useCoilChanges, + useCoilStats, + useConsumptionStats, + useCostStats, + useDeleteLiquidEntry, + useLiquidEntries, + useMixes, + useNicotineStats, + useProducts, + useSavingsStats, + useVapeDashboard, + useVapeSettings, +} from "../api"; +import type { LiquidEntry } from "../api"; +import { buildConsumptionOption, buildNicotineOption, hasPoints } from "../charts"; +import { CoilChangeButton } from "../components/CoilChangeButton"; +import { usePeriodControl } from "../components/PeriodBar"; +import { RefillModal } from "../components/RefillModal"; +import { useToast } from "../components/Toast"; +import { tobaccoCostPerDay } from "../costs"; +import { daysLabel, S } from "../strings"; + +const PAGE_SIZE = 20; + +/** Coil changes fetched for the chart markers — server cap (`page_size` ≤ 200). */ +const COIL_MARKERS_LIMIT = 200; + +/** Vape dashboard — consumption KPIs, ml/day and nicotine charts, refills log. */ +export default function ConsumptionPage() { + const { range, selector, widen } = usePeriodControl("30j"); + const period = { from: range.from, to: range.to }; + const toast = useToast(); + + const [page, setPage] = useState(1); + const [refillOpen, setRefillOpen] = useState(false); + const [edited, setEdited] = useState(null); + const [deleted, setDeleted] = useState(null); + + useEffect(() => setPage(1), [range.from, range.to]); + + const settings = useVapeSettings(); + const dashboard = useVapeDashboard(); + const consumption = useConsumptionStats(period); + const nicotine = useNicotineStats(period); + const costs = useCostStats(period); + const savings = useSavingsStats({}); + const coilStats = useCoilStats({}); + // Explicit page size: the coil markers of the ml/day chart must cover the + // whole period, not just the first page (server default = 50, cap = 200). + const coils = useCoilChanges({ ...period, limit: COIL_MARKERS_LIMIT }); + const mixes = useMixes(); + const products = useProducts({}); + const entries = useLiquidEntries({ + ...period, + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + }); + const remove = useDeleteLiquidEntry(); + + const rawPoints = pointsOf(consumption.data, "ml"); + const ma7Points = pointsOf(consumption.data, "ml_ma7"); + const nicotinePoints = pointsOf(nicotine.data, "nicotine_mg"); + const coilDays = useMemo( + () => itemsOf(coils.data).map((coil) => toIsoDate(new Date(coil.changed_at))), + [coils.data], + ); + /** Most recent coil change, whatever the list ordering — closes that cycle. */ + const lastCoilChangedAt = useMemo(() => { + let latest: string | null = null; + for (const coil of itemsOf(coils.data)) { + if (latest === null || new Date(coil.changed_at).getTime() > new Date(latest).getTime()) { + latest = coil.changed_at; + } + } + return latest; + }, [coils.data]); + const mlByDay = useMemo(() => { + const map = new Map(); + for (const [day, value] of rawPoints) if (value !== null) map.set(day, value); + return map; + }, [rawPoints]); + + const activeMix = itemsOf(mixes.data).find((mix) => mix.is_active); + const coilProducts = itemsOf(products.data).filter( + (product) => product.kind === "coil" || product.kind === "pod", + ); + const entryItems = itemsOf(entries.data); + + /* ------------------------------ KPI values ------------------------------ */ + const today = todayIso(); + const mlToday = num(dashboard.data?.ml_today) ?? mlByDay.get(today) ?? null; + const avg7 = num(consumption.data?.meta?.ml_per_day_7); + const trackedRatio = num(consumption.data?.meta?.tracked_days_ratio); + const nicotineToday = + num(dashboard.data?.nicotine_mg_today) ?? + nicotinePoints.find(([day]) => day === today)?.[1] ?? + null; + const nicotineRate = + mlToday !== null && mlToday > 0 && nicotineToday !== null + ? nicotineToday / mlToday + : (num(activeMix?.target_nicotine_mg_ml) ?? num(settings.data?.default_nicotine_mg_ml)); + const costPerMl = num(costs.data?.meta?.cost_per_ml); + const vapeCostPerDay = num(costs.data?.meta?.vape_cost_per_day); + const coilCostPerDay = num(costs.data?.meta?.coil_cost_per_day); + const cigCostPerDay = + num(savings.data?.meta?.cig_cost_per_day) ?? + tobaccoCostPerDay( + num(settings.data?.cigs_per_day_before), + settings.data?.cigs_per_pack ?? null, + num(settings.data?.cig_pack_price), + ); + const savingsTotal = + num(dashboard.data?.cumulative_savings) ?? + lastValue(pointsOf(savings.data, "savings_real")) ?? + lastValue(pointsOf(savings.data, "savings_theoretical")); + const daysSinceQuit = + num(savings.data?.meta?.days_since_quit) ?? + (settings.data?.quit_date ? Math.max(0, diffDays(settings.data.quit_date, today)) : null); + const cigsPerDayBefore = num(settings.data?.cigs_per_day_before); + const cigarettesAvoided = + num(savings.data?.meta?.cigarettes_avoided) ?? + (daysSinceQuit !== null && cigsPerDayBefore !== null + ? Math.floor(daysSinceQuit * cigsPerDayBefore) + : null); + const coilAge = num(coilStats.data?.meta?.current_coil_age_days); + const avgLifespan = num(coilStats.data?.meta?.avg_lifespan_days); + const coilTone = + coilAge !== null && avgLifespan !== null + ? coilAge > avgLifespan + ? "negative" + : coilAge >= avgLifespan - 2 + ? "warning" + : "neutral" + : "neutral"; + + /* ------------------------------ Charts ---------------------------------- */ + const consumptionOption = useMemo( + () => + buildConsumptionOption({ + raw: rawPoints, + ma7: ma7Points, + coilDays, + // No ml/day reduction target exists in the data model (§6.1). + targetMlPerDay: null, + }), + [rawPoints, ma7Points, coilDays], + ); + const nicotineOption = useMemo( + () => buildNicotineOption(nicotinePoints, mlByDay), + [nicotinePoints, mlByDay], + ); + + const nicotineTrend = nicotineTrendLabel(nicotine.data?.meta?.status); + + const columns: TableColumn[] = [ + { + key: "entry_date", + header: S.tables.date, + sortable: true, + render: (row) => formatDate(row.entry_date), + }, + { + key: "kind", + header: S.tables.kind, + render: (row) => ( + + {S.liquidKinds[row.kind] ?? row.kind} + + ), + }, + { + key: "ml", + header: S.tables.quantity, + align: "right", + sortable: true, + sortValue: (row) => num(row.ml), + render: (row) => { + const value = num(row.ml); + return value === null ? S.model.unknown : formatMl(value); + }, + }, + { + key: "nicotine_mg_ml", + header: S.tables.nicotine, + align: "right", + render: (row) => { + const value = num(row.nicotine_mg_ml) ?? nicotineRate; + return value === null ? S.model.unknown : `${formatMg(value)}/ml`; + }, + }, + { + key: "cost", + header: S.tables.estimatedCost, + align: "right", + render: (row) => { + const ml = num(row.ml); + return ml === null || costPerMl === null + ? S.model.unknown + : formatEuroAmount(ml * costPerMl); + }, + }, + { key: "note", header: S.tables.note, render: (row) => row.note ?? "" }, + { + key: "actions", + header: S.tables.actions, + align: "right", + render: (row) => ( +
    +
    + ), + }, + ]; + + const toolbar = ( +
    + {selector} +
    + + +
    +
    + ); + + const modals = ( + <> + { + setRefillOpen(false); + setEdited(null); + }} + settings={settings.data} + activeMix={activeMix} + entry={edited} + /> + setDeleted(null)} + onConfirm={() => { + if (!deleted) return; + remove.mutate(deleted.id, { + onSuccess: () => { + toast.success(S.refill.deleted); + setDeleted(null); + }, + onError: (error: unknown) => + toast.error(error instanceof ApiError ? error.message : S.misc.loadingError), + }); + }} + /> + + ); + + if (consumption.isLoading) { + return ( +
    + {toolbar} + +
    + ); + } + + if (consumption.isError) { + return ( +
    + {toolbar} +

    + {consumption.error instanceof ApiError + ? consumption.error.message + : S.misc.loadingError} +

    +
    + ); + } + + if (!hasPoints(rawPoints) && entryItems.length === 0) { + return ( +
    + {toolbar} + + + + + + } + /> + + {modals} +
    + ); + } + + return ( +
    + {toolbar} + +
    + + + + + + + + {trackedRatio !== null ? ( + + ) : null} +
    + + + {S.actions.widenPeriod} + + ) + } + /> + + + {S.actions.widenPeriod} + + ) + } + /> + + +
    row.id} + className="px-1 pb-4 pt-2" + empty={ + { + setEdited(null); + setRefillOpen(true); + }} + > + {S.actions.addRefill} + + } + /> + } + pagination={{ + page, + pageSize: PAGE_SIZE, + total: totalOf(entries.data), + onPageChange: setPage, + }} + /> + + + {modals} + + ); +} + +function lastValue(points: [string, number | null][]): number | null { + for (let index = points.length - 1; index >= 0; index -= 1) { + const value = points[index][1]; + if (value !== null) return value; + } + return null; +} + +function nicotineTrendLabel(status: unknown): string | null { + if (status === "down") return S.misc.trendDown; + if (status === "stable") return S.misc.trendStable; + if (status === "up") return S.misc.trendUp; + return null; +} diff --git a/apps/web/src/modules/vape/pages/CostsPage.tsx b/apps/web/src/modules/vape/pages/CostsPage.tsx new file mode 100644 index 0000000..c3f27db --- /dev/null +++ b/apps/web/src/modules/vape/pages/CostsPage.tsx @@ -0,0 +1,700 @@ +import { Beaker, Check, Package, Pencil, ShoppingCart, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { ChartCard } from "../../../components/charts/ChartCard"; +import { Badge } from "../../../components/ui/Badge"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { ConfirmDialog } from "../../../components/ui/ConfirmDialog"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { Select } from "../../../components/ui/Select"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { Table } from "../../../components/ui/Table"; +import type { TableColumn } from "../../../components/ui/Table"; +import { ApiError } from "../../../lib/api"; +import { formatDate, formatEuroAmount, formatMg, formatMl, formatNumber } from "../../../lib/format"; +import { + itemsOf, + num, + pointsOf, + totalOf, + useActivateMix, + useCoilStats, + useConsumptionStats, + useCostStats, + useDeleteMix, + useDeleteProduct, + useDeletePurchase, + useMixes, + useProducts, + usePurchases, + useVapeSettings, +} from "../api"; +import type { Mix, Product, ProductKind, Purchase } from "../api"; +import { buildDailyCostOption, buildMonthlyCostOption, hasPoints } from "../charts"; +import { MixModal } from "../components/MixModal"; +import { usePeriodControl } from "../components/PeriodBar"; +import { ProductModal } from "../components/ProductModal"; +import { PurchaseModal } from "../components/PurchaseModal"; +import { useToast } from "../components/Toast"; +import { + costBreakdownPerMl, + formatEuroPerMl, + movingAverage, + tobaccoCostPerDay, + unitPrice, +} from "../costs"; +import { S } from "../strings"; + +const KIND_FILTERS: ProductKind[] = ["coil", "base", "booster", "aroma", "pod", "hardware"]; +const PAGE_SIZE = 20; + +/** « Coûts & modèle » tab: cost model, products, recipes and purchases. */ +export default function CostsPage() { + const { range, selector, widen } = usePeriodControl("30j"); + const period = { from: range.from, to: range.to }; + const navigate = useNavigate(); + const toast = useToast(); + + const [kindFilter, setKindFilter] = useState<"" | ProductKind>(""); + const [includeArchived, setIncludeArchived] = useState(false); + const [modelOpen, setModelOpen] = useState(true); + const [purchasePage, setPurchasePage] = useState(1); + + const [productModal, setProductModal] = useState<{ open: boolean; product: Product | null }>({ + open: false, + product: null, + }); + const [mixModal, setMixModal] = useState<{ open: boolean; mix: Mix | null }>({ + open: false, + mix: null, + }); + const [purchaseModal, setPurchaseModal] = useState<{ open: boolean; purchase: Purchase | null }>({ + open: false, + purchase: null, + }); + const [deletedProduct, setDeletedProduct] = useState(null); + const [deletedMix, setDeletedMix] = useState(null); + const [deletedPurchase, setDeletedPurchase] = useState(null); + + const settings = useVapeSettings(); + const costs = useCostStats(period); + const consumption = useConsumptionStats(period); + const coilStats = useCoilStats({}); + const mixes = useMixes(); + const allProducts = useProducts({}); + const products = useProducts({ + kind: kindFilter === "" ? null : kindFilter, + include_archived: includeArchived, + }); + const purchases = usePurchases({ + ...period, + limit: PAGE_SIZE, + offset: (purchasePage - 1) * PAGE_SIZE, + }); + + const activate = useActivateMix(); + const removeProduct = useDeleteProduct(); + const removeMix = useDeleteMix(); + const removePurchase = useDeletePurchase(); + + const productList = itemsOf(products.data); + const catalogue = itemsOf(allProducts.data); + const productsById = useMemo( + () => new Map(catalogue.map((product) => [product.id, product])), + [catalogue], + ); + const mixList = itemsOf(mixes.data); + const activeMix = mixList.find((mix) => mix.is_active); + + /* --------------------------- Cost model (§12.4) -------------------------- */ + const costPerMl = num(costs.data?.meta?.cost_per_ml) ?? num(activeMix?.cost_per_ml); + const coilCostPerDay = num(costs.data?.meta?.coil_cost_per_day); + const vapeCostPerDay = num(costs.data?.meta?.vape_cost_per_day); + const realCostPerDay = num(costs.data?.meta?.real_cost_per_day); + const cigCostPerDay = tobaccoCostPerDay( + num(settings.data?.cigs_per_day_before), + settings.data?.cigs_per_pack ?? null, + num(settings.data?.cig_pack_price), + ); + const breakdown = useMemo(() => { + if (!activeMix) return {}; + const lines = activeMix.components.map((component) => ({ + product_id: component.product_id, + quantity: num(component.quantity) ?? 0, + })); + return costBreakdownPerMl(lines, productsById, num(activeMix.total_ml)); + }, [activeMix, productsById]); + + const avgLifespan = num(coilStats.data?.meta?.avg_lifespan_days); + const currentCoilProduct = coilStats.data?.meta?.current_coil_product_id + ? productsById.get(coilStats.data.meta.current_coil_product_id) + : undefined; + const coilUnitPrice = unitPrice(currentCoilProduct); + + /* ------------------------------- Charts --------------------------------- */ + const mlPoints = pointsOf(consumption.data, "ml"); + const dailyCostPoints = useMemo<[string, number | null][]>( + () => + mlPoints.map(([day, value]) => [ + day, + value === null || costPerMl === null ? null : value * costPerMl + (coilCostPerDay ?? 0), + ]), + [mlPoints, costPerMl, coilCostPerDay], + ); + const dailyCostOption = useMemo( + () => + buildDailyCostOption({ + raw: dailyCostPoints, + ma7: movingAverage(dailyCostPoints, 7), + tobaccoPerDay: cigCostPerDay, + }), + [dailyCostPoints, cigCostPerDay], + ); + const monthlyOption = useMemo( + () => + buildMonthlyCostOption( + pointsOf(costs.data, "theoretical_cost"), + pointsOf(costs.data, "real_spend"), + ), + [costs.data], + ); + + /* ------------------------------- Tables --------------------------------- */ + const productColumns: TableColumn[] = [ + { + key: "kind", + header: S.tables.kind, + render: (row) => {S.productKinds[row.kind] ?? row.kind}, + }, + { + key: "name", + header: S.tables.name, + sortable: true, + render: (row) => ( + + {row.name} + {row.is_archived ? {S.tables.archived} : null} + + ), + }, + { key: "brand", header: S.tables.brand, render: (row) => row.brand ?? "" }, + { + key: "price", + header: S.tables.price, + align: "right", + sortable: true, + sortValue: (row) => num(row.price), + render: (row) => { + const value = num(row.price); + return value === null ? S.model.unknown : formatEuroAmount(value); + }, + }, + { + key: "size_value", + header: S.tables.size, + align: "right", + render: (row) => { + const value = num(row.size_value); + return value === null + ? S.model.unknown + : `${formatNumber(value, value % 1 === 0 ? 0 : 1)} ${S.sizeUnitsShort[row.size_unit] ?? ""}`; + }, + }, + { + key: "unit_price", + header: S.tables.unitPrice, + align: "right", + render: (row) => { + const value = unitPrice(row); + if (value === null) return S.model.unknown; + // « ml » : quelques centimes le millilitre, 3 décimales indispensables + const amount = row.size_unit === "ml" ? formatEuroPerMl(value) : formatEuroAmount(value); + return `${amount} / ${S.sizeUnitsShort[row.size_unit] ?? ""}`; + }, + }, + { + key: "actions", + header: S.tables.actions, + align: "right", + render: (row) => ( +
    +
    + ), + }, + ]; + + const mixColumns: TableColumn[] = [ + { + key: "name", + header: S.tables.name, + sortable: true, + render: (row) => ( + + {row.name} + {row.is_active ? {S.mixes.activeBadge} : null} + + ), + }, + { + key: "total_ml", + header: S.mixes.totalMlLabel, + align: "right", + render: (row) => { + const value = num(row.total_ml); + return value === null ? S.model.unknown : formatMl(value); + }, + }, + { + key: "target_nicotine_mg_ml", + header: S.mixes.targetNicotineLabel, + align: "right", + render: (row) => { + const value = num(row.target_nicotine_mg_ml); + return value === null ? S.model.unknown : `${formatMg(value)}/ml`; + }, + }, + { + key: "cost_per_ml", + header: S.mixes.costPerMl, + align: "right", + render: (row) => { + const value = num(row.cost_per_ml); + return value === null ? S.model.unknown : `${formatEuroPerMl(value)} / ml`; + }, + }, + { + key: "nicotine_check_mg_ml", + header: S.mixes.nicotineCheck, + align: "right", + render: (row) => { + const value = num(row.nicotine_check_mg_ml); + return value === null ? S.model.unknown : `${formatMg(value)}/ml`; + }, + }, + { + key: "actions", + header: S.tables.actions, + align: "right", + render: (row) => ( +
    + {row.is_active ? null : ( + + )} +
    + ), + }, + ]; + + const purchaseColumns: TableColumn[] = [ + { + key: "purchased_on", + header: S.tables.date, + sortable: true, + render: (row) => formatDate(row.purchased_on), + }, + { + key: "product_id", + header: S.tables.product, + render: (row) => productsById.get(row.product_id)?.name ?? S.model.unknown, + }, + { + key: "qty", + header: S.tables.qty, + align: "right", + render: (row) => { + const value = num(row.qty); + return value === null ? S.model.unknown : formatNumber(value, value % 1 === 0 ? 0 : 2); + }, + }, + { + key: "unit_price", + header: S.tables.unitPrice, + align: "right", + render: (row) => { + const value = num(row.unit_price) ?? num(productsById.get(row.product_id)?.price); + return value === null ? S.model.unknown : formatEuroAmount(value); + }, + }, + { + key: "total", + header: S.tables.total, + align: "right", + render: (row) => { + const explicit = num(row.total); + if (explicit !== null) return formatEuroAmount(explicit); + const qty = num(row.qty); + const price = num(row.unit_price) ?? num(productsById.get(row.product_id)?.price); + return qty === null || price === null ? S.model.unknown : formatEuroAmount(qty * price); + }, + }, + { + key: "actions", + header: S.tables.actions, + align: "right", + render: (row) => ( +
    +
    + ), + }, + ]; + + if (costs.isLoading && mixes.isLoading && products.isLoading) return ; + + const modelLine = (label: string, value: string) => ( +
    + {label} + {value} +
    + ); + + return ( +
    +
    + {selector} +
    + + + +
    +
    + + {/* Modèle de coût (repliable) */} + + + +
    + } + > + {modelOpen ? ( +
    + {activeMix ? null :

    {S.model.noActiveMix}

    } + {(Object.keys(breakdown) as ProductKind[]).map((kind) => + modelLine( + S.productKinds[kind] ?? kind, + `${formatEuroPerMl(breakdown[kind] ?? 0)} / ml`, + ), + )} + {modelLine( + S.kpi.costPerMl, + costPerMl === null ? S.model.unknown : `${formatEuroPerMl(costPerMl)} / ml`, + )} + {modelLine( + S.model.coilLine, + coilCostPerDay === null + ? S.model.unknown + : `${ + coilUnitPrice !== null && avgLifespan !== null + ? `${formatEuroAmount(coilUnitPrice)} ÷ ${formatNumber(avgLifespan, 0)} j = ` + : "" + }${formatEuroAmount(coilCostPerDay)} / j`, + )} + {modelLine( + S.model.vapeLine, + vapeCostPerDay === null ? S.model.unknown : `${formatEuroAmount(vapeCostPerDay)} / j`, + )} + {modelLine( + S.kpi.realCostPerDay, + realCostPerDay === null ? S.model.unknown : `${formatEuroAmount(realCostPerDay)} / j`, + )} + {modelLine( + S.model.tobaccoLine, + cigCostPerDay === null ? S.model.unknown : `${formatEuroAmount(cigCostPerDay)} / j`, + )} +
    + ) : null} + + + + {S.actions.widenPeriod} + + ) + } + /> + + + + +
    row.id} + className="px-1 pb-4 pt-2" + empty={ + setMixModal({ open: true, mix: null })}> + {S.actions.addMix} + + } + /> + } + /> + + + + + + + } + noPadding + > +
    row.id} + className="px-1 pb-4 pt-2" + empty={ + setProductModal({ open: true, product: null })}> + {S.actions.addProduct} + + ) : ( + + ) + } + /> + } + /> + + + +
    row.id} + className="px-1 pb-4 pt-2" + empty={ + setPurchaseModal({ open: true, purchase: null })}> + {S.actions.addPurchase} + + } + /> + } + pagination={{ + page: purchasePage, + pageSize: PAGE_SIZE, + total: totalOf(purchases.data), + onPageChange: setPurchasePage, + }} + /> + + + setProductModal({ open: false, product: null })} + product={productModal.product} + /> + setMixModal({ open: false, mix: null })} + mix={mixModal.mix} + products={catalogue} + /> + setPurchaseModal({ open: false, purchase: null })} + purchase={purchaseModal.purchase} + products={catalogue} + /> + + setDeletedProduct(null)} + onConfirm={() => { + if (!deletedProduct) return; + removeProduct.mutate(deletedProduct.id, { + onSuccess: () => { + toast.success(S.products.deleted); + setDeletedProduct(null); + }, + onError: (error: unknown) => + toast.error(error instanceof ApiError ? error.message : S.misc.loadingError), + }); + }} + /> + setDeletedMix(null)} + onConfirm={() => { + if (!deletedMix) return; + removeMix.mutate(deletedMix.id, { + onSuccess: () => { + toast.success(S.mixes.deleted); + setDeletedMix(null); + }, + onError: (error: unknown) => + toast.error(error instanceof ApiError ? error.message : S.misc.loadingError), + }); + }} + /> + setDeletedPurchase(null)} + onConfirm={() => { + if (!deletedPurchase) return; + removePurchase.mutate(deletedPurchase.id, { + onSuccess: () => { + toast.success(S.purchases.deleted); + setDeletedPurchase(null); + }, + onError: (error: unknown) => + toast.error(error instanceof ApiError ? error.message : S.misc.loadingError), + }); + }} + /> + + ); +} diff --git a/apps/web/src/modules/vape/pages/SavingsPage.tsx b/apps/web/src/modules/vape/pages/SavingsPage.tsx new file mode 100644 index 0000000..e3a01e5 --- /dev/null +++ b/apps/web/src/modules/vape/pages/SavingsPage.tsx @@ -0,0 +1,193 @@ +import { PiggyBank } from "lucide-react"; +import { useMemo } from "react"; + +import { ChartCard } from "../../../components/charts/ChartCard"; +import { Button } from "../../../components/ui/Button"; +import { Card } from "../../../components/ui/Card"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { StatCard } from "../../../components/ui/StatCard"; +import { ApiError } from "../../../lib/api"; +import { diffDays, todayIso } from "../../../lib/dates"; +import { formatDate, formatEuroAmount, formatNumber } from "../../../lib/format"; +import { + itemsOf, + num, + pointsOf, + useCostStats, + useMilestones, + useSavingsStats, + useVapeSettings, +} from "../api"; +import { buildCostComparisonOption, buildSavingsOption, hasPoints } from "../charts"; +import { MilestoneTimeline } from "../components/MilestoneTimeline"; +import { Odometer } from "../components/Odometer"; +import { usePeriodControl } from "../components/PeriodBar"; +import { tobaccoCostPerDay } from "../costs"; +import { daysLabel, minutesLabel, S } from "../strings"; + +/** « Économies » tab: cumulative savings, avoided cigarettes and health milestones. */ +export default function SavingsPage() { + const { range, selector, widen } = usePeriodControl("tout"); + const period = { from: range.from, to: range.to }; + + const settings = useVapeSettings(); + const savings = useSavingsStats(period); + const costs = useCostStats(period); + const milestones = useMilestones(); + + const theoretical = pointsOf(savings.data, "savings_theoretical"); + const real = pointsOf(savings.data, "savings_real"); + + const meta = savings.data?.meta; + const cigCostPerDay = + num(meta?.cig_cost_per_day) ?? + tobaccoCostPerDay( + num(settings.data?.cigs_per_day_before), + settings.data?.cigs_per_pack ?? null, + num(settings.data?.cig_pack_price), + ); + const vapeCostPerDay = num(costs.data?.meta?.vape_cost_per_day); + const savingsPerDay = + num(meta?.savings_per_day) ?? + (cigCostPerDay !== null && vapeCostPerDay !== null ? cigCostPerDay - vapeCostPerDay : null); + const daysSinceQuit = + num(meta?.days_since_quit) ?? + (settings.data?.quit_date ? Math.max(0, diffDays(settings.data.quit_date, todayIso())) : null); + const cigsPerDayBefore = num(settings.data?.cigs_per_day_before); + const cigarettesAvoided = + num(meta?.cigarettes_avoided) ?? + (daysSinceQuit !== null && cigsPerDayBefore !== null + ? Math.floor(daysSinceQuit * cigsPerDayBefore) + : null); + const packsAvoided = + num(meta?.packs_avoided) ?? + (cigarettesAvoided !== null && settings.data?.cigs_per_pack + ? cigarettesAvoided / settings.data.cigs_per_pack + : null); + const timeRegained = num(meta?.time_regained_minutes); + const cumulative = lastValue(real) ?? lastValue(theoretical); + + const savingsOption = useMemo( + () => buildSavingsOption(theoretical, real), + [theoretical, real], + ); + const comparisonOption = useMemo( + () => buildCostComparisonOption(vapeCostPerDay, cigCostPerDay), + [vapeCostPerDay, cigCostPerDay], + ); + + if (savings.isLoading) { + return ( +
    +
    {selector}
    + +
    + ); + } + + if (savings.isError) { + return ( +
    +
    {selector}
    +

    + {savings.error instanceof ApiError ? savings.error.message : S.misc.loadingError} +

    +
    + ); + } + + return ( +
    +
    {selector}
    + +
    + + + } + sub={packsAvoided === null ? undefined : S.misc.packs(formatNumber(packsAvoided, 1))} + /> + + +
    + + + {S.actions.widenPeriod} + + ) + } + /> + +
    + + + + {milestones.isLoading ? ( + + ) : milestones.isError ? ( +

    + {milestones.error instanceof ApiError + ? milestones.error.message + : S.misc.loadingError} +

    + ) : ( + + )} +
    +
    + + +

    + + {S.misc.theoreticalHint} +

    +
    +
    + ); +} + +function lastValue(points: [string, number | null][]): number | null { + for (let index = points.length - 1; index >= 0; index -= 1) { + const value = points[index][1]; + if (value !== null) return value; + } + return null; +} diff --git a/apps/web/src/modules/vape/pages/VapeLayout.tsx b/apps/web/src/modules/vape/pages/VapeLayout.tsx new file mode 100644 index 0000000..7278a1c --- /dev/null +++ b/apps/web/src/modules/vape/pages/VapeLayout.tsx @@ -0,0 +1,68 @@ +import { CigaretteOff } from "lucide-react"; +import { Suspense } from "react"; +import { Outlet, useNavigate } from "react-router-dom"; + +import { ApiError, isNotFoundError } from "../../../lib/api"; +import { Button } from "../../../components/ui/Button"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { CenteredSpinner } from "../../../components/ui/Spinner"; +import { Tabs } from "../../../components/ui/Tabs"; +import { useVapeSettings } from "../api"; +import { ToastProvider } from "../components/Toast"; +import { S } from "../strings"; + +const TABS = [ + { id: "conso", label: S.tabs.consumption, to: "/vape" }, + { id: "couts", label: S.tabs.costs, to: "/vape/couts" }, + { id: "resistances", label: S.tabs.coils, to: "/vape/resistances" }, + { id: "economies", label: S.tabs.savings, to: "/vape/economies" }, +]; + +/** Réglages → onglet « Vape » (ux-pages §15.4) : cible des CTA de configuration. */ +const SETTINGS_VAPE = "/reglages?tab=vape"; + +/** + * Vape shell: sub-navigation tabs + « module non configuré » gate. + * The page title is rendered by the app topbar (nav label). + * + * On a brand-new account GET /vape/settings answers 404 (`setup_required`), as + * do /vape/dashboard, /vape/stats/savings and /vape/milestones: the gate below + * keeps those pages out of the tree entirely, so no child ever renders an error + * screen before the module is configured (ux-pages §16). + */ +export default function VapeLayout() { + const navigate = useNavigate(); + const settings = useVapeSettings(); + const notConfigured = isNotFoundError(settings.error); + + let content = ; + if (settings.isLoading) { + content = ; + } else if (notConfigured) { + content = ( + navigate(SETTINGS_VAPE)}>{S.actions.configure} + } + /> + ); + } else if (settings.isError) { + content = ( +

    + {settings.error instanceof ApiError ? settings.error.message : S.misc.loadingError} +

    + ); + } + + return ( + +
    + + }>{content} +
    +
    + ); +} diff --git a/apps/web/src/modules/vape/strings.ts b/apps/web/src/modules/vape/strings.ts new file mode 100644 index 0000000..30b858d --- /dev/null +++ b/apps/web/src/modules/vape/strings.ts @@ -0,0 +1,369 @@ +/** + * French UI strings of the vape module (CONVENTIONS C5.3). + * Typography: U+00A0 before « : ; ! ? » and inside guillemets, + * U+202F for thin no-break spaces (units are handled by lib/format). + */ +import { formatDuration, formatNumber } from "../../lib/format"; + +const NB = " "; // espace insécable +const NNB = " "; // espace fine insécable + +export const S = { + title: "Vape", + tabs: { + consumption: "Consommation", + costs: "Coûts & modèle", + coils: "Résistances", + savings: "Économies", + }, + + /* ---------------- KPI ---------------- */ + kpi: { + today: "Aujourd'hui", + nicotineToday: "Nicotine aujourd'hui", + costPerDay: "Coût moyen / jour", + savings: "Économies cumulées", + cigarettesAvoided: "Cigarettes évitées", + smokeFreeSince: "Sans tabac depuis", + currentCoil: "Résistance actuelle", + avgLifespan: "Durée de vie moyenne", + avgMlPerCoil: "Volume moyen par résistance", + savingsPerDay: "Économies par jour", + timeRegained: "Temps de vie récupéré", + realCostPerDay: "Dépenses réelles / jour", + costPerMl: `Coût de revient${NB}/${NB}ml`, + }, + + /* ---------------- Charts ---------------- */ + charts: { + consumption: "Consommation d'e-liquide", + consumptionMl: "ml / jour", + consumptionMa7: `Moyenne mobile 7${NNB}j`, + consumptionTarget: "Objectif", + coilChangedMark: "Résistance changée", + nicotine: "Nicotine absorbée", + nicotineSeries: "mg / jour", + dailyCost: "Coût quotidien", + dailyCostSeries: "Vape (€ / jour)", + dailyCostMa7: `Moyenne mobile 7${NNB}j`, + tobaccoReference: "Tabac", + monthlyCost: "Coût mensuel : théorique et dépenses réelles", + theoreticalCost: "Coût théorique", + realSpend: "Dépenses réelles", + coilLifespan: "Durée de vie des résistances", + coilLifespanSeries: "Durée (jours)", + coilCurrent: "En cours", + average: "Moyenne", + savings: "Économies cumulées", + savingsTheoretical: "Économies théoriques", + savingsReal: "Économies réelles", + comparison: "Coût quotidien : vape et tabac", + comparisonSeries: "€ / jour", + comparisonVape: "Vape", + comparisonTobacco: "Tabac évité", + }, + + /* ---------------- Actions ---------------- */ + actions: { + addRefill: "+ Recharge", + coilChanged: "Résistance changée", + addDetail: "Ajouter un détail", + addProduct: "+ Produit", + addMix: "+ Recette", + addPurchase: "+ Achat", + activate: "Activer", + edit: "Modifier", + remove: "Supprimer", + cancel: "Annuler", + save: "Enregistrer", + close: "Fermer", + configure: "Configurer la vape", + editModel: "Modifier le modèle", + widenPeriod: "Élargir la période", + resetFilters: "Réinitialiser les filtres", + applyToRecipe: "Reporter dans la recette", + addComponent: "+ Composant", + showModel: "Afficher le détail", + hideModel: "Masquer le détail", + }, + + /* ---------------- Empty states (ux-pages §16, textes exacts) ---------------- */ + empty: { + notConfiguredTitle: "Module vape non configuré", + notConfiguredText: `Renseignez votre consommation de cigarettes avant l'arrêt et votre modèle de coût${NB}: LifeTrack calculera vos économies au centime près.`, + noEntriesTitle: "Aucune recharge enregistrée", + noEntriesText: "Enregistrez votre première recharge d'e-liquide — deux clics suffisent.", + noResultTitle: "Aucun résultat", + noResultText: "Aucune ligne ne correspond à ces filtres.", + chartNoData: "Pas de données sur cette période", + noProductsTitle: "Aucun produit au catalogue", + noProductsText: + "Ajoutez vos bases, boosters, arômes et résistances pour calculer votre coût de revient.", + noMixesTitle: "Aucune recette enregistrée", + noMixesText: + "Créez une recette DIY : LifeTrack en déduit votre coût par millilitre et votre coût par jour.", + noPurchasesTitle: "Aucun achat enregistré", + noPurchasesText: "Enregistrez vos achats pour suivre vos économies réelles, au centime près.", + noCoilsTitle: "Aucun changement de résistance", + noCoilsText: + "Enregistrez un premier changement : la durée de vie moyenne se calcule dès le deuxième.", + noMilestones: "Les jalons santé apparaîtront dès que votre date d'arrêt sera renseignée.", + }, + + /* ---------------- Refill modal (§12.5) ---------------- */ + refill: { + title: "Recharge", + kindLabel: "Type de saisie", + kindRefill: "Recharge", + kindDailyTotal: "Conso du jour", + kindRefillHelp: + "Chaque remplissage du réservoir. La consommation du jour est la somme des recharges.", + kindDailyTotalHelp: + "Total consommé sur la journée. Il remplace la somme des recharges de ce jour (une seule saisie par jour).", + ml: "Quantité (ml)", + nicotine: "Taux de nicotine (mg/ml)", + date: "Date", + note: "Note", + notePlaceholder: "Arôme, recette, remarque…", + saved: (ml: string) => `Recharge de ${ml} enregistrée ✓`, + savedDaily: (ml: string) => `Consommation du jour fixée à ${ml} ✓`, + updated: "Saisie modifiée ✓", + deleted: "Saisie supprimée ✓", + confirmDelete: "Supprimer cette saisie de consommation ?", + confirmDeleteText: "Cette action est irréversible.", + }, + + /* ---------------- Coils (§12.6) ---------------- */ + coils: { + bigButtonTitle: "Vous venez de changer de résistance ?", + bigButtonHelp: + "Un clic enregistre le changement à l'instant présent et clôture le cycle précédent.", + changedToast: "Résistance changée ✓", + changedToastWithLifespan: (days: string) => + `Résistance changée ✓ — la précédente a duré ${days}`, + detailTitle: "Détail du changement", + productLabel: "Modèle de résistance", + productNone: "— Aucun —", + reasonLabel: "Motif", + reasonPlaceholder: "Goût de brûlé, préventif…", + noteLabel: "Note", + changedAtLabel: "Date et heure de pose", + detailSaved: "Détail enregistré ✓", + deleted: "Changement supprimé ✓", + confirmDelete: "Supprimer ce changement de résistance ?", + confirmDeleteText: "La durée de vie des cycles voisins sera recalculée.", + historyTitle: "Historique des résistances", + inProgress: "En cours", + estimation: "estimation", + insufficientData: `La durée de vie moyenne se calcule à partir de deux changements${NB}; en attendant, LifeTrack utilise une estimation de 14${NNB}jours.`, + }, + + /* ---------------- Tables ---------------- */ + tables: { + refillsTitle: "Historique des recharges", + productsTitle: "Catalogue produits", + mixesTitle: "Recettes DIY", + purchasesTitle: "Achats", + date: "Date", + kind: "Type", + quantity: "Quantité", + nicotine: "Nicotine", + estimatedCost: "Coût estimé", + note: "Note", + actions: "Actions", + name: "Nom", + brand: "Marque", + price: "Prix", + size: "Contenance", + unitPrice: "Prix unitaire", + ohm: "Ohms", + placedOn: "Posée le", + removedOn: "Retirée le", + duration: "Durée", + consumedMl: "Conso (ml)", + model: "Modèle", + product: "Produit", + qty: "Qté", + total: "Total", + archived: "Archivé", + }, + + /* ---------------- Products / mixes / purchases ---------------- */ + products: { + title: "Catalogue produits", + subtitle: "Résistances, bases, boosters et arômes utilisés dans vos recettes.", + newTitle: "Nouveau produit", + editTitle: "Modifier le produit", + kindLabel: "Type de produit", + nameLabel: "Nom", + brandLabel: "Marque", + priceLabel: "Prix du conditionnement (€)", + sizeValueLabel: "Contenance", + sizeUnitLabel: "Unité", + nicotineLabel: "Concentration (mg/ml)", + vgLabel: "Taux de VG (%)", + ohmLabel: "Résistance (Ω)", + archivedLabel: "Archiver ce produit", + noteLabel: "Note", + filterKind: "Filtrer par type", + filterAll: "Tous les types", + includeArchived: "Afficher les produits archivés", + saved: "Produit enregistré ✓", + deleted: "Produit supprimé ✓", + confirmDelete: "Supprimer ce produit ?", + confirmDeleteText: + "Si le produit est utilisé dans une recette ou un achat, préférez l'archivage.", + }, + mixes: { + title: "Recettes DIY", + subtitle: "La recette active fournit le coût par millilitre et le taux de nicotine par défaut.", + newTitle: "Nouvelle recette", + editTitle: "Modifier la recette", + nameLabel: "Nom de la recette", + totalMlLabel: "Volume total (ml)", + targetNicotineLabel: "Nicotine cible (mg/ml)", + noteLabel: "Note", + componentsTitle: "Composants", + componentProduct: "Produit", + componentQuantity: "Quantité (ml)", + activeBadge: "Active", + activated: "Recette activée ✓", + saved: "Recette enregistrée ✓", + deleted: "Recette supprimée ✓", + confirmDelete: "Supprimer cette recette ?", + confirmDeleteText: "Les consommations déjà enregistrées ne sont pas supprimées.", + costPerMl: "Coût par ml", + costTotal: "Coût total", + nicotineCheck: "Nicotine calculée", + nicotineMismatch: `L'écart avec la nicotine cible dépasse 10${NNB}%${NB}: vérifiez les quantités.`, + assistantTitle: "Assistant de recette", + assistantHelp: + "Choisissez une base, un booster et un arôme : LifeTrack calcule les quantités et le coût sans rien enregistrer.", + baseProduct: "Base PG/VG", + boosterProduct: "Booster de nicotine", + aromaProduct: "Arôme", + aromaPct: "Dosage de l'arôme (%)", + resultBase: "Base", + resultBooster: "Booster", + resultAroma: "Arôme", + componentsRequired: "Ajoutez au moins un composant.", + noProductsHint: "Ajoutez d'abord des produits au catalogue pour composer une recette.", + }, + purchases: { + title: "Achats", + subtitle: "Vos dépenses réelles alimentent les économies réelles et le coût réel par jour.", + newTitle: "Nouvel achat", + editTitle: "Modifier l'achat", + dateLabel: "Date d'achat", + productLabel: "Produit", + qtyLabel: "Nombre de conditionnements", + unitPriceLabel: "Prix payé par conditionnement (€)", + unitPriceHint: "Laissez vide pour reprendre le prix catalogue.", + noteLabel: "Note", + saved: "Achat enregistré ✓", + deleted: "Achat supprimé ✓", + confirmDelete: "Supprimer cet achat ?", + confirmDeleteText: "Cette action est irréversible.", + }, + + /* ---------------- Cost model card (§12.4) ---------------- */ + model: { + title: "Modèle de coût", + subtitle: "Décomposition du coût de revient à partir de la recette active.", + noActiveMix: "Aucune recette active : activez une recette pour afficher le coût par ml.", + perMl: "€ / ml", + perDay: "€ / jour", + coilLine: "Résistance", + tobaccoLine: "Référence tabac", + vapeLine: "Coût vape estimé", + unknown: "—", + }, + + /* ---------------- Milestones (§12.3) ---------------- */ + milestones: { + title: "Jalons santé", + subtitle: "Repères de récupération depuis votre date d'arrêt.", + reachedOn: (date: string) => `Atteint le ${date}`, + expectedOn: (date: string) => `Prévu le ${date}`, + next: "Prochain jalon", + labels: { + hr_bp_normal: "Fréquence cardiaque et tension redescendent", + co_halved: "Le monoxyde de carbone sanguin diminue de moitié", + co_normal: "Monoxyde de carbone éliminé ; les poumons évacuent les résidus", + nicotine_out: "Plus de nicotine dans le corps ; goût et odorat s'améliorent", + breathing_easier: "Respiration plus facile, énergie en hausse", + circulation: "Circulation sanguine améliorée", + lung_function: `Fonction pulmonaire améliorée jusqu'à +30${NNB}%`, + cilia_recovery: "Cils bronchiques régénérés ; toux et essoufflement diminuent", + chd_risk_half: "Risque de maladie coronarienne réduit de moitié", + stroke_risk_normal: "Risque d'AVC ramené à celui d'un non-fumeur", + lung_cancer_half: "Risque de cancer du poumon réduit de moitié", + chd_risk_normal: "Risque coronarien équivalent à celui d'un non-fumeur", + } as Record, + }, + + /* ---------------- Énumérations ---------------- */ + productKinds: { + coil: "Résistance", + base: "Base PG/VG", + booster: "Booster de nicotine", + aroma: "Arôme", + hardware: "Matériel", + pod: "Pod", + } as Record, + sizeUnits: { + ml: "millilitres (ml)", + unit: "unités", + g: "grammes (g)", + } as Record, + sizeUnitsShort: { ml: "ml", unit: "u.", g: "g" } as Record, + liquidKinds: { + refill: "Recharge", + daily_total: "Conso du jour", + } as Record, + + /* ---------------- Divers ---------------- */ + misc: { + days: (n: string) => `${n}${NNB}jours`, + day: (n: string) => `${n}${NNB}jour`, + perDay: "/ jour", + since: (date: string) => `depuis le ${date}`, + avg7: (v: string) => `Moyenne 7${NNB}j${NB}: ${v}/j`, + avg30: (v: string) => `Moyenne 30${NNB}j${NB}: ${v}/j`, + coilShare: (v: string) => `dont résistances ${v}/j`, + vsTobacco: (v: string) => `vs ${v}/j de tabac`, + perDayRate: (v: string) => `≈ ${v}/j`, + coilAge: (n: string) => `J+${n}`, + coilAvg: (v: string) => `Moyenne${NB}: ${v}`, + coilWatch: "à surveiller", + coilOverdue: "au-delà de la moyenne", + trackedRatio: (v: string) => `Jours suivis${NB}: ${v}`, + trendDown: "En baisse", + trendStable: "Stable", + trendUp: "En hausse", + cigEquivalent: (v: string) => `≈ ${v} cigarettes en nicotine (estimation indicative)`, + packs: (v: string) => `${v} paquets évités`, + timeRegainedHint: `Estimation indicative${NB}: 11${NNB}min de vie par cigarette.`, + estimatedCostHint: "Coût estimé à partir de la recette active.", + theoreticalHint: + "Les économies théoriques utilisent le prix du paquet actuel ; les économies réelles se basent sur vos achats.", + loadingError: "Une erreur est survenue.", + required: "Ce champ est obligatoire.", + invalidNumber: "Valeur numérique invalide.", + maxMl: (max: string) => `Le volume doit rester inférieur à ${max}.`, + positiveNumber: "La valeur doit être supérieure à zéro.", + }, +} as const; + +export type VapeStrings = typeof S; + +/** « 1 jour » / « 12 jours » — French singular/plural of a day count. */ +export function daysLabel(value: number, decimals = 0): string { + const formatted = formatNumber(value, decimals); + return Math.abs(value) < 2 ? S.misc.day(formatted) : S.misc.days(formatted); +} + +/** Long durations expressed in minutes are rendered in days above 24 h. */ +export function minutesLabel(minutes: number): string { + return minutes >= 1440 ? daysLabel(minutes / 1440) : formatDuration(minutes); +} diff --git a/apps/web/src/styles/index.css b/apps/web/src/styles/index.css new file mode 100644 index 0000000..137dd40 --- /dev/null +++ b/apps/web/src/styles/index.css @@ -0,0 +1,50 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root[data-theme="dark"] { + color-scheme: dark; +} + +@layer base { + html, + body, + #root { + height: 100%; + } + + body { + @apply bg-base font-sans text-ink antialiased; + } + + /* Thin scrollbars (dark theme) */ + * { + scrollbar-width: thin; + scrollbar-color: #383835 transparent; + } + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + *::-webkit-scrollbar-track { + background: transparent; + } + *::-webkit-scrollbar-thumb { + background-color: #383835; + border-radius: 4px; + } + *::-webkit-scrollbar-thumb:hover { + background-color: #4a4a47; + } + + ::selection { + background-color: rgba(57, 135, 229, 0.35); + } + + /* Native date/select controls follow the dark scheme */ + input, + select, + textarea { + color-scheme: dark; + } +} diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts new file mode 100644 index 0000000..618c263 --- /dev/null +++ b/apps/web/src/types/api.ts @@ -0,0 +1,16 @@ +/** Paginated list response — mirrors app.core.pagination.Page[T] on the API. */ +export interface Page { + items: T[]; + total: number; + page: number; + page_size: number; +} + +/** Unified API error body: {"error": {"code", "message", "details"}}. */ +export interface ApiErrorShape { + error: { + code: string; + message: string; + details: Record; + }; +} diff --git a/apps/web/src/types/module.ts b/apps/web/src/types/module.ts new file mode 100644 index 0000000..3fe5e39 --- /dev/null +++ b/apps/web/src/types/module.ts @@ -0,0 +1,24 @@ +import type { LucideIcon } from "lucide-react"; +import type { RouteObject } from "react-router-dom"; + +/** One sidebar entry contributed by a module. */ +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 +} + +/** + * Frontend module contract. Every folder src/modules// must have an + * index.ts whose DEFAULT export is a ModuleManifest — it is auto-discovered + * by src/app/modules.ts (import.meta.glob). Nobody edits shared files. + */ +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) +} diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 0000000..5f3dd8f --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -0,0 +1,49 @@ +import type { Config } from "tailwindcss"; + +// Dark-theme tokens from docs/design/ux-pages.md §4.1 (normative — do not change hex values). +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + darkMode: "class", + theme: { + extend: { + colors: { + base: "#0D0D0D", // page background + surface: "#1A1A19", // card / chart background + "surface-2": "#242423", // hover, nested elements, inputs + border: "rgba(255,255,255,0.10)", // hairline border + ink: { + DEFAULT: "#FFFFFF", // text-primary + secondary: "#C3C2B7", + muted: "#898781", // axis labels, placeholders + }, + grid: "#2C2C2A", // chart grid lines + axis: "#383835", // baseline / axis + accent: "#3987E5", + semantic: { + positive: "#0CA30C", + negative: "#D03B3B", + warning: "#FAB219", + serious: "#EC835A", + }, + chart: { + 1: "#3987E5", + 2: "#D95926", + 3: "#199E70", + 4: "#C98500", + 5: "#D55181", + 6: "#008300", + 7: "#9085E9", + 8: "#E66767", + }, + }, + borderColor: { + DEFAULT: "rgba(255,255,255,0.10)", + }, + borderRadius: { card: "12px" }, + fontFamily: { + sans: ["system-ui", "-apple-system", '"Segoe UI"', "sans-serif"], + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..e16b8db --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts", "tailwind.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..c230c3c --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,13 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// Dev server proxies /api to the FastAPI backend (no CORS needed in dev). +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": "http://localhost:8000", + }, + }, +}); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..3334561 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,48 @@ +# Development overlay (hot reload). Usage: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up +# Or run only postgres in Docker and uvicorn/vite on the host: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up postgres +services: + postgres: + ports: + - "5432:5432" # reachable from a host-run uvicorn + + api: + command: + [ + "uvicorn", + "app.main:app", + "--reload", + "--host", + "0.0.0.0", + "--port", + "8000", + ] + volumes: + - ./apps/api/app:/srv/app + environment: + LIFETRACK_CORS_ORIGINS: '["http://localhost:5173"]' + # The published port comes from the base file (API_PORT, default 8000): + # re-declaring it here would publish the API twice. + + web: + build: + context: . + dockerfile: docker/web.Dockerfile + target: build # stop at the node stage (deps installed, no nginx) + image: lifetrack-web-dev + working_dir: /build + command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] + # No nginx in this stage: the vite dev server proxies /api itself + # (apps/web/vite.config.ts), so the image healthcheck does not apply. + healthcheck: + disable: true + volumes: + - ./apps/web/src:/build/src + - ./apps/web/index.html:/build/index.html + - ./apps/web/vite.config.ts:/build/vite.config.ts + - ./apps/web/tailwind.config.ts:/build/tailwind.config.ts + - ./apps/web/postcss.config.js:/build/postcss.config.js + - ./apps/web/tsconfig.json:/build/tsconfig.json + ports: + - "5173:5173" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..feb98b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +name: lifetrack + +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 + # pg_trgm at initdb time (datamodel-finance §2.5). Read-only mount: these + # scripts only run on the very first start, when pgdata is still empty. + - ./docker/postgres-init:/docker-entrypoint-initdb.d:ro + 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 + image: lifetrack-api:1.0.0 + restart: unless-stopped + environment: + # Every name below is `LIFETRACK_` + a field of app/core/config.py Settings. + 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} + LIFETRACK_MAX_UPLOAD_BYTES: ${LIFETRACK_MAX_UPLOAD_BYTES:-20971520} + ports: + # Not needed by the SPA (nginx proxies /api/), but exposing it makes + # /api/docs and the ingestion endpoints reachable from other devices. + - "${API_PORT:-8000}:8000" + depends_on: + postgres: + condition: service_healthy + + web: + build: + context: . + dockerfile: docker/web.Dockerfile + args: + NODE_OPTIONS: ${NODE_OPTIONS:-} + image: lifetrack-web:1.0.0 + restart: unless-stopped + ports: + - "${WEB_PORT:-80}:80" + depends_on: + api: + condition: service_healthy + +volumes: + pgdata: diff --git a/docker/api.Dockerfile b/docker/api.Dockerfile new file mode 100644 index 0000000..5a375b0 --- /dev/null +++ b/docker/api.Dockerfile @@ -0,0 +1,33 @@ +# LifeTrack API — FastAPI + SQLAlchemy 2.0 on Python 3.12 (DESIGN.md, "Stack"). +# Build context = repository root (docker-compose.yml). + +FROM python:3.12-slim AS builder +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_ROOT_USER_ACTION=ignore +WORKDIR /build +COPY apps/api/requirements.txt . +RUN python -m venv /opt/venv \ + && /opt/venv/bin/pip install -r requirements.txt + +FROM python:3.12-slim +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 +WORKDIR /srv +COPY --from=builder /opt/venv /opt/venv +COPY apps/api/app ./app + +# Non-root runtime (C7): the API never writes to disk — imports are parsed in +# memory and everything is persisted in PostgreSQL — so the whole tree can stay +# read-only for the service account. +RUN useradd --system --create-home --uid 10001 --shell /usr/sbin/nologin lifetrack \ + && chown -R lifetrack:lifetrack /srv +USER lifetrack + +EXPOSE 8000 +# `python` is the venv interpreter (see PATH): no curl/wget needed in the image. +# start-period covers the first boot: metadata.create_all() builds 28 tables. +HEALTHCHECK --interval=15s --timeout=5s --start-period=45s --retries=5 \ + CMD ["python", "-c", "import sys, urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/healthz', timeout=4).status == 200 else 1)"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..4611958 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,61 @@ +server { + listen 80; + server_name _; + # Uploads d'imports — doit rester >= LIFETRACK_MAX_UPLOAD_BYTES (20 MiB), + # sinon nginx renvoie 413 avant que l'API ne puisse répondre en français. + client_max_body_size 25m; + + root /usr/share/nginx/html; + index index.html; + + # Docker's embedded DNS. Without a resolver nginx resolves `api` once, at + # config load, and caches that IP forever: `compose restart api` (or any + # recreate) hands out a new IP and every /api/ call would 502 until nginx + # is restarted too. It also lets nginx start while the api is still down. + resolver 127.0.0.11 valid=10s ipv6=off; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + + location /api/ { + # Variable upstream => re-resolved through the resolver above. With a + # variable, nginx does NOT append the request URI on its own, hence the + # explicit $request_uri, which preserves the /api prefix (C6). + set $api_upstream http://api:8000; + proxy_pass $api_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # POST /api/imports is synchronous: the whole file is parsed, deduped + # and inserted before the response. Measured on the reference host: + # ~100 rows/s, i.e. ~160 s for a 3 MB bank CSV. With the default 60 s — + # or even 120 s — nginx returns 504 while the import keeps running and + # commits, so the user sees a failure for an import that succeeded. + # This covers a file up to LIFETRACK_MAX_UPLOAD_BYTES (20 MiB). + proxy_connect_timeout 10s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # Vite fingerprints every asset name: they can be cached forever. + # A single Cache-Control header: `expires` emits one of its own, and the + # two together made every asset response carry two contradictory ones. + location /assets/ { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # The SPA entry point must never be cached, otherwise a browser keeps + # pointing at the asset hashes of the previous deployment. + location = /index.html { + add_header Cache-Control "no-cache"; + } + + location / { + try_files $uri $uri/ /index.html; # SPA fallback (deep links) + } +} diff --git a/docker/postgres-init/10-extensions.sql b/docker/postgres-init/10-extensions.sql new file mode 100644 index 0000000..65ec245 --- /dev/null +++ b/docker/postgres-init/10-extensions.sql @@ -0,0 +1,13 @@ +-- LifeTrack — extensions installed once, at cluster initialisation +-- (run by the postgres entrypoint from /docker-entrypoint-initdb.d, against +-- POSTGRES_DB, only when the pgdata volume is empty). +-- +-- pg_trgm backs the GIN trigram index on fin_transactions.label_clean +-- (docs/design/datamodel-finance.md §2.5): GET /api/finance/transactions?q= +-- searches with ILIKE '%…%', which without that index can only be answered by +-- a sequential scan over the whole transaction table. +-- +-- The API also attempts CREATE EXTENSION at startup (the after_create hook in +-- app/modules/finance/models.py); installing it here as well covers the +-- deployments where the API role is not the owner of the database. +CREATE EXTENSION IF NOT EXISTS pg_trgm; diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile new file mode 100644 index 0000000..0eede09 --- /dev/null +++ b/docker/web.Dockerfile @@ -0,0 +1,24 @@ +# LifeTrack web — React/Vite build served by nginx. +# Build context = repository root (docker-compose.yml). + +FROM node:20-alpine AS build +WORKDIR /build +# Cap the V8 heap on small hosts (the self-hosted target has ~1 GB of usable +# RAM and `vite build` bundles echarts): docker compose passes NODE_OPTIONS +# through, empty by default so a normal machine keeps node's own heuristics. +ARG NODE_OPTIONS="" +ENV NODE_OPTIONS=$NODE_OPTIONS +# Explicit names (not package*.json): a missing lockfile must fail here, loudly, +# instead of turning into a confusing `npm ci` error. +COPY apps/web/package.json apps/web/package-lock.json ./ +RUN npm ci --no-audit --no-fund +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 +# busybox wget ships with nginx:alpine; -O /dev/null keeps it out of the fs. +HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ + CMD ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1/index.html"] diff --git a/docs/GUIDE.md b/docs/GUIDE.md new file mode 100644 index 0000000..85290cb --- /dev/null +++ b/docs/GUIDE.md @@ -0,0 +1,910 @@ +# Guide d'utilisation de LifeTrack + +Ce guide décrit **comment se servir de LifeTrack au quotidien** : brancher ses vraies +données, suivre son poids, remplir son journal alimentaire, piloter le sevrage tabagique et +importer ses relevés bancaires. + +Il part du principe que l'application tourne déjà et que votre compte est créé. Sinon, +commencez par le [README](../README.md#démarrage-rapide-docker-compose). + +--- + +## Sommaire + +- [Avant de commencer](#avant-de-commencer) +- [Connecter Health Connect](#connecter-health-connect) +- [Suivi poids et calories au quotidien](#suivi-poids-et-calories-au-quotidien) +- [Nutrition](#nutrition) +- [Vape et sevrage tabagique](#vape-et-sevrage-tabagique) +- [Finances](#finances) +- [Dépannage](#dépannage) + +--- + +## Avant de commencer + +Trois réglages conditionnent presque tous les calculs. Faites-les une bonne fois pour +toutes dans **Réglages** : + +| Onglet | À renseigner | Ce que ça débloque | +|---|---|---| +| **Profil** | Taille, sexe, date de naissance, niveau d'activité | Métabolisme de base, dépense estimée, IMC | +| **Objectif** | Poids de départ, poids cible, mode de calcul | Budget calorique quotidien, projection, planning | +| **Vape** | Date d'arrêt, cigarettes/jour avant, prix du paquet | Économies, jalons santé | + +Le profil comporte aussi un **fuseau horaire** (`Europe/Paris` par défaut). C'est lui qui +détermine à quel jour appartient une donnée horodatée : changez-le si vous vivez ailleurs, +sinon laissez-le tranquille. + +--- + +## Connecter Health Connect + +Health Connect est le magasin de données santé d'Android. Point crucial à comprendre avant +tout : **c'est une base de données locale au téléphone. Aucun serveur ne peut aller y +chercher quoi que ce soit.** Il n'existe pas d'API cloud, et l'ancienne API Google Fit est +morte (inscriptions fermées depuis mai 2024, arrêt en 2026). + +Les données ne peuvent donc que **partir du téléphone vers LifeTrack**. Trois chemins, du +plus automatique au plus manuel. + +### Chemin 1 — Envoi automatique vers l'API d'ingestion + +#### Étape 1 : créer une clé d'appareil + +1. Ouvrez **Réglages → Appareils & API**. +2. Cliquez sur **Créer une clé**. +3. Donnez-lui un nom parlant (par exemple « Pixel de Julien »). +4. Cochez la portée **« Santé (pesées, activité, séances) »** — techniquement + `ingest:health`. +5. Validez. + +> ⚠️ **La clé en clair n'est affichée qu'une seule fois.** Copiez-la immédiatement. +> LifeTrack n'en conserve qu'une empreinte : personne, pas même vous, ne pourra la +> réafficher. Si vous la perdez, révoquez-la et créez-en une autre. +> +> Une clé ressemble à `ltk_` suivi d'une longue chaîne aléatoire. + +La liste vous montre ensuite, pour chaque clé, son préfixe, ses portées, sa date de création +et sa dernière utilisation — pratique pour vérifier qu'une passerelle envoie bien quelque +chose. + +#### Étape 2 : l'adresse à viser + +L'endpoint d'ingestion est : + +``` +POST http:///api/ingest/health +``` + +L'onglet **Imports → Connecteurs & API** affiche cette URL déjà construite avec l'adresse +depuis laquelle vous consultez l'application, avec un bouton **Copier**. + +Deux choses obligatoires dans la requête : + +- l'en-tête **`X-API-Key: ltk_…`** avec votre clé d'appareil ; +- un corps JSON de la forme suivante : + +```json +{ + "source": "health_connect", + "records": [ + { + "type": "steps", + "external_id": "uuid-fourni-par-health-connect", + "data": { "day": "2026-08-13", "steps": 9421 } + }, + { + "type": "weight", + "data": { "measured_at": "2026-08-13T06:42:00Z", "weight_kg": 83.4 } + } + ] +} +``` + +Vous pouvez tester tout de suite depuis n'importe quelle machine : + +```bash +curl -X POST http://localhost/api/ingest/health \ + -H "X-API-Key: ltk_votre_cle_ici" \ + -H "Content-Type: application/json" \ + -d '{"source":"health_connect","records":[{"type":"weight","data":{"measured_at":"2026-08-13T06:42:00Z","weight_kg":83.4}}]}' +``` + +La réponse indique combien d'enregistrements ont été insérés, mis à jour, ignorés comme +doublons, et le détail des lignes rejetées. **Un enregistrement invalide ne fait jamais +échouer le lot entier.** + +#### Étape 3 : quelles données passent + +Huit types d'enregistrements sont acceptés : + +| `type` | Ce que ça alimente | Champs principaux reconnus | +|---|---|---| +| `weight` | Pesées | `measured_at` / `time` / `start_time`, `weight_kg`, `body_fat_pct` | +| `steps` | Activité quotidienne | `day` ou `start_time`, `steps` / `count` | +| `distance` | Activité quotidienne | `distance_m` ou `distance_km` | +| `active_calories` | Activité quotidienne | `active_kcal` / `active_calories` | +| `total_calories` | Activité quotidienne (TDEE mesuré) | `total_kcal` / `total_calories` | +| `exercise_session` | Séances de sport | `start_time`, `end_time` ou `duration_s`, `exercise_type`, `energy_kcal`, `distance_m`, `avg_hr`, `max_hr` | +| `nutrition` | Journal alimentaire | `eaten_at`, `energy_kcal`, `name`, `meal_type`, macros | +| `hydration` | Hydratation | `drunk_at`, `volume_ml` ou `volume_liters` | + +Le format des champs est volontairement tolérant : LifeTrack accepte les noms +« Health Connect » en `snake_case`, les objets imbriqués `value` et `metadata`, les +horodatages ISO 8601 comme les époques Unix en secondes ou millisecondes. Un horodatage sans +fuseau est interprété en **Europe/Paris** puis converti en UTC. La charge utile d'origine est +toujours conservée dans la ligne créée. + +Les types de sport connus sont reconnus et traduits (`running`, `walking`, `cycling`, +`swimming`, `strength_training`, `hiit`, `yoga`, `hiking`, tapis de course en marche ou en +course…) ; le reste atterrit en « Autre » avec le libellé d'origine conservé. + +#### Étape 4 : la passerelle Android — et ses limites réelles + +L'application open source de référence est **health-connect-webhook** +(, AGPL-3.0, disponible sur le Play +Store). Elle lit Health Connect en tâche de fond et POSTe du JSON vers l'URL de votre choix. + +**Soyons parfaitement clairs : en l'état, elle ne peut pas être branchée directement sur +LifeTrack.** Deux obstacles, tous deux réels : + +1. **Elle ne sait pas poser d'en-tête HTTP personnalisé.** Sa sécurité repose entièrement + sur le secret de l'URL. Or `POST /api/ingest/health` exige `X-API-Key`. Les documents de + conception prévoyaient une route acceptant un jeton dans le chemin de l'URL pour ce cas + précis — **elle n'a pas été implémentée**. +2. **Son enveloppe JSON n'est pas celle attendue.** Elle envoie un objet contenant + `timestamp`, `app_version` et un tableau par type de données. LifeTrack attend + `{"source": …, "records": [{"type": …, "data": …}]}`. Le *contenu* de chaque + enregistrement est bien compris par LifeTrack ; c'est l'emballage qui diffère. + +Concrètement, vous avez trois options : + +- **Intercaler un petit relais** entre le téléphone et LifeTrack (un script, une + automatisation Node-RED ou n8n, une fonction sur votre serveur) qui reçoit le webhook, + découpe les tableaux en `records` et rejoue la requête avec l'en-tête `X-API-Key`. C'est + une trentaine de lignes. +- **Utiliser n'importe quelle application capable de poser un en-tête HTTP** (Tasker, + HTTP Shortcuts, Macrodroid, ou une future application compagnon) : le format canonique + ci-dessus est simple à produire. +- **Rester sur l'import CSV** (chemin 2), qui fonctionne aujourd'hui sans aucun bricolage. + +#### Les autres limites, à connaître avant de s'engager + +- **Fenêtre de 48 heures.** La passerelle ne relit que les 48 dernières heures. Si votre + téléphone reste éteint ou hors ligne plus longtemps, ces journées sont définitivement + perdues pour ce canal — d'où l'intérêt de l'export CSV en filet de sécurité. +- **Historique limité à 30 jours.** Health Connect n'autorise par défaut la lecture que des + 30 jours précédant la première autorisation. Tout ce qui est plus ancien ne viendra + **jamais** par ce chemin : il faudra passer par un export de fichiers. +- **Installation hors Play Store.** Une application compagnon maison devra être installée + manuellement (sideload) : APK signé localement, permissions accordées dans l'écran + Health Connect du téléphone. C'est faisable, mais ce n'est pas un simple clic. +- **Économiseurs de batterie.** Xiaomi, Huawei, Samsung et consorts tuent volontiers les + tâches de fond. Si les envois s'espacent sans raison, excluez l'application de + l'optimisation de batterie dans les réglages Android. +- **Sécurité réseau.** Une clé d'appareil circule à chaque envoi. N'exposez pas LifeTrack + en clair sur Internet : restez sur votre réseau local ou passez par un VPN, sinon mettez + du HTTPS devant nginx. + +### Chemin 2 — Import CSV avec Health Sync (le plus fiable aujourd'hui) + +**Health Sync** (, environ 4 € en achat unique, essai d'une semaine) +exporte automatiquement vos données Health Connect en CSV vers Google Drive, en tâche de +fond, sans intervention. + +1. Installez et configurez Health Sync sur le téléphone, avec Health Connect comme source. +2. Activez l'export automatique vers Google Drive (fichiers jour, 7 jours, mois ou 30 jours + glissants). +3. Récupérez le CSV sur votre ordinateur. +4. Dans LifeTrack, ouvrez **Imports**, déposez le fichier et laissez la **détection + automatique** faire son travail — le profil attendu est + **« Health Sync / Health Connect (export CSV) »**. +5. Vérifiez l'aperçu, puis lancez l'import. + +Ce chemin est aussi **le seul moyen de rattraper un historique profond** (au-delà des +30 jours de Health Connect) et de boucher les trous laissés par la fenêtre de 48 heures. +Ré-importer un fichier déjà traité ne crée aucun doublon : allez-y sans crainte. + +Un profil **« Historique de poids (CSV date/poids) »** existe également pour un simple +fichier à deux colonnes date et poids, quelle qu'en soit l'origine. + +### Chemin 3 — Saisie manuelle + +Toujours disponible, et parfois plus rapide qu'on ne croit : + +- une pesée depuis la carte **Aujourd'hui** du tableau de bord ou depuis + **Santé → Poids & Objectif** ; +- une séance de sport depuis **Santé → Activité & Sport** ; +- un repas depuis **Santé → Nutrition**. + +C'est le mode de repli recommandé pour les séances de tapis de course : l'application +FitShow ne partage ses données ni avec Health Connect, ni avec Google Fit, ni sous forme de +fichiers. Ses séances resteront enfermées dans son application tant qu'un enregistrement +Bluetooth direct n'aura pas été développé (envisagé en v3). + +--- + +## Suivi poids et calories au quotidien + +### Le planning de pesées + +LifeTrack ne vous demande pas de cocher des cases : **une habitude est considérée comme faite +dès qu'une donnée existe pour ce jour-là.** Vous vous pesez, la pesée compte. Vous +enregistrez un repas, le journal alimentaire du jour compte. + +Pour définir votre plan, allez dans **Réglages → Objectif → Planning de suivi** (ou depuis +la page **Santé → Poids & Objectif**). Trois habitudes indépendantes : + +| Habitude | Validée par | +|---|---| +| **Pesée** | Une pesée enregistrée ce jour-là | +| **Séance** | Une séance de sport ce jour-là | +| **Journal alimentaire** | Au moins un aliment enregistré ce jour-là | + +Pour chacune, cochez les jours de la semaine concernés et activez-la. Une pesée quotidienne +donne la tendance la plus fiable ; trois fois par semaine suffisent largement. + +LifeTrack en déduit ensuite : + +- l'**assiduité** : jours faits ÷ jours planifiés, en pourcentage ; +- la **série en cours** et la **meilleure série**, comptées uniquement sur les jours + planifiés ; +- un **calendrier** à quatre états : fait, manqué, fait hors planning, jour de repos. + +Détail appréciable : **le jour même ne casse jamais une série tant qu'il n'est pas fini.** +Si vous vous pesez habituellement le matin et que vous consultez l'application à midi sans +l'avoir fait, votre série reste intacte. + +### La carte « Aujourd'hui » + +En haut du tableau de bord, elle résume l'état du jour : ce qui était prévu, ce qui est fait, +et un bouton d'action directe pour chaque manque (**Noter ma pesée**, **Noter ma séance**). +C'est le point d'entrée quotidien de l'application. + +### La saisie rapide d'une pesée + +Le bouton **Noter ma pesée** ouvre une modale minimale : le poids, éventuellement la date et +l'heure si ce n'est pas maintenant. Deux secondes, et la tendance se recalcule. + +Une seule règle à connaître : **c'est la première pesée du jour qui alimente la tendance.** +Si vous vous pesez trois fois dans la journée, seule celle du matin compte pour la courbe — +les autres restent visibles dans l'historique. + +### Comprendre les chiffres + +C'est ici que LifeTrack cesse d'être un carnet et devient un outil. Quatre notions, dans +l'ordre où elles s'enchaînent. + +#### 1. Le métabolisme de base (BMR) + +C'est ce que votre corps brûle **au repos complet**, juste pour rester en vie. LifeTrack +utilise la formule de **Mifflin-St Jeor**, la référence actuelle : + +``` +BMR = 10 × poids(kg) + 6,25 × taille(cm) − 5 × âge(ans) + correctif +``` + +Le correctif vaut `+5` pour un homme, `−161` pour une femme, `−78` pour « autre ». + +*Exemple : un homme de 40 ans, 178 cm, 83 kg → 10 × 83 + 6,25 × 178 − 5 × 40 + 5 = **1 747 +kcal/jour**.* C'est ce que vous brûleriez en restant couché toute la journée. + +#### 2. La dépense énergétique totale (TDEE) + +C'est le BMR **plus tout le reste** : marcher, travailler, faire du sport, digérer. +LifeTrack le calcule chaque jour selon trois méthodes, par ordre de préférence : + +1. **Mesuré** — si votre montre ou votre téléphone a envoyé une valeur de calories totales + crédible (supérieure à 80 % du BMR), c'est elle qui gagne. C'est la plus juste. +2. **BMR + calories actives** — si vous n'avez que les calories *actives* de la journée, + elles s'ajoutent au BMR. +3. **Estimé** — sinon, le BMR est multiplié par le facteur de votre niveau d'activité : + + | Niveau | Facteur | + |---|---| + | Sédentaire | × 1,2 | + | Légèrement actif | × 1,375 | + | Modérément actif | × 1,55 | + | Très actif | × 1,725 | + | Extrêmement actif | × 1,9 | + +Le TDEE utilisé pour votre budget est **lissé sur 7 jours** : une journée exceptionnelle ne +fait pas bondir votre autorisation calorique du lendemain. + +#### 3. Les 7 700 kcal par kilo + +C'est la constante qui relie calories et poids : **perdre 1 kg de masse corporelle +correspond à un déficit d'environ 7 700 kcal.** + +D'où le calcul du budget quotidien : + +``` +Budget = TDEE lissé − (rythme visé en kg/semaine × 7 700 ⁄ 7) +``` + +Autrement dit, chaque tranche de **0,1 kg/semaine** que vous visez coûte **110 kcal par +jour**. + +*Exemple : TDEE lissé à 2 500 kcal, objectif 0,5 kg/semaine → déficit de 0,5 × 1 100 = 550 +kcal/jour → **budget de 1 950 kcal**.* + +Un **plancher de sécurité** s'applique : 1 500 kcal pour un homme, 1 200 kcal pour une +femme, sauf valeur personnalisée. Si votre objectif passe sous ce plancher, le budget est +relevé et l'interface vous avertit que le rythme visé est trop agressif. + +#### 4. La tendance, pas la balance + +Votre poids varie de un à deux kilos d'un jour à l'autre pour des raisons qui n'ont rien à +voir avec la graisse : sel, hydratation, digestion, cycle hormonal. Se fier au chiffre brut, +c'est se condamner à l'angoisse. + +LifeTrack calcule donc une **moyenne mobile exponentielle** (méthode dite « Hacker's Diet », +facteur de lissage 0,1) : chaque nouvelle pesée ne déplace la courbe que de 10 % de l'écart +constaté. Les jours sans pesée sont correctement compensés, sans fausser le lissage. + +**C'est cette courbe de tendance qu'il faut regarder, pas les points.** À partir de trois +pesées, LifeTrack calcule aussi une pente par régression linéaire et en déduit la **date +d'atteinte estimée** de votre objectif. Trois réponses possibles : + +- une date, si la pente va dans le bon sens ; +- **« objectif atteint »**, à moins de 100 g de la cible ; +- **« ne converge pas »**, si la pente est nulle, part dans le mauvais sens, ou donne une + échéance à plus de dix ans. Ce n'est pas un bug : c'est une information. + +#### 5. La calibration adaptative + +Après **21 jours au moins** de journal alimentaire renseigné, la page **Balance énergétique** +compare la perte de poids *prévue* par vos déficits cumulés à la perte *réellement observée* +sur la tendance, et en déduit votre **TDEE réel**. + +C'est la fonction la plus utile de LifeTrack sur la durée : elle corrige d'un coup les +erreurs de la formule théorique, la sous-estimation systématique des portions et l'adaptation +métabolique. Si l'écart est important, un bouton vous propose d'adopter cette valeur mesurée. + +--- + +## Nutrition + +### Recherche d'aliments avec Open Food Facts + +Dans **Santé → Nutrition**, le champ de recherche interroge, dans cet ordre : + +1. **le cache local** de LifeTrack — tous les aliments déjà consultés ; +2. **Open Food Facts**, la base collaborative mondiale (plus de 3 millions de produits, très + bonne couverture des rayons français). + +Tapez au moins **2 caractères**. La recherche est en français et passe par le serveur, jamais +par votre navigateur. + +**Chaque produit consulté est mis en cache définitivement.** Un produit que vous mangez +souvent n'est téléchargé qu'une seule fois, puis répond instantanément, même hors ligne. +Vous pouvez aussi chercher par **code-barres**. + +Les valeurs sont mémorisées pour 100 g : calories, protéines, glucides, sucres, lipides, +acides gras saturés, fibres, sel. Quand les calories manquent mais que les kilojoules sont +présents, la conversion est faite automatiquement (÷ 4,184). + +> **Open Food Facts est collaboratif : les données ne sont pas garanties.** Un produit peut +> avoir des valeurs fantaisistes ou une portion mal renseignée. Un coup d'œil au chiffre +> avant de valider évite bien des surprises. Si la base est injoignable, LifeTrack vous le +> dit clairement et vous propose la saisie manuelle. + +> Pas de base d'aliments génériques française à ce jour. La table CIQUAL de l'ANSES +> (« Pomme, crue », « Baguette courante »…) était prévue mais **n'a pas été intégrée**. Pour +> vos plats maison, saisissez les valeurs à la main — ou créez-vous un favori une bonne fois +> pour toutes. + +### Favoris et aliments récents + +Deux raccourcis qui font toute la différence sur la durée : + +- **Favoris** — vos aliments et portions habituels, épinglés en tête de liste. Votre café du + matin, votre yaourt, votre portion de riz. +- **Récents** — les derniers aliments enregistrés, proposés automatiquement. + +Le journal est organisé par repas : petit-déjeuner, déjeuner, dîner, collation. Une entrée +sans type de repas explicite est classée selon l'heure (avant 11 h, petit-déjeuner ; avant +15 h, déjeuner ; avant 18 h, collation ; ensuite, dîner). + +L'**hydratation** se suit à part, avec un objectif quotidien réglable dans le profil +(2 000 ml par défaut). + +### Importer son historique Foodvisor + +Foodvisor **n'expose aucune API** : ni OAuth, ni webhook, ni endpoint « mes repas ». Aucune +synchronisation continue n'est possible. Le seul chemin est un **export ponctuel**, à faire +une fois pour rapatrier votre historique. + +#### Étape 1 : demander l'export dans l'application + +Dans Foodvisor : **Réglages → Compte → « Demander mes données »** (l'intitulé et +l'emplacement varient selon les versions ; sur le tableau de bord web, cherchez sous +`Account → Privacy → Data`). + +L'export arrive **par e-mail**, généralement sous 24 à 72 heures. + +#### Étape 2 : si l'option est absente ou l'export incomplet, invoquez le RGPD + +Foodvisor est une société française : vous disposez du **droit d'accès (article 15)** et du +**droit à la portabilité (article 20)**. Écrivez à **`data@foodvisor.io`**. Modèle : + +> Objet : Demande d'accès et de portabilité de mes données personnelles (RGPD) +> +> Bonjour, +> +> Titulaire d'un compte Foodvisor associé à l'adresse ``, je souhaite exercer +> mes droits d'accès (article 15 du RGPD) et de portabilité (article 20). +> +> Je vous demande de me transmettre **l'intégralité de mon journal alimentaire dans un format +> structuré, couramment utilisé et lisible par machine (CSV ou JSON)**, incluant pour chaque +> entrée : l'horodatage, le type de repas, le nom de l'aliment, la portion et les valeurs +> nutritionnelles (calories et macronutriments). Merci d'y joindre également mon historique +> de poids et mes objectifs. +> +> Le délai légal de réponse est d'un mois à compter de la réception de la présente. +> +> Cordialement, +> `` + +Le délai légal est d'**un mois**, prolongeable de deux mois pour une demande complexe. + +#### Étape 3 : importer dans LifeTrack + +1. Ouvrez **Imports**. +2. Déposez le CSV du journal alimentaire (si vous avez reçu une archive ZIP, extrayez-la + d'abord : c'est le fichier contenant une ligne par aliment consommé qui nous intéresse). +3. Laissez la détection automatique, ou choisissez le profil **« Foodvisor (export CSV) »**. +4. Vérifiez l'aperçu, puis lancez l'import. + +L'importeur est volontairement tolérant : il accepte les en-têtes en français comme en +anglais, les séparateurs `,` et `;`, les encodages UTF-8 et Windows-1252, la virgule +décimale. Chaque ligne doit au minimum comporter **une date, un nom d'aliment et des +calories** — les autres colonnes sont facultatives. + +Ce que l'export Foodvisor **ne contient pas**, et que vous ne récupérerez donc jamais : les +photos, les scores de confiance de l'IA, les codes-barres scannés, les micronutriments +au-delà des macros, et la décomposition des recettes en ingrédients. + +--- + +## Vape et sevrage tabagique + +### Étape 1 : la référence tabac + +Rien ne fonctionne sans elle. **Réglages → Vape** : + +| Champ | Exemple | Rôle | +|---|---|---| +| **Date d'arrêt du tabac** | 12/03/2026 | Point de départ des économies et des jalons santé | +| **Cigarettes par jour** | 20 | Votre consommation **avant** l'arrêt | +| **Cigarettes par paquet** | 20 | Généralement 20 | +| **Prix du paquet** | 12,50 € | Prix **actuel** du paquet | +| **Taux de nicotine par défaut** | 6 mg/ml | Utilisé quand une recharge n'en précise pas | + +Cette référence est **gelée** : elle représente ce que vous dépenseriez si vous n'aviez pas +arrêté. Elle n'est jamais recalculée à partir de vos données de vape. + +> **Une nuance d'honnêteté que LifeTrack affiche lui-même :** le prix retenu est le prix +> d'aujourd'hui, pas celui du jour de votre arrêt. Comme le tabac augmente régulièrement, les +> économies affichées sur les périodes anciennes sont **légèrement optimistes**. C'est +> assumé, et c'est indiqué dans l'interface. + +Tant que ce formulaire n'est pas rempli, le module vape affiche « Module vape non +configuré » et refuse de calculer quoi que ce soit — c'est normal. + +### Étape 2 : saisir les recharges + +Onglet **Consommation**, bouton d'ajout. Deux façons de déclarer : + +- **Recharge** (`refill`) — le remplissage habituel : la date et le volume en ml. Plusieurs + recharges dans la journée s'additionnent. +- **Total du jour** (`daily_total`) — vous n'avez pas compté vos remplissages mais vous savez + que vous avez consommé 6 ml aujourd'hui. **Cette valeur remplace la somme des recharges du + jour**, elle ne s'y ajoute pas. + +Vous pouvez préciser le taux de nicotine et la recette utilisée ; sinon les valeurs par +défaut s'appliquent. + +Une journée sans aucune saisie n'est **pas** comptée comme zéro : elle est marquée « non +suivie ». Les moyennes ne portent que sur les jours réellement suivis, et l'interface +affiche la proportion de jours suivis pour que vous sachiez quelle confiance accorder aux +chiffres. + +### Étape 3 : changer une résistance en un clic + +Onglet **Résistances**. Un seul bouton : **la date et l'heure sont celles de maintenant**, +sans aucune saisie. C'est la fonction la plus utilisée du module, elle devait être +instantanée. + +LifeTrack en déduit : + +- la **durée de vie** de la résistance précédente, affichée immédiatement après le clic ; +- la **durée de vie moyenne**, calculée sur les **5 derniers cycles** (14 jours tant qu'il + n'y a pas au moins deux changements) ; +- le **volume d'e-liquide passé** dans chaque résistance ; +- l'**âge de la résistance en cours**. + +Vous pouvez rattacher un produit du catalogue au changement pour que son prix entre dans le +calcul d'amortissement. + +### Étape 4 : le modèle de coût DIY + +Onglet **Coûts & modèle**. Deux niveaux, selon votre patience. + +**Le catalogue de produits.** Chaque produit porte un prix et une contenance : base +(1 000 ml), booster nicotine (10 ml), arôme (30 ml), résistances (paquet de 5). C'est tout ce +dont LifeTrack a besoin. + +**Les recettes.** Une recette déclare un volume total, un taux de nicotine visé et ses +composants. Le coût se calcule simplement : + +``` +coût d'un composant = quantité × (prix du flacon ⁄ contenance du flacon) +coût au ml = somme des composants ⁄ volume total +``` + +*Exemple pour 100 ml : 20 ml de booster à 1 € les 10 ml (2,00 €) + 10 ml d'arôme à 6 € les +30 ml (2,00 €) + 70 ml de base à 12 € le litre (0,84 €) = **4,84 € pour 100 ml, soit 4,8 +centimes par ml**.* + +Un **assistant de recette** fait le calcul inverse : donnez-lui un volume total, un taux de +nicotine visé et le taux de votre booster, il vous rend les millilitres de booster, d'arôme +et de base. LifeTrack recalcule ensuite le taux de nicotine réel de la recette et vous +**avertit si l'écart avec la cible dépasse 10 %**. + +Si vous n'avez pas envie de saisir des recettes, LifeTrack se rabat sur la **moyenne pondérée +de vos achats de liquide** sur les 90 derniers jours. Moins précis, mais sans effort. + +**Le coût réel par jour** additionne : + +``` +coût/jour = ml consommés × coût au ml + (prix d'une résistance ⁄ durée de vie moyenne) +``` + +L'amortissement de la résistance est souvent la surprise du calcul : une résistance à 2,50 € +qui tient 10 jours, c'est 25 centimes par jour, parfois plus que le liquide lui-même. + +L'onglet affiche en parallèle vos **dépenses réelles** (issues de vos achats saisis) : l'écart +avec le coût théorique révèle vos stocks et vos achats d'impulsion. + +### Étape 5 : lire ses économies + +Onglet **Économies**. Le principe : + +``` +économies cumulées = (coût tabac de référence × jours écoulés) − (coût de vape cumulé) +``` + +Les journées sans saisie de recharge ne sont pas comptées à zéro — ce serait tricher et +gonfler artificiellement les économies. Elles sont **imputées à votre moyenne des 30 derniers +jours**. + +Deux courbes sont proposées : les **économies théoriques** (à partir du coût au ml calculé) +et les **économies réelles** (à partir de vos achats effectifs). La seconde est la vérité +comptable ; la première est plus stable. + +À côté : cigarettes évitées, paquets évités, et **temps de vie récupéré** (compté sur la base +de 11 minutes par cigarette). L'équivalence nicotine ↔ cigarettes est donnée à titre indicatif +sur la base de 12 mg par cigarette — c'est un ordre de grandeur, pas une mesure. + +Enfin, les **jalons santé** : douze étapes datées depuis votre arrêt, de la fréquence +cardiaque qui redescend après 20 minutes jusqu'au risque coronarien redevenu normal après +15 ans, avec la progression vers le prochain palier. + +--- + +## Finances + +### Étape 1 : exporter un relevé depuis sa banque + +Connectez-vous à l'espace client de votre banque et cherchez « Télécharger », « Exporter » ou +« Historique des opérations ». **Préférez toujours l'OFX au CSV quand il est proposé** : ce +format embarque un identifiant unique par opération, ce qui rend la déduplication parfaite. + +Presets intégrés à LifeTrack : + +| Banque | Format testé | À savoir | +|---|---|---| +| **BoursoBank / Boursorama** | CSV `;`, UTF-8 | Plusieurs années disponibles, période libre | +| **Crédit Agricole** | CSV `;`, ISO-8859-15 | Préambule de longueur variable ; 30 à 90 jours selon la caisse | +| **BNP Paribas** | CSV `;`, ISO-8859-1 | Première ligne = solde, pas d'en-tête de colonnes ; environ 90 jours | +| **Société Générale** | CSV `;`, ISO-8859-1 | Environ 6 mois d'historique | +| **La Banque Postale** | CSV `;`, ISO-8859-15 | Préambule d'environ 8 lignes ; environ 90 jours | +| **Caisse d'Épargne** | CSV `;`, ISO-8859-1 | Colonnes Débit/Crédit séparées, crédit préfixé `+` | +| **Fortuneo** | CSV `;`, encodage détecté | Jusqu'à environ 10 ans d'historique | +| **Revolut** | CSV `,`, UTF-8 | Historique complet ; les frais sont déduits du montant | +| **N26** | CSV `,`, UTF-8 | Historique complet | +| **PayPal** | CSV `,` | Rapport d'activité, 7 ans par tranches de 12 mois | +| **OFX** | `.ofx` / `.qfx` | Toutes banques — **à privilégier** | +| **CSV générique** | tout CSV | Avec mappage de colonnes ajustable | + +Une banque absente de la liste ? Le **CSV générique** avec l'éditeur de mappage traite +n'importe quel fichier tabulaire. + +> Les banques modifient la mise en page de leurs exports sans prévenir. C'est précisément +> pour cela que l'aperçu existe : **vérifiez-le systématiquement**. + +### Étape 2 : créer un compte de destination + +Avant le premier import, créez le compte correspondant dans **Finances → Comptes** : un nom, +un type (courant, épargne, PayPal, espèces). Chaque transaction est rattachée à un compte — +c'est ce qui permet ensuite de détecter les virements entre vos propres comptes. + +### Étape 3 : l'aperçu avant import + +Déposez le fichier depuis **Imports** ou depuis le module **Finances**, choisissez le compte +et le profil bancaire. **Rien n'est écrit en base à ce stade.** + +L'aperçu vous montre : + +- les **20 premières lignes** telles que LifeTrack les a comprises : date, libellé, montant ; +- le nombre de lignes lues, de **doublons qui seront ignorés**, et de lignes en erreur ; +- la **période couverte** par le fichier ; +- un avertissement si ce fichier a **déjà été importé** à l'identique. + +Contrôlez trois choses : les dates ne sont pas inversées (13/08 ne doit pas devenir le +8 mars), les montants ont le bon signe (les dépenses en négatif), les libellés ne sont pas +tronqués ni truffés de caractères bizarres. + +Si quelque chose cloche, ouvrez **Ajuster le mappage** : séparateur, encodage, séparateur +décimal, format de date, lignes d'en-tête à ignorer, correspondance des colonnes, mode des +montants (une colonne signée, ou colonnes Débit/Crédit séparées). Vos réglages sont +enregistrés dans un **profil personnel** — le preset d'origine n'est jamais modifié. + +Ensuite seulement, **Lancer l'import**. + +### Étape 4 : la déduplication lors des ré-imports + +C'est le point qui permet de ne pas réfléchir. **Vous pouvez ré-importer le même fichier +autant de fois que vous voulez, et importer des fichiers qui se chevauchent : aucune +transaction ne sera dupliquée.** + +Deux mécanismes : + +1. **Identifiant externe** — quand la source en fournit un (FITID de l'OFX, identifiant de + transaction PayPal), il fait foi. C'est le cas idéal. +2. **Empreinte de contenu** — sinon, LifeTrack calcule une empreinte à partir du compte, de + la date comptable, du montant et du libellé. Un **numéro d'occurrence** distingue les + opérations légitimement identiques (deux cafés à 2,50 € le même jour au même endroit sont + bien deux transactions, pas un doublon). + +Conséquence pratique : téléchargez le relevé du mois entier chaque mois sans vous soucier des +chevauchements. Seules les nouvelles opérations seront ajoutées. + +### Étape 5 : les règles de catégorisation + +Une arborescence de catégories françaises complète est créée automatiquement au premier usage +(Alimentation, Logement, Transports, Santé, Loisirs, Shopping, Vape & tabac, Banque & frais, +Impôts & taxes… et côté revenus : Salaire, Aides & prestations, Remboursements, Ventes…). + +Dans **Finances → Transactions**, une règle se compose de **conditions** et d'**actions** : + +| Condition | Effet | +|---|---| +| **Libellé contient** | Correspondance textuelle simple — commencez toujours par là | +| **Expression régulière** | Pour les cas tordus ; une expression invalide est refusée avec un message clair | +| **Sens** | Débit, crédit, ou indifférent | +| **Montant minimum / maximum** | Fourchette | +| **Compte** | Restreindre à un compte | + +Chaque règle porte une **priorité** ; on peut les réordonner. Par défaut une règle qui +correspond **arrête** l'évaluation des suivantes. + +Trois garde-fous utiles : + +- **Aperçu d'une règle** avant de l'enregistrer : vous voyez exactement quelles transactions + existantes elle attraperait. +- **Application en masse**, avec un choix de portée : uniquement les non catégorisées, toutes + celles qui n'ont pas été classées à la main, ou vraiment toutes. +- **Mode simulation** (`dry_run`) : LifeTrack compte ce qu'il changerait sans rien changer. + +> **Vos décisions manuelles sont sacrées.** Une transaction que vous avez catégorisée +> vous-même n'est jamais réécrite par une règle, sauf demande explicite de forçage. Vous +> pouvez lancer vos règles sans crainte de perdre votre travail de tri. + +Il existe aussi une **catégorisation en masse** directe, pour sélectionner plusieurs +transactions et les classer d'un coup sans écrire de règle. + +### Étape 6 : les budgets + +Dans **Finances → Budgets**, définissez un montant mensuel par catégorie, avec un mois de +début et éventuellement un mois de fin. LifeTrack affiche pour le mois en cours le budget, le +réalisé, le restant, le pourcentage d'avancement et une **projection de fin de mois** basée +sur votre rythme de dépense actuel — l'indicateur qui permet de corriger le tir avant le 30. + +Un budget peut être révisé sans perdre son historique : la nouvelle valeur s'applique à +partir du mois que vous indiquez. + +### Étape 7 : les virements internes + +Quand vous déplacez 500 € de votre compte courant vers votre livret, ce n'est **pas** une +dépense. Sans traitement, ces mouvements polluent tous vos graphiques. + +**Finances → Aperçu → Détecter les virements** cherche les paires de transactions qui +s'annulent : montants opposés, sur deux comptes différents, à **moins de 3 jours +d'intervalle**, avec un libellé évocateur (`VIR`, `VIREMENT`, `TRANSFERT`, `PAYPAL`). Les +paires trouvées sont classées dans la catégorie système **« Virements internes »** et exclues +des totaux de dépenses. + +La détection n'est pas infaillible : vous pouvez **lier deux transactions à la main**, ou +**délier** une paire mal appariée. + +### Étape 8 : les récurrents + +**Finances → Récurrents** détecte tout seul vos abonnements et prélèvements réguliers, sans +que vous ayez rien à déclarer. Une série est reconnue à partir de **3 occurrences** au moins, +avec un intervalle régulier : + +| Périodicité | Intervalle accepté | +|---|---| +| Hebdomadaire | 6 à 8 jours | +| Mensuelle | 25 à 35 jours | +| Trimestrielle | 85 à 97 jours | +| Annuelle | 350 à 380 jours | + +Pour chaque série : le montant moyen, le montant attendu, la dernière date, la **prochaine +échéance prédite**, et si elle est toujours active. En haut de page, le **total mensuel +estimé** ramène tout à une base mensuelle (une dépense annuelle compte pour un douzième) : +c'est le montant qui part de votre compte chaque mois avant même que vous ayez décidé quoi +que ce soit. + +--- + +## Dépannage + +### « Mon fichier CSV est refusé » / la détection automatique se trompe + +Dans l'ordre : + +1. **Vérifiez l'extension.** LifeTrack ne se fie pas qu'au contenu : `.csv`, `.txt` pour les + relevés bancaires, `.ofx` ou `.qfx` pour l'OFX. Un fichier renommé `.xls` sera rejeté même + s'il contient du CSV. +2. **Vérifiez la taille : 20 Mio maximum.** Au-delà, découpez l'export par périodes. +3. **Choisissez le profil à la main** au lieu de « Détection automatique ». La détection + s'appuie sur les en-têtes des 4 096 premiers octets ; un export exotique ou un préambule + inhabituel peut la mettre en défaut. +4. **Ouvrez « Ajuster le mappage »** si l'aperçu est vide ou incohérent. Les suspects + habituels : le séparateur (`;` en France, `,` pour Revolut, N26 et PayPal), l'encodage + (`cp1252` pour la plupart des banques françaises, `utf-8` pour les néobanques) et le + format de date (`%d/%m/%Y` contre `%Y-%m-%d`). +5. **Message « Aucune ligne exploitable — vérifiez le profil de source »** : le fichier a bien + été lu, mais aucune ligne n'a produit de transaction. C'est presque toujours un profil + inadapté, pas un fichier corrompu. + +Après l'import, le **rapport d'erreurs** est téléchargeable en CSV depuis l'historique : il +indique le numéro de ligne et le motif de chaque rejet. Les 100 premières erreurs sont +conservées. + +### « J'ai des doublons » + +Rappel : le ré-import du même fichier n'en crée jamais. Si vous en voyez malgré tout : + +- **La même opération saisie à la main *et* importée** — la déduplication ne joue qu'entre + lignes importées. Supprimez celle de trop. +- **Deux sources décrivant le même événement** — par exemple une séance de sport arrivée à la + fois par Health Connect et par un CSV. Les séances qui se chevauchent à plus de 80 % sont + signalées comme doublons potentiels, mais elles restent deux lignes distinctes : c'est + volontaire, chaque source garde ses données. +- **Deux comptes bancaires pour le même relevé** — la déduplication est calculée **par + compte**. Importer le même fichier sur deux comptes crée bien deux jeux de transactions. + Vérifiez le compte de destination. +- **Le même relevé importé avec deux profils différents** — les libellés interprétés + différemment produisent des empreintes différentes. Annulez l'un des deux lots. + +Pour les activités quotidiennes (pas, calories), il n'y a pas de doublon possible : une seule +ligne existe par jour et par source, et les sources sont fusionnées **champ par champ** selon +un ordre de priorité (saisie manuelle, puis Health Connect, puis les autres). LifeTrack +n'additionne jamais deux sources — ce serait le meilleur moyen de compter ses pas deux fois. + +### « Les dates ou les heures sont décalées » + +- **Une donnée apparaît la veille ou le lendemain.** Tout est stocké en UTC et réaffiché dans + votre fuseau. Vérifiez le **fuseau horaire du profil** (`Réglages → Profil`, + `Europe/Paris` par défaut). Une pesée à 00 h 30 heure française est enregistrée à 22 h 30 + UTC la veille : si le fuseau est mal réglé, elle tombe le mauvais jour. +- **Un horodatage sans fuseau** (fréquent dans les exports CSV et chez Foodvisor) est + interprété en **Europe/Paris**, jamais en UTC. C'est le bon choix dans 99 % des cas, mais + cela décale les données d'un export produit dans un autre pays. +- **Un jour de plus ou de moins autour du changement d'heure** : les agrégations journalières + utilisent le fuseau, pas un décalage fixe. Un décalage résiduel sur les journées de + transition n'est pas anormal. +- **Les dates du relevé bancaire sont fausses** (le 04/03 devient le 3 avril) : mauvais format + de date dans le profil. Corrigez `date_format` dans le mappage et **annulez puis + ré-importez** le lot. + +### « Module vape non configuré » + +Le module refuse de calculer tant que la **référence tabac** n'est pas saisie. Allez dans +**Réglages → Vape** et renseignez au minimum la date d'arrêt, les cigarettes par jour avant +l'arrêt et le prix du paquet. + +Autres cas voisins : + +- **Aucun coût affiché** : il faut soit au moins une recette avec ses composants et leurs + prix, soit au moins un achat de liquide enregistré. Sans l'un ou l'autre, LifeTrack ne peut + pas connaître votre coût au ml — et préfère n'afficher aucun chiffre plutôt qu'un chiffre + faux. +- **Durée de vie des résistances à 14 jours** : c'est la valeur par défaut tant qu'il n'y a + pas eu **deux changements** enregistrés. Elle deviendra réelle dès le deuxième clic. +- **Économies qui semblent trop belles** : vérifiez la proportion de jours suivis. Si vous ne + saisissez vos recharges qu'une fois sur trois, l'imputation par la moyenne fait le reste et + la précision s'en ressent. + +### Annuler un import (rollback) + +Tout lot d'import est réversible. + +1. Ouvrez **Imports** (ou **Finances** pour un relevé bancaire). +2. Dans **Historique des imports**, repérez la ligne concernée. +3. Cliquez sur **Annuler cet import**. +4. Confirmez : le nombre exact de lignes qui seront supprimées vous est indiqué. + +Les données créées par ce lot disparaissent des tableaux de bord. **Le fichier peut être +ré-importé ensuite** — c'est la manœuvre normale quand on s'aperçoit après coup qu'on a +choisi le mauvais profil ou le mauvais compte : on annule, on corrige le mappage, on +recommence. + +**Cas particulier des transactions modifiées à la main.** Si vous avez catégorisé +vous-même ou annoté des transactions du lot, LifeTrack **refuse l'annulation** et vous +avertit. Vous pouvez alors cocher **« Supprimer quand même les lignes modifiées +manuellement »** pour forcer. C'est irréversible : votre travail de catégorisation sur ces +lignes sera perdu. + +### « Open Food Facts est injoignable » + +Message attendu si votre serveur n'a pas d'accès Internet ou si le service est indisponible. +Ce n'est pas bloquant : + +- les aliments **déjà consultés** restent disponibles dans le cache local ; +- la **saisie manuelle** fonctionne toujours ; +- réessayez plus tard, la recherche repartira sans rien perdre. + +Si la recherche est simplement vide, vérifiez que vous avez tapé au moins **2 caractères**. + +### « La passerelle Health Connect n'envoie rien » + +Dans l'ordre : + +1. **Regardez la colonne « Dernière utilisation »** dans **Réglages → Appareils & API**. + Toujours « Jamais » ? Aucune requête n'est jamais arrivée : le problème est côté réseau ou + côté application. +2. **Testez l'endpoint avec `curl`** (exemple plus haut). Si `curl` fonctionne, LifeTrack + n'est pas en cause. +3. **Vérifiez l'en-tête `X-API-Key`.** C'est la cause numéro un — voir plus haut : la + passerelle `health-connect-webhook` **ne sait pas poser d'en-tête**, elle ne peut donc pas + fonctionner en direct. +4. **Vérifiez la portée de la clé.** Une erreur 403 « Cette clé d'appareil ne dispose pas du + droit requis » signifie qu'il manque `ingest:health`. +5. **Erreur 404 « Domaine d'ingestion inconnu »** : seul le domaine `health` est traité + aujourd'hui. `POST /api/ingest/vape` et `POST /api/ingest/finance` n'existent pas encore, + même si les portées correspondantes sont proposées à la création d'une clé. +6. **Le téléphone atteint-il le serveur ?** Depuis le navigateur du téléphone, ouvrez + `http:///api/healthz`. Vous devez voir `{"status":"ok"}`. Sinon, c'est + un problème de réseau local, de pare-feu ou de VPN. +7. **Économiseur de batterie** : excluez l'application des optimisations Android. + +### L'application ne démarre pas du tout + +```bash +docker compose ps # quels conteneurs tournent +docker compose logs api # journaux du backend +docker compose logs web # journaux de nginx +``` + +- **`api` redémarre en boucle** : presque toujours la base. Vérifiez que `POSTGRES_PASSWORD` + dans `.env` correspond bien à celui avec lequel le volume a été créé. Si vous avez changé + le mot de passe **après** le premier démarrage, l'ancien volume conserve l'ancien : soit + vous remettez l'ancien mot de passe, soit vous repartez de zéro avec + `docker compose down -v` (**qui efface toutes vos données**). +- **Page blanche sur `http://localhost`** : `web` tourne mais `api` non. Regardez les + journaux de `api`. +- **Port déjà utilisé** : changez `WEB_PORT` dans `.env` (par exemple `8080`) puis relancez. +- **Déconnexion permanente / boucle vers l'écran de connexion** : `LIFETRACK_JWT_SECRET` a + changé entre deux démarrages, ce qui invalide tous les jetons existants. Reconnectez-vous + une fois, et ne modifiez plus ce secret. + +> Rappel : la pile Docker Compose **n'a jamais été démarrée pendant le développement** (le +> démon Docker était indisponible). Si le tout premier `docker compose up --build` bute sur +> un détail d'infrastructure, ce n'est pas anormal — commencez toujours par lire les journaux +> du conteneur fautif. diff --git a/docs/design/addendum-planning.md b/docs/design/addendum-planning.md new file mode 100644 index 0000000..c71d82f --- /dev/null +++ b/docs/design/addendum-planning.md @@ -0,0 +1,50 @@ +# Addendum — Planning de suivi (jours de pesée, séances, journal) + +> Demande utilisateur : « pour le suivi calorique/poids/sport il faut un genre de planning avec +> les jours de pesée, pouvoir noter la pesée du jour, etc. » + +## Concept + +Un **planning hebdomadaire par habitude** (pesée, séance de sport, journal alimentaire) + +une **checklist du jour** + un **suivi d'assiduité** (streaks, calendrier, %). +Le « fait / pas fait » est **dérivé des données existantes** — aucune double saisie : + +| Habitude | Jour « fait » si… | +|---|---| +| `weigh_in` (pesée) | une `WeightEntry` existe ce jour local | +| `workout` (séance) | un `Workout` existe ce jour local | +| `food_log` (journal) | ≥ 1 `FoodEntry` ce jour local | + +## Modèle (module `health`) + +`tracking_schedules` : `id`, `user_id`, `kind` enum (`weigh_in`|`workout`|`food_log`, +`native_enum=False`), `weekdays` JSON (liste d'entiers, 0 = lundi … 6 = dimanche), +`enabled` bool — **une ligne max par (user, kind)** (contrainte unique). Pas de table de +check-ins en v1 (dérivation ci-dessus) ; habitudes personnalisées = v2. + +## API (module `health`) + +- `GET /api/health/schedules` → liste des 3 plannings (avec défauts désactivés si absents) ; + `PUT /api/health/schedules/{kind}` → upsert `{weekdays, enabled}`. +- `GET /api/health/today?tz=` → `{ date, items: [{kind, planned, done, value}], streaks }` + — `value` : poids saisi (kg) / nb de séances / kcal saisies du jour. +- `GET /api/health/stats/adherence?from&to&tz&kind=` → données prêtes pour un **calendar + heatmap ECharts** (par jour : `planned`/`done`/`missed`) + `% d'assiduité` (fait ÷ planifié), + `streak` courant et record par habitude (le streak ne compte que les jours planifiés). + +## UX + +- **Accueil** : carte « **Aujourd'hui** » en tête — checklist du jour : « Pesée » (✓ + valeur si + faite, sinon bouton **« Noter ma pesée »** ouvrant la modale rapide), « Séance », « Journal + alimentaire » (kcal saisies) ; badge streak « 🔥 n jours ». Les habitudes non planifiées + aujourd'hui sont grisées (« Repos »). +- **Page Poids & Objectif** : éditeur de planning (cases `L M M J V S D` + interrupteur), + carte « Pesée du jour » (planifiée / faite / manquée + CTA), **calendrier heatmap** des pesées + (vert = faite, contour = planifiée manquée), KPI « Assiduité 30 j » + streak. +- **Page Activité & Sport** : même motif pour les jours de séance planifiés. +- **Réglages → Objectif** : raccourci vers l'éditeur de planning. + +## Empty states (français) + +- Planning vide : « Aucun jour planifié. Choisis tes jours de pesée pour suivre ton assiduité. » +- Aujourd'hui, rien de planifié : « Rien de prévu aujourd'hui. Profites-en bien ! » diff --git a/docs/design/architecture.md b/docs/design/architecture.md new file mode 100644 index 0000000..75fb203 --- /dev/null +++ b/docs/design/architecture.md @@ -0,0 +1,1584 @@ +# 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.* diff --git a/docs/design/datamodel-finance.md b/docs/design/datamodel-finance.md new file mode 100644 index 0000000..61299e7 --- /dev/null +++ b/docs/design/datamodel-finance.md @@ -0,0 +1,953 @@ +# LifeTrack — Module FINANCE : modèle de données & logique + +> **Statut** : spécification prête pour implémentation — les agents d'implémentation ne feront **aucune** recherche complémentaire. +> **Stack imposée** : Python 3.12 / FastAPI / SQLAlchemy 2.0 / PostgreSQL 16 — React 18 + TS + Vite + Tailwind + Apache ECharts. +> **Conventions** : identifiants de code en **anglais**, prose et chaînes UI en **français**. Timestamps stockés en UTC (`TIMESTAMPTZ`), dates bancaires stockées telles quelles (`DATE`, dates civiles, **jamais** converties de fuseau). Fuseau d'affichage : `Europe/Paris`. + +--- + +## 1. Vue d'ensemble + +Le module FINANCE couvre : + +1. **Import** de relevés bancaires (CSV banques françaises, OFX) et d'exports d'activité PayPal (CSV), via le framework générique de connecteurs/importeurs de LifeTrack (les importeurs fichiers du module FINANCE sont des implémentations du contrat commun `FileImporter`). +2. **Liste unifiée de transactions** multi-comptes, avec déduplication robuste. +3. **Catégorisation automatique** par moteur de règles rejouable. +4. **Budgets mensuels** par catégorie, avec suivi budget vs réalisé. +5. **Détection de virements internes** (exclus des statistiques de dépenses). +6. **Détection de dépenses récurrentes** (abonnements, prélèvements) avec prédiction de la prochaine échéance. +7. **Tableaux de bord** : agrégats mensuels par catégorie (avec rollup de l'arbre de catégories), cashflow, top commerçants, sankey revenus → catégories — toutes les réponses sont *chart-ready* pour ECharts. + +### 1.1 Principes transverses (rappel des conventions projet) + +- **Multi-utilisateur prêt** : toutes les tables métier portent `user_id` (FK vers `users.id`, table commune du socle). Toutes les requêtes filtrent par `user_id` (extrait du JWT). Aucune donnée partagée entre utilisateurs. +- **Clés primaires** : `UUID` générés côté base via `gen_random_uuid()` (extension `pgcrypto` déjà activée par le socle). +- **Horodatage** : `created_at TIMESTAMPTZ NOT NULL DEFAULT now()`, `updated_at TIMESTAMPTZ NOT NULL DEFAULT now()` (trigger ou `onupdate` SQLAlchemy) sur toutes les tables. +- **Montants** : `NUMERIC(12,2)` **signé**. Convention : **négatif = débit/dépense, positif = crédit/revenu**. Côté Python : `decimal.Decimal` exclusivement (jamais `float`). Côté JSON : nombres à 2 décimales. +- **Devise** : `CHAR(3)` ISO 4217, défaut `'EUR'`. **v1 : aucune conversion de change** — les statistiques agrègent uniquement les transactions en EUR ; les autres devises sont listées mais exclues des agrégats (champ `excluded_foreign_currency` dans les réponses stats si pertinent). +- **Schéma SQL** : toutes les tables du module vivent dans le schéma PostgreSQL par défaut `public`, préfixées `fin_` pour éviter les collisions inter-modules (`fin_accounts`, `fin_transactions`, …). Les modèles SQLAlchemy vivent dans `api/app/modules/finance/models.py`. + +### 1.2 Diagramme entités-relations + +```mermaid +erDiagram + users ||--o{ fin_accounts : owns + users ||--o{ fin_categories : owns + users ||--o{ fin_rules : owns + users ||--o{ fin_budgets : owns + users ||--o{ fin_import_runs : owns + users ||--o{ fin_source_profiles : owns + fin_accounts ||--o{ fin_transactions : contains + fin_categories ||--o{ fin_categories : parent + fin_categories ||--o{ fin_transactions : categorizes + fin_categories ||--o{ fin_budgets : budgeted + fin_import_runs ||--o{ fin_transactions : imported_by + fin_source_profiles ||--o{ fin_import_runs : used_by +``` + +--- + +## 2. Modèle de données — DDL exact + +Tous les `CREATE TYPE` / `CREATE TABLE` ci-dessous sont la **référence normative**. Les migrations Alembic doivent produire exactement ces structures (ordre de création : types → `fin_source_profiles` → `fin_accounts` → `fin_categories` → `fin_import_runs` → `fin_transactions` → `fin_rules` → `fin_budgets`). + +### 2.0 Types énumérés + +```sql +CREATE TYPE fin_account_kind AS ENUM ('checking', 'savings', 'paypal', 'cash', 'other'); +CREATE TYPE fin_category_kind AS ENUM ('income', 'expense', 'transfer'); +CREATE TYPE fin_source_kind AS ENUM ('csv', 'ofx', 'paypal_csv'); +CREATE TYPE fin_import_status AS ENUM ('pending', 'running', 'completed', 'failed'); +CREATE TYPE fin_category_source AS ENUM ('rule', 'user'); +``` + +> SQLAlchemy : mapper via `sqlalchemy.Enum(..., name="fin_account_kind", create_type=False)` et créer les types dans la migration. Côté Python, définir des `enum.StrEnum` équivalents dans `api/app/modules/finance/enums.py` (ex. `AccountKind.CHECKING = "checking"`). + +### 2.1 `fin_accounts` — comptes + +```sql +CREATE TABLE fin_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, -- ex. "Compte courant BoursoBank" + kind fin_account_kind NOT NULL DEFAULT 'checking', + currency CHAR(3) NOT NULL DEFAULT 'EUR', + institution VARCHAR(100), -- ex. "BoursoBank", "PayPal" + iban_masked VARCHAR(34), -- ex. "FR76 **** **** **** 1234" ; jamais l'IBAN complet + initial_balance NUMERIC(12,2) NOT NULL DEFAULT 0, -- solde au point zéro (avant la 1re transaction importée) + is_archived BOOLEAN NOT NULL DEFAULT FALSE, -- masqué des filtres par défaut, données conservées + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_fin_accounts_user_name UNIQUE (user_id, name) +); +CREATE INDEX ix_fin_accounts_user ON fin_accounts(user_id); +``` + +Notes : +- **Ne jamais stocker l'IBAN complet.** Si un import OFX fournit un numéro de compte, le masquer avant stockage (`****` + 4 derniers caractères). +- `initial_balance` permet de calculer un solde courant : `initial_balance + SUM(amount)`. Le solde n'est pas stocké, il est calculé. +- La suppression d'un compte est **refusée** (HTTP 409) s'il contient des transactions ; proposer l'archivage à la place. (`ON DELETE CASCADE` existe en base par sécurité, mais l'API bloque en amont.) + +### 2.2 `fin_categories` — catégories (arbre) + +```sql +CREATE TABLE fin_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + parent_id UUID REFERENCES fin_categories(id) ON DELETE CASCADE, -- NULL = catégorie racine + name VARCHAR(80) NOT NULL, -- ex. "Alimentation", "Courses" + icon VARCHAR(50), -- nom d'icône (set lucide-react), ex. "shopping-cart" + color CHAR(7), -- hex "#RRGGBB", hérite du parent si NULL + kind fin_category_kind NOT NULL DEFAULT 'expense', + is_system BOOLEAN NOT NULL DEFAULT FALSE, -- catégories créées au seed, non supprimables + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_fin_categories_sibling UNIQUE (user_id, parent_id, name) +); +CREATE INDEX ix_fin_categories_user ON fin_categories(user_id); +CREATE INDEX ix_fin_categories_parent ON fin_categories(parent_id); +``` + +Règles métier (validées côté API, pas en base) : +- **Profondeur maximale : 2 niveaux** (racine + enfants). Refuser la création d'un enfant sous une catégorie qui a elle-même un parent (HTTP 422). +- Un enfant hérite du `kind` de son parent (forcé à la création/édition). +- Il existe **une seule** catégorie de `kind = 'transfer'` par utilisateur : la catégorie système **« Virements internes »** (créée au seed, `is_system = TRUE`). Le moteur de virements l'utilise ; interdire création/suppression d'autres catégories `transfer`. +- La suppression d'une catégorie remet à `NULL` le `category_id` des transactions concernées (fait explicitement par l'API : `UPDATE fin_transactions SET category_id = NULL, category_source = NULL WHERE category_id IN (...)` avant le `DELETE`), et supprime les budgets et met à `NULL` les actions de règles qui la référencent. + +#### Seed par défaut (créé par le wizard de premier lancement pour chaque utilisateur) + +Racines `expense` : Alimentation (enfants : Courses, Restaurants & bars, Livraison), Logement (Loyer/Crédit, Énergie, Eau, Internet & mobile, Assurance habitation, Entretien), Transport (Carburant, Péages & parking, Transports en commun, Entretien véhicule, Assurance auto), Santé (Pharmacie, Médecin, Mutuelle), Loisirs (Abonnements & streaming, Jeux vidéo, Sorties, Sport, Vacances), Shopping (Vêtements, High-tech, Maison), Vape & tabac, Banque & frais (Frais bancaires, Intérêts), Impôts & taxes, Enfants & famille, Animaux, Dons & cadeaux, Autres dépenses. +Racines `income` : Salaire, Aides & prestations (CAF, etc.), Remboursements (Santé, Autres), Ventes (Leboncoin, Vinted…), Intérêts & placements, Autres revenus. +Racine `transfer` (système) : **Virements internes**. +Chaque racine du seed reçoit une couleur distincte de la palette du design system et un `icon` ; les enfants héritent (`color = NULL`). + +### 2.3 `fin_source_profiles` — profils de source d'import + +```sql +CREATE TABLE fin_source_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, -- NULL = preset intégré (built-in), visible par tous + name VARCHAR(100) NOT NULL, -- ex. "BoursoBank CSV", "PayPal (rapport d'activité)" + kind fin_source_kind NOT NULL, + config JSONB NOT NULL DEFAULT '{}', -- voir §3 pour le schéma exact par kind + is_builtin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT ck_fin_source_profiles_builtin CHECK ((is_builtin AND user_id IS NULL) OR (NOT is_builtin AND user_id IS NOT NULL)) +); +CREATE UNIQUE INDEX uq_fin_source_profiles_builtin_name ON fin_source_profiles(name) WHERE user_id IS NULL; +CREATE UNIQUE INDEX uq_fin_source_profiles_user_name ON fin_source_profiles(user_id, name) WHERE user_id IS NOT NULL; +``` + +- Les presets intégrés (§3.4) sont insérés par une migration de données Alembic. Ils sont **en lecture seule** via l'API ; l'utilisateur peut les **cloner** pour les personnaliser (endpoint `POST /source-profiles/{id}/clone`). + +### 2.4 `fin_import_runs` — exécutions d'import + +```sql +CREATE TABLE fin_import_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source_profile_id UUID REFERENCES fin_source_profiles(id) ON DELETE SET NULL, + account_id UUID NOT NULL REFERENCES fin_accounts(id) ON DELETE CASCADE, -- compte cible choisi à l'import + filename VARCHAR(255) NOT NULL, + file_sha256 CHAR(64) NOT NULL, -- hash du fichier brut : avertir si fichier déjà importé à l'identique + status fin_import_status NOT NULL DEFAULT 'pending', + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + finished_at TIMESTAMPTZ, + stats JSONB NOT NULL DEFAULT '{}', + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX ix_fin_import_runs_user_started ON fin_import_runs(user_id, started_at DESC); +``` + +Forme exacte de `stats` (renseignée à la fin du run) : + +```json +{ + "rows_total": 143, + "rows_imported": 120, + "rows_skipped_duplicate": 21, + "rows_skipped_filtered": 1, + "rows_error": 1, + "rules_applied": 97, + "transfers_detected": 2, + "date_min": "2026-06-01", + "date_max": "2026-07-31", + "errors": [ { "row": 57, "message": "Date invalide : '32/06/2026'" } ] +} +``` + +- `rows_skipped_filtered` : lignes volontairement ignorées par le parseur (ex. lignes PayPal de type `Authorization`, voir §3.3). +- `errors` est plafonné à 50 entrées (au-delà : `"errors_truncated": true`). +- **Rollback d'un import** : `DELETE /imports/{id}` supprime les transactions liées (`import_run_id = id`) puis le run. Refusé (409) si une transaction du run a été modifiée manuellement (`category_source = 'user'` ou notes non nulles) sauf si `?force=true`. + +### 2.5 `fin_transactions` — transactions + +```sql +CREATE TABLE fin_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES fin_accounts(id) ON DELETE CASCADE, + booked_date DATE NOT NULL, -- date comptable (celle du relevé) + value_date DATE, -- date de valeur si fournie + amount NUMERIC(12,2) NOT NULL, -- signé : négatif = débit, positif = crédit + currency CHAR(3) NOT NULL DEFAULT 'EUR', + label_raw TEXT NOT NULL, -- libellé brut d'origine, jamais modifié + label_clean TEXT NOT NULL, -- libellé lisible ; initialisé = normalisation légère de label_raw, + -- modifiable par règle ou par l'utilisateur + counterparty VARCHAR(150), -- commerçant/tiers si identifiable (règle ou saisie) + category_id UUID REFERENCES fin_categories(id) ON DELETE SET NULL, + category_source fin_category_source, -- 'rule' | 'user' | NULL (non catégorisé) + applied_rule_id UUID, -- FK logique vers fin_rules.id (pas de contrainte : règle supprimable) + notes TEXT, + import_run_id UUID REFERENCES fin_import_runs(id) ON DELETE SET NULL, -- NULL = saisie manuelle + external_id VARCHAR(255), -- FITID OFX ou Transaction ID PayPal + dedup_hash CHAR(64) NOT NULL, -- sha256 hex, voir §4.3 + transfer_group_id UUID, -- deux jambes d'un virement interne partagent ce UUID + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_fin_transactions_dedup UNIQUE (account_id, dedup_hash) +); +CREATE UNIQUE INDEX uq_fin_transactions_external + ON fin_transactions(account_id, external_id) WHERE external_id IS NOT NULL; +CREATE INDEX ix_fin_transactions_user_date ON fin_transactions(user_id, booked_date DESC); +CREATE INDEX ix_fin_transactions_account_date ON fin_transactions(account_id, booked_date DESC); +CREATE INDEX ix_fin_transactions_category ON fin_transactions(category_id); +CREATE INDEX ix_fin_transactions_transfer ON fin_transactions(transfer_group_id) WHERE transfer_group_id IS NOT NULL; +CREATE INDEX ix_fin_transactions_label_trgm ON fin_transactions USING gin (label_clean gin_trgm_ops); -- extension pg_trgm +``` + +Notes : +- Activer l'extension `pg_trgm` dans la migration (`CREATE EXTENSION IF NOT EXISTS pg_trgm;`) pour la recherche plein-texte approximative sur `label_clean` (`ILIKE '%...%'` performant). +- `applied_rule_id` est informatif (debug/traçabilité) ; volontairement **sans** contrainte FK pour que la suppression d'une règle ne touche pas aux transactions. +- Saisie manuelle : `import_run_id = NULL`, `dedup_hash` calculé quand même (mêmes règles §4.3) pour protéger d'un doublon avec un import futur, `external_id = NULL`. +- Une transaction avec `transfer_group_id IS NOT NULL` a toujours `category_id` = catégorie système « Virements internes » et `category_source` conservé tel quel (`rule`/`user`/NULL selon origine du marquage). + +### 2.6 `fin_rules` — règles de catégorisation + +```sql +CREATE TABLE fin_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, -- ex. "Courses Carrefour" + priority INTEGER NOT NULL DEFAULT 100, -- croissant = évalué en premier (1 avant 100) + enabled BOOLEAN NOT NULL DEFAULT TRUE, + stop BOOLEAN NOT NULL DEFAULT TRUE, -- TRUE : arrêt après application ; FALSE : les règles suivantes continuent + matchers JSONB NOT NULL, -- schéma §5.1 + actions JSONB NOT NULL, -- schéma §5.2 + hit_count INTEGER NOT NULL DEFAULT 0, -- compteur cumulé d'applications (informatif) + last_applied_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX ix_fin_rules_user_priority ON fin_rules(user_id, priority, created_at); +``` + +Ordre d'évaluation déterministe : `ORDER BY priority ASC, created_at ASC, id ASC`. + +### 2.7 `fin_budgets` — budgets mensuels + +```sql +CREATE TABLE fin_budgets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category_id UUID NOT NULL REFERENCES fin_categories(id) ON DELETE CASCADE, + monthly_amount NUMERIC(12,2) NOT NULL CHECK (monthly_amount > 0), -- toujours positif (plafond de dépense) + start_month DATE NOT NULL, -- toujours le 1er du mois, ex. '2026-01-01' ; CHECK ci-dessous + end_month DATE, -- 1er du dernier mois inclus ; NULL = sans fin + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT ck_fin_budgets_first_of_month CHECK (date_trunc('month', start_month) = start_month + AND (end_month IS NULL OR date_trunc('month', end_month) = end_month)), + CONSTRAINT ck_fin_budgets_range CHECK (end_month IS NULL OR end_month >= start_month) +); +CREATE INDEX ix_fin_budgets_user_cat ON fin_budgets(user_id, category_id); +``` + +Règles métier : +- Un budget cible une catégorie `kind = 'expense'` uniquement (validation API). +- **Non-chevauchement** : pour une même `category_id`, les périodes `[start_month, end_month]` ne doivent pas se chevaucher. Validé côté API à la création/édition (requête d'intersection ; HTTP 409 si conflit). Changer le montant d'un budget « à partir du mois M » = clore l'ancien (`end_month = M - 1 mois`) + créer le nouveau (`start_month = M`) — l'API `PUT` propose ce comportement via le paramètre `effective_from`. +- Un budget sur une catégorie **racine** couvre le rollup de tout son sous-arbre ; un budget sur un **enfant** ne couvre que lui. Si les deux existent, ils sont affichés tous les deux (le budget racine inclut la consommation de l'enfant — documenté dans l'UI). + +--- + +## 3. Profils de source : schémas `config` et presets intégrés + +### 3.1 `kind = 'csv'` — CSV générique à mapping configurable + +Schéma JSON complet de `config` (toutes clés présentes ; valeurs par défaut indiquées) : + +```json +{ + "encoding": "cp1252", // "utf-8" | "utf-8-sig" | "cp1252" | "iso-8859-15" | "auto" (voir §4.1) + "delimiter": ";", // ";" | "," | "\t" | "|" + "quote_char": "\"", + "decimal_separator": ",", // "," | "." + "thousands_separator": " ", // "" | " " | "." | " " (espace insécable, fréquent dans les exports FR) + "date_format": "%d/%m/%Y", // format strptime Python + "has_header": true, + "skip_rows_top": 0, // lignes de préambule à ignorer AVANT l'en-tête (relevés CA/LBP) + "skip_rows_bottom": 0, // lignes de pied (totaux) à ignorer + "columns": { + // Chaque valeur est SOIT un nom de colonne d'en-tête (string), SOIT un index 0-based (int) si has_header=false. + "booked_date": "dateOp", // OBLIGATOIRE + "value_date": "dateVal", // optionnel -> null + "label": "label", // OBLIGATOIRE + "amount": "amount", // mode "signed" : obligatoire ; sinon null + "debit": null, // mode "split" : colonne débit (valeurs positives ou déjà négatives) + "credit": null, // mode "split" : colonne crédit + "currency": null, // optionnel ; sinon devise du compte cible + "external_id": null // optionnel (rare en CSV bancaire) + }, + "amount_mode": "signed", // "signed" (une colonne signée) | "split" (colonnes débit/crédit séparées) + "invert_sign": false, // true si la banque exporte les débits en positif dans la colonne signée + "label_join": [], // colonnes supplémentaires concaténées au label (ex. ["category","supplierFound"]) — " — " comme séparateur + "skip_row_if": [] // filtres d'exclusion : [{"column": "type", "equals": "Solde"}] +} +``` + +Règles de parsing : +- Mode `split` : `amount = credit - abs(debit)` ; une ligne doit avoir exactement une des deux colonnes non vide, sinon `rows_error`. +- Montants : retirer `thousands_separator` et les espaces, remplacer `decimal_separator` par `.`, retirer un éventuel symbole `€` ou `EUR`, parser en `Decimal`. Gérer le signe unicode `−` (U+2212) comme `-`. +- Dates : parser avec `datetime.strptime(value.strip(), date_format).date()`. Échec → ligne en erreur (l'import continue). +- Résolution des colonnes par nom : insensible à la casse et aux accents, espaces trimés. + +### 3.2 `kind = 'ofx'` — OFX (Open Financial Exchange) + +`config` : + +```json +{ + "fallback_encoding": "cp1252", // utilisé si l'en-tête OFX ne déclare pas de charset exploitable + "account_match": null // optionnel : si le fichier contient plusieurs comptes, ACCTID à retenir ; null = premier compte + avertissement +} +``` + +Spécification de parsing (l'implémentation utilise la bibliothèque **`ofxtools`** ; si le fichier la fait échouer, fallback sur un parseur tolérant par expressions régulières décrit ci-dessous) : +- **OFX 1.x (SGML)** : en-tête texte `OFXHEADER:100 ... ENCODING:USASCII / CHARSET:1252`. Les banques françaises livrent quasi systématiquement du **cp1252**. Décoder selon `CHARSET` si présent, sinon `fallback_encoding`. +- **OFX 2.x (XML)** : prologue XML standard, encodage déclaré dans le prologue. +- Champs extraits par transaction (bloc ``) : + - `DTPOSTED` → `booked_date`. Format `YYYYMMDD` éventuellement suivi de `HHMMSS[.XXX][gmt offset]` — ne garder que les 8 premiers chiffres, **sans conversion de fuseau**. + - `TRNAMT` → `amount` (point décimal, déjà signé — négatif = débit). + - `FITID` → `external_id` (déduplication prioritaire, §4.3). + - `NAME` + `MEMO` → `label_raw = NAME` si `MEMO` vide, sinon `NAME + " — " + MEMO` (si `NAME` absent : `MEMO` seul). + - `TRNTYPE` : ignoré pour le montant (déjà signé), mais concaténé nulle part ; conservé uniquement si besoin futur. + - `CURDEF` du relevé → `currency`. +- Fallback regex (OFX 1.x mal formé, très courant) : découper sur ``, extraire chaque champ par `r"([^<\r\n]*)"`. Tolérer l'absence de balises fermantes (SGML). + +### 3.3 `kind = 'paypal_csv'` — export d'activité PayPal + +PayPal (paypal.com → Activité → Télécharger → « Tous les types de transactions », CSV). Colonnes du rapport FR (l'ordre peut varier, résoudre **par nom d'en-tête**, insensible casse/accents) : `Date`, `Heure`, `Fuseau horaire`, `Nom`, `Type`, `État`, `Devise`, `Brut`, `Frais`, `Net`, `Adresse email de l'expéditeur`, `Adresse email du destinataire`, `Numéro de transaction`, `Titre de l'objet`, `Numéro de la transaction de référence`, `Solde`, ... +Le rapport EN utilise : `Date`, `Time`, `TimeZone`, `Name`, `Type`, `Status`, `Currency`, `Gross`, `Fee`, `Net`, `From Email Address`, `To Email Address`, `Transaction ID`, `Item Title`, `Reference Txn ID`, `Balance`. Le parseur mappe les **deux** jeux d'en-têtes (dictionnaire d'alias intégré au code). + +`config` : + +```json +{ + "encoding": "utf-8-sig", // PayPal exporte en UTF-8 avec BOM + "delimiter": ",", + "decimal_separator": ",", // export FR : virgule ; export EN : point — "auto" essaie "," puis "." + "date_format": "%d/%m/%Y", + "use_net_amount": true, // montant = Net (Brut - Frais) ; false = Brut + "skip_types": [ + "Autorisation", "Authorization", + "Commande", "Order", + "Annulation d'autorisation", "Void of Authorization", + "Retenue pour vérification par PayPal", "Payment Review Hold", + "Annulation de la retenue", "Payment Review Release" + ], + "skip_status_not_completed": true, // ne garder que État = "Effectué"/"Completed" + "conversion_as_skip": true // lignes "Conversion de devise générale"/"General Currency Conversion" ignorées (comptées en rows_skipped_filtered) +} +``` + +Règles spécifiques : +- `Numéro de transaction` / `Transaction ID` → `external_id` (**toujours présent et unique** chez PayPal : c'est la clé de dédup prioritaire). +- `label_raw = Nom + " — " + Type` (+ `" — " + Titre de l'objet` si non vide). `counterparty` = `Nom` directement. +- `amount` = colonne `Net` (signée par PayPal : paiement envoyé = négatif). `currency` = colonne `Devise`. +- Les paires de conversion de devise (deux lignes, une par devise) sont ignorées si `conversion_as_skip = true` — sinon elles créeraient un faux revenu et une fausse dépense. Le montant réellement débité apparaît sur la ligne de paiement principale. +- Le compte cible d'un import PayPal est typiquement un compte `kind = 'paypal'`. Le rechargement PayPal depuis la banque apparaîtra des deux côtés → détecté comme virement interne (§6). + +### 3.4 Presets intégrés (migration de données) + +8 lignes insérées avec `user_id = NULL, is_builtin = TRUE` : + +| `name` | `kind` | Particularités du `config` | +|---|---|---| +| `CSV générique` | `csv` | Le config de §3.1 tel quel (valeurs par défaut) ; l'UI d'import propose un « aperçu + mapping » basé sur ce preset. | +| `BoursoBank / Boursorama (CSV)` | `csv` | `delimiter=";"`, `encoding="utf-8-sig"`, `date_format="%Y-%m-%d"`, colonnes : `booked_date="dateOp"`, `value_date="dateVal"`, `label="label"`, `amount="amount"`, `amount_mode="signed"`, `decimal_separator=","`, `label_join=["supplierFound"]`. | +| `Crédit Agricole (CSV)` | `csv` | `delimiter=";"`, `encoding="cp1252"`, `date_format="%d/%m/%Y"`, `skip_rows_top=9` (préambule de relevé), `amount_mode="split"`, colonnes : `booked_date="Date"`, `label="Libellé"`, `debit="Débit euros"`, `credit="Crédit euros"`, `decimal_separator=","`, `thousands_separator=" "`. | +| `La Banque Postale (CSV)` | `csv` | `delimiter=";"`, `encoding="cp1252"`, `date_format="%d/%m/%Y"`, `skip_rows_top=6`, `amount_mode="signed"`, colonnes : `booked_date="Date"`, `label="Libellé"`, `amount="Montant(EUROS)"`, `decimal_separator=","`. | +| `Société Générale (CSV)` | `csv` | `delimiter=";"`, `encoding="cp1252"`, `date_format="%d/%m/%Y"`, `skip_rows_top=2`, `amount_mode="signed"`, colonnes : `booked_date="Date de l'opération"` (alias `"Date"`), `label="Libellé"` (alias `"Détail de l'écriture"`), `amount="Montant de l'opération"` (alias `"Montant"`), `decimal_separator=","`. | +| `Fortuneo (CSV)` | `csv` | `delimiter=";"`, `encoding="cp1252"`, `date_format="%d/%m/%Y"`, `amount_mode="split"`, colonnes : `booked_date="Date opération"`, `value_date="Date valeur"`, `label="Libellé"`, `debit="Débit"`, `credit="Crédit"`, `decimal_separator=","`. | +| `OFX (toutes banques)` | `ofx` | Config §3.2 par défaut. À privilégier quand la banque propose l'OFX (dédup via FITID). | +| `PayPal — rapport d'activité (CSV)` | `paypal_csv` | Config §3.3 par défaut. | + +> **Important pour l'implémentation** : les layouts CSV des banques changent régulièrement. Ces presets sont des **valeurs de départ raisonnables** ; l'écran d'import doit TOUJOURS afficher un aperçu des 20 premières lignes parsées (dates, montants, libellés) avant confirmation, et permettre d'ajuster le mapping (ce qui clone le preset en profil utilisateur). Les alias de noms de colonnes indiqués ci-dessus sont mis dans le config sous forme de listes : toute valeur de `columns.*` peut être `string | int | string[]` (première colonne trouvée dans l'en-tête). + +--- + +## 4. Pipeline d'import — logique détaillée + +Module : `api/app/modules/finance/importer/`. Point d'entrée : `run_import(user, account, profile, file_bytes, filename) -> ImportRun`. Exécution **synchrone** dans la requête HTTP (fichiers < 5 Mo, quelques milliers de lignes : < 2 s) ; statut `running` → `completed`/`failed`. Limite upload : 20 Mo (HTTP 413 au-delà). + +### 4.1 Étape 1 — Décodage + +```python +def decode_bytes(raw: bytes, encoding_cfg: str) -> str: + if raw.startswith(b"\xef\xbb\xbf"): + return raw.decode("utf-8-sig") + if encoding_cfg != "auto": + return raw.decode(encoding_cfg, errors="replace") + # mode "auto" : utf-8 strict d'abord, sinon cp1252 (jamais d'échec : cp1252 décode tout octet) + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return raw.decode("cp1252") +``` + +### 4.2 Étapes 2–3 — Parsing + normalisation + +Chaque parseur (`CsvParser`, `OfxParser`, `PaypalCsvParser` — sélectionné par `profile.kind`) produit une liste de `NormalizedRow` : + +```python +@dataclass +class NormalizedRow: + booked_date: date + value_date: date | None + amount: Decimal # signé, quantifié à 2 décimales : amount.quantize(Decimal("0.01"), ROUND_HALF_UP) + currency: str # ISO 4217 upper ; défaut = account.currency + label_raw: str # brut, trimé, sauts de ligne remplacés par un espace + counterparty: str | None # renseigné uniquement par le parseur PayPal + external_id: str | None + source_row_index: int # index 1-based dans le fichier (pour les messages d'erreur) +``` + +Les lignes invalides (date/montant imparsables) sont collectées dans `stats.errors` **sans interrompre** l'import. Si `rows_error == rows_total` (aucune ligne valide), le run passe en `failed` avec `error_message = "Aucune ligne exploitable — vérifiez le profil de source."`. + +### 4.3 Étape 4 — Normalisation du libellé et `dedup_hash` + +Deux normalisations distinctes, dans `importer/normalize.py` : + +```python +def normalize_label_for_hash(label: str) -> str: + """Normalisation STABLE et CONSERVATRICE : utilisée pour le hash de dédup. + Ne retire aucune information variable, sinon deux transactions distinctes + fusionneraient. Uppercase + accents retirés + espaces normalisés, rien d'autre.""" + s = unicodedata.normalize("NFKD", label) + s = "".join(c for c in s if not unicodedata.combining(c)) # é -> e + s = s.upper() + s = re.sub(r"\s+", " ", s).strip() + return s + +def compute_dedup_hash(account_id: UUID, booked_date: date, amount: Decimal, + label_raw: str, occurrence: int) -> str: + canonical = "|".join([ + str(account_id), + booked_date.isoformat(), # "2026-08-13" + f"{amount:.2f}", # "-12.50" (signe inclus) + normalize_label_for_hash(label_raw), + str(occurrence), # rang parmi les lignes identiques du MÊME fichier (0,1,2…) + ]) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() +``` + +- **`occurrence`** résout le cas réel de deux transactions identiques le même jour (deux cafés à 2,50 €) : dans un même fichier, les lignes de tuple `(booked_date, amount, label_norm)` identique sont numérotées 0, 1, 2… dans l'ordre du fichier. Les exports bancaires étant cumulatifs et ordonnés, un ré-import chevauchant reproduit les mêmes rangs → dédup correcte, sans perdre de vraie transaction dupliquée. +- Le hash inclut `account_id` par défense en profondeur, même si la contrainte est déjà scopée compte. +- `label_clean` initial = normalisation **légère** de `label_raw` : trim + espaces collapsés + suppression des préfixes bancaires purement techniques via la regex `^(CARTE \d{2}/\d{2}(/\d{2,4})? |PAIEMENT (PSC |CB )?\d{4} |PRLV SEPA |VIR SEPA |VIR INST |ACHAT CB )` (insensible à la casse). Le reste est conservé tel quel — les règles et l'utilisateur affinent ensuite. + +### 4.4 Étapes 5–7 — Dédup, règles, insertion (pseudocode complet) + +```python +def run_import(user, account, profile, file_bytes, filename) -> ImportRun: + run = create_import_run(user, account, profile, filename, + file_sha256=sha256(file_bytes), status="running") + # Avertissement fichier identique (non bloquant) : + # si un run 'completed' du même user a le même file_sha256 -> stats["duplicate_file_of"] = run_id + text = decode_bytes(file_bytes, profile.config.get("encoding", "auto")) + rows = PARSERS[profile.kind](profile.config, text, account).parse() # list[NormalizedRow] + erreurs + + # -- numérotation des occurrences intra-fichier -- + counter: dict[tuple, int] = defaultdict(int) + prepared = [] + for row in rows: + key = (row.booked_date, row.amount, normalize_label_for_hash(row.label_raw)) + occ = counter[key]; counter[key] += 1 + prepared.append((row, compute_dedup_hash(account.id, row.booked_date, + row.amount, row.label_raw, occ))) + + # -- pré-chargement des doublons existants (1 requête, pas N) -- + dmin, dmax = min(r.booked_date for r, _ in prepared), max(r.booked_date for r, _ in prepared) + existing_hashes = set(select fin_transactions.dedup_hash + where account_id = account.id and booked_date between dmin and dmax) + existing_ext = set(select external_id ... where external_id is not null + and account_id = account.id) # sans borne de date : FITID est global au compte + + rules = load_enabled_rules(user) # ORDER BY priority, created_at, id + to_insert, skipped = [], 0 + for row, dhash in prepared: + if (row.external_id and row.external_id in existing_ext) or dhash in existing_hashes: + skipped += 1 + continue + existing_hashes.add(dhash) # protège aussi des doublons intra-fichier après occurrence + tx = build_transaction(user, account, row, dhash, run, + label_clean=light_clean(row.label_raw)) + apply_rules_to_tx(tx, rules) # §5.3 — mutation en mémoire avant insert + to_insert.append(tx) + + bulk_insert(to_insert) # une seule transaction SQL pour tout le run + transfers = detect_transfers(user, date_min=dmin - timedelta(days=3), + date_max=dmax + timedelta(days=3)) # §6 + finalize_run(run, stats={...}, status="completed") + return run +``` + +Garanties : +- **Idempotence** : ré-importer le même fichier (ou un export chevauchant) n'insère aucun doublon. +- **Atomicité** : l'insertion des transactions du run est une transaction SQL unique ; en cas d'exception, rollback complet et `status = 'failed'`. +- La détection de virements est relancée automatiquement sur la fenêtre de dates importée (± 3 jours). + +--- + +## 5. Moteur de règles + +### 5.1 Schéma JSON de `matchers` + +Toutes les conditions présentes sont **combinées en ET**. Clés absentes ou `null` = non contraint. Au moins une clé non nulle exigée (validation API). + +```json +{ + "label_contains": ["CARREFOUR", "CRF "], // OU logique entre les éléments ; comparaison sur + // normalize_label_for_hash(label_raw) ET sur label_clean normalisé + // (insensible casse/accents) ; match par sous-chaîne + "label_regex": "^CB\\s+CARREFOUR\\b", // regex Python (module re), flags IGNORECASE appliqué, + // testée sur label_raw puis label_clean ; regex invalide = 422 à la sauvegarde + "amount_min": -200.00, // borne inférieure INCLUSIVE sur le montant SIGNÉ + "amount_max": -0.01, // borne supérieure INCLUSIVE sur le montant SIGNÉ + "direction": "debit", // "debit" (amount < 0) | "credit" (amount > 0) | "any" (défaut) + "account_id": null // UUID : restreint la règle à un compte +} +``` + +> Note UX : l'UI présente `amount_min`/`amount_max` en valeur absolue avec le sélecteur `direction` ; l'API stocke les bornes signées telles quelles. + +### 5.2 Schéma JSON de `actions` + +Au moins une clé non nulle exigée. + +```json +{ + "set_category_id": "d290f1ee-...", // UUID d'une catégorie de l'utilisateur (validé à la sauvegarde) + "set_label_clean": "Carrefour", // remplace label_clean + "set_counterparty": "Carrefour", // remplace counterparty + "mark_transfer": false // true : marque la transaction comme virement interne + // (catégorie forcée = « Virements internes » ; transfer_group_id reste NULL + // jusqu'à appariement par le détecteur §6 — jambe « orpheline » acceptée) +} +``` + +### 5.3 Application — pseudocode + +```python +def rule_matches(rule, tx) -> bool: + m = rule.matchers + if m.get("account_id") and str(tx.account_id) != m["account_id"]: return False + if m.get("direction") == "debit" and tx.amount >= 0: return False + if m.get("direction") == "credit" and tx.amount <= 0: return False + if m.get("amount_min") is not None and tx.amount < Decimal(str(m["amount_min"])): return False + if m.get("amount_max") is not None and tx.amount > Decimal(str(m["amount_max"])): return False + if m.get("label_contains"): + hay = normalize_label_for_hash(tx.label_raw) + " | " + normalize_label_for_hash(tx.label_clean) + if not any(normalize_label_for_hash(n) in hay for n in m["label_contains"]): return False + if m.get("label_regex"): + rx = compiled_regex_cache[rule.id] # re.compile(pattern, re.IGNORECASE) mis en cache + if not (rx.search(tx.label_raw) or rx.search(tx.label_clean)): return False + return True + +def apply_rules_to_tx(tx, rules) -> None: + """Ne touche JAMAIS une catégorisation manuelle (category_source == 'user'), + sauf mode force explicite (§5.4).""" + for rule in rules: # déjà triées par priorité + if not rule_matches(rule, tx): continue + a = rule.actions + if a.get("set_category_id") and tx.category_source != "user": + tx.category_id, tx.category_source, tx.applied_rule_id = a["set_category_id"], "rule", rule.id + if a.get("set_label_clean"): tx.label_clean = a["set_label_clean"] + if a.get("set_counterparty"): tx.counterparty = a["set_counterparty"] + if a.get("mark_transfer") and tx.category_source != "user": + tx.category_id, tx.category_source = transfer_category_id(tx.user_id), "rule" + rule.hit_count += 1 + if rule.stop: break +``` + +### 5.4 Ré-exécution à la demande (`POST /rules/apply`) + +Paramètres : `scope` (`"uncategorized"` par défaut — uniquement `category_id IS NULL` ; `"all_non_manual"` — tout sauf `category_source = 'user'` ; `"all"` — tout, **écrase** même le manuel, protégé par `force: true` obligatoire), `date_from`/`date_to` optionnels, `account_id` optionnel, `rule_id` optionnel (tester une seule règle), `dry_run` (défaut `false`). + +Traitement par lots de 1 000 transactions (streaming SQLAlchemy `yield_per`), réponse : + +```json +{ "scanned": 4210, "matched": 1830, "updated": 1790, "dry_run": false, + "by_rule": [ { "rule_id": "…", "name": "Courses Carrefour", "matched": 240 } ] } +``` + +En `dry_run`, rien n'est écrit (rollback), les compteurs sont retournés à l'identique. + +--- + +## 6. Détection de virements internes + +Service `detect_transfers(user, date_min=None, date_max=None) -> int` (nombre de paires créées). Lancé : automatiquement en fin d'import (fenêtre du fichier ± 3 jours), et à la demande (`POST /transfers/detect`). + +```python +def detect_transfers(user, date_min=None, date_max=None) -> int: + # Candidats : transactions non encore appariées, comptes de l'utilisateur, même devise + txs = load(user, transfer_group_id=None, date_between=(date_min, date_max)) + debits = [t for t in txs if t.amount < 0] + credits_by_amount = index_by(lambda t: -t.amount, [t for t in txs if t.amount > 0]) + + pairs = [] + for d in debits: + for c in credits_by_amount.get(-d.amount, []): + if c.account_id == d.account_id: continue # même compte : pas un virement + if c.currency != d.currency: continue + delta = abs((c.booked_date - d.booked_date).days) + if delta > 3: continue # tolérance ±3 jours + score = (3 - delta) * 10 # proximité de date d'abord + joined = normalize_label_for_hash(d.label_raw + " " + c.label_raw) + if re.search(r"\bVIR(EMENT)?\b|\bVIRT\b|TRANSFERT|PAYPAL", joined): score += 5 + pairs.append((score, d, c)) + + pairs.sort(key=lambda p: (-p[0], p[1].booked_date)) # gloutonne : meilleurs scores d'abord + used, created = set(), 0 + for score, d, c in pairs: + if d.id in used or c.id in used: continue + gid = uuid4() + for t in (d, c): + t.transfer_group_id = gid + if t.category_source != "user": # ne pas écraser un choix manuel + t.category_id, t.category_source = transfer_category_id(user.id), "rule" + used |= {d.id, c.id}; created += 1 + return created +``` + +Règles associées : +- **Exclusion des statistiques** : toute requête d'agrégat de dépenses/revenus (§8) filtre `transfer_group_id IS NULL AND (category IS NULL OR category.kind <> 'transfer')`. Les virements restent visibles dans la liste des transactions (badge « Virement interne »). +- **Liaison manuelle** : `POST /transfers/link {transaction_id_a, transaction_id_b}` — valide montants opposés (sinon 422 avec message explicite ; tolérance zéro sur le montant), comptes différents ; crée le groupe. +- **Déliaison** : `DELETE /transfers/{transfer_group_id}` — remet `transfer_group_id = NULL` sur les deux jambes et `category_id = NULL, category_source = NULL` si la catégorie était « Virements internes » posée par `rule`. +- Une jambe peut rester orpheline (ex. compte PayPal pas encore importé) : marquée transfer par règle (§5.2 `mark_transfer`), elle sera appariée au prochain `detect_transfers`. + +--- + +## 7. Détection de récurrences + +Service **calculé à la volée** (pas de table dédiée en v1 ; si le temps de calcul dépasse ~200 ms sur des volumes réels, ajouter une table de cache `fin_recurring_series` recalculée après chaque import — hors périmètre v1). Module : `finance/recurring.py`. + +### 7.1 Clé de regroupement (`merchant_key`) + +Normalisation **agressive**, distincte de celle du hash : + +```python +def merchant_key(label_raw: str, counterparty: str | None) -> str: + if counterparty: + return normalize_label_for_hash(counterparty) + s = normalize_label_for_hash(label_raw) + s = re.sub(r"\b\d{2}/\d{2}(/\d{2,4})?\b", "", s) # dates dans le libellé + s = re.sub(r"\b\d{4,}\b", "", s) # numéros (carte, référence, facture) + s = re.sub(r"\b(CB|CARTE|PRLV|SEPA|VIR|ECH|PAIEMENT|ACHAT|WEB|FACT(URE)?)\b", "", s) + s = re.sub(r"[^A-Z0-9 ]", " ", s) + s = re.sub(r"\s+", " ", s).strip() + return s[:60] +``` + +### 7.2 Algorithme + +```python +PERIODICITIES = [ # (nom, intervalle_min_jours, intervalle_max_jours, intervalle_nominal) + ("weekly", 6, 8, 7), + ("monthly", 25, 35, 30), # tolérance : prélèvements glissant autour du même jour du mois + ("quarterly",85, 97, 91), + ("yearly", 350, 380, 365), +] + +def detect_recurring(user, lookback_months=18) -> list[RecurringSeries]: + txs = load_expenses(user, since=today - relativedelta(months=lookback_months), + exclude_transfers=True) # dépenses uniquement (amount < 0) + groups = group_by(lambda t: (merchant_key(t.label_raw, t.counterparty)), txs) + series = [] + for key, items in groups.items(): + if len(items) < 3 or not key: continue + items.sort(key=lambda t: t.booked_date) + # dédoublonner les jours multiples (2 achats même jour même commerçant = 1 occurrence datée) + dates = sorted({t.booked_date for t in items}) + if len(dates) < 3: continue + intervals = [ (b - a).days for a, b in zip(dates, dates[1:]) ] + med_int = median(intervals) + period = next((p for p in PERIODICITIES if p[1] <= med_int <= p[2]), None) + if period is None: continue + # régularité : au moins 80 % des intervalles dans la fenêtre de la périodicité + ok = sum(1 for i in intervals if period[1] <= i <= period[2]) + if ok / len(intervals) < 0.8: continue + amounts = [abs(t.amount) for t in items] + med_amt = median(amounts) + # stabilité du montant : écart absolu médian <= max(1 €, 10 % du montant médian) + mad = median([abs(a - med_amt) for a in amounts]) + if mad > max(Decimal("1.00"), med_amt * Decimal("0.10")): continue + series.append(RecurringSeries( + merchant_key=key, + label_display=most_common(t.label_clean for t in items), + category=most_common_category(items), # catégorie majoritaire, sinon None + periodicity=period[0], + occurrences=len(dates), + average_amount=round(mean(amounts), 2), + expected_amount=med_amt, # prédiction = médiane (robuste) + last_date=dates[-1], + next_date_predicted=dates[-1] + timedelta(days=int(round(median(intervals)))), + is_active=(today - dates[-1]).days <= period[2] * 2, # inactif si 2 périodes manquées + )) + series.sort(key=lambda s: (-s.is_active, s.next_date_predicted)) + return series +``` + +Les revenus récurrents (salaire) sont détectés par le même algorithme exécuté sur `amount > 0` (paramètre `direction` de l'endpoint, §9.8). + +--- + +## 8. Agrégats — définitions de calcul + +### 8.1 Périmètre commun des statistiques + +Toutes les stats de dépenses/revenus utilisent le prédicat commun (CTE ou vue SQL `fin_stats_base`) : + +```sql +SELECT t.*, COALESCE(c_parent.id, c.id) AS root_category_id +FROM fin_transactions t +LEFT JOIN fin_categories c ON c.id = t.category_id +LEFT JOIN fin_categories c_parent ON c_parent.id = c.parent_id +WHERE t.user_id = :user_id + AND t.currency = 'EUR' + AND t.transfer_group_id IS NULL + AND (c.kind IS NULL OR c.kind <> 'transfer') + AND (:account_ids IS NULL OR t.account_id = ANY(:account_ids)) +``` + +Mois d'une transaction : `to_char(booked_date, 'YYYY-MM')` — les dates sont civiles, aucune conversion de fuseau. + +### 8.2 Rollup de l'arbre de catégories + +L'arbre étant limité à 2 niveaux, le rollup se fait par le `LEFT JOIN` sur le parent ci-dessus (`root_category_id`). Le total d'une catégorie **racine** = ses transactions directes + celles de tous ses enfants. Une transaction sans catégorie va dans le pseudo-groupe `"uncategorized"` (affiché « Non catégorisé », couleur grise `#9ca3af`). Si l'arbre devait un jour dépasser 2 niveaux, remplacer le join par une CTE récursive — non nécessaire en v1. + +### 8.3 Budget vs réalisé (mois M) + +``` +budget applicable = fin_budgets où start_month <= M et (end_month IS NULL ou end_month >= M) +actual(catégorie) = SOMME(ABS(amount)) des dépenses (amount < 0) du périmètre §8.1 pour le mois M + sur le sous-arbre de la catégorie budgétée (elle-même + enfants si racine) +progress_pct = round(actual / monthly_amount * 100, 1) (peut dépasser 100) +remaining = monthly_amount - actual (peut être négatif) +projected_eom = mois courant uniquement : actual / jours_écoulés * jours_du_mois (linéaire, arrondi 2 déc.) +``` + +--- + +## 9. API — spécification des endpoints + +Préfixe commun : **`/api/v1/finance`**. Auth : JWT Bearer obligatoire partout (dépendance FastAPI commune du socle) ; le `user_id` provient exclusivement du token. Erreurs : enveloppe commune du socle `{ "detail": "message en français" }` avec codes 400/401/404/409/413/422. Validation : schémas Pydantic v2 dans `finance/schemas.py`. + +**Pagination** (listes) : `?page=1&page_size=50` (max 200) → `{ "items": [...], "total": 1234, "page": 1, "page_size": 50 }`. +**Dates** : query params en ISO `YYYY-MM-DD` ; mois en `YYYY-MM`. +**Montants JSON** : nombres (sérialisation `Decimal` → 2 décimales via encoder Pydantic). + +### 9.1 Comptes + +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/accounts` | Liste (query `include_archived=false`). Chaque item inclut `balance` (calculé : `initial_balance + SUM(amount)`) et `transaction_count`. | +| `POST` | `/accounts` | Corps : `{name, kind, currency?, institution?, iban_masked?, initial_balance?}`. 409 si nom déjà pris. | +| `PATCH` | `/accounts/{id}` | Mise à jour partielle des mêmes champs + `is_archived`. | +| `DELETE` | `/accounts/{id}` | 409 si transactions existantes (message : « Archivez le compte à la place. »). | + +### 9.2 Transactions + +`GET /transactions` — filtres (tous optionnels, combinés en ET) : + +| Param | Type | Sémantique | +|---|---|---| +| `date_from`, `date_to` | date | Sur `booked_date`, bornes incluses. | +| `account_id` | UUID, répétable | Multi-comptes. | +| `category_id` | UUID ou littéral `none`, répétable | `none` = non catégorisées. Une catégorie **racine** inclut ses enfants. | +| `q` | string | `ILIKE '%q%'` (via pg_trgm) sur `label_clean`, `label_raw`, `counterparty`, `notes`. | +| `direction` | `debit`\|`credit` | Signe du montant. | +| `amount_min`, `amount_max` | number | Sur `ABS(amount)`. | +| `is_transfer` | bool | `transfer_group_id IS (NOT) NULL`. | +| `import_run_id` | UUID | Transactions d'un import. | +| `sort` | string | `booked_date` (défaut, desc), `amount`, `label_clean` ; préfixe `-` pour desc. | + +Item de réponse : + +```json +{ "id": "…", "account_id": "…", "account_name": "BoursoBank", "booked_date": "2026-08-02", + "value_date": null, "amount": -42.90, "currency": "EUR", + "label_raw": "CARTE 01/08 CARREFOUR CITY PARIS", "label_clean": "Carrefour City Paris", + "counterparty": "Carrefour", "category_id": "…", "category_name": "Courses", + "category_color": "#10b981", "category_source": "rule", "notes": null, + "transfer_group_id": null, "external_id": null, "import_run_id": "…" } +``` + +Autres opérations : + +| Méthode | Route | Description | +|---|---|---| +| `POST` | `/transactions` | Saisie manuelle : `{account_id, booked_date, amount, label_clean, category_id?, notes?, counterparty?}` ; `label_raw = label_clean`, `category_source = 'user'` si catégorie fournie ; dedup_hash calculé (§4.3, occurrence via requête count sur tuple identique). 409 si collision de hash. | +| `PATCH` | `/transactions/{id}` | Champs éditables : `label_clean`, `counterparty`, `category_id` (pose `category_source='user'` ; `null` remet `category_source=NULL`), `notes`, `booked_date`, `amount` (uniquement si saisie manuelle : `import_run_id IS NULL`, sinon 422). | +| `DELETE` | `/transactions/{id}` | Uniquement saisie manuelle (`import_run_id IS NULL`), sinon 422 (« Supprimez l'import complet ou ignorez la ligne. »). | +| `POST` | `/transactions/bulk-categorize` | `{ "transaction_ids": ["…"], "category_id": "…" }` (max 500 ids) → pose `category_source='user'` sur chaque ; réponse `{ "updated": 42 }`. `category_id: null` = décatégoriser. | + +### 9.3 Catégories + +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/categories` | Arbre complet : racines avec `children: [...]`, + `transaction_count` par catégorie. | +| `POST` | `/categories` | `{name, parent_id?, icon?, color?, kind?}` — kind hérité/forcé si parent ; profondeur max 2 (422). | +| `PATCH` | `/categories/{id}` | `name`, `icon`, `color`, `sort_order`, `parent_id` (re-parentage : refusé si la catégorie a des enfants et gagnerait un parent). 422 sur catégories système pour `name`/`kind`. | +| `DELETE` | `/categories/{id}` | 422 si `is_system`. Décatégorise les transactions (§2.2), supprime enfants, budgets liés, et nettoie `actions.set_category_id` des règles concernées (action mise à `null` ; règle désactivée si elle devient vide). | + +### 9.4 Règles + +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/rules` | Triées par `priority ASC`. Inclut `hit_count`, `last_applied_at`. | +| `POST` | `/rules` | `{name, priority?, enabled?, stop?, matchers, actions}` — validation des schémas §5.1/§5.2 (regex compilée à la validation ; 422 si invalide). | +| `PATCH` | `/rules/{id}` | Mise à jour partielle. | +| `DELETE` | `/rules/{id}` | Suppression (les transactions gardent leur catégorie, `applied_rule_id` devient un id orphelin, acceptable). | +| `POST` | `/rules/reorder` | `{ "ordered_ids": ["…"] }` → réécrit `priority = index * 10`. | +| `POST` | `/rules/apply` | §5.4. Corps : `{scope?, date_from?, date_to?, account_id?, rule_id?, dry_run?, force?}`. | +| `POST` | `/rules/preview` | Corps : `{matchers}` (règle non sauvegardée) → 50 premières transactions qui matcheraient + `total_matched`. Sert d'assistant de création depuis une transaction (« créer une règle depuis cette ligne »). | + +### 9.5 Budgets + +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/budgets` | Query `month=YYYY-MM` (défaut : mois courant) → budgets applicables ce mois, avec `actual`, `progress_pct`, `remaining`, `projected_eom` (§8.3). | +| `POST` | `/budgets` | `{category_id, monthly_amount, start_month, end_month?}` — 409 si chevauchement (§2.7), 422 si catégorie non-expense. | +| `PATCH` | `/budgets/{id}` | `monthly_amount`, `end_month` ; ou `{monthly_amount, effective_from: "YYYY-MM"}` → clôture + création (§2.7), réponse : les deux budgets. | +| `DELETE` | `/budgets/{id}` | Suppression simple. | + +### 9.6 Imports & profils de source + +| Méthode | Route | Description | +|---|---|---| +| `GET` | `/source-profiles` | Presets intégrés + profils de l'utilisateur (`is_builtin` distingue). | +| `POST` | `/source-profiles` | Création d'un profil utilisateur `{name, kind, config}` (config validé par le schéma du kind). | +| `POST` | `/source-profiles/{id}/clone` | Clone un preset (ou un profil) vers un profil utilisateur éditable. | +| `PATCH` / `DELETE` | `/source-profiles/{id}` | 403 sur les builtins. | +| `POST` | `/imports/preview` | Multipart : `file` + `source_profile_id` + `account_id`. Parse **sans écrire** : `{ "rows_preview": [20 premières NormalizedRow sérialisées], "rows_total": 143, "rows_error": 1, "would_skip_duplicates": 21, "date_min": "…", "date_max": "…", "errors": [...] }`. L'UI affiche ce retour avant confirmation. | +| `POST` | `/imports` | Multipart identique → exécute §4.4, réponse : l'`ImportRun` complet avec `stats`. | +| `GET` | `/imports` | Historique paginé des runs. | +| `GET` | `/imports/{id}` | Détail d'un run. | +| `DELETE` | `/imports/{id}` | Rollback (§2.4), query `force=true` pour passer outre les modifications manuelles. | + +### 9.7 Virements + +| Méthode | Route | Description | +|---|---|---| +| `POST` | `/transfers/detect` | Corps optionnel `{date_from?, date_to?}` → `{ "pairs_created": 3 }`. | +| `POST` | `/transfers/link` | `{transaction_id_a, transaction_id_b}` (§6). | +| `DELETE` | `/transfers/{transfer_group_id}` | Déliaison (§6). | + +### 9.8 Statistiques (chart-ready ECharts) + +Tous ces endpoints acceptent `account_id` (répétable) pour restreindre le périmètre ; défaut = tous les comptes non archivés. Les montants de dépenses sont retournés **en valeur absolue** (positive) — le signe est porté par la sémantique du champ. + +#### `GET /stats/monthly-by-category?months=12&level=root&direction=debit` +Barres empilées par mois. `level` : `root` (rollup §8.2, défaut) | `child` (catégories feuilles). Réponse : + +```json +{ + "months": ["2025-09", "2025-10", "…", "2026-08"], + "series": [ + { "category_id": "…", "name": "Alimentation", "color": "#10b981", + "data": [412.50, 388.10, 0, 401.00, "…"] }, + { "category_id": null, "name": "Non catégorisé", "color": "#9ca3af", "data": ["…"] } + ], + "totals": [1830.20, 1795.00, "…"] +} +``` +`data[i]` correspond à `months[i]` ; mois sans dépense = `0`. Mapping ECharts direct : `xAxis.data = months`, une `series` bar `stack:'total'` par entrée. + +#### `GET /stats/cashflow?months=12` +```json +{ "months": ["2025-09", "…"], + "income": [2843.00, "…"], "expenses": [1830.20, "…"], + "net": [1012.80, "…"], "cumulative_net": [1012.80, 2130.60, "…"] } +``` + +#### `GET /stats/top-merchants?months=3&limit=15&direction=debit` +Groupé par `COALESCE(counterparty, merchant_key(label_clean))` : +```json +{ "period": { "from": "2026-06-01", "to": "2026-08-31" }, + "items": [ { "merchant": "Carrefour", "total": 512.40, "count": 14, + "average": 36.60, "category_name": "Courses", "category_color": "#10b981" } ] } +``` + +#### `GET /stats/recurring?direction=debit&include_inactive=false` +Sérialisation directe de §7.2 : +```json +{ "items": [ { "merchant_key": "NETFLIX", "label_display": "Netflix", + "category_id": "…", "category_name": "Abonnements & streaming", + "periodicity": "monthly", "occurrences": 14, "average_amount": 13.49, + "expected_amount": 13.49, "last_date": "2026-07-28", + "next_date_predicted": "2026-08-27", "is_active": true } ], + "monthly_total_estimate": 187.40 } +``` +`monthly_total_estimate` = somme des `expected_amount` actifs normalisés au mois (weekly ×4.33, quarterly ÷3, yearly ÷12). + +#### `GET /stats/budget-progress?month=2026-08` +```json +{ "month": "2026-08", "items": [ + { "budget_id": "…", "category_id": "…", "category_name": "Alimentation", + "category_color": "#10b981", "budget": 450.00, "actual": 312.40, + "remaining": 137.60, "progress_pct": 69.4, "projected_eom": 468.60, + "status": "warning" } ], + "totals": { "budget": 1650.00, "actual": 1204.10, "progress_pct": 73.0 } } +``` +`status` : `ok` (< 80 %), `warning` (80–100 % ou `projected_eom > budget`), `over` (> 100 %). `projected_eom` = `null` pour les mois passés. + +#### `GET /stats/sankey?month=2026-08` (ou `?months=3` : agrégat de la période) +Trois étages : catégories de revenus → nœud central `"Revenus"` → catégories racines de dépenses → catégories enfants (uniquement celles avec dépense > 0). Solde : si revenus > dépenses, lien `Revenus → Épargne du mois` avec l'excédent ; si déficit, nœud `Découvert / réserves → Revenus` avec le manque. Format **directement consommable par `series-sankey` ECharts** (les nœuds sont référencés par `name`, garantis uniques — préfixer un enfant homonyme par « Parent · Enfant ») : + +```json +{ + "period": { "from": "2026-08-01", "to": "2026-08-31" }, + "nodes": [ + { "name": "Salaire", "color": "#3b82f6" }, { "name": "Revenus", "color": "#64748b" }, + { "name": "Alimentation", "color": "#10b981" }, { "name": "Courses", "color": "#10b981" }, + { "name": "Épargne du mois", "color": "#22c55e" } + ], + "links": [ + { "source": "Salaire", "target": "Revenus", "value": 2843.00 }, + { "source": "Revenus", "target": "Alimentation", "value": 412.50 }, + { "source": "Alimentation", "target": "Courses", "value": 355.20 }, + { "source": "Revenus", "target": "Épargne du mois", "value": 1012.80 } + ] +} +``` +Les transactions non catégorisées apparaissent comme nœud « Non catégorisé » côté dépenses (et « Autres revenus » côté revenus si crédits non catégorisés). Les virements internes sont exclus (§8.1). + +--- + +## 10. Points d'implémentation et cas limites (checklist) + +1. **Encodage cp1252** : le mode `"auto"` (§4.1) ne peut pas échouer ; ne jamais utiliser `chardet` (dépendance inutile). +2. **Virgule décimale** : toujours passer par le nettoyage §3.1 avant `Decimal(...)` ; tester `"1 234,56"`, `"1.234,56"` (thousands `.`), `"-12,5"`, `"−12,50"` (U+2212), `"12,50 €"`. +3. **Deux transactions identiques le même jour** : couvertes par `occurrence` (§4.3) — test unitaire obligatoire (même fichier ré-importé = 0 insertion ; fichier avec 2 lignes identiques = 2 insertions). +4. **OFX FITID non fiable chez certaines banques** (FITID régénérés) : la dédup par hash (§4.3) reste le filet de sécurité — l'`external_id` en doublon est ignoré au profit du test de hash si l'`external_id` n'existe pas encore mais que le hash existe. +5. **Ordre du pipeline** : règles appliquées **avant** insertion (une passe), détection de virements **après** insertion (besoin des deux jambes en base). +6. **`category_source = 'user'` est sacré** : aucun traitement automatique (règles, virements) n'écrase une décision manuelle, sauf `force`. +7. **Montants dans les stats** : dépenses en valeur absolue, revenus positifs ; ne jamais additionner des signes mélangés sans filtre `direction`. +8. **Tests de non-régression importeurs** : un fichier d'exemple anonymisé par preset dans `api/tests/fixtures/finance/` (BoursoBank, CA, LBP, SG, Fortuneo, OFX 1.x, OFX 2.x, PayPal FR, PayPal EN) avec snapshot des `NormalizedRow` attendues. +9. **Performance** : volumes attendus < 100 k transactions ; les index définis en §2.5 suffisent. Les stats font des agrégats SQL (jamais de boucle Python sur toutes les transactions), sauf la détection de récurrences (§7) qui charge 18 mois de dépenses (~5 k lignes max, acceptable). +10. **UI française** : libellés d'erreurs API en français (ils remontent tels quels dans l'UI) ; formats d'affichage : dates `dd/MM/yyyy`, montants `1 234,56 €` (espace insécable), gérés côté front par `Intl.NumberFormat('fr-FR', {style:'currency', currency:'EUR'})`. diff --git a/docs/design/datamodel-health-vape.md b/docs/design/datamodel-health-vape.md new file mode 100644 index 0000000..9dcd1c2 --- /dev/null +++ b/docs/design/datamodel-health-vape.md @@ -0,0 +1,1146 @@ +# LifeTrack — Modèle de données & calculs : modules SANTÉ / FITNESS / NUTRITION et VAPE + +> **Statut** : spécification de conception, prête pour implémentation. +> **Portée** : tables SQL (types SQLAlchemy 2.0 / PostgreSQL 16), contraintes, index, stratégies de fusion/déduplication, formules de calcul exactes avec pseudocode, et endpoints API. +> **Hors portée** : module FINANCE, framework de connecteurs/importeurs (documents séparés) — seuls les points de contact sont mentionnés ici. +> **Langue** : prose en français, identifiants de code en anglais (conforme aux conventions projet). + +--- + +## 1. Conventions transverses + +Ces conventions s'appliquent à **toutes** les tables décrites dans ce document. Les agents d'implémentation doivent les respecter sans exception. + +### 1.1 Multi-utilisateur + +- Toutes les tables de données portent une colonne `user_id` : `ForeignKey("users.id", ondelete="CASCADE")`, `nullable=False`. +- La table `users` (id `BigInteger` identity PK, email, password_hash, created_at…) est définie dans le document d'architecture auth ; ici on la considère acquise. +- **Tous les index composites commencent par `user_id`** afin que chaque requête filtrée par utilisateur soit couverte. +- Aucune donnée n'est jamais lue sans filtre `user_id == current_user.id` (imposé au niveau des repositories/services). + +### 1.2 Clés primaires et horodatage + +- PK : `id = mapped_column(BigInteger, Identity(), primary_key=True)` sur toutes les tables (pas d'UUID : app mono-instance, IDs séquentiels suffisants et plus compacts pour les index). +- Colonnes d'audit sur toutes les tables : + - `created_at : DateTime(timezone=True), server_default=func.now(), nullable=False` + - `updated_at : DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False` +- Ces colonnes ne sont **pas répétées** dans les tableaux ci-dessous pour alléger la lecture, mais elles sont obligatoires. + +### 1.3 Temps et fuseaux horaires + +Règle projet : **stockage UTC, affichage Europe/Paris**. + +- Tout instant précis : `DateTime(timezone=True)` (⇒ `timestamptz`), stocké en UTC. +- Les colonnes de type `Date` (`activity_daily.date`, `liquid_entries.entry_date`, etc.) représentent un **jour civil local** (fuseau du profil utilisateur, défaut `Europe/Paris`). C'est une décision volontaire : « les pas du 12 août » sont un concept local, pas UTC. +- **Convention d'agrégation journalière** : pour agréger des `timestamptz` par jour (repas, pesées, recharges), on convertit d'abord en local : `date_local = (ts AT TIME ZONE 'UTC') AT TIME ZONE user.timezone` puis `::date`. En Python : `ts.astimezone(user_tz).date()`. Toutes les séries « par jour » de ce document utilisent cette convention. + +### 1.4 Types numériques + +- Mesures corporelles, volumes, prix : `Numeric(p, s)` (jamais `Float` pour l'argent ni les poids). +- Compteurs entiers (pas, ml arrondis non — ml sont décimaux) : `Integer` / `BigInteger`. +- Calories : `Numeric(7, 1)` (permet les décimales des exports Foodvisor tout en restant compact). + +### 1.5 Provenance des données (`source`) et déduplication + +Le framework de connecteurs doit pouvoir ajouter des sources sans migration SQL. La colonne `source` est donc un `String(32)` **contrôlé côté application** (registre Python), et non un enum PostgreSQL. + +Valeurs initiales du registre `DataSource` (module `app/core/sources.py`) : + +```python +class DataSource(str, enum.Enum): + MANUAL = "manual" # saisie UI + CSV_IMPORT = "csv_import" # import fichier générique + HEALTH_CONNECT = "health_connect" # push app compagnon Android + FOODVISOR = "foodvisor" # export Foodvisor + FITSHOW = "fitshow" # export/synchro FitShow (tapis) + API = "api" # ingestion REST générique +``` + +**Clé de déduplication standard** : toute table alimentée par des connecteurs porte : + +- `external_id : String(128), nullable=True` — identifiant chez la source (UUID Health Connect, id de ligne d'export, hash de ligne CSV…). Pour les imports CSV sans identifiant natif, l'importeur calcule `external_id = sha256(ligne_normalisée)[:32]`. +- Contrainte : **index unique partiel** (les `UniqueConstraint` classiques laissent passer les doublons de saisie manuelle où `external_id IS NULL`) : + +```python +Index( + "uq_
    _user_source_extid", + "user_id", "source", "external_id", + unique=True, + postgresql_where=text("external_id IS NOT NULL"), +) +``` + +- Comportement des importeurs : `INSERT ... ON CONFLICT (user_id, source, external_id) DO UPDATE` (upsert idempotent) ⇒ ré-importer le même fichier ne crée jamais de doublon. +- `raw : JSONB, nullable=True` — charge utile brute de la source (payload Health Connect, ligne CSV parsée…). Conservée pour ré-interprétation future ; jamais utilisée dans les calculs. + +### 1.6 Enums PostgreSQL natifs + +Utilisés uniquement pour les domaines **stables** (une migration Alembic par ajout de valeur est acceptable) : `sex`, `activity_level`, `meal_type`, `goal_mode`, `goal_status`, `sport_type`, `product_kind`, `size_unit`, `liquid_entry_kind`. Déclaration SQLAlchemy : `Enum(PyEnum, name="", native_enum=True)`. + +### 1.7 Suppression + +Suppression **physique** (hard delete) pour toutes les tables de ce document — pas de soft delete. Exceptions : `products` et `mixes` utilisent un drapeau `is_archived` car ils sont référencés par l'historique (`purchases`, `coil_changes`, `liquid_entries`) ; leur suppression physique n'est autorisée que si aucune référence n'existe (`ondelete="RESTRICT"`). + +--- + +## 2. Vue d'ensemble (diagramme ER) + +```mermaid +erDiagram + users ||--|| user_profile : "1-1" + users ||--o{ weight_entries : "" + users ||--o{ body_measurements : "" + users ||--o{ activity_daily : "" + users ||--o{ workouts : "" + users ||--o{ goals : "" + users ||--o{ food_entries : "" + users ||--o{ food_favorites : "" + users ||--o{ water_entries : "" + users ||--|| vape_settings : "1-1" + users ||--o{ liquid_entries : "" + users ||--o{ products : "" + users ||--o{ mixes : "" + users ||--o{ coil_changes : "" + users ||--o{ purchases : "" + mixes ||--o{ mix_components : "" + products ||--o{ mix_components : "" + products ||--o{ coil_changes : "" + products ||--o{ purchases : "" + mixes ||--o{ liquid_entries : "mix utilisé" +``` + +--- + +## 3. Module SANTÉ / FITNESS + +### 3.1 Table `user_profile` + +Une ligne par utilisateur (relation 1-1 avec `users`). Contient les paramètres physiologiques nécessaires aux calculs BMR/TDEE. + +| Colonne | Type SQLAlchemy | Null | Défaut | Description | +|---|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | — | PK | +| `user_id` | `BigInteger, ForeignKey("users.id", ondelete="CASCADE")` | non | — | Propriétaire | +| `height_cm` | `Numeric(4, 1)` | non | — | Taille en cm (ex. 178.5) | +| `sex` | `Enum(Sex, name="sex")` | non | — | `male` / `female` / `other` | +| `birthdate` | `Date` | non | — | Date de naissance (âge dérivé) | +| `activity_level` | `Enum(ActivityLevel, name="activity_level")` | non | `'sedentary'` | Niveau d'activité habituel | +| `timezone` | `String(64)` | non | `'Europe/Paris'` | Fuseau IANA de l'utilisateur | +| `water_goal_ml` | `Integer` | oui | `2000` | Objectif hydratation quotidien | +| `calorie_floor_kcal` | `Integer` | oui | `NULL` | Plancher calorique personnalisé (sinon défaut par sexe, cf. §5.3) | + +Enums : + +```python +class Sex(str, enum.Enum): + MALE = "male" + FEMALE = "female" + OTHER = "other" # formule BMR : moyenne homme/femme (cf. §5.1) + +class ActivityLevel(str, enum.Enum): + SEDENTARY = "sedentary" # facteur 1.2 + LIGHT = "light" # 1.375 + MODERATE = "moderate" # 1.55 + ACTIVE = "active" # 1.725 + VERY_ACTIVE = "very_active" # 1.9 +``` + +Contraintes / index : +- `UniqueConstraint("user_id", name="uq_user_profile_user")` — une seule ligne par utilisateur. +- `CheckConstraint("height_cm > 0 AND height_cm < 300", name="ck_user_profile_height")`. + +### 3.2 Table `weight_entries` + +Pesées. Plusieurs pesées par jour possibles (matin/soir) ; les calculs de tendance utilisent la **première pesée du jour local** (convention balance à jeun). + +| Colonne | Type SQLAlchemy | Null | Défaut | Description | +|---|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | — | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | — | | +| `measured_at` | `DateTime(timezone=True)` | non | — | Instant de pesée (UTC) | +| `weight_kg` | `Numeric(5, 2)` | non | — | Poids en kg (ex. 92.40) | +| `body_fat_pct` | `Numeric(4, 1)` | oui | — | % masse grasse (balance impédancemètre) | +| `muscle_mass_kg` | `Numeric(5, 2)` | oui | — | Masse musculaire si fournie | +| `water_pct` | `Numeric(4, 1)` | oui | — | % eau si fourni | +| `source` | `String(32)` | non | `'manual'` | Registre `DataSource` | +| `external_id` | `String(128)` | oui | — | Dédup connecteurs (cf. §1.5) | +| `note` | `String(255)` | oui | — | Commentaire libre | +| `raw` | `JSONB` | oui | — | Payload source | + +Contraintes / index : +- `Index("ix_weight_entries_user_measured", "user_id", "measured_at")` — requêtes de séries. +- Index unique partiel `uq_weight_entries_user_source_extid` (§1.5). +- `UniqueConstraint("user_id", "measured_at", "source", name="uq_weight_entries_user_ts_source")` — bloque le double-clic de saisie et les doubles imports sans external_id. +- `CheckConstraint("weight_kg > 20 AND weight_kg < 400", name="ck_weight_entries_range")`. + +### 3.3 Table `body_measurements` + +Mensurations. Une ligne = une séance de mesure ; toutes les colonnes de mesure sont optionnelles (l'utilisateur mesure ce qu'il veut). + +| Colonne | Type SQLAlchemy | Null | Description | +|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | | +| `measured_at` | `DateTime(timezone=True)` | non | Instant de mesure | +| `neck_cm` | `Numeric(4, 1)` | oui | Cou | +| `chest_cm` | `Numeric(4, 1)` | oui | Poitrine | +| `waist_cm` | `Numeric(4, 1)` | oui | Taille (nombril) — sert à la formule Navy | +| `hips_cm` | `Numeric(4, 1)` | oui | Hanches | +| `biceps_left_cm` | `Numeric(4, 1)` | oui | Biceps gauche | +| `biceps_right_cm` | `Numeric(4, 1)` | oui | Biceps droit | +| `thigh_left_cm` | `Numeric(4, 1)` | oui | Cuisse gauche | +| `thigh_right_cm` | `Numeric(4, 1)` | oui | Cuisse droite | +| `calf_left_cm` | `Numeric(4, 1)` | oui | Mollet gauche | +| `calf_right_cm` | `Numeric(4, 1)` | oui | Mollet droit | +| `source` | `String(32)` | non (déf. `'manual'`) | | +| `external_id` | `String(128)` | oui | Dédup | +| `note` | `String(255)` | oui | | + +Contraintes / index : +- `Index("ix_body_measurements_user_measured", "user_id", "measured_at")`. +- Index unique partiel `uq_body_measurements_user_source_extid`. + +**Bonus dérivé (formule US Navy, % masse grasse estimé)** — calculé à la volée si `waist_cm`, `neck_cm` (et `hips_cm` pour les femmes) sont présents, avec `h = height_cm` : + +``` +homme : bf% = 495 / (1.0324 − 0.19077·log10(waist − neck) + 0.15456·log10(h)) − 450 +femme : bf% = 495 / (1.29579 − 0.35004·log10(waist + hips − neck) + 0.22100·log10(h)) − 450 +``` + +### 3.4 Table `activity_daily` + +Agrégats d'activité **par jour local et par source**. On stocke une ligne par `(user, date, source)` ; la fusion inter-sources est faite en lecture (cf. §3.5). Ne jamais écraser la ligne d'une source par une autre. + +| Colonne | Type SQLAlchemy | Null | Description | +|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | | +| `date` | `Date` | non | Jour civil local (Europe/Paris) | +| `steps` | `Integer` | oui | Nombre de pas | +| `active_kcal` | `Numeric(7, 1)` | oui | Calories actives (hors métabolisme de base) | +| `total_kcal` | `Numeric(7, 1)` | oui | Dépense totale du jour (TDEE mesuré) si la source la fournit | +| `distance_m` | `Integer` | oui | Distance en mètres | +| `active_minutes` | `Integer` | oui | Minutes actives si fournies | +| `floors` | `Integer` | oui | Étages si fournis | +| `source` | `String(32)` | non | Registre `DataSource` | +| `external_id` | `String(128)` | oui | Dédup (souvent inutile ici : la clé naturelle suffit) | +| `raw` | `JSONB` | oui | Payload source | + +Contraintes / index : +- `UniqueConstraint("user_id", "date", "source", name="uq_activity_daily_user_date_source")` — **clé de dédup naturelle** : un connecteur upserte sur ce triplet (`ON CONFLICT DO UPDATE` — Health Connect re-pousse le même jour plusieurs fois dans la journée avec des valeurs croissantes). +- `Index("ix_activity_daily_user_date", "user_id", "date")`. +- `CheckConstraint("steps IS NULL OR steps >= 0", name="ck_activity_daily_steps")`. + +### 3.5 Fusion multi-sources de `activity_daily` + +**Problème** : le même jour peut exister via `health_connect`, `fitshow`, `csv_import` et `manual`. **Stratégie : priorité par source, champ par champ** (pas ligne par ligne — une source peut fournir les pas, une autre les calories). + +Ordre de priorité par défaut (du plus prioritaire au moins prioritaire), constante `ACTIVITY_SOURCE_PRIORITY` dans `app/health/merge.py` : + +```python +ACTIVITY_SOURCE_PRIORITY = [ + "manual", # une correction manuelle gagne toujours + "health_connect", # agrégateur Android : donnée la plus complète/fiable + "fitshow", # spécifique tapis : fiable pour distance/kcal du tapis mais partiel + "csv_import", + "api", +] +``` + +Algorithme de fusion (exécuté en lecture par le service, pas de table matérialisée en v1) : + +```python +def merge_activity_day(rows: list[ActivityDaily]) -> MergedActivity: + """rows = toutes les lignes (user, date) triées par priorité croissante d'index + dans ACTIVITY_SOURCE_PRIORITY (les sources inconnues vont en dernier).""" + merged = MergedActivity(date=rows[0].date) + for field in ("steps", "active_kcal", "total_kcal", "distance_m", + "active_minutes", "floors"): + for row in rows: # ordre = priorité décroissante + value = getattr(row, field) + if value is not None: + setattr(merged, field, value) + merged.field_sources[field] = row.source # traçabilité UI + break + return merged +``` + +Points importants : +- La réponse API expose `field_sources` (dict champ → source retenue) pour que l'UI affiche l'origine (« Pas : Health Connect »). +- **On n'additionne jamais deux sources** (risque de double comptage : FitShow est déjà agrégé dans Health Connect si l'app y écrit). +- L'ordre est stocké en dur en v1 ; prévoir une table `user_settings` clé/valeur en v2 si l'utilisateur veut le personnaliser. + +### 3.6 Table `workouts` + +Séances de sport (tapis FitShow, autres sports, saisie manuelle). + +| Colonne | Type SQLAlchemy | Null | Description | +|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | | +| `started_at` | `DateTime(timezone=True)` | non | Début | +| `ended_at` | `DateTime(timezone=True)` | non | Fin | +| `sport_type` | `Enum(SportType, name="sport_type")` | non | Type de sport | +| `sport_label` | `String(100)` | oui | Précision libre quand `sport_type='other'` | +| `kcal` | `Numeric(7, 1)` | oui | Calories brûlées annoncées | +| `distance_m` | `Integer` | oui | Distance | +| `steps` | `Integer` | oui | Pas de la séance si fournis | +| `avg_hr` | `Integer` | oui | FC moyenne (bpm) | +| `max_hr` | `Integer` | oui | FC max (bpm) | +| `avg_speed_kmh` | `Numeric(4, 1)` | oui | Vitesse moyenne (tapis) | +| `elevation_m` | `Integer` | oui | Dénivelé | +| `is_hidden` | `Boolean` | non (déf. `false`) | Marqué doublon inter-sources (cf. ci-dessous) | +| `source` | `String(32)` | non | | +| `external_id` | `String(128)` | oui | Dédup | +| `note` | `String(255)` | oui | | +| `raw` | `JSONB` | oui | Payload source complet (FitShow : splits, courbe FC…) | + +```python +class SportType(str, enum.Enum): + TREADMILL_WALK = "treadmill_walk" + TREADMILL_RUN = "treadmill_run" + WALKING = "walking" + RUNNING = "running" + CYCLING = "cycling" + SWIMMING = "swimming" + STRENGTH = "strength" + HIIT = "hiit" + YOGA = "yoga" + HIKING = "hiking" + OTHER = "other" +``` + +Contraintes / index : +- `Index("ix_workouts_user_started", "user_id", "started_at")`. +- Index unique partiel `uq_workouts_user_source_extid` (§1.5) — dédup principal. +- `CheckConstraint("ended_at > started_at", name="ck_workouts_duration")`. + +**Dédup inter-sources (chevauchement)** : une séance tapis peut arriver via FitShow **et** Health Connect avec des `external_id` différents. Règle appliquée par le service à l'insertion : + +```python +def flag_overlapping_duplicates(new_wo): + overlaps = find_workouts( + user_id=new_wo.user_id, + started_at < new_wo.ended_at, ended_at > new_wo.started_at, + source != new_wo.source, is_hidden == False) + for other in overlaps: + inter = overlap_seconds(new_wo, other) + shorter = min(duration(new_wo), duration(other)) + if inter / shorter >= 0.8: # 80 % de recouvrement + loser = lower_priority(new_wo, other, ACTIVITY_SOURCE_PRIORITY) + loser.is_hidden = True # conservé mais exclu des stats +``` + +Les stats et listes excluent `is_hidden = true` par défaut (`?include_hidden=true` pour audit). + +### 3.7 Table `goals` + +Objectifs de poids. Historisés (un objectif atteint/abandonné reste en base) ; **un seul objectif actif** à la fois. + +| Colonne | Type SQLAlchemy | Null | Description | +|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | | +| `mode` | `Enum(GoalMode, name="goal_mode")` | non | Comment le budget est dérivé (cf. §5.3) | +| `start_date` | `Date` | non | Début de l'objectif (jour local) | +| `start_weight_kg` | `Numeric(5, 2)` | non | Poids (tendance) au démarrage — figé à la création | +| `target_weight_kg` | `Numeric(5, 2)` | non | Poids cible | +| `target_date` | `Date` | oui | Échéance (requis si `mode='target_date'`) | +| `weekly_rate_kg` | `Numeric(4, 2)` | oui | Rythme visé en kg/semaine, **positif = perte** (requis si `mode='weekly_rate'`) | +| `status` | `Enum(GoalStatus, name="goal_status")` | non (déf. `'active'`) | | +| `note` | `String(255)` | oui | | + +```python +class GoalMode(str, enum.Enum): + WEEKLY_RATE = "weekly_rate" # l'utilisateur fixe kg/semaine → budget dérivé + TARGET_DATE = "target_date" # l'utilisateur fixe la date → rythme dérivé + MAINTAIN = "maintain" # maintien : budget = TDEE + +class GoalStatus(str, enum.Enum): + ACTIVE = "active" + COMPLETED = "completed" + ABANDONED = "abandoned" +``` + +Contraintes / index : +- Index unique partiel : `Index("uq_goals_user_active", "user_id", unique=True, postgresql_where=text("status = 'active'"))` — un seul objectif actif. +- `CheckConstraint("weekly_rate_kg IS NULL OR (weekly_rate_kg > -1.01 AND weekly_rate_kg <= 1.5)", name="ck_goals_rate_sane")` (négatif = prise de masse autorisée, bornée). +- `CheckConstraint("mode <> 'target_date' OR target_date IS NOT NULL", name="ck_goals_target_date")`. +- `CheckConstraint("mode <> 'weekly_rate' OR weekly_rate_kg IS NOT NULL", name="ck_goals_weekly_rate")`. + +--- + +## 4. Module NUTRITION + +### 4.1 Table `food_entries` + +Journal alimentaire. Alimenté par l'export Foodvisor (CSV/JSON), la saisie manuelle et les favoris. + +| Colonne | Type SQLAlchemy | Null | Description | +|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | | +| `eaten_at` | `DateTime(timezone=True)` | non | Instant de consommation | +| `meal` | `Enum(MealType, name="meal_type")` | non | Repas | +| `name` | `String(200)` | non | Nom de l'aliment (FR, tel que saisi/importé) | +| `brand` | `String(100)` | oui | Marque | +| `quantity` | `Numeric(8, 2)` | non | Quantité consommée | +| `unit` | `String(20)` | non (déf. `'g'`) | Unité libre normalisée : `g`, `ml`, `portion`, `piece` | +| `kcal` | `Numeric(7, 1)` | non | Calories **de la quantité consommée** (pas pour 100 g) | +| `protein_g` | `Numeric(6, 1)` | oui | Protéines (quantité consommée) | +| `carbs_g` | `Numeric(6, 1)` | oui | Glucides | +| `fat_g` | `Numeric(6, 1)` | oui | Lipides | +| `fiber_g` | `Numeric(6, 1)` | oui | Fibres | +| `sugar_g` | `Numeric(6, 1)` | oui | Sucres (présent dans exports Foodvisor) | +| `sat_fat_g` | `Numeric(6, 1)` | oui | Acides gras saturés | +| `sodium_mg` | `Numeric(8, 1)` | oui | Sodium | +| `source` | `String(32)` | non (déf. `'manual'`) | | +| `external_id` | `String(128)` | oui | Dédup Foodvisor / CSV | +| `raw` | `JSONB` | oui | Ligne d'export brute | + +```python +class MealType(str, enum.Enum): + BREAKFAST = "breakfast" # petit-déjeuner + LUNCH = "lunch" # déjeuner + DINNER = "dinner" # dîner + SNACK = "snack" # collation +``` + +Contraintes / index : +- `Index("ix_food_entries_user_eaten", "user_id", "eaten_at")`. +- Index unique partiel `uq_food_entries_user_source_extid` (§1.5). +- `CheckConstraint("kcal >= 0 AND quantity > 0", name="ck_food_entries_positive")`. + +**Toutes les valeurs nutritionnelles sont absolues** (pour la quantité saisie), jamais « pour 100 g » : c'est le format des exports Foodvisor et cela évite toute ambiguïté d'agrégation. La conversion pour-100g → absolu est la responsabilité de l'importeur. + +### 4.2 Table `food_favorites` + +Aliments favoris pour la saisie rapide. Les **récents** ne sont pas une table : c'est une requête sur `food_entries` (cf. endpoint `/nutrition/recent`). Les macros sont stockées **pour la quantité par défaut** (même convention absolue que `food_entries`). + +| Colonne | Type SQLAlchemy | Null | Description | +|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | | +| `name` | `String(200)` | non | | +| `brand` | `String(100)` | oui | | +| `default_quantity` | `Numeric(8, 2)` | non | Quantité par défaut proposée | +| `unit` | `String(20)` | non (déf. `'g'`) | | +| `kcal` | `Numeric(7, 1)` | non | Pour `default_quantity` | +| `protein_g` | `Numeric(6, 1)` | oui | | +| `carbs_g` | `Numeric(6, 1)` | oui | | +| `fat_g` | `Numeric(6, 1)` | oui | | +| `fiber_g` | `Numeric(6, 1)` | oui | | +| `default_meal` | `Enum(MealType)` | oui | Pré-sélection du repas | +| `use_count` | `Integer` | non (déf. `0`) | Incrémenté à chaque utilisation | +| `last_used_at` | `DateTime(timezone=True)` | oui | Pour trier « favoris récents » | + +Contraintes / index : +- `UniqueConstraint("user_id", "name", "brand", name="uq_food_favorites_user_name_brand")` (NB : PostgreSQL considère deux `NULL` comme distincts — utiliser `postgresql_nulls_not_distinct=True` sur cette contrainte, disponible en PG15+, pour que `brand NULL` dédoublonne aussi). +- `Index("ix_food_favorites_user_usage", "user_id", "use_count")`. + +Lors de la saisie via un favori : le service copie les valeurs dans `food_entries` en les **proratisant** si l'utilisateur modifie la quantité (`kcal_entry = kcal_fav × quantity_entry / default_quantity`), puis incrémente `use_count` et met à jour `last_used_at`. + +### 4.3 Table `water_entries` + +Hydratation (optionnelle mais spécifiée pour l'implémentation directe). + +| Colonne | Type SQLAlchemy | Null | Description | +|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | | +| `drunk_at` | `DateTime(timezone=True)` | non | Instant | +| `volume_ml` | `Integer` | non | Volume (ex. 250) | +| `source` | `String(32)` | non (déf. `'manual'`) | | +| `external_id` | `String(128)` | oui | Dédup Health Connect (hydration records) | + +Contraintes / index : +- `Index("ix_water_entries_user_drunk", "user_id", "drunk_at")`. +- Index unique partiel `uq_water_entries_user_source_extid`. +- `CheckConstraint("volume_ml > 0 AND volume_ml <= 5000", name="ck_water_entries_volume")`. + +--- + +## 5. Calculs SANTÉ / NUTRITION (formules exactes + pseudocode) + +Tous les calculs vivent dans `app/health/calculations.py` (fonctions pures, testables unitairement) et sont orchestrés par les endpoints de stats. Constante centrale : + +```python +KCAL_PER_KG_FAT = 7700 # 1 kg de masse corporelle ≈ 7700 kcal +EMA_ALPHA = 0.1 # lissage tendance poids (Hacker's Diet) +MIN_SLOPE_KG_PER_DAY = 0.005 # sous ce seuil, pente considérée nulle +``` + +### 5.1 BMR — Mifflin-St Jeor + +Entrées : `weight_kg` (poids **tendance** courant, cf. §5.5 ; repli : dernière pesée), `height_cm`, `age` (années révolues à la date du calcul), `sex`. + +``` +homme : BMR = 10·weight_kg + 6.25·height_cm − 5·age + 5 +femme : BMR = 10·weight_kg + 6.25·height_cm − 5·age − 161 +other : BMR = 10·weight_kg + 6.25·height_cm − 5·age − 78 # moyenne des deux +``` + +```python +def bmr_mifflin(weight_kg: float, height_cm: float, age: int, sex: Sex) -> float: + base = 10 * weight_kg + 6.25 * height_cm - 5 * age + offset = {Sex.MALE: 5, Sex.FEMALE: -161, Sex.OTHER: -78}[sex] + return base + offset +``` + +### 5.2 TDEE (dépense énergétique totale) — estimé vs mesuré + +Facteurs d'activité (`ACTIVITY_FACTORS`) : + +| `activity_level` | Facteur | +|---|---| +| `sedentary` | 1.2 | +| `light` | 1.375 | +| `moderate` | 1.55 | +| `active` | 1.725 | +| `very_active` | 1.9 | + +**TDEE effectif d'un jour donné** — ordre de préférence : + +1. `total_kcal` de l'activité fusionnée du jour (mesure directe de l'appareil/Health Connect) — si présent et plausible (`> 0.8 × BMR`, garde-fou contre les jours partiels) ; +2. sinon, si seulement `active_kcal` est présent : `TDEE = BMR + active_kcal` (léger sous-comptage assumé : la thermogenèse alimentaire n'est pas incluse — documenté dans l'UI) ; +3. sinon : `TDEE = BMR × activity_factor`. + +```python +def tdee_effective(day: date, profile, merged_activity) -> TdeeResult: + bmr = bmr_mifflin(trend_weight(day), profile.height_cm, + age_on(day, profile.birthdate), profile.sex) + a = merged_activity # peut être None + if a and a.total_kcal and a.total_kcal > 0.8 * bmr: + return TdeeResult(kcal=float(a.total_kcal), method="measured_total") + if a and a.active_kcal is not None: + return TdeeResult(kcal=bmr + float(a.active_kcal), method="bmr_plus_active") + return TdeeResult(kcal=bmr * ACTIVITY_FACTORS[profile.activity_level], + method="estimated") +``` + +La réponse API renvoie toujours `method` pour que l'UI signale « mesuré » vs « estimé ». Pour les affichages agrégés (budget), on utilise aussi `tdee_smoothed = moyenne mobile 7 jours` de `tdee_effective` afin d'éviter qu'un jour très actif gonfle le budget du lendemain. + +### 5.3 Budget calorique quotidien + +Déficit quotidien visé à partir de l'objectif actif : + +``` +deficit_kcal_day = weekly_rate_kg × 7700 / 7 = weekly_rate_kg × 1100 +budget_kcal_day = TDEE_effectif(jour) − deficit_kcal_day +``` + +Selon `goals.mode` : + +- **`weekly_rate`** : `weekly_rate_kg` vient de l'objectif (positif = perte ⇒ déficit ; négatif = prise ⇒ surplus). +- **`target_date`** : le rythme est **recalculé chaque jour** à partir de la tendance courante : + `weekly_rate_kg = (trend_weight_now − target_weight_kg) / max(weeks_remaining, 1)` + avec `weeks_remaining = (target_date − today).days / 7`. Rythme borné à `[−0.5, +1.0]` kg/semaine ; si le rythme requis dépasse 1.0 kg/sem, l'API renvoie un avertissement `rate_clamped=true` (l'échéance n'est plus tenable). +- **`maintain`** : `deficit = 0`, `budget = TDEE`. + +**Plancher de sécurité** : `budget = max(budget, floor)` avec `floor = profile.calorie_floor_kcal` si défini, sinon `1500` (homme) / `1200` (femme, `other`). Si le plancher est appliqué, l'API renvoie `floor_applied=true`. + +```python +def daily_budget(day, profile, goal, tdee_smoothed_kcal) -> BudgetResult: + if goal is None or goal.mode == GoalMode.MAINTAIN: + rate = 0.0 + elif goal.mode == GoalMode.WEEKLY_RATE: + rate = float(goal.weekly_rate_kg) + else: # TARGET_DATE + weeks_left = max(((goal.target_date - day).days) / 7, 1.0) + rate = (trend_weight(day) - float(goal.target_weight_kg)) / weeks_left + rate = clamp(rate, -0.5, 1.0) + deficit = rate * KCAL_PER_KG_FAT / 7 # = rate × 1100 + budget = tdee_smoothed_kcal - deficit + floor = profile.calorie_floor_kcal or (1500 if profile.sex == Sex.MALE else 1200) + return BudgetResult(kcal=max(budget, floor), deficit_target=deficit, + floor_applied=budget < floor) +``` + +### 5.4 Bilan énergétique quotidien + +Pour chaque jour local `d` : + +``` +intake_kcal(d) = Σ food_entries.kcal où jour_local(eaten_at) = d +balance(d) = intake_kcal(d) − TDEE_effectif(d) # négatif = déficit +vs_budget(d) = intake_kcal(d) − budget_kcal(d) # négatif = sous le budget +``` + +Les jours **sans aucune** `food_entry` sont renvoyés avec `intake=null, balance=null` (jour non tracké ≠ jeûne) et **exclus** des cumuls du §5.7. + +### 5.5 Tendance de poids — EMA (Hacker's Diet) + pente par régression + +**Série d'entrée** : une valeur par jour local = **première pesée du jour** (min `measured_at` du jour). Jours sans pesée : pas de point (la formule gère les trous). + +**EMA avec correction des trous** (équivalent à appliquer l'EMA quotidienne en maintenant la tendance les jours sans mesure) : + +``` +alpha = 0.1 +alpha_eff = 1 − (1 − alpha)^gap_days # gap_days = jours écoulés depuis la mesure précédente +trend_i = trend_{i−1} + alpha_eff × (weight_i − trend_{i−1}) +trend_0 = weight_0 # initialisation sur la première pesée +``` + +```python +def weight_trend(entries: list[tuple[date, float]], alpha: float = 0.1): + """entries triées par date croissante, une par jour. Renvoie [(date, trend)].""" + out = [] + prev_date, trend = None, None + for d, w in entries: + if trend is None: + trend = w + else: + gap = (d - prev_date).days + alpha_eff = 1 - (1 - alpha) ** gap + trend = trend + alpha_eff * (w - trend) + out.append((d, round(trend, 2))) + prev_date = d + return out +``` + +**Pente par régression linéaire** (moindres carrés ordinaires) sur la **série de tendance** (moins bruitée que le poids brut), fenêtres glissantes de 14 et 30 jours. `t_i` = numéro de jour (float), `w_i` = tendance : + +``` +slope_kg_day = Σ (t_i − t̄)(w_i − w̄) / Σ (t_i − t̄)² +slope_kg_week = slope_kg_day × 7 +``` + +```python +def regression_slope(points: list[tuple[date, float]]) -> float | None: + if len(points) < 3: + return None # pas assez de données + t0 = points[0][0] + ts = [(d - t0).days for d, _ in points] + ws = [w for _, w in points] + t_mean, w_mean = mean(ts), mean(ws) + denom = sum((t - t_mean) ** 2 for t in ts) + if denom == 0: + return None + return sum((t - t_mean) * (w - w_mean) for t, w in zip(ts, ws)) / denom +``` + +L'API expose `slope_14d` et `slope_30d` (kg/jour et kg/semaine). Règle d'usage : **14 j** pour la réactivité (affichage « rythme actuel »), **30 j** pour la projection (plus stable). + +### 5.6 Projection de la date d'atteinte de l'objectif + +Deux projections, toutes deux renvoyées : + +**a) Projection « tendance »** (au rythme constaté) — utilise `slope_30d` (repli `slope_14d` si < 30 j de données) : + +```python +def project_target_date(trend_now, target_weight, slope_kg_day, today): + delta = trend_now - target_weight # >0 s'il reste du poids à perdre + if abs(delta) < 0.1: + return Projection(status="reached") + losing_needed = delta > 0 + if slope_kg_day is None or abs(slope_kg_day) < MIN_SLOPE_KG_PER_DAY \ + or (losing_needed and slope_kg_day >= 0) \ + or (not losing_needed and slope_kg_day <= 0): + return Projection(status="not_converging") # UI : « non atteignable au rythme actuel » + days = delta / (-slope_kg_day) if losing_needed else delta / (-slope_kg_day) + # delta et slope sont de signes opposés quand ça converge ⇒ days > 0 + days = abs(delta / slope_kg_day) + if days > 3650: + return Projection(status="not_converging") + return Projection(status="ok", date=today + timedelta(days=round(days))) +``` + +**b) Projection « plan »** (au rythme théorique de l'objectif) : `days = delta / (weekly_rate_kg / 7)` — sert de ligne de référence sur le graphique poids (droite `start_weight_kg → target_weight_kg`). + +Le graphique « poids » du dashboard superpose : pesées brutes (points), tendance EMA (courbe), droite du plan, et bande de projection tendance. + +### 5.7 Comparaison déficit cumulé vs perte réelle (calibration du TDEE) + +Sur une période `[d1, d2]` (défaut : depuis `goal.start_date`), en n'utilisant que les jours **trackés** (intake non nul) : + +``` +expected_change_kg = Σ balance(d) / 7700 # somme des bilans quotidiens +actual_change_kg = trend(d2) − trend(d1) # variation de la TENDANCE (pas du brut) +gap_kg = actual_change_kg − expected_change_kg +``` + +Interprétation renvoyée par l'API : +- `gap ≈ 0` (±10 % du changement attendu) : le modèle TDEE est bien calibré ; +- `gap < 0` (perte plus rapide que prévu) : TDEE réel > TDEE modélisé, ou intake sous-estimé n'est pas le cas ici — proposer `tdee_correction_kcal` ; +- `gap > 0` : TDEE surestimé et/ou intake sous-déclaré. + +**Correction adaptative proposée** (affichée, jamais appliquée automatiquement) : + +``` +tracked_days = nombre de jours avec intake sur [d1, d2] +tdee_adaptive_kcal_day = mean(intake) − actual_change_kg × 7700 / tracked_days +tdee_correction_kcal = tdee_adaptive − mean(tdee_effective) +``` + +Fenêtre minimale : 21 jours trackés, sinon `status="insufficient_data"`. + +--- + +## 6. Module VAPE / SEVRAGE TABAC + +### 6.1 Table `vape_settings` + +Une ligne par utilisateur. Référentiel du sevrage et valeurs par défaut. + +| Colonne | Type SQLAlchemy | Null | Défaut | Description | +|---|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | — | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | — | | +| `quit_date` | `Date` | non | — | Date d'arrêt de la cigarette (jour local) | +| `cigs_per_day_before` | `Numeric(4, 1)` | non | — | Cigarettes/jour avant arrêt (ex. 15.0) | +| `cig_pack_price` | `Numeric(6, 2)` | non | — | Prix du paquet en € (valeur actuelle de référence) | +| `cigs_per_pack` | `Integer` | non | `20` | Cigarettes par paquet | +| `default_nicotine_mg_ml` | `Numeric(4, 1)` | non | — | Taux de nicotine par défaut des liquides (mg/ml) | +| `currency` | `String(3)` | non | `'EUR'` | Code ISO 4217 (prêt pour multi-devise, UI fige €) | + +Contraintes : +- `UniqueConstraint("user_id", name="uq_vape_settings_user")`. +- `CheckConstraint("cigs_per_pack > 0 AND cig_pack_price >= 0", name="ck_vape_settings_pack")`. + +*Note d'évolution* : le prix du paquet évolue (France : hausses régulières). En v1 on stocke la valeur courante — les économies « théoriques » utilisent donc le prix actuel (léger avantage à l'utilisateur, assumé et documenté dans l'UI). Une table d'historique `cig_price_history(user_id, effective_date, pack_price)` est prévue en v2 ; ne pas l'implémenter maintenant. + +### 6.2 Table `products` (catalogue vape) + +Catalogue des produits achetés : résistances, bases, boosters, arômes, matériel, pods. Sert aux recettes DIY, au suivi des résistances et aux achats. + +| Colonne | Type SQLAlchemy | Null | Description | +|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | | +| `kind` | `Enum(ProductKind, name="product_kind")` | non | Type de produit | +| `name` | `String(150)` | non | Ex. « Base 50/50 1L », « GT Cores Mesh 0.6Ω » | +| `brand` | `String(100)` | oui | | +| `price` | `Numeric(8, 2)` | non | Prix catalogue du conditionnement (€) | +| `size_value` | `Numeric(8, 2)` | non | Taille du conditionnement (ex. 1000, 10, 5) | +| `size_unit` | `Enum(SizeUnit, name="size_unit")` | non | `ml` / `unit` / `g` | +| `nicotine_mg_ml` | `Numeric(4, 1)` | oui | Boosters uniquement (ex. 20.0) | +| `vg_pct` | `Numeric(4, 1)` | oui | % VG (bases/boosters/arômes, ex. 50.0) | +| `ohm` | `Numeric(4, 2)` | oui | Résistance en ohms (kind=`coil`) | +| `is_archived` | `Boolean` | non (déf. `false`) | Produit plus utilisé (masqué des listes) | +| `note` | `String(255)` | oui | | + +```python +class ProductKind(str, enum.Enum): + COIL = "coil" # résistance + BASE = "base" # base PG/VG + BOOSTER = "booster" # booster de nicotine + AROMA = "aroma" # arôme concentré + HARDWARE = "hardware" # matériel (box, clearomiseur…) — hors coût/ml + POD = "pod" # cartouche pod + +class SizeUnit(str, enum.Enum): + ML = "ml" + UNIT = "unit" # à l'unité (résistances : boîte de 5 ⇒ size_value=5, size_unit=unit) + G = "g" +``` + +Contraintes / index : +- `UniqueConstraint("user_id", "kind", "name", "brand", name="uq_products_user_kind_name_brand", postgresql_nulls_not_distinct=True)`. +- `Index("ix_products_user_kind", "user_id", "kind")`. +- `CheckConstraint("price >= 0 AND size_value > 0", name="ck_products_price_size")`. +- `CheckConstraint("kind <> 'booster' OR nicotine_mg_ml IS NOT NULL", name="ck_products_booster_nic")`. + +**Propriété dérivée (hybrid property, pas de colonne)** : `unit_price = price / size_value` → €/ml pour les liquides, €/unité pour les résistances. + +### 6.3 Tables `mixes` + `mix_components` (recettes DIY) + +Une recette = un volume total, un taux de nicotine cible et des composants. Le coût est **calculé, jamais stocké** (il suit les prix catalogue). Un seul mix « actif » (celui actuellement vapoté) — il fournit le `cost_per_ml` et le taux de nicotine par défaut des recharges. + +`mixes` : + +| Colonne | Type SQLAlchemy | Null | Description | +|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | | +| `name` | `String(150)` | non | Ex. « Fraise 6 mg 50/50 » | +| `total_ml` | `Numeric(7, 1)` | non | Volume total préparé (ex. 260.0) | +| `target_nicotine_mg_ml` | `Numeric(4, 1)` | non | Taux visé (ex. 6.0) | +| `is_active` | `Boolean` | non (déf. `false`) | Mix en cours d'utilisation | +| `is_archived` | `Boolean` | non (déf. `false`) | | +| `note` | `String(255)` | oui | | + +`mix_components` : + +| Colonne | Type SQLAlchemy | Null | Description | +|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | PK | +| `mix_id` | `BigInteger, FK mixes.id, ondelete=CASCADE` | non | | +| `product_id` | `BigInteger, FK products.id, ondelete=RESTRICT` | non | Base, booster ou arôme | +| `quantity` | `Numeric(7, 2)` | non | Quantité utilisée, dans `product.size_unit` (ml en pratique) | + +Contraintes / index : +- `Index("uq_mixes_user_active", "user_id", unique=True, postgresql_where=text("is_active = true"))` — un seul mix actif. +- `UniqueConstraint("mix_id", "product_id", name="uq_mix_components_mix_product")`. +- `CheckConstraint("quantity > 0", name="ck_mix_components_qty")`. +- (`mix_components` n'a pas de `user_id` : l'appartenance passe par `mixes` ; les jointures de contrôle d'accès vérifient `mixes.user_id`.) + +**Calculs de recette** (service `app/vape/mixing.py`) : + +``` +cost_total = Σ composant.quantity × (product.price / product.size_value) +cost_per_ml = cost_total / mix.total_ml +nicotine_check_mg_ml = Σ (qty_booster × booster.nicotine_mg_ml) / total_ml +vg_pct_mix = Σ (qty_i × vg_pct_i) / total_ml # si tous les vg_pct connus +``` + +L'API renvoie `nicotine_check_mg_ml` à côté de `target_nicotine_mg_ml` ; si l'écart dépasse 10 %, réponse avec `warning="nicotine_mismatch"`. + +**Assistant de recette** (endpoint calculateur, ne persiste rien) — entrées : `total_ml`, `target_nic`, `booster_product_id` (taux `n_b`), `aroma_pct`, `base_product_id` : + +``` +booster_ml = total_ml × target_nic / n_b +aroma_ml = total_ml × aroma_pct / 100 +base_ml = total_ml − booster_ml − aroma_ml # erreur si ≤ 0 +``` + +### 6.4 Table `liquid_entries` (consommation de liquide) + +Journal de consommation. Deux modes de saisie coexistent, distingués par `kind` : + +- **`refill`** : évènement de recharge du réservoir/pod (« j'ai remis 4 ml »). La consommation du jour = somme des recharges du jour (approximation assumée : le liquide rechargé est considéré consommé le jour même). +- **`daily_total`** : total quotidien saisi ou importé directement. **S'il existe un `daily_total` pour un jour, il remplace la somme des `refill` de ce jour** (règle appliquée en lecture). + +| Colonne | Type SQLAlchemy | Null | Description | +|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | | +| `entry_date` | `Date` | non | Jour local de consommation | +| `kind` | `Enum(LiquidEntryKind, name="liquid_entry_kind")` | non (déf. `'refill'`) | `refill` / `daily_total` | +| `ml` | `Numeric(6, 2)` | non | Volume (ex. 4.00) | +| `nicotine_mg_ml` | `Numeric(4, 1)` | oui | Override ; sinon mix actif, sinon `vape_settings.default_nicotine_mg_ml` | +| `mix_id` | `BigInteger, FK mixes.id, ondelete=SET NULL` | oui | Mix concerné (rempli automatiquement = mix actif à la saisie) | +| `source` | `String(32)` | non (déf. `'manual'`) | | +| `external_id` | `String(128)` | oui | Dédup imports | +| `note` | `String(255)` | oui | | + +```python +class LiquidEntryKind(str, enum.Enum): + REFILL = "refill" + DAILY_TOTAL = "daily_total" +``` + +Contraintes / index : +- `Index("ix_liquid_entries_user_date", "user_id", "entry_date")`. +- Index unique partiel : `Index("uq_liquid_entries_user_date_dailytotal", "user_id", "entry_date", unique=True, postgresql_where=text("kind = 'daily_total'"))` — au plus un total quotidien par jour. +- Index unique partiel `uq_liquid_entries_user_source_extid` (§1.5). +- `CheckConstraint("ml > 0 AND ml <= 100", name="ck_liquid_entries_ml")`. + +**Consommation quotidienne effective** (fonction de lecture centrale) : + +```python +def daily_ml(user_id, d: date) -> Decimal | None: + rows = liquid_entries_for(user_id, d) + totals = [r for r in rows if r.kind == "daily_total"] + if totals: + return totals[0].ml + refills = [r for r in rows if r.kind == "refill"] + return sum(r.ml for r in refills) if refills else None # None = jour non tracké +``` + +**Nicotine effective d'une entrée** : `entry.nicotine_mg_ml or entry.mix.target_nicotine_mg_ml or settings.default_nicotine_mg_ml`. + +### 6.5 Table `coil_changes` + +Changements de résistance. La durée de vie est dérivée de l'écart entre deux changements consécutifs. + +| Colonne | Type SQLAlchemy | Null | Description | +|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | | +| `changed_at` | `DateTime(timezone=True)` | non | Instant de pose de la **nouvelle** résistance | +| `product_id` | `BigInteger, FK products.id, ondelete=RESTRICT` | oui | Modèle de résistance posé (kind=`coil`/`pod`) | +| `reason` | `String(50)` | oui | Libre : « goût de brûlé », « préventif »… | +| `note` | `String(255)` | oui | | + +Contraintes / index : +- `Index("ix_coil_changes_user_changed", "user_id", "changed_at")`. + +**Dérivés** (service, cf. §7.3) : durée de vie de la résistance *i* = `changed_at_{i+1} − changed_at_i` (la dernière posée est « en cours », durée de vie provisoire = `now − changed_at_last`). Volume traversé = Σ `daily_ml` sur l'intervalle. + +### 6.6 Table `purchases` (achats réels) + +Dépenses réelles vape (pour les économies « réelles » et le coût réel/ml). + +| Colonne | Type SQLAlchemy | Null | Description | +|---|---|---|---| +| `id` | `BigInteger, Identity(), primary_key=True` | non | PK | +| `user_id` | `BigInteger, FK users.id, ondelete=CASCADE` | non | | +| `purchased_on` | `Date` | non | Date d'achat (jour local) | +| `product_id` | `BigInteger, FK products.id, ondelete=RESTRICT` | non | Produit acheté | +| `qty` | `Numeric(6, 2)` | non (déf. `1`) | Nombre de conditionnements (ex. 2 boîtes) | +| `unit_price` | `Numeric(8, 2)` | oui | Prix payé par conditionnement ; défaut = `product.price` | +| `note` | `String(255)` | oui | | + +Contraintes / index : +- `Index("ix_purchases_user_date", "user_id", "purchased_on")`. +- `CheckConstraint("qty > 0", name="ck_purchases_qty")`. + +Dérivé : `total = qty × coalesce(unit_price, product.price)`. + +--- + +## 7. Calculs VAPE (formules exactes + pseudocode) + +Module `app/vape/calculations.py`. Convention : les moyennes « /jour » se calculent sur une fenêtre glissante paramétrable `window` ∈ {7, 30, all} (défaut 30 j), en **ignorant les jours non trackés** (`daily_ml is None`) — sauf mention contraire. + +### 7.1 Coût par ml du mix actif + +``` +cost_per_ml = Σ (component.quantity × product.price / product.size_value) / mix.total_ml +``` + +Repli si aucun mix actif : coût moyen pondéré des achats de liquides (`kind ∈ {base, booster, aroma}`) sur 90 j : `Σ totaux_achats / Σ ml_achetés` ; si aucune donnée, `cost_per_ml = null` et les métriques de coût sont renvoyées `null` (l'UI invite à créer un mix). + +### 7.2 Consommation moyenne + +``` +ml_per_day(window) = mean(daily_ml(d) pour d ∈ window, daily_ml non null) +tracked_days_ratio = jours trackés / jours de la fenêtre # indicateur de fiabilité +``` + +### 7.3 Durée de vie moyenne des résistances et amortissement + +```python +def coil_stats(user_id, last_n: int = 5) -> CoilStats: + changes = coil_changes_sorted(user_id) # par changed_at croissant + if len(changes) < 2: + return CoilStats(status="insufficient_data") + intervals = [] + for prev, nxt in pairwise(changes): + days = (nxt.changed_at - prev.changed_at).total_seconds() / 86400 + ml = sum_daily_ml(user_id, prev.changed_at.date(), nxt.changed_at.date()) + intervals.append((days, ml)) + recent = intervals[-last_n:] # moyenne sur les 5 derniers cycles + avg_days = mean(d for d, _ in recent) + avg_ml = mean(m for _, m in recent if m is not None) + current_age_days = (now() - changes[-1].changed_at).total_seconds() / 86400 + return CoilStats(avg_lifespan_days=avg_days, avg_ml_through_coil=avg_ml, + current_coil_age_days=current_age_days, + current_coil_product_id=changes[-1].product_id) +``` + +**Coût d'amortissement résistance** : + +``` +coil_unit_price = price / size_value (du produit coil le plus récemment posé) +coil_cost_per_day = coil_unit_price / avg_lifespan_days +``` + +Repli si < 2 changements : `avg_lifespan_days = 14` (constante `DEFAULT_COIL_LIFESPAN_DAYS`, affichée comme « estimation »). + +### 7.4 Coût vape par jour + +``` +vape_cost_per_day = ml_per_day × cost_per_ml + coil_cost_per_day +``` + +(Le matériel `hardware` n'est **pas** amorti dans le coût/jour v1 — il apparaît uniquement dans les dépenses réelles via `purchases`.) + +**Coût réel par jour** (basé sur les achats) : `real_cost_per_day(window) = Σ purchases.total sur window / jours de la fenêtre`. + +### 7.5 Coût de référence cigarette et économies + +``` +cig_cost_per_day = cigs_per_day_before / cigs_per_pack × cig_pack_price +days_since_quit = (today_local − quit_date).days # ≥ 0 +savings_per_day = cig_cost_per_day − vape_cost_per_day +cumulative_savings_theoretical = cig_cost_per_day × days_since_quit + − Σ_{d=quit_date}^{today} vape_cost(d) +``` + +Pour le cumul théorique, `vape_cost(d)` = `daily_ml(d) × cost_per_ml + coil_cost_per_day` si le jour est tracké, sinon `ml_per_day(30) × cost_per_ml + coil_cost_per_day` (imputation par la moyenne — les trous de saisie ne gonflent pas artificiellement les économies). + +``` +cumulative_savings_real = cig_cost_per_day × days_since_quit − Σ purchases.total depuis quit_date +``` + +L'API renvoie les **deux** cumuls (`theoretical` et `real`) ; le dashboard met en avant `real` si `purchases` contient au moins un achat, sinon `theoretical`. + +### 7.6 Nicotine par jour et tendance + +``` +nicotine_mg(d) = Σ sur les entrées du jour : entry.ml × nicotine_effective(entry) +``` + +(Si le jour est représenté par un `daily_total`, une seule entrée porte tout le volume.) Série renvoyée avec moyenne mobile 7 j. Tendance = pente de régression (même fonction `regression_slope` qu'au §5.5) sur 30 j, en mg/jour², affichée comme « en baisse / stable / en hausse » (seuils : ±0.05 mg/j par jour). + +**Équivalence informative** (affichée avec un disclaimer, absorption réelle variable) : `cig_equivalent_nicotine = nicotine_mg(d) / 12` (~12 mg de nicotine contenue par cigarette ; constante `NICOTINE_MG_PER_CIG = 12`, configurable). + +### 7.7 Compteur de cigarettes évitées + +``` +cigarettes_avoided = days_since_quit × cigs_per_day_before +``` + +Affiché en entier (`floor`). Dérivés d'affichage : `packs_avoided = cigarettes_avoided / cigs_per_pack`, et le temps de vie « récupéré » informatif : `time_regained_minutes = cigarettes_avoided × 11` (constante `MINUTES_PER_CIG = 11`, estimation classique de 11 min de vie par cigarette — libellé UI : « estimation indicative »). + +### 7.8 Jalons santé après arrêt (timeline OMS) + +Table **statique en code** (`app/vape/milestones.py`), pas en base. Offsets depuis `quit_date` (datetime local à minuit). Libellés = clés i18n, textes FR par défaut : + +| `code` | Offset | Libellé FR | +|---|---|---| +| `hr_bp_normal` | 20 min | Fréquence cardiaque et tension redescendent | +| `co_halved` | 8 h | Le monoxyde de carbone sanguin diminue de moitié | +| `co_normal` | 24 h | Monoxyde de carbone éliminé ; les poumons commencent à évacuer les résidus | +| `nicotine_out` | 48 h | Plus de nicotine dans le corps ; goût et odorat s'améliorent | +| `breathing_easier` | 72 h | Respiration plus facile, énergie en hausse (bronches détendues) | +| `circulation` | 14 j | Circulation sanguine améliorée | +| `lung_function` | 90 j | Fonction pulmonaire améliorée jusqu'à +30 % | +| `cilia_recovery` | 270 j | Cils bronchiques régénérés ; toux et essoufflement diminuent | +| `chd_risk_half` | 365 j | Risque de maladie coronarienne réduit de moitié | +| `stroke_risk_normal` | 5 ans | Risque d'AVC ramené à celui d'un non-fumeur | +| `lung_cancer_half` | 10 ans | Risque de cancer du poumon réduit de moitié | +| `chd_risk_normal` | 15 ans | Risque coronarien équivalent à celui d'un non-fumeur | + +```python +def milestones(quit_date: date, tz) -> list[MilestoneStatus]: + t0 = midnight_local(quit_date, tz) + now_ = now(tz) + out = [] + for m in MILESTONES: # (code, offset_timedelta, label_key) + reached_at = t0 + m.offset + out.append(MilestoneStatus( + code=m.code, reached_at=reached_at, achieved=now_ >= reached_at, + progress_pct=min(100, 100 * (now_ - t0) / m.offset))) + return out +``` + +--- + +## 8. Endpoints API + +Préfixe global : `/api/v1`. Auth JWT obligatoire partout (`Authorization: Bearer`). Pagination des listes : `?limit=` (déf. 50, max 500) `&offset=`, tri décroissant sur la date métier. Filtres de période : `?from=YYYY-MM-DD&to=YYYY-MM-DD` (jours locaux, bornes incluses). + +### 8.1 Format standard des séries (chart-ready pour ECharts) + +Toutes les réponses `stats/*` suivent ce contrat : + +```json +{ + "from": "2026-07-01", + "to": "2026-08-13", + "unit": "kg", + "series": [ + {"name": "weight_raw", "type": "scatter", "points": [["2026-07-01", 92.4], ["2026-07-03", 92.1]]}, + {"name": "weight_trend", "type": "line", "points": [["2026-07-01", 92.4], ["2026-07-03", 92.25]]} + ], + "meta": { "...": "valeurs scalaires spécifiques à l'endpoint" } +} +``` + +`points` = tableaux `[date_ISO, valeur|null]` directement injectables dans `dataset.source` d'ECharts. Les noms de séries sont des identifiants stables (l'UI mappe vers les libellés FR). + +### 8.2 SANTÉ + +| Méthode | Chemin | Description | +|---|---|---| +| GET | `/health/profile` | Profil (avec `age`, `bmr`, `tdee_estimated` calculés) | +| PUT | `/health/profile` | Mise à jour du profil | +| GET | `/health/weights` | Liste des pesées (filtres période) | +| POST | `/health/weights` | Créer une pesée | +| PUT | `/health/weights/{id}` | Modifier | +| DELETE | `/health/weights/{id}` | Supprimer | +| GET | `/health/weights/stats` | Séries : `weight_raw`, `weight_trend` (EMA), `plan_line` ; `meta`: `trend_now`, `slope_14d_kg_week`, `slope_30d_kg_week`, `total_change_kg`, `projection` (§5.6) | +| GET | `/health/measurements` | Liste mensurations | +| POST/PUT/DELETE | `/health/measurements[/{id}]` | CRUD | +| GET | `/health/measurements/stats` | Une série par mesure renseignée + `body_fat_navy_pct` si calculable | +| GET | `/health/activity` | Jours **fusionnés** (§3.5) avec `field_sources` ; `?raw=true` renvoie les lignes par source | +| POST | `/health/activity` | Upsert manuel d'un jour (source=`manual`, conflit sur (user,date,source)) | +| DELETE | `/health/activity/{id}` | Supprimer une ligne source | +| GET | `/health/activity/stats` | Séries : `steps`, `active_kcal`, `total_kcal`, `distance_m` + moyennes mobiles 7 j ; `meta`: moyennes de la période | +| GET | `/health/workouts` | Liste (filtres : période, `sport_type`, `include_hidden`) | +| POST/PUT/DELETE | `/health/workouts[/{id}]` | CRUD | +| GET | `/health/workouts/stats` | Séries hebdo : `sessions_count`, `total_kcal`, `total_distance_m`, `total_duration_min` ; répartition par `sport_type` | +| GET | `/health/goals` | Historique des objectifs | +| POST | `/health/goals` | Créer (bascule l'éventuel objectif actif en `abandoned` si `?replace_active=true`, sinon 409) | +| PUT | `/health/goals/{id}` | Modifier / changer `status` | +| GET | `/health/goals/active` | Objectif actif + `daily_budget` (§5.3) + `projection` (§5.6) + avancement (`done_kg`, `remaining_kg`, `pct`) | +| GET | `/health/energy-balance` | Séries par jour : `intake_kcal`, `tdee_kcal` (+`method` par point dans `meta.tdee_methods`), `balance_kcal`, `budget_kcal` ; `meta`: cumuls + comparaison §5.7 (`expected_change_kg`, `actual_change_kg`, `gap_kg`, `tdee_correction_kcal`) | +| GET | `/health/dashboard` | Agrégat du jour : dernier poids/tendance, budget restant du jour (`budget − intake`), pas du jour, série poids 30 j — un seul appel pour l'écran d'accueil | + +### 8.3 NUTRITION + +| Méthode | Chemin | Description | +|---|---|---| +| GET | `/nutrition/entries` | Liste (filtres : période, `meal`, `q` recherche nom) | +| POST | `/nutrition/entries` | Créer (option `favorite_id` pour créer depuis un favori, avec proratisation §4.2) | +| PUT/DELETE | `/nutrition/entries/{id}` | CRUD | +| GET | `/nutrition/days` | Agrégats par jour local : `kcal`, `protein_g`, `carbs_g`, `fat_g`, `fiber_g`, `vs_budget` ; `meta`: moyennes, répartition macros % | +| GET | `/nutrition/days/{date}` | Détail d'un jour groupé par repas (pour l'écran journal) | +| GET | `/nutrition/favorites` | Liste triée par `use_count` desc | +| POST/PUT/DELETE | `/nutrition/favorites[/{id}]` | CRUD | +| GET | `/nutrition/recent` | 20 derniers aliments distincts saisis (dédupliqués sur `(name, brand)`, ordre `eaten_at` desc) — requête sur `food_entries` | +| GET | `/nutrition/water` | Liste entrées eau | +| POST/DELETE | `/nutrition/water[/{id}]` | CRUD | +| GET | `/nutrition/water/stats` | Série `volume_ml` par jour + ligne objectif `water_goal_ml` | + +### 8.4 VAPE + +| Méthode | Chemin | Description | +|---|---|---| +| GET | `/vape/settings` | Paramètres (404 → l'UI lance l'assistant de configuration) | +| PUT | `/vape/settings` | Upsert des paramètres | +| GET | `/vape/liquids` | Liste consommations (filtres période, `kind`) | +| POST/PUT/DELETE | `/vape/liquids[/{id}]` | CRUD (POST refuse un 2ᵉ `daily_total` le même jour → 409) | +| GET | `/vape/products` | Liste (filtres `kind`, `include_archived`) | +| POST/PUT/DELETE | `/vape/products[/{id}]` | CRUD (DELETE → 409 si référencé ; proposer archivage) | +| GET | `/vape/mixes` | Liste avec `cost_per_ml`, `cost_total`, `nicotine_check_mg_ml` calculés | +| POST/PUT/DELETE | `/vape/mixes[/{id}]` | CRUD (composants imbriqués dans le payload, remplacement complet à l'update) | +| POST | `/vape/mixes/{id}/activate` | Active ce mix (désactive l'ancien) | +| POST | `/vape/mixes/calculator` | Assistant recette §6.3 (stateless : entrées → quantités et coût, ne persiste rien) | +| GET | `/vape/coils` | Liste des changements + durée de vie de chaque cycle | +| POST/PUT/DELETE | `/vape/coils[/{id}]` | CRUD | +| GET | `/vape/coils/stats` | `avg_lifespan_days`, `avg_ml_through_coil`, `current_coil_age_days`, série `lifespan_days` par changement (§7.3) | +| GET | `/vape/purchases` | Liste achats (filtre période, `product_id`) | +| POST/PUT/DELETE | `/vape/purchases[/{id}]` | CRUD | +| GET | `/vape/stats/consumption` | Séries par jour : `ml`, `ml_ma7` (moyenne mobile 7 j) ; `meta`: `ml_per_day_7`, `ml_per_day_30`, `tracked_days_ratio` | +| GET | `/vape/stats/nicotine` | Séries : `nicotine_mg`, `nicotine_mg_ma7` ; `meta`: pente 30 j, statut baisse/stable/hausse, `cig_equivalent` (§7.6) | +| GET | `/vape/stats/costs` | Séries mensuelles : `theoretical_cost`, `real_spend` ; `meta`: `cost_per_ml`, `vape_cost_per_day`, `coil_cost_per_day`, `real_cost_per_day` | +| GET | `/vape/stats/savings` | Série cumulative `savings_theoretical`, `savings_real` depuis `quit_date` ; `meta`: `cig_cost_per_day`, `savings_per_day`, `days_since_quit`, `cigarettes_avoided`, `packs_avoided`, `time_regained_minutes` | +| GET | `/vape/milestones` | Timeline §7.8 : liste `{code, label_fr, reached_at, achieved, progress_pct}` | +| GET | `/vape/dashboard` | Agrégat : ml aujourd'hui, nicotine aujourd'hui, économies cumulées, âge résistance courante, prochain jalon santé | + +### 8.5 Points de contact avec le framework d'import (référence) + +Définis en détail dans le document connecteurs ; rappelés ici car ils écrivent dans les tables de ce document : + +- `POST /imports/upload` (multipart : fichier + `connector` ∈ {`foodvisor`, `fitshow`, `generic_weight_csv`, …}) → job d'import qui upserte via les clés de dédup §1.5. +- `POST /ingest/health-connect` (token d'appareil dédié) : lots JSON de l'app compagnon Android → upsert dans `weight_entries`, `activity_daily` (source=`health_connect`, conflit sur `(user_id, date, source)`), `workouts`, `water_entries` avec `external_id` = UUID du record Health Connect. +- Tous les imports sont **idempotents** : rejouer un fichier ou un lot ne modifie le résultat que si les valeurs source ont changé. + +--- + +## 9. Récapitulatif des contraintes de dédup (aide-mémoire implémentation) + +| Table | Clé de dédup connecteurs | Autre unicité | +|---|---|---| +| `weight_entries` | `(user_id, source, external_id)` partiel | `(user_id, measured_at, source)` | +| `body_measurements` | `(user_id, source, external_id)` partiel | — | +| `activity_daily` | — (clé naturelle suffit) | `(user_id, date, source)` **UNIQUE**, cible d'upsert | +| `workouts` | `(user_id, source, external_id)` partiel | + dédup chevauchement 80 % → `is_hidden` | +| `food_entries` | `(user_id, source, external_id)` partiel | — | +| `food_favorites` | — | `(user_id, name, brand)` nulls-not-distinct | +| `water_entries` | `(user_id, source, external_id)` partiel | — | +| `user_profile`, `vape_settings` | — | `(user_id)` | +| `goals` | — | un seul `status='active'` par user (partiel) | +| `liquid_entries` | `(user_id, source, external_id)` partiel | un seul `daily_total` par `(user_id, entry_date)` (partiel) | +| `products` | — | `(user_id, kind, name, brand)` nulls-not-distinct | +| `mixes` | — | un seul `is_active=true` par user (partiel) | +| `mix_components` | — | `(mix_id, product_id)` | +| `coil_changes`, `purchases` | — | — | + +## 10. Constantes de calcul (fichier `app/core/constants.py`) + +| Constante | Valeur | Usage | +|---|---|---| +| `KCAL_PER_KG_FAT` | 7700 | Conversions déficit ↔ poids | +| `EMA_ALPHA` | 0.1 | Tendance poids | +| `MIN_SLOPE_KG_PER_DAY` | 0.005 | Seuil de pente « nulle » | +| `ACTIVITY_FACTORS` | 1.2 / 1.375 / 1.55 / 1.725 / 1.9 | TDEE estimé | +| `CALORIE_FLOOR_MALE` / `_FEMALE` | 1500 / 1200 | Plancher budget | +| `DEFAULT_COIL_LIFESPAN_DAYS` | 14 | Repli amortissement résistance | +| `NICOTINE_MG_PER_CIG` | 12 | Équivalence informative | +| `MINUTES_PER_CIG` | 11 | « Temps de vie récupéré » | +| `WORKOUT_OVERLAP_THRESHOLD` | 0.8 | Dédup séances inter-sources | +| `ADAPTIVE_TDEE_MIN_DAYS` | 21 | Fenêtre min. calibration §5.7 | diff --git a/docs/design/ux-pages.md b/docs/design/ux-pages.md new file mode 100644 index 0000000..85c29ba --- /dev/null +++ b/docs/design/ux-pages.md @@ -0,0 +1,872 @@ +# LifeTrack — Spécification UX complète (page par page) + +> **Document de référence pour l'implémentation.** Prose en français, identifiants de code en anglais. +> Stack imposée : React 18 + TypeScript + Vite + TailwindCSS + Apache ECharts. UI 100 % en français, thème sombre par défaut. +> Ce document est exhaustif : les agents d'implémentation ne feront **aucune** recherche complémentaire. + +--- + +## Table des matières + +1. [Principes globaux et layout](#1-principes-globaux-et-layout) +2. [Formatage français (nombres, dates, unités)](#2-formatage-français) +3. [Sémantique des couleurs](#3-sémantique-des-couleurs) +4. [Design system (tokens Tailwind, composants, palette charts)](#4-design-system) +5. [Composants transverses](#5-composants-transverses) +6. [Configuration ECharts commune](#6-configuration-echarts-commune) +7. [Page — Tableau de bord](#7-page--tableau-de-bord) +8. [Page — Poids & Objectif](#8-page--poids--objectif) +9. [Page — Nutrition](#9-page--nutrition) +10. [Page — Activité & Sport](#10-page--activité--sport) +11. [Page — Balance énergétique](#11-page--balance-énergétique) +12. [Page — Vape](#12-page--vape) +13. [Page — Finances](#13-page--finances) +14. [Page — Imports](#14-page--imports) +15. [Page — Réglages](#15-page--réglages) +16. [États vides (empty states) — récapitulatif](#16-états-vides) +17. [Accessibilité](#17-accessibilité) + +--- + +## 1. Principes globaux et layout + +### 1.1 Structure générale + +``` +┌────────────┬──────────────────────────────────────────────┐ +│ │ Topbar : titre de page + PeriodSelector + │ +│ Sidebar │ actions rapides + menu utilisateur │ +│ (nav) ├──────────────────────────────────────────────┤ +│ │ Contenu : grille de KpiCard, ChartCard, │ +│ │ tables, sections │ +└────────────┴──────────────────────────────────────────────┘ +``` + +- **Sidebar** (desktop ≥ 1024 px) : largeur `260px`, repliable en mode icônes `72px` (bouton chevron en bas de la sidebar, état persisté dans `localStorage`). Fond `bg-base` (`#0D0D0D`), séparée du contenu par une bordure hairline. +- **Entrées de navigation** (ordre fixe, icônes Lucide entre parenthèses) : + 1. **Tableau de bord** (`layout-dashboard`) — route `/` + 2. **Poids & Objectif** (`scale`) — route `/poids` + 3. **Nutrition** (`utensils`) — route `/nutrition` + 4. **Activité & Sport** (`footprints`) — route `/activite` + 5. **Balance énergétique** (`flame`) — route `/balance` + 6. **Vape** (`cloud`) — route `/vape` + 7. **Finances** (`wallet`) — route `/finances` + 8. **Imports** (`upload`) — route `/imports` + 9. **Réglages** (`settings`) — route `/reglages` +- Item actif : fond `bg-surface-2`, barre verticale `3px` couleur `accent` (#3987E5) à gauche, texte `text-primary`. Items inactifs : `text-secondary`, hover `bg-surface-2/50`. +- En bas de sidebar : logo + version + bouton repli. +- **Topbar** : hauteur `56px`, contient (gauche → droite) : titre de la page (h1, 18 px semibold), `PeriodSelector` (voir §5.1), bouton **« + Ajouter »** (menu déroulant d'actions rapides, voir §5.6), avatar/menu utilisateur (Profil, Réglages, Déconnexion). + +### 1.2 Responsive + +Breakpoints Tailwind standard : `sm` 640, `md` 768, `lg` 1024, `xl` 1280, `2xl` 1536. + +| Élément | Mobile (< 768) | Tablette (768–1023) | Desktop (≥ 1024) | +|---|---|---|---| +| Navigation | Barre inférieure fixe 5 items : Tableau de bord, Poids, Nutrition, Vape, **« Plus »** (bottom-sheet listant les autres pages) | Sidebar repliée (icônes) | Sidebar complète | +| PeriodSelector | Menu déroulant compact (icône calendrier + libellé court) | Segmented control | Segmented control | +| Grille KPI | 2 colonnes | 3 colonnes | 4 à 6 colonnes | +| ChartCard | Pleine largeur, hauteur min `240px` | 1–2 colonnes | Grille 2 colonnes (graphique principal en pleine largeur) | +| Tables | Défilement horizontal **dans la carte** (`overflow-x-auto`), colonnes clés épinglées à gauche (Date, Libellé) ; jamais de scroll horizontal de page | idem | Table complète | +| Ajout rapide | FAB `+` en bas à droite (au-dessus de la nav), ouvre le menu d'actions rapides | Bouton topbar | Bouton topbar | + +- Toutes les zones tactiles ≥ `44px`. Les graphiques restent lisibles à 360 px de large (labels d'axe X inclinés ou échantillonnés via `axisLabel.interval: 'auto'`). +- Les modales deviennent des **bottom-sheets** plein écran sur mobile. + +### 1.3 Thème + +- **Sombre par défaut.** Un thème clair existe (Réglages → Application) mais le sombre est la référence de conception. Tout hex de ce document est donné pour le fond sombre. +- `color-scheme: dark` sur `:root[data-theme="dark"]` ; bascule via attribut `data-theme` (`dark` | `light` | `auto`). + +--- + +## 2. Formatage français + +Toutes les valeurs affichées passent par des helpers centralisés (`src/lib/format.ts`) basés sur `Intl` avec locale `fr-FR`. Le séparateur de milliers est l'**espace fine insécable** (U+202F, produite nativement par `Intl.NumberFormat('fr-FR')`), le séparateur décimal est la **virgule**. + +| Type | Helper | Exemple | +|---|---|---| +| Monnaie | `formatCurrency(v)` → `Intl.NumberFormat('fr-FR', {style:'currency', currency:'EUR'})` | `1 234,56 €` · `−45,90 €` | +| Poids | `formatWeight(v)` — 1 décimale | `82,4 kg` | +| Calories | `formatKcal(v)` — entier groupé | `1 850 kcal` | +| Millilitres | `formatMl(v)` — 1 décimale | `4,2 ml` | +| Nicotine | `formatMg(v)` — 1 décimale | `12,6 mg` | +| Distance | `formatKm(v)` — 2 décimales | `5,25 km` | +| Pas | `formatSteps(v)` — entier groupé | `9 542` | +| Pourcentage | `formatPercent(v)` — espace avant % | `82 %` | +| Grammes (macros) | `formatG(v)` — entier | `132 g` | +| Durée | `formatDuration(min)` | `1 h 05` · `45 min` | +| Date | `formatDate(d)` — `dd/MM/yyyy` | `13/08/2026` | +| Date + heure | `formatDateTime(d)` — `dd/MM/yyyy HH:mm` | `13/08/2026 19:30` | +| Date courte (axes) | `formatDateShort(d)` — `dd/MM` | `13/08` | +| Mois (axes) | `formatMonth(d)` | `août 2026` (axe : `août 26`) | +| Semaine (axes) | `formatWeek(d)` | `Sem. 33` (tooltip : `du 10/08 au 16/08`) | +| Nombre signé | `formatSigned(v)` — signe explicite, « − » U+2212 | `+0,3 kg` · `−450 kcal` | + +Règles : +- **Saisie** : les champs numériques acceptent la virgule **et** le point comme séparateur décimal (normalisation à la volée). +- **Stockage** : UTC en base ; affichage converti en `Europe/Paris` côté client (le backend renvoie de l'ISO 8601 UTC). +- « Aujourd'hui » et « Hier » remplacent la date dans les listes quand pertinent (journal nutrition, dernières transactions). +- Alignement : montants et nombres **alignés à droite** dans les tables, avec `font-variant-numeric: tabular-nums`. + +--- + +## 3. Sémantique des couleurs + +Deux familles distinctes : la **palette de séries** (identité, §4.4) et les **couleurs sémantiques** (polarité/état). Ne jamais utiliser une couleur sémantique comme couleur de série ordinaire. + +| Signification | Couleur (fond sombre) | Usage | +|---|---|---| +| **Positif / favorable** — déficit calorique, économies vape, cashflow positif, budget respecté, perte de poids (si l'objectif est de perdre) | `#0CA30C` (`semantic-positive`) | Barres de balance nette en déficit, aire « Économies cumulées », deltas KPI favorables, cashflow > 0 | +| **Négatif / défavorable** — surplus calorique, dépassement de budget, cashflow négatif, reprise de poids | `#D03B3B` (`semantic-negative`) | Barres de surplus, budget > 100 %, deltas KPI défavorables | +| **Avertissement** — budget entre 80 et 100 %, résistance en fin de vie, import partiel | `#FAB219` (`semantic-warning`) | Badges, jauges, statuts | +| **Sérieux** (entre warning et negative) | `#EC835A` (`semantic-serious`) | Réservé aux statuts d'import et alertes | +| **Neutre / information** | `#3987E5` (slot 1 de la palette) | Liens, éléments actifs, info | + +Règles impératives : +- **Direction consciente de l'objectif** : pour le poids, le vert signifie « va dans le sens de l'objectif ». Si `goal.direction = lose`, une variation négative est verte ; si `gain`, c'est l'inverse. Centraliser dans un helper `deltaTone(value, goalDirection)`. +- Une couleur sémantique n'est **jamais seule** porteuse de sens : elle est toujours accompagnée d'un signe (`+`/`−`), d'une icône (`trending-down`, `alert-triangle`…) ou d'un libellé. +- Montants dans les tables Finances : dépenses en `text-primary` avec signe `−`, revenus en `semantic-positive` avec signe `+` (le signe porte l'info, la couleur renforce). + +--- + +## 4. Design system + +### 4.1 Tokens Tailwind (extrait de `tailwind.config.ts`) + +```ts +// tailwind.config.ts — theme.extend +colors: { + base: '#0D0D0D', // fond de page + surface: '#1A1A19', // fond des cartes / graphiques + 'surface-2': '#242423', // hover, éléments imbriqués, inputs + border: 'rgba(255,255,255,0.10)', // bordure hairline + ink: { + DEFAULT: '#FFFFFF', // text-primary + secondary:'#C3C2B7', + muted: '#898781', // labels d'axes, placeholders + }, + grid: '#2C2C2A', // lignes de grille des graphiques + axis: '#383835', // ligne de base / axe + accent: '#3987E5', + semantic: { + positive: '#0CA30C', + negative: '#D03B3B', + warning: '#FAB219', + serious: '#EC835A', + }, + chart: { + 1: '#3987E5', 2: '#D95926', 3: '#199E70', 4: '#C98500', + 5: '#D55181', 6: '#008300', 7: '#9085E9', 8: '#E66767', + }, +}, +borderRadius: { card: '12px' }, +fontFamily: { sans: ['system-ui', '-apple-system', '"Segoe UI"', 'sans-serif'] }, +``` + +Thème clair (non prioritaire, mêmes rôles) : `base #F9F9F7`, `surface #FCFCFB`, `ink #0B0B0B`, `ink-secondary #52514E`, `border rgba(11,11,11,0.10)`, `grid #E1E0D9`, palette charts clair : `#2A78D6, #EB6834, #1BAF7A, #EDA100, #E87BA4, #008300, #4A3AA7, #E34948`, `semantic-positive` texte : `#006300`. + +### 4.2 Composant `Card` + +Base de **toutes** les cartes (KPI, graphiques, tables, sections de réglages) : +- Fond `bg-surface`, `rounded-card` (12 px), bordure `1px solid border`, padding `16px` (mobile) / `20px` (desktop). Pas d'ombre portée (thème sombre) — la séparation vient du contraste fond/carte et de la bordure. +- En-tête optionnel : titre 14 px semibold `text-ink`, sous-titre 12 px `text-ink-muted`, zone d'actions à droite. + +### 4.3 `KpiCard` + +``` +┌──────────────────────────────┐ +│ LIBELLÉ (12px, muted, caps) │ +│ 82,4 kg (28px, semibold) │ +│ ▼ −0,4 kg sur 7 j (12px) │ ← delta coloré + icône +│ [sparkline optionnelle 40px] │ +└──────────────────────────────┘ +``` +- Valeur principale : 28 px desktop / 24 px mobile, `text-ink`. +- Delta : icône `trending-up`/`trending-down` + valeur signée, colorée via `deltaTone`. +- Variante avec **jauge de progression** (barre 6 px arrondie) pour les KPI « X / budget ». +- Toute la carte est cliquable quand elle renvoie vers une page de détail (curseur pointer + hover `bg-surface-2/40`). + +### 4.4 Palette de graphiques (fond sombre — **validée CVD, ne pas réordonner**) + +L'ordre des slots est un mécanisme de sécurité daltonisme (validé par script : pire paire adjacente CVD ΔE 8,4 ; vision normale 19,3 ; tous les slots ≥ 3:1 de contraste sur `#1A1A19`). **Attribution fixe par entité — jamais recyclée, jamais réassignée quand une série est filtrée.** + +| Slot | Hex | Attribution LifeTrack (identité fixe) | +|---|---|---| +| `chart-1` bleu | `#3987E5` | Poids (tendance), dépense énergétique (kcal out), distance, solde total | +| `chart-2` orange | `#D95926` | Apports caloriques (kcal in), kcal actives | +| `chart-3` vert d'eau | `#199E70` | Protéines, pas quotidiens | +| `chart-4` jaune | `#C98500` | Glucides, coût vape | +| `chart-5` magenta | `#D55181` | Lipides, nicotine | +| `chart-6` vert | `#008300` | (réservé — éviter près de `semantic-positive`) | +| `chart-7` violet | `#9085E9` | Vape (ml), poids théorique | +| `chart-8` rouge | `#E66767` | Dernier recours (jamais pour un sens « négatif ») | + +- Variantes d'une même entité (ex. projection du poids) : **teintes séquentielles du même bleu** — `#86B6EF` (clair), `#3987E5` (base), `#1C5CAB` (foncé) — jamais un nouveau slot. +- Catégories de dépenses (Finances) : slots 1→7 dans l'ordre, au-delà **regroupement « Autres »** en `ink-muted` (`#898781`). Pour les donuts et le sankey (formes où toutes les paires se comparent), limiter à 7 catégories + « Autres ». +- Chaque catégorie financière reçoit un slot **à sa création** (stocké en base) → couleur stable dans le temps. +- ≥ 2 séries ⇒ **légende toujours affichée** ; 1 série ⇒ pas de légende (le titre nomme la série). +- Jamais de valeur numérique sur chaque point : labels directs **sélectifs** (dernier point, max, min) uniquement. +- **Jamais de double axe Y.** Deux mesures d'échelles différentes = deux graphiques empilés partageant le même axe X et le même zoom (`echarts.connect`). + +### 4.5 Boutons et champs + +- Bouton primaire : fond `accent`, texte blanc, `rounded-lg`, hauteur 40 px. Secondaire : fond `surface-2`, bordure hairline. Destructif : fond `semantic-negative`. Ghost : texte `accent`. +- Inputs : fond `surface-2`, bordure hairline, focus ring `accent`, hauteur 40 px, labels au-dessus (12 px `text-secondary`), erreurs en 12 px `semantic-negative` sous le champ. +- Selects natifs stylés + combobox avec recherche pour listes longues (catégories, aliments). + +--- + +## 5. Composants transverses + +### 5.1 `PeriodSelector` (sélecteur de période) + +- Segmented control : **`7 j` · `30 j` · `90 j` · `1 an` · `Tout` · `Personnalisé`**. Option active : fond `surface-2`, texte `text-ink` ; inactives `text-secondary`. +- `Personnalisé` ouvre un popover avec deux champs date (`dd/MM/yyyy`, datepicker fr, lundi premier jour de semaine) + raccourcis « Ce mois-ci », « Le mois dernier », « Cette année ». Libellé affiché ensuite : `01/06/2026 – 13/08/2026`. +- Par défaut : **30 j** partout, sauf Finances (**Ce mois-ci**) et Imports (pas de période). +- La période est **globale à la page**, persistée par page (`localStorage`, clé `period:`) et reflétée dans l'URL (`?periode=30j` ou `?du=2026-06-01&au=2026-08-13`). +- Chaque `ChartCard` peut surcharger localement sa période (menu ⋯), la surcharge est signalée par un badge sur la carte. + +### 5.2 `ChartCard` + +`Card` + en-tête standard : titre du graphique, sous-titre optionnel (période effective), actions à droite : menu `⋯` avec **« Voir les données »** (bascule le graphique en table triable des mêmes valeurs — obligatoire, c'est la vue accessible), **« Exporter PNG »**, **« Exporter CSV »**, **« Plein écran »**. Hauteur par défaut du canvas : 280 px (mini-graphes du tableau de bord : 120 px ; graphiques principaux : 360 px). + +### 5.3 `DataTable` + +- En-têtes triables (flèche), lignes hauteur 44 px, zébrage désactivé (bordures hairline entre lignes), hover `surface-2/40`. +- Pagination 20 lignes (50/100 au choix), pied « 1–20 sur 254 ». +- Barre de filtres au-dessus, **sur une seule ligne** (wrap sur mobile) ; champ recherche à gauche, filtres à droite. +- Ligne vide → composant `EmptyState` (voir §16). + +### 5.4 `EmptyState` + +Illustration légère (icône 48 px `ink-muted`), titre 16 px, texte d'aide 14 px `text-secondary`, **1 à 2 boutons d'action** (primaire = action de saisie ou d'import). Textes exacts par page en §16. + +### 5.5 Modales + +Overlay `rgba(0,0,0,0.6)`, carte centrée `max-w-md` (formulaires rapides) ou `max-w-2xl` (règles, mapping d'import). Titre + croix de fermeture. Boutons en pied : « Annuler » (ghost) + action primaire. `Esc` ferme, `Entrée` valide les formulaires à un champ principal. Sur mobile : bottom-sheet plein écran. + +### 5.6 Menu « + Ajouter » (actions rapides globales) + +Disponible partout (topbar / FAB mobile). Items : +1. **« Pesée »** → modale Quick-add poids (§8.5) +2. **« Aliment »** → modale Quick-add aliment (§9.5) +3. **« Recharge vape »** → modale Quick-add recharge (§12.5) +4. **« Résistance changée »** → action un clic + toast (§12.6) +5. **« Séance de sport »** → modale séance (§10.5) +6. **« Transaction »** → modale transaction manuelle (§13.6) +7. **« Importer un fichier »** → route `/imports` + +Raccourcis clavier : `a` ouvre le menu ; `p` pesée ; `n` aliment ; `v` recharge. + +### 5.7 Toasts + +En bas à droite (desktop) / haut (mobile). Succès : « Pesée enregistrée ✓ » avec lien « Annuler » (undo 5 s). Erreur : `semantic-negative` + détail. + +--- + +## 6. Configuration ECharts commune + +Fichier `src/lib/echarts.ts` : enregistrement des composants nécessaires (tree-shaking), locale `FR` d'ECharts, et un thème `lifetrack-dark` : + +```ts +// Theme ECharts "lifetrack-dark" (extrait) +{ + backgroundColor: 'transparent', + color: ['#3987E5','#D95926','#199E70','#C98500','#D55181','#008300','#9085E9','#E66767'], + textStyle: { color: '#C3C2B7', fontFamily: 'system-ui, "Segoe UI", sans-serif' }, + axisLine: { lineStyle: { color: '#383835' } }, + splitLine: { lineStyle: { color: '#2C2C2A' } }, + axisLabel: { color: '#898781', fontSize: 11 }, + legend: { textStyle: { color: '#C3C2B7' }, icon: 'circle', itemWidth: 8, itemHeight: 8, top: 0 }, + tooltip: { + backgroundColor: '#1A1A19', borderColor: 'rgba(255,255,255,0.10)', + textStyle: { color: '#FFFFFF' }, padding: [8, 12], + }, +} +``` + +Règles communes à **tous** les graphiques : +- `grid: { left: 8, right: 16, top: 36, bottom: 8, containLabel: true }` (bottom 44 si `dataZoom` slider). +- **Tooltip** : `trigger: 'axis'` avec `axisPointer: { type: 'line' }` pour les séries temporelles ; `trigger: 'item'` pour donut/sankey/barres horizontales. Formatter systématiquement en français via les helpers de §2 (date complète en tête, puis `● Série valeur` par ligne, valeurs alignées à droite). +- **Zoom** : tous les graphiques temporels reçoivent `dataZoom: [{ type: 'inside' }]` (molette + pincement) ; les graphiques « principaux » de page (poids, transactions par mois, ml vape) ajoutent `{ type: 'slider', height: 20 }`. +- Lignes : `width: 2`, `symbol: 'circle'`, `symbolSize: 6`, `showSymbol: false` (symboles visibles au hover seulement) ; `smooth: 0.2` maximum, jamais de lissage exagéré. +- Barres : `barMaxWidth: 28`, coins arrondis côté extrémité de donnée uniquement (`borderRadius: [4,4,0,0]` vers le haut, inversé vers le bas), **espace de 2 px** entre segments empilés (`itemStyle.borderColor: '#1A1A19', borderWidth: 1`). +- Aires : dégradé vertical de la couleur de série à 25 % → 0 % d'opacité. +- Axe Y : commence à 0 pour les barres ; pour le poids (ligne), `min`/`max` auto avec marge (`scale: true`). +- `markLine` (budgets, objectifs) : pointillés `[4,4]`, couleur `#C3C2B7`, label au bout à droite (ex. « Budget 1 800 »), `symbol: 'none'`. +- Pas d'animation à la mise à jour de période > 300 ms (`animationDuration: 300`). +- Chaque graphique expose `aria-label` descriptif et l'alternative « Voir les données » (§5.2). + +--- + +## 7. Page — Tableau de bord + +**Route** `/` · **Titre** « Tableau de bord » · **Période par défaut** : 30 j (s'applique aux mini-graphes ; les KPI « aujourd'hui » et « ce mois-ci » l'ignorent). + +**Objectif UX** : une vue croisée de tous les modules en un écran, chaque bloc cliquable vers sa page de détail. Composé d'une rangée de 6 KPI, d'une rangée d'actions rapides, puis d'une grille de 6 **cartes-modules** avec mini-graphe. + +### 7.1 KPI (rangée 1 — 6 cartes, 2×3 sur mobile) + +| # | Libellé | Valeur | Sous-texte | Couleur delta | +|---|---|---|---|---| +| 1 | **Poids actuel** | `82,4 kg` (dernière pesée) | `▼ −0,4 kg sur 7 j` (delta de la tendance EMA) | `deltaTone` selon objectif | +| 2 | **Calories aujourd'hui** | `1 450 / 1 800 kcal` | jauge + `Reste 350 kcal` (ou `Dépassement de 120 kcal` en rouge) | vert ≤ budget, rouge > | +| 3 | **Déficit cumulé (30 j)** | `−12 450 kcal` | `≈ −1,6 kg théoriques` | vert si déficit | +| 4 | **Vape aujourd'hui** | `3,8 ml` | `≈ 11,4 mg de nicotine` | neutre | +| 5 | **Économies vape** | `1 245,80 €` | `depuis le 15/03/2025` | toujours vert | +| 6 | **Dépenses du mois** | `1 234,56 € / 1 500,00 €` | jauge + `82 % du budget global` | vert < 80 %, jaune 80–100 %, rouge > 100 % | + +Si un module n'a pas de données, sa KpiCard affiche `—` + lien « Configurer ». + +### 7.2 Actions rapides (rangée 2) + +Boutons pleine largeur sur une ligne : **« + Pesée » · « + Aliment » · « + Recharge vape » · « Résistance changée » · « + Séance » · « Importer »** — mêmes actions que §5.6, en accès direct. + +### 7.3 Cartes-modules (grille 2 colonnes desktop, 1 colonne mobile) + +Chaque carte : titre + valeur clé + mini-graphe ECharts (120 px, sans axe Y visible, axe X en dates courtes, tooltip actif, pas de zoom) + lien « Voir le détail → ». + +1. **« Poids »** — mini-line : tendance EMA (bleu `chart-1`, 2 px) + `markLine` horizontale objectif (pointillés muted, label « Objectif 78,0 kg »). Période sélectionnée. +2. **« Nutrition »** — mini-bar 7 derniers jours : kcal/jour, chaque barre colorée `semantic-positive` si ≤ budget du jour, `semantic-negative` sinon + `markLine` budget. +3. **« Balance énergétique »** — mini-bar : balance nette/jour (in − out), barres vertes si < 0 (déficit), rouges si > 0, ligne zéro visible (`axisLine` sur y=0). +4. **« Vape »** — mini-line ml/jour (violet `chart-7`) + moyenne mobile 7 j en trait plein, valeurs brutes en points 40 % d'opacité. +5. **« Économies »** — mini-area cumulée verte (`#0CA30C`, dégradé) ; label direct sur le dernier point : `1 245,80 €`. +6. **« Finances »** — mini-bar 6 derniers mois : dépenses/mois (barres `chart-1`) + `markLine` budget mensuel global ; le mois courant est hachuré (mois incomplet, `decal` ECharts). + +### 7.4 États particuliers + +- Première visite (aucune donnée nulle part) : le tableau de bord est remplacé par un **écran d'accueil** : « Bienvenue sur LifeTrack 👋 » + 3 cartes d'onboarding : « Configurez votre profil » (→ `/reglages`), « Définissez votre objectif de poids » (→ `/reglages?tab=objectif`), « Importez vos premières données » (→ `/imports`). + +--- + +## 8. Page — Poids & Objectif + +**Route** `/poids` · **Titre** « Poids & Objectif » · **Période par défaut** : 90 j. + +Définitions de calcul (implémentation) : +- `trendWeight` : EMA des pesées, `alpha = 0.1` (≈ tendance sur ~20 jours), calculée sur jours calendaires (interpolation : l'EMA n'avance que sur les jours avec pesée). +- `weeklyRate` : pente de la tendance sur 7 jours glissants, en kg/semaine. +- `projection` : régression linéaire de la tendance sur les 21 derniers jours, extrapolée jusqu'à `goal.targetWeight` (bornée à +365 j). +- `bmi = poids / taille²`. + +### 8.1 KPI (6 cartes) + +| Libellé | Valeur | Sous-texte | +|---|---|---| +| **Poids actuel** | `82,4 kg` | `Pesée du 13/08/2026` | +| **Tendance (EMA)** | `82,7 kg` | `▼ −0,4 kg sur 7 j` | +| **Rythme hebdo** | `−0,45 kg/sem` | `Objectif : −0,50 kg/sem` (vert si |rythme| ≥ objectif dans le bon sens) | +| **IMC** | `26,3` | `Surpoids` (catégories : `< 18,5 Maigreur` · `18,5–25 Corpulence normale` · `25–30 Surpoids` · `≥ 30 Obésité`) | +| **Objectif** | `78,0 kg` | `Reste 4,4 kg · 61 %` + jauge (départ → objectif) | +| **Atteinte estimée** | `24/10/2026` | `dans 10 semaines` (ou `— · rythme insuffisant` si la pente ne converge pas) | + +### 8.2 Graphiques + +**G1 — « Évolution du poids »** (principal, pleine largeur, 360 px) +- Type : `line` + `scatter` combinés, axe X `time` (dates), axe Y kg (`scale: true`, marge ±1 kg). +- Séries : + 1. `Pesées` — scatter, symboles 6 px, `#3987E5` à 45 % d'opacité ; + 2. `Tendance` — line 2 px `#3987E5`, sans symboles ; + 3. `Projection` — line pointillée `[6,4]` `#86B6EF`, ne démarre qu'au dernier point de tendance, s'étend dans le futur ; + 4. `Objectif` — `markLine` horizontale à `targetWeight`, pointillés, label « Objectif 78,0 kg » ; + 5. `markPoint` discret à l'intersection projection/objectif avec label date estimée. +- Interactions : tooltip axe (Date · Pesée · Tendance), `dataZoom` inside + slider, légende (3 séries), clic-légende pour masquer les pesées brutes. + +**G2 — « Rythme hebdomadaire »** (demi-largeur) +- Type : `bar`, une barre par semaine ISO, axe X semaines (`Sem. 31`…), axe Y kg/sem. +- Couleur par pièce (`visualMap.pieces`) : variation dans le sens de l'objectif → `semantic-positive`, sens inverse → `semantic-negative` (direction-aware §3). +- `markLine` au rythme cible (ex. `−0,50`). Tooltip item : `Sem. 32 · du 03/08 au 09/08 — −0,45 kg`. + +**G3 — « IMC »** (demi-largeur) +- Type : `line` (IMC de la tendance), axe Y IMC `min 16, max 35`. +- `markArea` de fond aux 4 bandes (opacité 6 % : bleu maigreur, vert normal, jaune surpoids, rouge obésité) + labels de bande à droite en 10 px muted. Tooltip : `13/08/2026 — IMC 26,3 (Surpoids)`. + +**G4 — « Mensurations »** (pleine largeur, affiché seulement si ≥ 1 mesure existe) +- Type : `line` multi-séries, axe Y cm. Séries (ordre de palette) : `Tour de taille` (1), `Hanches` (2), `Poitrine` (3), `Bras` (4), `Cuisse` (5). Légende obligatoire + labels directs en bout de ligne. Tooltip axe. `connect` du zoom avec G1. + +### 8.3 Table « Historique des pesées » + +Colonnes : `Date` · `Poids` · `Tendance` · `Variation` (vs pesée précédente, signée et colorée) · `Note` · actions (✎ modifier, 🗑 supprimer avec confirmation). Tri par date desc. Bouton d'en-tête « + Ajouter une pesée ». + +### 8.4 Carte « Objectif » (résumé) + +Rappel de l'objectif configuré : `Départ 89,2 kg (12/01/2026) → Objectif 78,0 kg` + jauge de progression + « Modifier l'objectif » (→ Réglages, onglet Objectif). + +### 8.5 Modale « Ajouter une pesée » (quick-add global) + +Champs : +- **Poids (kg)** — numérique, pas 0,1, focus auto, pré-rempli avec la dernière valeur. Validation : 20–300 kg. +- **Date** — datepicker, défaut aujourd'hui. **Heure** — optionnelle, défaut maintenant. +- **Note** — texte libre optionnel (placeholder : « ex. après le sport »). +- Section repliable **« Mensurations (optionnel) »** : Tour de taille, Hanches, Poitrine, Bras, Cuisse (cm, 1 décimale). +- Si une pesée existe déjà ce jour : avertissement « Une pesée existe déjà le 13/08/2026 (82,6 kg). Enregistrer remplacera cette valeur. » avec choix « Remplacer » / « Ajouter quand même ». +- Boutons : « Annuler » / « Enregistrer » → toast « Pesée enregistrée ✓ ». + +--- + +## 9. Page — Nutrition + +**Route** `/nutrition` · **Titre** « Nutrition » · **Période par défaut** : 30 j (graphes) ; le **journal** est journalier avec son propre navigateur de date. + +Le budget kcal du jour vient de la Balance énergétique (§15.3) : `dailyBudget = TDEE − targetDeficit`, ou valeur manuelle. + +### 9.1 KPI (6 cartes) + +| Libellé | Valeur | Sous-texte | +|---|---|---| +| **Aujourd'hui** | `1 450 / 1 800 kcal` | jauge + `Reste 350 kcal` | +| **Moyenne 7 j** | `1 720 kcal/j` | `Budget moyen : 1 800 kcal/j` | +| **Protéines aujourd'hui** | `96 / 130 g` | jauge (objectif §15.3) | +| **Répartition du jour** | `P 27 % · G 45 % · L 28 %` | en % des kcal | +| **Jours dans le budget** | `18 / 30 j` | sur la période sélectionnée | +| **Écart moyen au budget** | `−80 kcal/j` | vert si négatif | + +### 9.2 Graphiques + +**G1 — « Calories par jour »** (principal, pleine largeur) +- Type : `bar` + `line`. Axe X jours, axe Y kcal (un seul axe — même unité). +- Série 1 `Apports` : barres, couleur par jour via callback `itemStyle.color` : `semantic-positive` si `kcal ≤ budgetOfDay`, `semantic-negative` sinon (comparaison au budget **du jour**, le budget peut évoluer). +- Série 2 `Budget` : ligne en escalier (`step: 'end'`), pointillés, `#C3C2B7`. +- Tooltip axe : `mar. 12/08 — Apports 1 940 kcal · Budget 1 800 kcal · Écart +140 kcal`. Zoom inside + slider. Légende (2 séries). + +**G2 — « Macronutriments par jour »** (pleine largeur) +- Type : `bar` empilées, unité **kcal** (P ×4, G ×4, L ×9 — permet la comparaison visuelle avec G1). Séries : `Protéines` `#199E70`, `Glucides` `#C98500`, `Lipides` `#D55181` (espace 2 px entre segments). Légende. Tooltip axe avec grammes ET kcal : `Protéines 96 g (384 kcal)`. Toggle dans le menu ⋯ : « Afficher en grammes ». + +**G3 — « Répartition du jour »** (tiers de largeur) +- Type : `pie` (donut, radius `['55%','80%']`). 3 parts P/G/L, mêmes couleurs que G2. Centre : `1 450 kcal` (graphic text). Labels directs : `Protéines 27 %`. Tooltip item : `Glucides — 652 kcal (45 %) · 163 g`. Suit la date du journal (§9.4). + +**G4 — « Répartition par repas »** (deux tiers de largeur) +- Type : `bar` empilées 100 % (`stack` + normalisation), une barre par jour. Séries : `Petit-déjeuner` (slot 1), `Déjeuner` (2), `Dîner` (3), `Collations` (4). Tooltip : kcal réels + %. Légende. Objectif : voir si un repas dérape (grignotage). + +**G5 — « Top aliments »** (pleine largeur, 320 px) +- Type : `bar` horizontales, top 10 des aliments par kcal totales sur la période. Axe Y : noms d'aliments (tronqués à 24 caractères + tooltip complet), axe X kcal. Une seule série → couleur unique `chart-2`, pas de légende. Label direct à droite de chaque barre : `12 340 kcal`. Tooltip item : `Pain complet — 12 340 kcal · 28 fois · 441 kcal/fois en moyenne`. Clic sur une barre → filtre le journal sur cet aliment (badge de filtre actif au-dessus du journal). + +### 9.3 Bibliothèque d'aliments (panneau secondaire) + +Accessible par un bouton « Mes aliments » dans l'en-tête de page : panneau latéral (drawer) listant les aliments mémorisés — `Nom` · `kcal/100 g` (ou /portion) · `P/G/L` · `Utilisé n fois` · actions ✎ 🗑. Recherche en tête. C'est cette bibliothèque qu'interroge l'autocomplete du quick-add (§9.5) ; les aliments importés de Foodvisor y sont ajoutés automatiquement (dédupliqués par nom normalisé). + +### 9.4 Journal alimentaire (section « Journal du jour ») + +- Navigateur de date : `‹ mer. 13/08/2026 ›` + bouton « Aujourd'hui ». Swipe gauche/droite sur mobile. +- Bandeau du jour : `Total : 1 450 / 1 800 kcal · P 96 g · G 163 g · L 45 g` + jauge. +- 4 groupes repliables : **Petit-déjeuner / Déjeuner / Dîner / Collations**, chacun avec sous-total (`620 kcal`) et bouton « + Ajouter un aliment ». +- Ligne d'entrée : nom · quantité (`150 g`) · kcal · P/G/L (masqués sur mobile, visibles au tap) · actions ✎ / 🗑 / ⧉ (dupliquer vers un autre jour/repas). +- Les entrées importées de Foodvisor portent un badge source `Foodvisor` (tooltip : « Importé le 10/08/2026 »). + +### 9.5 Modale « Ajouter un aliment » (quick-add global) + +- **Recherche** (focus auto) : autocomplete sur la bibliothèque personnelle (aliments déjà saisis/importés), résultats avec kcal/100 g. Sélection → pré-remplit tout. +- **Nom** (texte, requis) · **Repas** (select : Petit-déjeuner / Déjeuner / Dîner / Collations — défaut selon l'heure : < 11 h petit-déj., 11–15 h déjeuner, 15–18 h collations, > 18 h dîner) · **Date** (défaut : jour affiché dans le journal). +- **Quantité** + **Unité** (`g` / `ml` / `portion`) — si l'aliment vient de la bibliothèque, kcal et macros se recalculent proportionnellement. +- **Calories (kcal)** (requis) · **Protéines (g)** · **Glucides (g)** · **Lipides (g)** (optionnels). +- Case « Mémoriser cet aliment dans ma bibliothèque » (cochée par défaut pour une saisie manuelle). +- Boutons : « Annuler » / « Ajouter » / « Ajouter et continuer » (garde la modale ouverte, vide la recherche — saisie en rafale d'un repas). + +--- + +## 10. Page — Activité & Sport + +**Route** `/activite` · **Titre** « Activité & Sport » · **Période par défaut** : 30 j. + +Sources : Health Connect (pas, distance, kcal actives via bridge), FitShow (séances tapis), saisie manuelle. `stepsGoal` configurable (défaut 10 000). + +### 10.1 KPI (6 cartes) + +| Libellé | Valeur | Sous-texte | +|---|---|---| +| **Pas aujourd'hui** | `7 842 / 10 000` | jauge | +| **Moyenne pas (7 j)** | `9 120 /j` | `▲ +6 % vs 7 j précédents` | +| **Kcal actives aujourd'hui** | `320 kcal` | `Moyenne 7 j : 410 kcal/j` | +| **Distance (période)** | `86,4 km` | `≈ 2,9 km/j` | +| **Séances cette semaine** | `3 séances · 2 h 15` | `Semaine dernière : 4 · 3 h 05` | +| **Objectif atteint** | `21 / 30 j` | jours ≥ objectif de pas sur la période | + +### 10.2 Graphiques + +**G1 — « Pas par jour »** (principal, pleine largeur) +- Type : `bar` + `line`. Série `Pas` : barres `#199E70` (`chart-3`) ; jours ≥ objectif : opacité 100 %, sinon 55 % (renfort non-couleur : l'axe le montre aussi). Série `Moyenne 7 j` : ligne 2 px blanche à 60 %. `markLine` objectif (« Objectif 10 000 »). Tooltip axe, zoom inside + slider. Légende (2 séries). + +**G2 — « Calories actives par jour »** (demi-largeur) +- Type : `bar`, série unique `#D95926` (`chart-2`), pas de légende. `markLine` moyenne de période (label « Moy. 410 »). Tooltip axe. + +**G3 — « Entraînement par semaine »** (demi-largeur) +- Type : `bar` empilées par semaine ISO, axe Y **heures** (`1 h 30` au tooltip). Séries = types de séance : `Tapis de course` (1), `Marche` (2), `Vélo` (3), `Renforcement` (4), `Autre` (5, muted). Légende. Tooltip axe : détail par type + total semaine. + +**G4 — « Distance cumulée »** (pleine largeur, 240 px) +- Type : `line` en aire, cumul de la distance (pas + séances) depuis le début de la période, `#3987E5` avec dégradé. Label direct sur le dernier point (`86,4 km`). Tooltip axe : `12/08 — cumul 84,1 km (+2,3 km ce jour)`. + +### 10.3 Carte « Records » (stat tiles, pas un graphique) + +4 tuiles : **Max pas en un jour** `18 452 · 21/06/2026` · **Plus longue séance** `1 h 32 · tapis · 05/07/2026` · **Meilleure distance en séance** `12,4 km` · **Meilleure semaine** `78 500 pas · Sem. 25`. Chaque record est cliquable → surligne le jour dans G1. + +### 10.4 Table « Séances » + +Colonnes : `Date` · `Type` (badge icône) · `Durée` · `Distance` · `Kcal` · `FC moy.` (si dispo) · `Source` (badge : FitShow / Health Connect / Manuel) · actions ✎ 🗑. Filtres : type, source. Tri par date desc. + +### 10.5 Modale « Ajouter une séance » + +Champs : **Type** (select : Tapis de course / Marche / Course à pied / Vélo / Renforcement / Natation / Autre) · **Date** (défaut aujourd'hui) · **Heure de début** · **Durée** (champ `hh:mm`, requis) · **Distance (km)** (optionnel) · **Calories (kcal)** (optionnel — placeholder « estimées automatiquement si vide », estimation MET simple par type) · **FC moyenne (bpm)** (optionnel) · **Note**. Boutons « Annuler » / « Enregistrer ». + +--- + +## 11. Page — Balance énergétique + +**Route** `/balance` · **Titre** « Balance énergétique » · **Période par défaut** : 30 j. + +Définitions (constantes en `src/lib/energy.ts`) : +- `BMR` : Mifflin-St Jeor (`10×kg + 6.25×cm − 5×âge + s`, `s = +5` homme / `−161` femme), recalculé chaque jour avec le poids de tendance. +- `TDEE = BMR × activityFactor` (§15.1) **ou** `BMR + kcal actives mesurées` si la source d'activité est complète (choix dans Réglages : `tdeeMode: 'factor' | 'measured'`). +- `netBalance(day) = kcalIn − TDEE(day)`. **Négatif = déficit = vert.** +- `KCAL_PER_KG = 7700` pour toutes les conversions kcal ↔ kg. +- Un jour sans journal alimentaire est **exclu** des cumuls (et hachuré dans les graphes) plutôt que compté à 0 — règle anti-fausses-données. + +### 11.1 KPI (6 cartes) + +| Libellé | Valeur | Sous-texte | +|---|---|---| +| **Balance aujourd'hui** | `−520 kcal` | `Apports 1 450 · Dépense 1 970` | +| **Déficit moyen (7 j)** | `−430 kcal/j` | `Cible : −550 kcal/j` | +| **Cumul (période)** | `−12 450 kcal` | `≈ −1,6 kg théoriques` | +| **TDEE estimé** | `2 350 kcal/j` | `BMR 1 780 × 1,32` (ou `BMR + actives mesurées`) | +| **Budget quotidien** | `1 800 kcal` | `TDEE − 550` | +| **Réel vs théorique** | `+0,4 kg` | `le réel décroche au-dessus du modèle` (voir G4) | + +### 11.2 Graphiques + +**G1 — « Entrées vs sorties »** (principal, pleine largeur) +- Type : `bar` **en miroir** sur un seul axe kcal : série `Apports` en valeurs positives (`#D95926`, `chart-2`), série `Dépense énergétique` en valeurs négatives (`#3987E5`, `chart-1`, arrondis vers le bas). Ligne zéro marquée (`axisLine` y=0 en `#383835`). +- Tooltip axe : `mar. 12/08 — Apports 1 940 · Dépense 2 310 · Balance −370 kcal` (balance colorée). Légende (2 séries). Zoom inside + slider. Jours sans journal : barres hachurées (`decal`) + mention tooltip « journal incomplet ». + +**G2 — « Balance nette quotidienne »** (demi-largeur) +- Type : `bar`, une série `net = in − out`. `visualMap.pieces` : `< 0` → `semantic-positive` (déficit), `> 0` → `semantic-negative` (surplus). `markLine` à la cible de déficit (`−550`, pointillés). Tooltip : `12/08 — Balance −370 kcal (déficit)`. + +**G3 — « Déficit cumulé »** (demi-largeur) +- Type : `line` en aire, cumul de `net` depuis le début de période. Couleur `semantic-positive` si le cumul est négatif (cas normal), l'aire se remplit **sous** zéro. Axe Y kcal ; le tooltip donne la double lecture : `Cumul −12 450 kcal ≈ −1,6 kg` (pas de second axe — l'équivalence kg vit dans le tooltip et le KPI). + +**G4 — « Poids théorique vs poids réel »** (pleine largeur) +- Type : `line`, 2 séries, axe Y kg (même unité, un seul axe) : + 1. `Poids réel (tendance)` — `#3987E5`, 2 px ; + 2. `Poids théorique` — `#9085E9` (`chart-7`), pointillés `[6,4]` : `startWeight + cumul(net)/7700`, ancré sur la tendance au 1er jour de la période. +- Légende + labels directs en bout de lignes. Tooltip axe : les deux valeurs + `Écart +0,4 kg`. C'est **le** graphique de vérité du module : si le réel et le théorique divergent durablement, le TDEE est recalibré (voir carte méthode). + +### 11.3 Carte « Méthode & calibration » + +Carte informative repliable : explication en 3 phrases du modèle (BMR Mifflin-St Jeor, TDEE, 7 700 kcal/kg), et ligne de calibration : « Sur les 30 derniers jours, votre dépense réelle estimée d'après la pesée est de **2 410 kcal/j** (modèle : 2 350). » + bouton « Utiliser cette valeur comme TDEE » (écrit un override dans Réglages). + +### 11.4 État dégradé + +Cette page exige **pesées + journal alimentaire**. S'il manque l'un des deux sur la période : bandeau jaune « Données incomplètes : X jours sans journal alimentaire sur la période. Les cumuls excluent ces jours. » + +--- + +## 12. Page — Vape + +**Route** `/vape` · **Titre** « Vape » · **Période par défaut** : 30 j. + +Modèle de coût (constantes dans Réglages §15.4) : +- `costPerMl` = (prix base/ml + prix nicotine/ml au taux cible + prix arôme/ml au dosage) — détail affiché en §12.4. +- `coilCostPerDay` = prix résistance ÷ durée de vie moyenne (jours). +- `vapeCostPerDay(d) = ml(d) × costPerMl + coilCostPerDay`. +- `tobaccoCostPerDay` = (cigarettes/jour avant arrêt ÷ cigarettes/paquet) × prix du paquet — **figé à la config baseline**, avec historique de prix optionnel. +- `savings(d) = Σ depuis quitDate (tobaccoCostPerDay − vapeCostPerDay)`. +- `avoidedCigarettes = jours depuis quitDate × cigarettes/jour avant` (compteur temps réel au prorata de la journée). +- Consommation ml/jour : dérivée des **recharges** (une recharge de 10 ml le 12/08 puis une le 15/08 ⇒ ~3,3 ml/j lissés sur l'intervalle) ; une saisie quotidienne directe est aussi possible — les deux modes coexistent, la donnée quotidienne prime. + +### 12.1 KPI (8 cartes, 2 rangées) + +| Libellé | Valeur | Sous-texte | +|---|---|---| +| **Aujourd'hui** | `3,8 ml` | `Moyenne 7 j : 4,1 ml/j` | +| **Nicotine aujourd'hui** | `11,4 mg` | `3,8 ml × 3,0 mg/ml` | +| **Coût moyen / jour** | `0,62 €/j` | `dont résistances 0,17 €/j` | +| **Économies cumulées** | `1 245,80 €` | `vs 8,50 €/j de tabac` — toujours vert | +| **Cigarettes évitées** | `7 654` | compteur animé (odometer), `≈ 15/j` | +| **Sans tabac depuis** | `511 jours` | `depuis le 15/03/2025` | +| **Résistance actuelle** | `J+9` | `Moyenne : 12 j — à surveiller` (badge jaune si ≥ moyenne − 2 j, rouge si > moyenne) | +| **Stock estimé** | `34 ml` | `≈ 8 jours restants` (si suivi de stock activé, sinon carte masquée) | + +### 12.2 Graphiques + +**G1 — « Consommation d'e-liquide »** (principal, pleine largeur) +- Type : `line` + `scatter`. Série `ml/jour` : points `#9085E9` (`chart-7`) à 45 % ; série `Moyenne mobile 7 j` : ligne 2 px `#9085E9`. `markLine` optionnelle « Objectif » si un objectif de réduction est défini (§15.4). Tooltip axe : `12/08 — 4,2 ml · moyenne 7 j : 4,0 ml`. Zoom inside + slider. Légende (2 séries). +- Les jours de **changement de résistance** apparaissent en `markPoint` discrets (petit pictogramme ⚙ sous l'axe, tooltip « Résistance changée »). + +**G2 — « Nicotine absorbée »** (demi-largeur) +- Type : `bar`, série unique `mg/jour` `#D55181` (`chart-5`). `markLine` moyenne de période. Tooltip : `12/08 — 12,6 mg (4,2 ml × 3,0 mg/ml)`. Si le taux a changé dans la période, chaque jour utilise le taux effectif de la recharge courante. + +**G3 — « Coût quotidien »** (demi-largeur) +- Type : `line`, série `€/jour` `#C98500` (`chart-4`), moyenne mobile 7 j (le brut en points 45 %). `markLine` de référence : `Tabac : 8,50 €/j` (pointillés `#C3C2B7`) — l'écart visuel entre la ligne et la markLine **est** l'économie quotidienne. Tooltip : `12/08 — vape 0,66 € · tabac évité 8,50 € · gain 7,84 €`. + +**G4 — « Économies cumulées »** (pleine largeur, 320 px — le graphique plaisir) +- Type : `line` en aire, cumul `savings` depuis `quitDate`, couleur `#0CA30C` avec dégradé d'aire. Période forcée « Tout » par défaut (surcharge locale possible). Label direct sur le dernier point : `1 245,80 €`. `markPoint` aux paliers franchis : `100 €`, `250 €`, `500 €`, `1 000 €`, `2 000 €`, `5 000 €` (pin avec étiquette). Tooltip axe : `12/08 — 1 238,40 € économisés`. Zoom inside + slider. + +**G5 — « Durée de vie des résistances »** (pleine largeur, 260 px) +- Type : `bar`, une barre par résistance **terminée**, axe X = date de changement (catégorie), axe Y = durée en jours. Couleur unique `chart-7` ; la résistance courante (en cours) apparaît en dernière barre hachurée (`decal`) avec sa durée provisoire. `markLine` moyenne (label « Moy. 12 j »). Tooltip item : `Changée le 04/08/2026 — a duré 13 j · ~52 ml vapotés · GT Mesh 0,15 Ω`. + +### 12.3 Timeline « Jalons santé » (composant liste, pas ECharts) + +Timeline verticale (rail à gauche, points verts = atteint, gris = à venir, le prochain jalon porte une jauge de progression). Jalons depuis `quitDate` (libellés exacts) : + +| Échéance | Libellé | +|---|---| +| 20 minutes | « Pouls et tension redescendent » | +| 8 heures | « Le monoxyde de carbone diminue de moitié » | +| 24 heures | « Le CO est éliminé, les poumons évacuent les résidus » | +| 48 heures | « Goût et odorat s'améliorent » | +| 72 heures | « Respirer devient plus facile » | +| 2 semaines | « La circulation sanguine s'améliore » | +| 1 mois | « La peau retrouve son éclat » | +| 3 mois | « Toux et fatigue diminuent, souffle nettement meilleur » | +| 6 mois | « Capacité pulmonaire en nette hausse » | +| 1 an | « Risque d'infarctus réduit de moitié » | +| 5 ans | « Risque d'AVC redevenu comparable à un non-fumeur » | +| 10 ans | « Risque de cancer du poumon réduit de moitié » | + +### 12.4 Carte « Modèle de coût » (repliable) + +Décomposition affichée : `Base PG/VG 0,08 €/ml + Nicotine 0,11 €/ml + Arôme 0,09 €/ml = 0,28 €/ml` · `Résistance : 3,90 € ÷ 12 j = 0,33 €/j` · `Référence tabac : 15 cig./j × (12,50 € / 20) = 9,38 €/j` (valeurs = exemples). Bouton « Modifier le modèle » → Réglages §15.4. + +### 12.5 Modale « Recharge » (quick-add global) + +Champs : **Quantité (ml)** — numérique, défaut = contenance de flacon par défaut (§15.4), boutons rapides `10` `30` `50` · **Taux de nicotine (mg/ml)** — défaut = taux courant des Réglages · **Date et heure** — défaut maintenant · **Arôme / recette** (texte optionnel, autocomplete sur les précédents) · **Note**. Boutons « Annuler » / « Enregistrer » → toast « Recharge de 10,0 ml enregistrée ✓ ». +Variante dans la même modale (onglets) : **« Conso du jour »** — saisie directe `ml` pour une date (corrige/remplace l'estimation par recharges). + +### 12.6 Bouton « Résistance changée » (action un clic) + +Depuis le menu « + Ajouter », la page Vape (bouton dédié dans l'en-tête de G5) ou le tableau de bord : enregistre `coilChange(now)` immédiatement, toast « Résistance changée ✓ — la précédente a duré 13 jours » + lien « Ajouter un détail » (ouvre une mini-modale : Modèle (texte, autocomplete), Valeur (Ω), Prix unitaire (défaut Réglages), Note). + +### 12.7 Tables + +- **« Historique des recharges »** : `Date` · `Quantité` · `Nicotine` · `Arôme` · `Coût estimé` · actions ✎ 🗑. +- **« Historique des résistances »** : `Posée le` · `Retirée le` · `Durée` · `Conso (ml)` · `Modèle` · `Prix` · actions ✎ 🗑. + +--- + +## 13. Page — Finances + +**Route** `/finances` · **Titre** « Finances » · **Période par défaut** : **Ce mois-ci** (le `PeriodSelector` affiche ici en tête : `Ce mois-ci` · `3 mois` · `6 mois` · `1 an` · `Tout` · `Personnalisé`). + +La page est organisée en 4 onglets internes (tabs sous la topbar) : **Vue d'ensemble · Transactions · Budgets · Récurrents**. L'URL les reflète (`/finances`, `/finances/transactions`, `/finances/budgets`, `/finances/recurrents`). + +### 13.1 KPI (communs, affichés sur « Vue d'ensemble ») + +| Libellé | Valeur | Sous-texte | +|---|---|---| +| **Solde total** | `4 812,34 €` | `3 comptes` (au dernier import) | +| **Dépenses du mois** | `1 234,56 €` | `Moyenne 6 mois : 1 480,00 €` | +| **Revenus du mois** | `2 300,00 €` | | +| **Cashflow du mois** | `+1 065,44 €` | vert si > 0, rouge sinon | +| **Budget global** | `82 %` | jauge `1 234,56 € / 1 500,00 €` | +| **À catégoriser** | `14 transactions` | carte cliquable → onglet Transactions filtré `catégorie = aucune` (badge jaune si > 0) | + +### 13.2 Onglet « Vue d'ensemble » — graphiques + +**G1 — « Évolution du solde total »** (pleine largeur) +- Type : `line` en aire, solde total reconstitué jour par jour (somme des comptes), `#3987E5`. Tooltip axe : `12/08 — 4 812,34 €`. Zoom inside + slider. Sous le graphique, rangée de **tuiles par compte** : nom du compte, solde `1 852,10 €`, date de dernière donnée (badge jaune « Dernier import il y a 32 j » si > 30 j). + +**G2 — « Dépenses par catégorie »** (demi-largeur, période courante) +- Type : `pie` donut (radius `['50%','78%']`), top 7 catégories + part `Autres` (`#898781`). Couleur = slot fixe de chaque catégorie (§4.4). Centre : total `1 234,56 €`. Labels directs : `Alimentation 24 %`. Tooltip item : `Alimentation — 296,40 € (24 %) · 18 transactions`. **Clic sur une part → onglet Transactions filtré sur la catégorie + la période.** + +**G3 — « Dépenses mensuelles par catégorie »** (demi-largeur) +- Type : `bar` empilées, 12 derniers mois, mêmes couleurs de catégories (top 7 + Autres, espaces 2 px). Légende. Tooltip axe : détail par catégorie + total du mois. Mois courant hachuré (`decal`, incomplet). Clic sur un segment → Transactions filtrées (mois + catégorie). + +**G4 — « Cashflow mensuel »** (pleine largeur) +- Type : `bar` groupées + `line`, un seul axe € : `Revenus` (barres `semantic-positive`), `Dépenses` (barres `semantic-negative`, affichées en positif, côte à côte), `Net` (ligne blanche 2 px, peut passer sous zéro). Légende (3). Tooltip axe : `juin 2026 — Revenus 2 300,00 € · Dépenses 1 890,50 € · Net +409,50 €`. + +**G5 — « Flux du mois »** (pleine largeur, 380 px) +- Type : `sankey`, orientation gauche → droite : nœuds `Revenus` (par catégorie de revenu : Salaire, Autres revenus) → nœud central `Budget du mois` → catégories de dépenses (top 7 + Autres) → l'écart restant sort vers un nœud `Épargne` (vert) si positif. Liens colorés par catégorie cible à 40 % d'opacité. Tooltip lien : `Budget → Alimentation : 296,40 €`. Nœuds : label + montant. Pas de zoom ; drag des nœuds désactivé (`draggable: false`). + +### 13.3 Onglet « Transactions » + +**Barre de filtres** (une ligne, wrap mobile) : Recherche texte (libellé) · Compte (multi-select) · Catégorie (multi-select avec « Sans catégorie ») · Type (`Tout / Dépenses / Revenus`) · Montant min/max · période (héritée de la page). Badges de filtres actifs effaçables ; bouton « Réinitialiser ». + +**Table** (le cœur du module) — colonnes : + +| Colonne | Contenu | +|---|---| +| `Date` | `dd/MM/yyyy` (groupes visuels par mois au scroll : sous-en-tête « août 2026 — 1 234,56 € de dépenses ») | +| `Libellé` | libellé bancaire nettoyé, sous-ligne : libellé brut original en 11 px muted | +| `Compte` | badge court (« Courant », « PayPal ») | +| `Catégorie` | **éditable en ligne** : badge coloré (pastille couleur du slot) cliquable → combobox avec recherche ; `Sans catégorie` = badge jaune pointillé | +| `Montant` | aligné droite, tabular ; dépenses `−45,90 €` en `text-ink`, revenus `+2 300,00 €` en `semantic-positive` | +| Actions (hover / menu ⋯) | ✎ Modifier · ⚡ **« Créer une règle »** · 🗑 Supprimer (les transactions importées se suppriment avec confirmation renforcée) | + +- Sélection multiple (checkbox) → barre d'actions groupées : « Catégoriser (n) », « Supprimer (n) ». +- Une transaction re-catégorisée à la main affiche une pastille « manuel » (les règles ne l'écrasent plus). + +**Modale « Créer une règle »** (ouverte depuis une transaction, pré-remplie) : +- **Si le libellé contient** (texte, pré-rempli avec le mot significatif du libellé, ex. `CARREFOUR`) — select d'opérateur : `contient` / `commence par` / `expression régulière`. +- Conditions optionnelles (ajout par bouton « + Condition ») : **Compte est** (select) · **Montant entre** (min/max) · **Type** (dépense/revenu). +- **Alors catégoriser en** : combobox catégorie (+ « Créer une catégorie… » inline). +- Aperçu en direct : « **12 transactions existantes** correspondent à cette règle » + mini-liste des 5 premières. +- Case « Appliquer aux transactions existantes non catégorisées manuellement » (cochée par défaut). +- Boutons « Annuler » / « Créer la règle ». Toast : « Règle créée ✓ — 12 transactions catégorisées ». + +### 13.4 Onglet « Budgets » + +- Sélecteur de mois (`‹ août 2026 ›`). +- Liste de **barres de progression par catégorie** (composant, pas ECharts) : pastille + nom · `296,40 € / 350,00 €` · barre 8 px (vert < 80 %, jaune 80–100 %, rouge > 100 % — le dépassement déborde en rouge au-delà de 100 % avec largeur plafonnée et libellé `118 %`) · reste `53,60 €` ou `Dépassé de 22,40 €`. +- En tête : budget global (somme) avec la même barre + **« Rythme »** : `Au 13/08, vous avez dépensé 82 % du budget pour 42 % du mois écoulé` (jauge à double repère : position du jour vs consommation). +- Graphique **G6 — « Budget vs réalisé (6 mois) »** : `bar` groupées par mois : `Budget` (barres `#898781` 40 %) vs `Dépensé` (barres `chart-1`), un axe €. Légende. Tooltip axe. +- Bouton « Modifier les budgets » → Réglages §15.5 (ou édition inline du montant au clic). + +### 13.5 Onglet « Récurrents » + +- Détection automatique : transactions au libellé similaire, périodicité ~mensuelle/hebdo/annuelle (tolérance ±4 j), ≥ 3 occurrences. +- **Table « Dépenses récurrentes détectées »** : `Libellé` · `Catégorie` · `Fréquence` (`Mensuel`, `Annuel`…) · `Montant moyen` · `Dernière occurrence` · `Prochaine échéance estimée` (badge jaune si dépassée = possible résiliation ou changement) · `Coût annuel` (tri par défaut, desc) · actions : « Confirmer » / « Ignorer » (les ignorés vont dans un panneau repliable). +- KPI d'onglet : **« Total récurrent mensuel »** `184,90 €/mois` · **« Part des dépenses »** `15 %` · **« Coût annuel »** `2 218,80 €`. +- Graphique **G7 — « Échéancier du mois »** : frise horizontale du mois (composant custom léger) avec les échéances positionnées au jour, passées en plein / à venir en contour. + +### 13.6 Modale « Ajouter une transaction » (manuelle) + +Champs : **Type** (toggle Dépense / Revenu) · **Montant (€)** · **Date** · **Libellé** · **Compte** (select) · **Catégorie** (combobox) · **Note**. Utilisée pour les espèces ; badge source « Manuel ». + +--- + +## 14. Page — Imports + +**Route** `/imports` · **Titre** « Imports » · pas de `PeriodSelector`. + +### 14.1 Zone d'upload (carte principale) + +- **Drag & drop** pleine largeur : pointillés `border`, icône `upload-cloud`, texte : « **Glissez un fichier ici** ou cliquez pour parcourir » · sous-texte : « CSV, OFX, XLSX ou ZIP — 20 Mo max ». Multi-fichiers accepté (file d'attente). +- **Choix du profil de source** (select, avec auto-détection : le parseur tente de reconnaître en-têtes/format et pré-sélectionne, badge « détecté automatiquement ») : + - `Health Connect — export (zip/csv)` — pas, poids, distance, kcal actives, séances + - `Foodvisor — export` — journal alimentaire + - `FitShow — export` — séances tapis + - `Relevé bancaire — CSV générique` (avec étape de mappage) + - `Relevé bancaire — OFX` + - `PayPal — activité (CSV)` + - `Pesées — CSV générique` (date; poids[; note]) + - `Vape — CSV générique` (date; ml[; mg/ml]) +- Flux en 3 étapes (stepper « 1. Fichier → 2. Vérification → 3. Import ») : + 1. **Fichier** : upload + profil (+ pour les profils bancaires : select **Compte de destination**, avec « Créer un compte… » inline). + 2. **Vérification** : pour le CSV générique, écran de **mappage de colonnes** (aperçu des 5 premières lignes ; pour chaque colonne cible — Date, Libellé, Montant (ou Débit/Crédit séparés), etc. — un select de colonne source ; format de date détecté ; option « ignorer la première ligne ») ; le mappage est mémorisé par profil+banque. Puis **aperçu** : table des 20 premières lignes normalisées + bandeau : « 254 lignes lues · **12 doublons ignorés** (déjà importés) · 2 lignes en erreur ». + 3. **Import** : barre de progression, puis récapitulatif. +- Déduplication : hash par ligne normalisée (source + date + montant + libellé, ou date+valeur pour les mesures) — les ré-imports du même fichier sont sans effet (idempotent). + +### 14.2 Table « Historique des imports » + +Colonnes : `Date` (`13/08/2026 19:32`) · `Fichier` (nom + taille) · `Profil` (badge) · `Lignes` (`254`) · `Importées` (`240` en vert) · `Doublons` (`12` en muted) · `Erreurs` (`2` — badge rouge cliquable) · `Statut` (`Terminé` vert / `Partiel` jaune / `Échec` rouge / `Annulé` muted) · actions : « Détails », **« Annuler cet import »** (rollback : supprime les lignes créées par cet import, avec confirmation « Cette action supprimera les 240 entrées créées par cet import. »). + +### 14.3 Panneau « Erreurs » (détail d'un import) + +Ouvert depuis la table : liste des lignes en erreur — `N° de ligne` · `Contenu brut` (mono, tronqué) · `Motif` en français (« Date invalide : "31/02/2026" », « Montant illisible : "12,3O" », « Colonne "Poids" absente »). Bouton « Télécharger le rapport d'erreurs (CSV) ». + +### 14.4 Carte « Connecteurs & API » + +Rappel : « Votre téléphone peut envoyer les données Health Connect automatiquement via l'application compagnon. » + statut de la dernière synchronisation (`Dernière réception : 13/08/2026 07:12 · 4 types de données`) + bouton « Gérer les clés d'appareil » → Réglages §15.6. + +--- + +## 15. Page — Réglages + +**Route** `/reglages` · **Titre** « Réglages » · navigation par onglets verticaux (desktop) / accordéon (mobile) : **Profil · Objectif · Nutrition · Vape · Finances · Appareils & API · Application**. Chaque onglet = cartes de formulaires avec bouton « Enregistrer » par carte (toast de confirmation). + +### 15.1 Onglet « Profil » (profil corporel) + +- **Prénom / pseudonyme** (affichage) · **Sexe** (radio : Homme / Femme — utilisé pour le BMR) · **Date de naissance** (datepicker) · **Taille (cm)**. +- **Niveau d'activité** (select avec descriptions) : `Sédentaire (×1,2)` · `Légèrement actif (×1,375)` · `Modérément actif (×1,55)` · `Très actif (×1,725)` · `Extrêmement actif (×1,9)`. +- **Mode de calcul du TDEE** (radio) : « Facteur d'activité (recommandé au départ) » / « BMR + calories actives mesurées » / « Valeur manuelle : ___ kcal/j » (renseignée aussi par la calibration §11.3). +- Encart calculé en direct : `BMR : 1 780 kcal · TDEE estimé : 2 350 kcal/j`. + +### 15.2 Onglet « Objectif » + +- **Poids de départ** (pré-rempli : première pesée) et **date de départ**. +- **Poids cible (kg)** · **Rythme visé** (slider `0,25 — 1,0 kg/semaine`, graduations 0,25 · repère « recommandé : 0,5 ») ; +- Lignes calculées en direct : `Déficit quotidien nécessaire : ≈ 550 kcal/j` · `Date d'atteinte estimée : 24/10/2026` ; +- Alternative : saisir une **date cible** → le rythme et le déficit se recalculent (les trois champs sont liés, le dernier modifié gagne). +- Garde-fou : si le budget résultant < 1 200 kcal/j (femme) / 1 500 (homme) : avertissement jaune « Ce rythme impose un budget très bas. Envisagez un objectif plus progressif. » + +### 15.3 Onglet « Nutrition » + +- **Budget calorique** (radio) : « Automatique : TDEE − déficit (1 800 kcal/j actuellement) » / « Manuel : ___ kcal/j ». +- **Objectifs de macros** : Protéines (`g/kg de poids` — défaut 1,6 — ou g fixes), Glucides / Lipides en % des kcal restantes (deux sliders liés). +- **Repas** : liste réordonnable des repas du journal (défaut : Petit-déjeuner, Déjeuner, Dîner, Collations) — renommage possible. +- **Objectif de pas** (utilisé page Activité) : défaut `10 000`. + +### 15.4 Onglet « Vape » + +Carte **« Référence tabac (avant l'arrêt) »** : +- **Date d'arrêt du tabac** (datepicker — ancre des économies et jalons) · **Cigarettes par jour** (défaut 15) · **Cigarettes par paquet** (défaut 20) · **Prix du paquet (€)** (défaut 12,50) — ligne calculée : `Coût tabac de référence : 9,38 €/j`. + +Carte **« Modèle de coût DIY »** : +- **Base PG/VG** : prix (€) + contenance (ml) → `€/ml` calculé. +- **Booster de nicotine** : prix du flacon (€), contenance (ml, défaut 10), concentration (mg/ml, défaut 20). +- **Taux de nicotine cible (mg/ml)** de la préparation (défaut 3,0) → part booster calculée. +- **Arôme** : prix (€), contenance (ml), **dosage ( %)** (défaut 10 %). +- **Résistances** : prix unitaire (€) (+ prix du pack et nombre, au choix). +- **Contenance de flacon par défaut (ml)** pour le quick-add recharge (défaut 10). +- Encart calculé : `Coût de revient : 0,28 €/ml · avec résistances : ≈ 0,62 €/j au rythme actuel`. +- **Objectif de réduction** (optionnel) : `ml/jour visés` et/ou `taux nicotine visé` → matérialisés en markLine sur G1/G2 de la page Vape. + +### 15.5 Onglet « Finances » + +Quatre cartes : +1. **Comptes** — table CRUD : `Nom` (« Compte courant ») · `Type` (Courant / Épargne / PayPal / Espèces / Autre) · `Banque` · `Solde initial` + `Date du solde initial` · `Devise` (EUR fixe v1) · actions ✎ 🗑 (suppression bloquée si transactions liées — proposer l'archivage). +2. **Catégories** — liste hiérarchique (2 niveaux max) : pastille couleur (slot proposé automatiquement, modifiable parmi les 8), icône (picker), nom ; catégories par défaut livrées : Alimentation, Logement, Transports, Santé, Loisirs, Abonnements, Restaurants, Shopping, Vape/Tabac, Épargne, Revenus (type revenu), Autres. Drag pour réordonner. +3. **Règles de catégorisation** — table : `Priorité` (drag & drop, la première règle qui matche gagne) · `Condition` (résumé lisible : « Libellé contient "CARREFOUR" ») · `Catégorie` · `Nb de transactions touchées` · actif (switch) · actions ✎ 🗑. Bouton « Tester les règles » : ré-exécute sur les non-catégorisées (aperçu avant application). +4. **Budgets mensuels** — table : catégorie · montant €/mois (input inline) · switch « reporter le non-dépensé » (v2, désactivé) ; ligne de total. + +### 15.6 Onglet « Appareils & API » + +- Texte d'intro : « Créez une clé pour permettre à l'application compagnon Android (Health Connect) d'envoyer ses données à LifeTrack. » +- **Table des clés** : `Nom` (« Pixel de Julien ») · `Préfixe` (`ltk_a3f4…`) · `Portées` (badges : `health:write`, `weight:write`…) · `Créée le` · `Dernière utilisation` (« il y a 2 h » — vert si < 24 h) · actions : 🗑 « Révoquer » (confirmation). +- **Bouton « Créer une clé »** → modale : Nom, portées (checkboxes), puis écran d'affichage **unique** de la clé complète avec bouton copier + avertissement « Cette clé ne sera plus jamais affichée. » +- Encart développeur repliable : méthode + URL du endpoint d'ingestion (`POST /api/v1/ingest`), en-tête `Authorization: Bearer `, exemple de payload JSON minimal. +- **Journal de synchronisation** : 20 dernières réceptions (date, appareil, types de données, nb d'enregistrements, statut). + +### 15.7 Onglet « Application » + +- **Thème** : Sombre (défaut) / Clair / Automatique. · **Langue** : Français (v1). · **Fuseau horaire** : Europe/Paris (info, non modifiable v1). · **Premier jour de la semaine** : Lundi. +- **Accessibilité** : switch « Motifs et textures sur les graphiques » (active les textures ECharts `decal` pour daltonisme/impression). +- **Sauvegarde** : « Exporter toutes mes données (JSON) » · « Exporter (CSV par module, ZIP) ». +- **Zone dangereuse** (bordure rouge) : « Vider un module… » (select module + confirmation par saisie du mot SUPPRIMER) · « Supprimer le compte ». +- **Utilisateurs** (préparation multi-utilisateur) : table des utilisateurs (v1 : le seul admin) + bouton « Inviter » désactivé avec badge « bientôt ». + +### 15.8 Assistant de premier lancement (first-run wizard) + +Plein écran, 4 étapes, jamais bloquant (« Passer » partout sauf étape 1) : +1. **« Créez votre compte administrateur »** — e-mail, mot de passe (×2, jauge de robustesse). +2. **« Votre profil »** — sexe, naissance, taille, poids actuel, niveau d'activité (§15.1). +3. **« Votre objectif »** — poids cible + rythme (§15.2), affichage immédiat du budget kcal. +4. **« Vos modules »** — 3 cartes activables : Vape (si activée → mini-formulaire baseline tabac §15.4), Finances (créer le premier compte), Import (lien vers `/imports`). Bouton final : « C'est parti → » (vers le tableau de bord). + +--- + +## 16. États vides + +Chaque page/section a un `EmptyState` (§5.4) avec ce texte exact : + +| Contexte | Titre | Texte | Actions | +|---|---|---|---| +| Tableau de bord (tout vide) | « Bienvenue sur LifeTrack 👋 » | « Configurez votre profil et importez vos premières données pour voir vos tableaux de bord prendre vie. » | « Configurer mon profil » · « Importer des données » | +| Poids | « Aucune pesée pour l'instant » | « Ajoutez votre première pesée ou importez un historique — la tendance et les projections apparaîtront dès 3 pesées. » | « + Ajouter une pesée » · « Importer un CSV » | +| Nutrition (journal du jour) | « Rien dans le journal aujourd'hui » | « Ajoutez votre premier aliment ou importez votre historique Foodvisor depuis la page Imports. » | « + Ajouter un aliment » · « Importer Foodvisor » | +| Activité | « Aucune activité enregistrée » | « Connectez l'application compagnon Health Connect, importez un export FitShow, ou saisissez une séance manuellement. » | « + Ajouter une séance » · « Voir les imports » | +| Balance énergétique | « Il manque des données » | « La balance énergétique a besoin de vos pesées **et** de votre journal alimentaire. Complétez ces deux modules pour débloquer cette page. » | « Aller à Poids » · « Aller à Nutrition » | +| Vape | « Module vape non configuré » | « Renseignez votre consommation de cigarettes avant l'arrêt et votre modèle de coût : LifeTrack calculera vos économies au centime près. » | « Configurer la vape » | +| Vape (configurée, sans données) | « Aucune recharge enregistrée » | « Enregistrez votre première recharge d'e-liquide — deux clics suffisent. » | « + Recharge » | +| Finances | « Aucune transaction » | « Importez un relevé bancaire (CSV ou OFX) ou un export PayPal pour démarrer. La catégorisation automatique fera le tri. » | « Importer un relevé » | +| Finances → Récurrents | « Pas encore de récurrents détectés » | « La détection a besoin d'au moins 3 mois de transactions pour repérer vos abonnements et charges fixes. » | — | +| Imports (historique) | « Aucun import pour l'instant » | « Déposez un fichier ci-dessus : LifeTrack détecte le format et vous montre un aperçu avant d'importer quoi que ce soit. » | — | +| Table filtrée sans résultat | « Aucun résultat » | « Aucune ligne ne correspond à ces filtres. » | « Réinitialiser les filtres » | +| Graphique sans données sur la période | (dans le canvas) « Pas de données sur cette période » | — | « Élargir la période » (passe à `Tout`) | + +--- + +## 17. Accessibilité + +- **Palette validée** (script de validation CVD — voir §4.4) ; l'ordre des slots ne doit pas être modifié sans re-validation. +- La couleur n'est jamais seule : légendes systématiques dès 2 séries, signes `+`/`−`, icônes de tendance, labels directs sélectifs, badges texte sur les statuts. +- **« Voir les données »** sur chaque ChartCard = équivalent tabulaire complet (triable, exportable CSV). +- Option « Motifs et textures » (§15.7) : applique des hachures `decal` à 45°/135° sur les séries des graphiques empilés et miroirs. +- Navigation clavier complète : sidebar et tabs focusables, modales avec focus trap, `Esc` ferme, tables navigables aux flèches. +- Contrastes : textes ≥ 4,5:1 sur `surface` ; les 8 slots de série ≥ 3:1 sur `#1A1A19`. +- `prefers-reduced-motion` : désactive l'animation des graphiques et le compteur odometer (affichage direct). +- Tooltips ECharts doublés d'un `aria-label` descriptif par graphique (« Graphique en ligne : évolution du poids du 15/05 au 13/08, de 85,1 à 82,4 kg »). + +--- + +*Fin de la spécification UX. Toute divergence d'implémentation doit être signalée et arbitrée contre ce document.* diff --git a/docs/research/finance-sources.md b/docs/research/finance-sources.md new file mode 100644 index 0000000..da81f2d --- /dev/null +++ b/docs/research/finance-sources.md @@ -0,0 +1,590 @@ +# Module FINANCE — Recherche sur les sources de données et l'import + +> Document de recherche pour LifeTrack (module Finance). Rédigé le 2026-08-13. +> Public : agents d'implémentation. Ce document est autoportant — **aucune recherche complémentaire n'est prévue**. +> Convention : prose en français, identifiants de code en anglais. + +--- + +## Sommaire + +1. [Vue d'ensemble et enseignements clés](#1-vue-densemble-et-enseignements-clés) +2. [Formats d'export des banques françaises (fichiers)](#2-formats-dexport-des-banques-françaises-fichiers) +3. [Export d'activité PayPal](#3-export-dactivité-paypal) +4. [Le format OFX en France (et QIF)](#4-le-format-ofx-en-france-et-qif) +5. [Agrégation PSD2 : état des lieux 2025/2026](#5-agrégation-psd2--état-des-lieux-20252026) +6. [Stratégies de déduplication](#6-stratégies-de-déduplication) +7. [Recommandations d'implémentation — v1 (import fichiers)](#7-recommandations-dimplémentation--v1-import-fichiers) +8. [Recommandations d'implémentation — v2 (synchronisation automatique)](#8-recommandations-dimplémentation--v2-synchronisation-automatique) +9. [Sources](#9-sources) + +--- + +## 1. Vue d'ensemble et enseignements clés + +### 1.1 Constats structurants + +1. **Le "CSV bancaire français" n'existe pas** : chaque banque a son propre dialecte. Points communs majoritaires : séparateur `;`, virgule décimale, dates `JJ/MM/AAAA`, encodage `ISO-8859-1`/`ISO-8859-15` (Windows-1252 en pratique). Exceptions notables : BoursoBank (dates `AAAA-MM-JJ`), Revolut et N26 (séparateur `,`, point décimal, UTF-8). +2. **Beaucoup de fichiers ont un préambule** (lignes d'en-tête métier avant la ligne d'en-têtes de colonnes) : Crédit Agricole (nombre de lignes **variable**), Société Générale (1 ligne), La Banque Postale (~8 lignes), BNP (1 ligne de solde). Le mapper CSV générique doit donc savoir **sauter N lignes** et/ou **détecter la ligne d'en-tête**. +3. **Les profondeurs d'historique téléchargeable sont faibles** chez les banques traditionnelles (30 à 90 jours typiquement, 6 mois chez SG) : l'utilisateur devra importer régulièrement, d'où l'importance capitale de la **déduplication** et des **fenêtres de recouvrement** (mieux vaut réimporter large que de créer des trous). +4. **OFX est disponible mais imparfait en France** : versions SGML 1.x, encodages legacy, et surtout des `FITID` non fiables chez certaines banques (cas documenté LCL : FITID = type+date+montant ⇒ collisions). Le FITID est un bon signal de dédup, **jamais une garantie**. +5. **Bouleversement PSD2 2025** : GoCardless Bank Account Data (ex-Nordigen), la solution gratuite historique des self-hosters (utilisée par Firefly III et Actual Budget), **n'accepte plus de nouveaux comptes depuis juillet 2025** et est en cours d'extinction. La relève gratuite pour un particulier est **Enable Banking** (mode "restricted" gratuit sur ses propres comptes), désormais supportée par Firefly III et Actual Budget. +6. Firefly III et Actual Budget fournissent des modèles éprouvés de déduplication : identifiant externe prioritaire (`imported_id` / "external identifier"), puis hash de contenu, puis rapprochement flou (montant identique + date proche + libellé similaire). Nous reprenons cette hiérarchie. + +### 1.2 Décision recommandée (résumé) + +- **v1** : import par fichiers uniquement. Un **mapper CSV générique** (encodage, séparateur, préambule, mapping de colonnes, format de date, virgule décimale, colonnes débit/crédit vs montant signé) + **presets par banque** livrés en JSON + **parseur OFX** (lib Python `ofxparse`) + **preset PayPal**. Déduplication à 3 niveaux (voir §6.4). Saisie manuelle et règles de catégorisation. +- **v2** : connecteur **Enable Banking** (PSD2 officiel, gratuit en mode restreint pour ses propres comptes, couvre les grandes banques françaises) branché sur le **même pipeline de staging/dédup** que les fichiers. Connecteur GoCardless conservé en option pour les détenteurs de comptes historiques. `woob` documenté comme adaptateur optionnel "fragile", non prioritaire. + +--- + +## 2. Formats d'export des banques françaises (fichiers) + +### 2.0 Tableau de synthèse + +| Banque | Formats | Séparateur CSV | Encodage | Format date | Décimale | Montant | Préambule | Historique | +|---|---|---|---|---|---|---|---|---| +| BoursoBank | CSV, OFX, QIF | `;` | UTF-8 | `AAAA-MM-JJ` | virgule (sauf col. solde : point !) | signé, 1 colonne | non | plusieurs années, période libre | +| Crédit Agricole | CSV, Excel, OFX, QIF (selon caisse) | `;` | ISO-8859-15 | `JJ/MM/AAAA` | virgule | 2 colonnes Débit/Crédit | oui, **variable** (fin = ligne `Date;`) | ~30–90 j selon caisse | +| BNP Paribas | CSV, OFX, QIF, PDF | `;` | ISO-8859-1 | `JJ/MM/AAAA` | virgule (+ espace milliers) | signé, 1 colonne | 1 ligne (solde, HTML-échappée 2×) | ~90 j | +| Société Générale | CSV, QIF (revenu en 2020) | `;` | ISO-8859-1/15, CRLF | `JJ/MM/AAAA` | virgule | signé, 1 colonne | 1 ligne (`="compte"`;début;fin;) | ~6 mois | +| La Banque Postale | CSV, TSV, OFX | `;` | ISO-8859-15 | `JJ/MM/AAAA` | virgule | signé, 1 colonne | ~8 lignes (n° compte, soldes) | ~90 j | +| Caisse d'Épargne | CSV (OFX selon interfaces) | `;` | ISO-8859-1 | `JJ/MM/AAAA` | virgule | 2 colonnes Débit/Crédit (crédit préfixé `+`) | non (nouveau format) | limité (pas de solde dans le fichier) | +| Fortuneo | CSV, XLS, QIF, OFX | `;` | ISO-8859-1 / Windows-1252 (à détecter) | `JJ/MM/AAAA` | virgule | 2 colonnes Débit/Crédit | non | jusqu'à ~10 ans | +| Revolut | CSV, Excel, PDF | `,` | UTF-8 | `AAAA-MM-JJ HH:MM:SS` | point | signé + colonne `Fee` | non | historique complet | +| N26 | CSV | `,` | UTF-8 | `AAAA-MM-JJ` | point | signé, 1 colonne | non | historique complet | +| PayPal | CSV, TAB (QIF USD only) | `,` | UTF-8 | selon locale (`JJ/MM/AAAA` en FR) | selon locale (virgule en FR) | Gross/Fee/Net | non | 7 ans (tranches de 12 mois) | + +Détails et pièges banque par banque ci-dessous. Les en-têtes cités sont **exacts** (issus de fichiers réels analysés dans des projets open-source, notamment `mincong-h/finance-toolkit`, et de documentations d'import OpenFlyers). + +### 2.1 BoursoBank (ex-Boursorama Banque) + +- **Accès** : espace client → historique du compte → sélection de période libre → « Exporter » ; formats **CSV, OFX, QIF** proposés. Tous les comptes sélectionnés sont regroupés dans **un seul fichier**. +- **Nom de fichier** : `export-operations-{JJ}-{MM}-{AAAA}_{hh}-{mm}-{ss}.csv` (date de génération). +- **Encodage** : UTF-8. **Séparateur** : `;`. Champs texte entre guillemets doubles. +- **En-tête exact (1re ligne)** : + ``` + dateOp;dateVal;label;category;categoryParent;amount;comment;accountNum;accountLabel;accountbalance + ``` +- **Exemple de ligne réelle** : + ``` + 2021-08-17;2021-08-17;"Prime Parrainage";"Virements reçus";"Virements reçus";130,00;;001234;"BOURSORAMA BANQUE";226.68 + ``` +- **Particularités / pièges** : + - Dates en **`AAAA-MM-JJ`** (seule banque française classique dans ce cas). + - `amount` utilise la **virgule** décimale, mais `accountbalance` utilise le **point** décimal dans le même fichier. Ne jamais parser les deux colonnes avec la même routine. + - La colonne `accountbalance` est un solde recalculé glissant, peu fiable : **l'ignorer** pour la comptabilité ; le solde de compte doit être saisi/rapproché séparément. + - `category`/`categoryParent` : catégorisation maison BoursoBank — utile comme **suggestion** de catégorie initiale au mapping. + - `accountNum` répété sur chaque ligne ⇒ permet de **router les lignes vers plusieurs comptes** LifeTrack depuis un fichier unique (le preset doit gérer un fichier multi-comptes). + - Le PEA n'est pas exportable. + +### 2.2 Crédit Agricole + +- **Accès** : espace client (par **caisse régionale** — les interfaces varient) → « Vos opérations » → « Télécharger vos opérations ». Formats selon caisse : **CSV, Excel (xls), OFX, QIF, TXT**. Historique en ligne court (souvent 30 à 90 jours). +- **Structure CSV** (modèle documenté par OpenFlyers) : + - **Encodage** : ISO-8859-15. **Séparateur** : `;`. + - **Préambule de longueur variable** (infos compte, période, solde). La fin du préambule est repérable par la ligne commençant par `Date;`. + - **En-tête** : `Date;Date valeur;Libellé;Débit Euros;Crédit Euros;` + - Dates `JJ/MM/AAAA`, virgule décimale, montants ventilés en **deux colonnes** Débit/Crédit. + - **Les libellés peuvent contenir des retours à la ligne** (champ multi-lignes entre guillemets) — le parseur CSV doit être configuré pour les champs quotés multi-lignes (le module Python `csv` le gère nativement si on ne pré-découpe pas par lignes). + - Un **pied de page** peut suivre les données (séparé par des lignes vides) — arrêter le parsing à la première ligne dont la 1re colonne n'est pas une date valide. +- **Implémentation preset** : `skip_until_header_startswith: "Date;"` + `stop_on_non_date_row: true`. + +### 2.3 BNP Paribas + +- **Accès** : mabanque.bnpparibas → « Virements et services » → « Téléchargement des opérations » (URL directe : `/fr/secure/virements-services/telechargement-des-operations` ; la page a été retirée de la navigation en juillet 2023 mais restait accessible en direct). Formats : **PDF, CSV, OFX, QIF**, fenêtre ~90 jours. +- **Nom de fichier** : type `E{digits}.csv` se terminant par les 4 derniers chiffres du compte (ex. `E0790170.csv`) ; regex utilisable : `E\d+{last4}\.csv`. +- **Structure CSV** (fichier réel) : + - **Encodage** : ISO-8859-1. **Séparateur** : `;`. + - **1re ligne = métadonnées de solde**, PAS un en-tête de colonnes : + ``` + "Crédit immobilier";"Cr&eacute;dit immobilier";****1234;18/03/2022;;-123 456,78 + ``` + Soit : libellé compte ; libellé compte (échappé HTML **deux fois** — il faut appliquer `html.unescape()` **2×**) ; n° compte masqué ; date d'export ; (vide) ; **solde** avec espace de milliers et virgule décimale. + - **Lignes suivantes = opérations**, sans ligne d'en-tête : + ``` + 05/01/2022;;; AMORTISSEMENT PRET 1234;70,93 + ``` + Colonnes : `date; (vide); (vide); libellé; montant_signé`. Date `JJ/MM/AAAA`, virgule décimale, espaces de milliers possibles. +- **Implémentation preset** : `header_rows: 1` (ligne solde à parser à part pour proposer un rapprochement de solde), `columns: [date, skip, skip, label, amount]`, `has_column_header: false`. + +### 2.4 Société Générale + +- **Accès** : espace client → « Gestion et suivi » / « Relevés et documents » → export **CSV** (~6 mois d'historique). Le **QIF** avait disparu à la refonte du site (2019) puis est **revenu en février 2020**. +- **Structure CSV** (fichier réel analysé par enodev.fr) : + - **Encodage** : ISO-8859-1 (ou -15), fins de ligne **CRLF**. **Séparateur** : `;`. + - **Ligne 1 (préambule)** : `="0201900016400270";17/05/2019;16/11/2019;` — n° de compte en notation Excel `="…"` (pour préserver les zéros de tête), puis début et fin de période. + - **Ligne 2 (en-tête)** : `date_comptabilisation;libellé_complet_operation;montant_operation;devise;` + - **Ligne de données** : `15/11/2019;CARTE X7527 15/11 METRO ;-14,90;EUR;` + - Date `JJ/MM/AAAA`, **montant signé** unique, virgule décimale, devise explicite, point-virgule terminal (colonne vide finale). + - Les libellés sont **paddés d'espaces** et peuvent s'étaler sur plusieurs lignes pour les virements/prélèvements ⇒ `strip()` + gestion des champs multi-lignes. +- **Implémentation preset** : `header_rows: 1` puis ligne d'en-têtes ; extraire le n° de compte de la ligne 1 via regex `="(\d+)"`. + +### 2.5 La Banque Postale + +- **Accès** : espace client → menu « OPÉRATIONS » → « Téléchargement d'opérations » → choix du compte → « Format CSV (compatible Excel) » (aussi **TSV** et **OFX** selon le type de compte, via le bouton « Télécharger le détail »). **Limite : ~90 jours** d'historique. +- **Structure CSV** (modèle documenté par OpenFlyers) : + - **Encodage** : ISO-8859-15. **Séparateur** : `;`. Pas de pied de page. + - **Préambule (~8 lignes)**, exploitables pour le rapprochement de solde : + ``` + Numéro Compte ;[numéro] + Type ;COMPTE + Compte tenu en ;euros + Date ;[date] + Solde (EUROS) ;[solde] + Solde (FRANCS) ;[solde] + ``` + - **En-tête de colonnes** : `Date;Libellé;Montant(EUROS);Montant(FRANCS)` + - Date `JJ/MM/AAAA`, **montant signé** (négatif = débit), virgule décimale. La colonne `Montant(FRANCS)` est un vestige à ignorer. + - Attention : l'export **TSV perd la date de valeur** et est découpé mois par mois — préférer CSV. +- **Implémentation preset** : `skip_until_header_startswith: "Date;"` (robuste face aux variations du préambule), colonne FRANCS ignorée. + +### 2.6 Caisse d'Épargne (groupe BPCE) + +- **Accès** : espace client → sur le compte, « Gérer » → « Télécharger les opérations » (page d'aide officielle : `aide.caisse-epargne.fr/contents/comment-exporter-mes-operations`). Le fichier **ne contient pas le solde**. +- **Noms de fichier observés** : `{DDMMYYYY}_{numéro}.csv` (ancien) et `{numéro}_{DDMMYYYY}_{DDMMYYYY}.csv` (récent, période début/fin). Regex preset : `\d*{account}_\d{8}_\d{8}\.csv` et `\d{8}_{account}\.csv`. +- **Structure CSV « nouveau format » (2024+, fichier réel)** : + - **Encodage** : ISO-8859-1. **Séparateur** : `;`. Pas de préambule. + - **En-tête exact** : + ``` + Date de comptabilisation;Libelle simplifie;Libelle operation;Reference;Informations complementaires;Type operation;Categorie;Sous categorie;Debit;Credit;Date operation;Date de valeur;Pointage operation + ``` + - **Exemple** : + ``` + 15/11/2024;SUPERMARCHE;CB SUPERMARCHE CENTRAL FACT 141124;;;Carte bancaire;Alimentation;Hyper/supermarche;-45,50;;14/11/2024;15/11/2024;0 + 10/11/2024;EMPLOYEUR SA;VIR INST Employeur SA;REF123456;Salaire Novembre-;Virement recu;Revenus;Salaires;;+3500,00;09/11/2024;09/11/2024;0 + ``` + - Dates `JJ/MM/AAAA` (3 colonnes de dates : comptabilisation / opération / valeur), virgule décimale, **Débit en négatif** dans sa colonne, **Crédit préfixé `+`** — le parseur de montants doit accepter `+` et `-`. + - `Categorie`/`Sous categorie` : suggestions de catégorisation. `Libelle simplifie` = nom de marchand nettoyé, excellent pour l'affichage et les règles. +- Banque Populaire (même groupe BPCE) a des exports proches (« Documents → Vos écritures et opérations → CSV ») — le preset CE servira de base si besoin. + +### 2.7 Fortuneo + +- **Accès** : espace client → historique du compte → export. Formats annoncés : **CSV, Excel, QIF, OFX**, avec un historique allant jusqu'à **10 ans** (le plus généreux des banques FR). +- **Nom de fichier** : `HistoriqueOperations_{compte}_du_JJ_MM_AAAA_au_JJ_MM_AAAA.csv`. +- **Structure CSV** (fichier réel) : + - **Séparateur** : `;`. **Encodage** : historiquement ISO-8859-1/Windows-1252 (des accents cassés sont observés si lu en UTF-8) ; des exports récents semblent être en UTF-8 ⇒ **toujours passer par la détection d'encodage** (voir §7.3). + - **En-tête exact** (noter la casse et le `;` final) : + ``` + Date opération;Date valeur;libellé;Débit;Crédit; + ``` + - **Exemple** : `13/12/2019;13/12/2019;CARTE 12/12 FNAC METZ;-6,4;` + - Dates `JJ/MM/AAAA`, virgule décimale, **Débit déjà signé négatif**, montants parfois sans zéro final (`-6,4`), espaces de milliers possibles. +- **Pièges** : les opérations **carte à débit différé** sont isolées puis intégrées à la liste le dernier jour du mois (risque de « trou » puis d'apparition tardive ⇒ importance de la fenêtre de recouvrement) ; les opérations Bourse ne sont pas distinguées des crédits ordinaires. + +### 2.8 Revolut + +- **Accès** : app/web → Relevés (« Statements ») → export **CSV / Excel / PDF** par compte-devise et par période. Pas d'OFX/QIF. Historique complet disponible. +- **Nom de fichier** : `account-statement_{AAAA-MM-JJ}_{AAAA-MM-JJ}_{...}_{id}.csv`. +- **Structure CSV** (fichier réel) : + - **Séparateur** : `,`. **Encodage** : UTF-8. **Point décimal**. + - **En-tête exact** : + ``` + Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance + ``` + - **Exemple** : `TOPUP,Current,2024-01-05 14:00:40,2024-01-05 14:00:41,Payment from M Huang Mincong,10.00,0.00,USD,COMPLETED,74.43` + - Dates `AAAA-MM-JJ HH:MM:SS` (heure locale du compte). +- **Règles d'import** : + - **N'importer que `State == COMPLETED`** (les états `PENDING`/`REVERTED` changent ou disparaissent — source classique de doublons). + - `Amount` est **hors frais** ; l'impact réel sur le solde = `Amount - Fee` (Fee est positif). Deux stratégies : (a) créer une transaction unique de montant net, en notant le frais dans un champ `metadata` ; (b) créer deux transactions (opération + frais). Recommandé v1 : **montant net + note**, plus simple pour les budgets. + - Un compte Revolut = plusieurs devises ⇒ un export par devise ; modéliser un `account` LifeTrack par devise, ou stocker `currency` par transaction. + - `Type` utiles : `TOPUP`, `CARD_PAYMENT`, `TRANSFER`, `EXCHANGE`, `ATM`, `FEE` — mappables vers des catégories par défaut. + +### 2.9 N26 + +- **Accès** : application web (app.n26.com) → téléchargement des activités (« Download activities ») par période. **CSV uniquement** (pas d'OFX/QIF). +- **Structure CSV actuelle (format 2023+)** : + - **Séparateur** : `,`. **Encodage** : UTF-8. **Point décimal**. Dates `AAAA-MM-JJ`. Champs quotés (sauf `Type`). + - **En-tête exact** : + ``` + "Booking Date","Value Date","Partner Name","Partner Iban",Type,"Payment Reference","Account Name","Amount (EUR)","Original Amount","Original Currency","Exchange Rate" + ``` + - `Partner Iban` = IBAN de la contrepartie (précieux pour les règles de catégorisation et la détection de virements internes). `Original Amount/Currency/Exchange Rate` renseignés pour les paiements hors EUR. +- **Ancien format (avant ~2023)**, à supporter en option dans le preset (des utilisateurs ont des archives) : + ``` + "Date","Payee","Account number","Transaction type","Payment reference","Amount (EUR)","Amount (Foreign Currency)","Type Foreign Currency","Exchange Rate" + ``` +- Le preset N26 doit **détecter la variante par la ligne d'en-tête**. + +--- + +## 3. Export d'activité PayPal + +### 3.1 Où et quoi + +- **Accès** : paypal.com → Activité → « Télécharger » / Relevés → « Activité personnalisée » ; ou Rapports (comptes business) → « Activity Download ». +- **Formats** : **CSV**, **TAB**, PDF ; QIF (USD uniquement) et IIF (US uniquement) — ignorer QIF/IIF. +- **Limites** : historique **7 ans**, période max **12 mois par rapport**, **50 000 lignes max** par fichier (sinon ZIP multi-fichiers). Préréglages : « depuis le dernier téléchargement », mois écoulé, 3 mois, 6 mois… +- **Encodage** : UTF-8. **Séparateur** : `,` avec champs quotés. +- **Locale FR — piège majeur** : dates en `JJ/MM/AAAA` et **virgule décimale à l'intérieur des champs quotés** (ex. `"1 234,56"`). Un compte configuré en anglais exporte en `MM/DD/YYYY` avec point décimal. Le preset PayPal doit donc proposer le choix de locale (défaut FR). + +### 3.2 Colonnes + +Le rapport « Activity Download » est **personnalisable** (87 champs possibles). Champs **obligatoires** (toujours présents) : + +``` +Date, Time, TimeZone, Name, Type, Status, Currency, Gross, Fee, Net, +From Email Address, To Email Address, Transaction ID, Reference Txn ID, +Receipt ID, Balance Impact +``` + +Champs cochés par défaut (sélection) : `Balance`, `Subject`, `Note`, `Invoice Number`, `Country Code`, adresses, etc. + +Sur les **comptes personnels**, l'export simplifié peut ne contenir qu'un sous-ensemble du type : `Date, Time, TimeZone, Name, Type, Status, Currency, Amount, Receipt ID, Balance` (une seule colonne `Amount` au lieu de Gross/Fee/Net). Le preset doit accepter **les deux variantes** (détection par en-tête). + +### 3.3 Sémantique des montants et devises + +- `Gross` = montant brut signé ; `Fee` = frais (négatif) ; **`Net = Gross + Fee`**. +- `Balance Impact` ∈ {`Credit`, `Debit`, `Memo`} : les lignes `Memo` (autorisations, paiements en attente, lignes informatives) **n'affectent pas le solde ⇒ les exclure de l'import**. +- `Status` : n'importer que `Completed` (exclure `Pending`, `Denied`, `Reversed`…). +- **Conversions de devises** : un achat en devise génère 2–3 lignes liées (`Type` contenant "Currency Conversion" / « Conversion de devise » : une ligne de débit dans la devise d'origine, une ligne de crédit en EUR, liées par `Reference Txn ID`). Stratégie v1 recommandée : **importer uniquement les lignes dont `Currency` == devise du compte LifeTrack (EUR) et `Balance Impact` != `Memo`**, ce qui capture l'effet net en euros sans doublonner. +- `Transaction ID` : identifiant alphanumérique 17 caractères, **unique et stable** ⇒ clé de déduplication idéale (`external_id`). +- `Reference Txn ID` : lie remboursements/conversions à la transaction d'origine — à stocker en métadonnée. + +--- + +## 4. Le format OFX en France (et QIF) + +### 4.1 Ce qu'on trouve réellement + +- Les banques françaises qui proposent OFX (BoursoBank, BNP, La Banque Postale, Crédit Agricole selon caisse, Fortuneo…) livrent quasi toujours de l'**OFX 1.x SGML** (en-tête `OFXHEADER:100`, `DATA:OFXSGML`, `VERSION:102`), **pas** de l'OFX 2.x XML. Encodage souvent déclaré `ENCODING:USASCII`/`CHARSET:1252` mais réellement Windows-1252/Latin-1. +- Structure utile par transaction (``) : `TRNTYPE` (DEBIT/CREDIT/XFER/…), `DTPOSTED` (`AAAAMMJJ`), `TRNAMT` (**point décimal**, signé), `FITID`, `NAME`, `MEMO` éventuel. Le bloc `` donne banque/guichet/compte — parfait pour router vers le bon compte. +- **Avantages vs CSV** : pas d'ambiguïté de date/décimale, identifiant `FITID`, solde de fin (``), n° de compte inclus. + +### 4.2 Fiabilité du FITID — mise en garde + +La spec OFX exige que le FITID identifie de façon unique une transaction **dans le périmètre d'un compte** et reste **stable entre téléchargements** (« FITIDs must be unique within the scope of […] an account » ; unicité inter-banques non garantie ⇒ clé = banque+compte+FITID). En pratique : + +- **LCL (cas documenté par Akretion)** : FITID fabriqué comme `code_type + date JJMMAA + montant en centimes` (ex. `948 200423 -1275`) ⇒ **deux paiements CB du même montant le même jour = même FITID**. Violation flagrante, présente depuis au moins 2016. +- D'autres établissements (cas US documentés : Discover…) **régénèrent des FITID différents à chaque téléchargement** pour la même transaction. + +**Conséquence pour LifeTrack** : traiter le FITID comme `external_id` de dédup **prioritaire mais non exclusif** — toujours doubler d'un hash de contenu, et ne jamais planter sur un FITID dupliqué à l'intérieur d'un même fichier (suffixer par un compteur d'occurrence, cf. §6.4). + +### 4.3 QIF + +Format texte Quicken sans identifiants, dates ambiguës (`D` au format local), pas de devise, pas de n° de compte. Fortuneo, SG, BNP, CA le proposent encore. **Ne pas l'implémenter en v1** (CSV + OFX couvrent tout) ; à garder en idée v3 si un utilisateur n'a que ça. + +### 4.4 Bibliothèques Python + +- **`ofxparse`** : tolérant, gère l'OFX 1.x SGML sale des banques (via BeautifulSoup/sgmllib). Maintenance faible mais c'est le standard de fait pour ce besoin. **Recommandé v1**, avec pré-traitement : détection/normalisation d'encodage avant parsing. +- **`ofxtools`** : plus strict/complet (OFX 2.x, typage), moins indulgent avec les fichiers non conformes français. Alternative si `ofxparse` pose problème. + +--- + +## 5. Agrégation PSD2 : état des lieux 2025/2026 + +### 5.1 GoCardless Bank Account Data (ex-Nordigen) — en extinction + +- Historiquement **LA** solution gratuite : jusqu'à **50 connexions bancaires/mois** gratuites, couverture de ~2500 banques UE dont toutes les grandes banques françaises, consentement PSD2 de 90 jours (180 pour certaines banques), jusqu'à 24 mois d'historique selon banque. +- **Limite de taux introduite en 2024 : ~4 appels de synchronisation par jour et par compte** (les importeurs comme Firefly III data importer ≥ 1.5.6 la gèrent). +- API simple : `secret_id`/`secret_key` → token ; `GET /institutions?country=fr` ; création d'une « requisition » (lien d'autorisation redirigeant vers la banque) ; puis `GET /accounts/{id}/transactions` (JSON, montants `transactionAmount.amount` + `currency`, `internalTransactionId`/`transactionId` pour la dédup). +- **⚠️ Depuis juillet 2025 : plus aucune inscription nouvelle** (« GoCardless has stopped accepting new Bank Account Data accounts », confirmé par la doc Actual Budget) ; le produit est en cours d'abandon. Les comptes existants continuent de fonctionner, sans garantie de durée. +- **Conclusion** : notre utilisateur ne pourra probablement **pas** créer de compte ⇒ GoCardless ne peut plus être le connecteur v2 par défaut ; il reste pertinent comme **connecteur optionnel** pour détenteurs de comptes historiques. + +### 5.2 Enable Banking — la relève recommandée + +- Agrégateur finlandais s'appuyant sur les **API PSD2 officielles** (~2500 banques, 29 pays). Utilisé comme alternative par les communautés Firefly III (tutoriel officiel `docs.firefly-iii.org/tutorials/data-importer/eb/`) et Actual Budget (guide de setup dédié). +- **Mode « restricted » gratuit, adapté à un particulier** : on enregistre une application de **production** dans le Control Panel (enablebanking.com), puis « **Activate by linking accounts** » : on autorise ses propres comptes via le portail Enable Banking + la page d'autorisation de la banque. L'application ne peut ensuite récupérer **que les comptes préalablement liés** (whitelist), tant qu'aucun contrat commercial n'est signé. C'est exactement le périmètre « mes propres comptes ». +- **Authentification API** : l'application possède une **clé privée RSA** ; chaque requête est signée par un **JWT RS256** (kid = application_id). Endpoints principaux : `POST /auth` (démarrage d'autorisation, URL de redirection), `POST /sessions` (échange du code), `GET /sessions/{id}`, `GET /accounts/{uid}/transactions` (pagination par `continuation_key`), `GET /accounts/{uid}/balances`. +- **Couverture France confirmée** (doc `enablebanking.com/docs/markets/fr/`) : BNP Paribas, **Crédit Agricole** (choix de la **caisse régionale** ; SCA via l'app « Ma Banque »), Société Générale, La Banque Postale, Crédit Mutuel, CIC, LCL, Banque Populaire, **Caisse d'Épargne**, etc. Les flux d'authentification français sont de type **redirect** avec SCA sur l'app mobile de la banque. BoursoBank/Fortuneo figurent dans le réseau PSD2 français (STET) ; **Revolut et N26** exposent aussi des API PSD2 européennes — vérifier leur présence exacte dans la liste d'ASPSP du Control Panel au moment de l'implémentation (non listés sur la page marché FR). +- **Contraintes PSD2 invariables** : consentement à renouveler (90 jours réglementaires, jusqu'à 180 selon banque), historique initial limité par la banque (souvent 90 jours à 24 mois au premier accès), chaque session doit être autorisée via API même en mode restreint. + +### 5.3 Autres options commerciales (non retenues) + +- **Powens** (ex-Budget Insight, adossé Crédit Mutuel Arkéa) : excellente couverture FR (y compris épargne/assurance-vie), mais **B2B, tarification sur contrat**, pas d'offre particulier. +- **Bridge** (ex-Bankin' B2B, adossé BPCE) : idem, B2B uniquement. +- **Tink** (Visa) : B2B, plus de free tier significatif pour un usage personnel. +- **SimpleFIN** : populaire chez Actual Budget mais **couvre les banques nord-américaines** — hors sujet pour la France. + +### 5.4 woob (ex-weboob) — scraping open-source + +- Framework Python AGPL de scraping bancaire (modules `boursorama`, `creditagricole`, `fortuneo`, etc.), moteur historique de **Kresus** (gestionnaire de finances self-hosted français). Projet actif (GitLab `woob/woob`). +- **Fragilité structurelle** : les modules cassent à chaque refonte des sites (ex. documenté : synchronisation BoursoBank cassée à l'automne 2025, `AttributeError` dans le module boursorama). Nécessite de stocker les **identifiants bancaires en clair côté serveur**, et le scraping peut déclencher des blocages / est contraire aux CGU de certaines banques. +- **Position LifeTrack** : ne pas en faire une dépendance cœur. Au mieux, un adaptateur optionnel v3 (« woob bridge » qui exporte du JSON/CSV consommé par notre pipeline d'import). + +### 5.5 Ce que font Firefly III et Actual Budget (référence) + +| App | Import fichiers | Sync bancaire | +|---|---|---| +| Firefly III (+ Data Importer) | CSV (mapper générique + configs communautaires JSON par banque, dépôt `firefly-iii/import-configurations` : profils `fr/boursorama`, `fr/fortuneo`…), camt.053 | GoCardless (legacy), **Enable Banking** (nouveau), SimpleFIN | +| Actual Budget | CSV/OFX/QFX/QIF/CAMT | GoCardless (legacy), SimpleFIN (US), **Enable Banking**, Pluggy.ai (Brésil) | + +Enseignement : les deux références self-hosted ont pivoté **GoCardless → Enable Banking** pour l'Europe ; leur modèle « mapper générique + profils par banque en JSON versionnés » est exactement l'architecture retenue pour LifeTrack v1. + +--- + +## 6. Stratégies de déduplication + +Le besoin : l'utilisateur réimporte régulièrement des fichiers **qui se chevauchent** (historique court chez les banques FR), et pourra un jour cumuler fichier + sync API sur le même compte. Il faut être **idempotent** sans perdre de vraies transactions identiques (deux cafés à 2,50 € le même jour chez le même commerçant sont légitimes). + +### 6.1 Ce que fait l'état de l'art + +- **Firefly III** : deux mécanismes — (1) « content-based » : hash SHA-256 du JSON complet de la transaction soumise, comparé aux hashes existants (fragile : le hash change si la banque change la casse ou si le mapping change) ; (2) « identifier-based » : une colonne mappée sur `external_id`/`internal_reference` est recherchée avant import — « a very reliable way to detect duplicates ». +- **Actual Budget** : champ **`imported_id`** (FITID pour OFX, id GoCardless pour la sync) — « transactions with the same imported_id will never be added more than once » ; sinon **rapprochement flou** : même montant + date proche (fenêtre de quelques jours) + payee similaire ⇒ fusion proposée. Bug historique corrigé en 2024 : le fuzzy match ne doit **pas** fusionner deux transactions portant des `imported_id` différents (sauf quirk GoCardless) — règle à reprendre telle quelle. + +### 6.2 Clés candidates + +1. **`external_id` fourni par la source** : OFX `FITID` (voir caveats §4.2), PayPal `Transaction ID` (fiable), GoCardless `internalTransactionId` (fiable), Enable Banking `entry_reference` (fiabilité variable selon banque). Unicité à imposer **par (account_id, source_kind)**, jamais globalement. +2. **Hash de contenu** : les CSV français n'ont **aucun identifiant** ⇒ hash déterministe sur les champs stables : compte + date comptable + montant + libellé normalisé + devise. +3. **Rapprochement flou** (aide à la décision, jamais automatique en suppression) : même compte, même montant, date à ±3 jours, similarité de libellé (trigrammes `pg_trgm`) — utile parce que les banques FR **changent le libellé et la date** entre l'opération « en cours » et l'opération comptabilisée. + +### 6.3 Pièges spécifiques observés + +- **Libellés instables** : SG padde d'espaces ; CB « en cours » devient « CARTE 12/12 FNAC METZ » une fois comptabilisée ; Fortuneo intègre le débit différé en fin de mois. ⇒ normalisation agressive du libellé avant hash (cf. ci-dessous) et fenêtre de recouvrement. +- **Doublons légitimes intra-journée** : gérés par un **compteur d'occurrence** intégré au hash — technique éprouvée (les import-id YNAB/Actual sont suffixés d'un index d'occurrence). Dans un même fichier, la n-ième ligne strictement identique reçoit `occurrence = n`. +- **FITID dupliqué dans un même fichier OFX** (cas LCL) : appliquer le même compteur d'occurrence au FITID. +- **Balance/solde glissant** (Boursorama `accountbalance`) : ne jamais inclure de colonne de solde dans le hash. +- **Catégories fournies par la banque** (Bourso, CE) : ne pas les inclure dans le hash (elles changent au gré des algos de la banque). + +### 6.4 Algorithme retenu pour LifeTrack + +Pipeline d'import (fichier ou API) → table de **staging** → dédup → commit : + +```text +for each parsed row: + 1. Normalize: booking_date (ISO), amount (Decimal, 2 dec), label_norm, currency. + 2. If source provides an external id: + ext_key = (account_id, source_kind, external_id [+ ":" + occurrence]) + if exists in transactions.external_key -> mark DUPLICATE (skip) + 3. Compute content hash: + basis = f"{account_id}|{booking_date}|{amount:+.2f}|{currency}|{label_norm}|{occurrence}" + dedup_hash = sha256(basis) + occurrence = index of this exact basis within the CURRENT import file (0,1,2…) + if dedup_hash exists in transactions -> mark DUPLICATE (skip) + 4. Fuzzy pass (only for rows that survived 2 and 3): + candidates = same account, same amount, |date diff| <= 3 days, + similarity(label_norm) >= 0.5 (pg_trgm), + AND (candidate.external_id IS NULL OR row.external_id IS NULL) + if candidates -> mark NEEDS_REVIEW (user confirms merge/import in preview UI) + 5. Else -> mark NEW +commit: user validates the preview (counts NEW / DUPLICATE / NEEDS_REVIEW), then batch insert. +``` + +Normalisation de libellé (`label_norm`) : + +```python +import re, unicodedata + +def normalize_label(raw: str) -> str: + s = unicodedata.normalize("NFKD", raw) + s = "".join(c for c in s if not unicodedata.combining(c)) # strip accents + s = s.upper() + s = re.sub(r"\s+", " ", s).strip() # collapse whitespace/newlines + return s +``` + +Parsing des montants français (couvre tous les cas observés : `-6,4`, `+3500,00`, `-123 456,78`, `1 234,56`, `226.68`) : + +```python +from decimal import Decimal + +def parse_amount(raw: str) -> Decimal: + s = raw.strip().replace(" ", "").replace(" ", "").replace(" ", "") + if "," in s: + s = s.replace(".", "").replace(",", ".") # French style + return Decimal(s) # accepts leading + or - +``` + +**Fenêtre de recouvrement** : encourager l'utilisateur (UI) à toujours exporter avec chevauchement (ex. « depuis 7 jours avant le dernier import ») ; côté serveur, la dédup rend le chevauchement inoffensif. Stocker par compte `last_imported_max_date` pour afficher « dernière opération connue : … » et suggérer la période d'export. + +**Traçabilité** : chaque ligne insérée référence un `import_batch` (fichier source, profil utilisé, horodatage, checksum du fichier). Un batch est **annulable en bloc** (undo), et un même fichier (même checksum SHA-256) déjà importé est refusé d'emblée avec message clair. + +--- + +## 7. Recommandations d'implémentation — v1 (import fichiers) + +### 7.1 Périmètre v1 + +1. **Mapper CSV générique** avec presets par banque (BoursoBank, Crédit Agricole, BNP, SG, La Banque Postale, Caisse d'Épargne, Fortuneo, Revolut, N26, PayPal). +2. **Parseur OFX** (`ofxparse`) — couvre BoursoBank, BNP, LBP, CA, Fortuneo pour les utilisateurs qui préfèrent l'OFX. +3. **Preset PayPal** (variante business Gross/Fee/Net + variante perso Amount). +4. Saisie manuelle + import batch annulable + moteur de règles de catégorisation. +5. Pas de QIF, pas de PDF, pas de sync API en v1. + +### 7.2 Modèle de données (SQLAlchemy — esquisse) + +```python +class BankAccount(Base): # finance account, multi-user ready + id: UUID; user_id: UUID + name: str; kind: str # checking|savings|card|paypal|cash + currency: str = "EUR" + iban_last4: str | None + last_imported_max_date: date | None + +class ImportProfile(Base): # a saved CSV mapping (preset or user-defined) + id: UUID; user_id: UUID | None # None => built-in preset + slug: str # "boursobank", "credit-agricole", ... + config: JSONB # see 7.4 + +class ImportBatch(Base): + id: UUID; user_id: UUID; account_id: UUID | None + profile_slug: str | None; source_kind: str # csv|ofx|paypal|api-... + filename: str; file_sha256: str # reject identical re-upload + created_at: datetime + stats: JSONB # {new, duplicates, review} + status: str # pending|committed|rolled_back + +class Transaction(Base): + id: UUID; account_id: UUID + booking_date: date; value_date: date | None + amount: Numeric(14, 2); currency: str + label_raw: str; label_norm: str + counterparty_iban: str | None + category_id: UUID | None + source_kind: str # csv|ofx|paypal|api-enablebanking|manual + external_id: str | None # FITID / PayPal Transaction ID / API id + occurrence: int = 0 + dedup_hash: str # sha256 hex, UNIQUE per account + import_batch_id: UUID | None + metadata: JSONB # bank category, fee, reference_txn_id, state... + __table_args__ = ( + UniqueConstraint("account_id", "dedup_hash"), + Index(..., "account_id", "source_kind", "external_id", unique=True, + postgresql_where=text("external_id IS NOT NULL")), + ) +``` + +Activer l'extension **`pg_trgm`** pour le fuzzy match (`similarity(label_norm, :candidate)`). + +### 7.3 Chaîne de lecture des fichiers + +1. **Encodage** : lire les premiers Ko ; si BOM UTF-8 ⇒ `utf-8-sig` ; sinon tenter `utf-8` strict ; en cas d'échec, `charset-normalizer` avec repli forcé `cp1252` (couvre ISO-8859-1/15 pour nos banques). Ne jamais faire confiance à l'extension. +2. **Séparateur** : `csv.Sniffer` sur un échantillon, restreint à `;`, `,`, `\t` ; heuristique de départage : compter les occurrences hors guillemets sur les 5 premières lignes. +3. **Préambule** : trois stratégies configurables par profil : `header_rows: N` (fixe) ; `skip_until_header_startswith: "Date;"` (CA, LBP) ; `has_column_header: false` + mapping positionnel (BNP). Toujours afficher un **aperçu brut** des 20 premières lignes dans l'UI pour que l'utilisateur ajuste. +4. **Champs multi-lignes** : parser avec le module `csv` sur le flux complet (jamais de `split("\n")` préalable) — requis pour CA et SG. +5. **Fin de données** : option `stop_on_non_date_row` (CA : pied de page après lignes vides). +6. **Dates** : essai ordonné des formats candidats du profil (`%d/%m/%Y`, `%Y-%m-%d`, `%Y-%m-%d %H:%M:%S`, `%d.%m.%Y`) ; en mode générique, auto-détection sur l'échantillon avec désambiguïsation JJ/MM par la présence de valeurs > 12. +7. **Montants** : `parse_amount()` de §6.4 ; modes `signed_column`, `debit_credit_columns` (CA, CE, Fortuneo), `invert_sign` (option). + +### 7.4 Schéma JSON d'un profil d'import (`ImportProfile.config`) + +```json +{ + "file": { + "encoding": "auto", + "delimiter": ";", + "quotechar": "\"", + "header_rows": 0, + "skip_until_header_startswith": null, + "has_column_header": true, + "stop_on_non_date_row": false, + "filename_regex": "export-operations-.*\\.csv" + }, + "columns": { + "booking_date": "dateOp", + "value_date": "dateVal", + "label": "label", + "amount": "amount", + "debit": null, + "credit": null, + "currency": null, + "external_id": null, + "account_hint": "accountNum", + "bank_category": ["categoryParent", "category"], + "counterparty_iban": null + }, + "parsing": { + "date_formats": ["%Y-%m-%d"], + "decimal_comma": true, + "invert_sign": false, + "row_filters": [{"column": "State", "op": "equals", "value": "COMPLETED"}] + }, + "dedup": {"external_id_is_reliable": false} +} +``` + +Les presets sont livrés dans le repo (`api/app/finance/presets/*.json`), versionnés, et **clonables** par l'utilisateur pour ajustement (les banques changent leurs formats sans préavis — c'est certain à moyen terme). La détection automatique du preset combine `filename_regex` et **signature d'en-tête** (liste de noms de colonnes attendus, comparaison insensible à la casse/accents). + +### 7.5 Contenu initial des presets (récapitulatif opérationnel) + +| Preset | file | columns (essentiel) | +|---|---|---| +| `boursobank` | `;`, UTF-8, header ligne 1 | `dateOp`→booking, `dateVal`→value, `label`, `amount` (virgule), `accountNum`→routage multi-comptes, `category*`→suggestion ; ignorer `accountbalance` | +| `credit-agricole` | `;`, cp1252, `skip_until_header_startswith: "Date;"`, stop_on_non_date_row | `Date`, `Date valeur`, `Libellé` (multi-lignes), `Débit Euros`/`Crédit Euros` | +| `bnp-paribas` | `;`, latin-1, `header_rows: 1`, `has_column_header: false` | positions : 0=date, 3=label, 4=amount ; ligne 1 = solde (double `html.unescape`) | +| `societe-generale` | `;`, latin-1, `header_rows: 1` | `date_comptabilisation`, `libellé_complet_operation` (trim), `montant_operation`, `devise` | +| `banque-postale` | `;`, latin-9, `skip_until_header_startswith: "Date;"` | `Date`, `Libellé`, `Montant(EUROS)` ; ignorer `Montant(FRANCS)` ; préambule → solde affichable | +| `caisse-epargne` | `;`, latin-1, header ligne 1 | `Date operation`→booking, `Date de valeur`→value, `Libelle operation`→label (+`Libelle simplifie` en metadata), `Debit`/`Credit` (accepter `+`), `Categorie`/`Sous categorie`→suggestion | +| `fortuneo` | `;`, encodage **auto**, header ligne 1 (`;` final ⇒ colonne fantôme à ignorer) | `Date opération`, `Date valeur`, `libellé`, `Débit`/`Crédit` | +| `revolut` | `,`, UTF-8, point décimal | `Completed Date`→booking, `Description`, `Amount`+`Fee`→net, `Currency`, filtre `State == COMPLETED` | +| `n26` | `,`, UTF-8, point décimal, 2 variantes d'en-tête | `Booking Date`/`Value Date`, `Partner Name`+`Payment Reference`→label, `Partner Iban`, `Amount (EUR)` | +| `paypal` | `,`, UTF-8, locale FR (date `JJ/MM/AAAA`, virgule) | `Date`+`Time`, `Name`+`Type`→label, `Net` (ou `Amount`), `Currency`, `Transaction ID`→external_id (fiable), filtres `Status == Completed` et `Balance Impact != Memo` et `Currency == EUR` | + +### 7.6 Import OFX + +- Endpoint d'upload commun ; si le contenu commence par `OFXHEADER` ou ``, router vers le parseur OFX. +- Pré-traitement : décoder selon §7.3 puis passer à `ofxparse.OfxParser.parse()`. +- Mapper : `stmt.account.account_id`/`routing_number` → proposition de compte LifeTrack ; par transaction : `date`→booking_date, `amount` (Decimal, point), `payee`+`memo`→label, `id` (FITID)→external_id avec `external_id_is_reliable: false` (⇒ le hash de contenu reste co-vérifié) et compteur d'occurrence en cas de FITID dupliqué dans le fichier (cas LCL). +- Exploiter `` pour proposer un rapprochement de solde après import. + +### 7.7 UI d'import (rappel UX, libellés FR) + +Assistant en 4 étapes : **1. Fichier** (drag & drop, détection preset, choix du compte) → **2. Réglages** (aperçu brut, mapping colonnes éditable, formats) → **3. Prévisualisation** (tableau : Nouvelles / Doublons ignorés / À vérifier, avec diff pour les fuzzy matches) → **4. Confirmation** (stats du batch, bouton « Annuler cet import » disponible ensuite dans l'historique des imports). + +--- + +## 8. Recommandations d'implémentation — v2 (synchronisation automatique) + +### 8.1 Choix du fournisseur : Enable Banking (et pourquoi pas GoCardless) + +- **GoCardless BAD est fermé aux nouveaux comptes depuis juillet 2025** (§5.1) ⇒ inutilisable pour un nouvel utilisateur. Garder un connecteur optionnel « legacy » n'est justifié que si peu coûteux (l'API est simple) ; le marquer *deprecated* dès le départ. +- **Enable Banking** coche toutes les cases pour LifeTrack : API PSD2 officielles, **gratuit en mode restreint sur ses propres comptes** (whitelist via « Activate by linking accounts »), couverture des banques du user (CA par caisse régionale, BNP, SG, LBP, CE…), déjà éprouvé par Firefly III et Actual Budget. Prérequis utilisateur : créer un compte enablebanking.com, une application de production, télécharger la **clé privée**, lier ses comptes dans le portail. + +### 8.2 Architecture du connecteur (framework « connector ») + +```python +class FinanceConnector(Protocol): # in the shared connector framework + slug: str + async def list_accounts(self) -> list[RemoteAccount]: ... + async def fetch_transactions(self, remote_account_id: str, + date_from: date) -> AsyncIterator[RawTransaction]: ... + async def fetch_balance(self, remote_account_id: str) -> Balance: ... + async def authorization_status(self) -> AuthStatus # consent expiry etc. +``` + +- **Config stockée chiffrée** (table `connector_credentials`) : `application_id`, clé privée PEM (chiffrée au repos avec la clé applicative), mapping `remote_account_uid → bank_account_id`. +- **Auth Enable Banking** : générer un JWT RS256 par requête (`iss`/`aud` selon doc, `kid = application_id`, exp courte). Flux de consentement : `POST /auth` → URL de redirection bancaire (ouvrir dans le navigateur, callback vers l'UI LifeTrack) → `POST /sessions` → stocker `session_id` + échéance de consentement. +- **Sync** : job planifié (APScheduler dans l'API) 1–2×/jour + bouton « Synchroniser maintenant ». `fetch_transactions(date_from = last_synced_date - 7 jours)` (fenêtre de recouvrement), pagination `continuation_key`, puis injection dans **le même pipeline staging + dédup** que les imports fichiers (`source_kind = "api-enablebanking"`, `external_id = entry_reference` si présent, hash de contenu sinon). Les statuts `PDNG` (pending) sont ignorés ou marqués provisoires ; n'entériner que `BOOK` (booked). +- **Gestion du consentement (UX critique)** : bannière « Consentement expire le JJ/MM » + relance guidée du flux d'autorisation (échéance ~90/180 jours selon banque). Prévoir l'état « connecteur en erreur d'auth » visible sur le dashboard. +- **Multi-source sur un même compte** (fichier + API) : la dédup §6.4 est l'unique garde-fou — raison de plus pour ne jamais court-circuiter le pipeline commun. + +### 8.3 Hors périmètre v2 (documenté pour v3+) + +- Adaptateur **woob** optionnel (fragile, credentials en clair — §5.4). +- Import **QIF** et relevés **PDF** (OCR/extraction — projets type `LBPExtract` montrent la faisabilité pour LBP). +- **camt.053** (XML ISO 20022) si un jour utile (Firefly l'accepte ; peu diffusé côté particuliers FR). + +--- + +## 9. Sources + +Formats bancaires : +- [ScanCompte — Exporter son relevé BoursoBank (CSV/PDF/OFX)](https://www.scancompte.com/banques/exporter-releve-boursorama) ; [What In My Pocket — Export BoursoBank](https://whatinmypocket.com/guides/exporter-releve-boursobank/) +- [mincong-h/finance-toolkit](https://github.com/mincong-h/finance-toolkit) — parseurs + fichiers d'exemple réels BNP / Boursorama / Fortuneo / Revolut / Caisse d'Épargne (en-têtes cités §2), docs `docs/boursorama.md`, `docs/bnp.md`, `docs/caisse-epargne.md` +- [OpenFlyers — Modèle CSV Crédit Agricole](https://doc4-fr.openflyers.com/Mod%C3%A8le-d'import-de-relev%C3%A9-bancaire-CSV-Cr%C3%A9dit-Agricole-avec-point-virgule) ; [Modèle CSV Banque Postale](https://doc4-fr.openflyers.com/Mod%C3%A8le-d'import-de-relev%C3%A9-bancaire-CSV-Banque-Postale-avec-point-virgule) ; [Modèle CSV Société Générale](https://doc4-fr.openflyers.com/Mod%C3%A8le-d'import-de-relev%C3%A9-bancaire-CSV-Soci%C3%A9t%C3%A9-G%C3%A9n%C3%A9rale-avec-point-virgule) ; [page générale exports banques](https://doc4-fr.openflyers.com/Exporter-un-relev%C3%A9-bancaire-depuis-un-site-internet-de-banque) +- [enodev.fr — Fin du QIF à la Société Générale (structure CSV réelle)](https://enodev.fr/posts/fin-du-qif-a-la-societe-generale.html) +- [ofxpress — BNP Paribas](https://ofxpress.fr/telecharger-votre-releve-bancaire-bnp-paribas/), [La Banque Postale](https://ofxpress.fr/telecharger-votre-releve-bancaire-la-banque-postale/), [Crédit Agricole](https://ofxpress.fr/telecharger-votre-releve-bancaire-credit-agricole/) +- [Aide Caisse d'Épargne — Comment exporter mes opérations](https://www.aide.caisse-epargne.fr/contents/comment-exporter-mes-operations) +- [statementsheet — Fortuneo (formats, 10 ans)](https://statementsheet.com/how-to-convert-fortuneo-bank-statement-to-excel-csv/) ; [kdecherf — Fortuneo + woob](https://kdecherf.com/blog/2022/11/13/importer-des-transactions-fortuneo-dans-homebank-avec-woob/) +- [Lido — comparatif exports banques FR](https://www.lido.app/fr/releve-bancaire-excel) ; [MoneyVox — profondeur d'historique CSV/OFX par banque](https://www.moneyvox.fr/forums/fil/maximum-historique-des-telechargements-csv-ofx-chez-votre-banque.35927/) +- N26 : [dekodi — colonnes CSV N26](https://manuals.dekodi.de/nexuspub/datenbereitstellungsbuch/n26.html), [KontoCSV N26](https://www.kontocsv.de/en/n26) +- Revolut/N26 (bank2ynab, formats confirmés) : [bank2ynab.conf](https://github.com/bank2ynab/bank2ynab/blob/develop/bank2ynab/data/bank2ynab.conf) + +PayPal : +- [PayPal Developer — Activity Download report (champs, formats, limites)](https://developer.paypal.com/docs/reports/online-reports/activity-download/) ; [PDF spec PP_ActivityDownload](https://www.paypalobjects.com/webstatic/en_US/developer/docs/pdf/PP_ActivityDownload.pdf) +- [Putler — export PayPal 2025](https://www.putler.com/export-paypal-transactions/) ; [KontoCSV — PayPal CSV](https://www.kontocsv.de/en/guides/paypal-transactions-csv) + +OFX / dédup : +- [Akretion — LCL ne respecte pas la norme OFX (FITID non uniques)](https://akretion.com/fr/blog/lcl-ne-respecte-pas-la-norme-ofx) +- [Quinthar — OFX FITIDs: Not as permanent as you might think](http://blog.quinthar.com/2008/12/ofx-fitids-not-as-permanent-as-you.html) ; [OFX spec (OpenExchange, unicité par compte)](https://xml.coverpages.org/OFEXFIN1.html) +- [Firefly III — Duplicate detection (référence)](https://docs.firefly-iii.org/references/data-importer/duplicate-detection/) (source : dépôt `firefly-iii/docs`) +- [Actual Budget — Importing transactions (imported_id + fuzzy)](https://actualbudget.org/docs/transactions/importing/) ; [PR #2991 — fuzzy match vs imported_id](https://github.com/actualbudget/actual/pull/2991) + +PSD2 / agrégation : +- [Actual Budget — GoCardless setup (« stopped accepting new accounts » juillet 2025, 50 connexions, 4 syncs/jour)](https://actualbudget.org/docs/advanced/bank-sync/gocardless/) +- [OpenBankingTracker — Free & Indie Open Banking APIs 2026](https://www.openbankingtracker.com/guides/free-open-banking-apis) +- [Firefly III — tutoriel Enable Banking](https://docs.firefly-iii.org/tutorials/data-importer/eb/) ; [issue #10753 — Enable Banking comme alternative à GoCardless](https://github.com/firefly-iii/firefly-iii/issues/10753) +- [Enable Banking — Linked accounts / restricted mode](https://enablebanking.com/docs/api/linked-accounts/) ; [Enable Banking — marché France](https://enablebanking.com/docs/markets/fr/) ; [FAQ](https://enablebanking.com/docs/faq/) +- [Powens](https://www.powens.com/fr/plateforme/) ; [Bridge (openfinanceguide)](https://openfinanceguide.com/en/glossary/bridge) ; [BPI — mapping open banking FR 2025](https://bigmedia.bpifrance.fr/nos-actualites/mapping-2025-des-acteurs-francais-de-lopen-banking) +- [woob (GitLab)](https://gitlab.com/woob/woob) ; [issue #803 — sync BoursoBank cassée](https://gitlab.com/woob/woob/-/issues/803) +- [Firefly III — import-configurations communautaires (profils fr/boursorama, fr/fortuneo)](https://github.com/firefly-iii/import-configurations) diff --git a/docs/research/health-connect.md b/docs/research/health-connect.md new file mode 100644 index 0000000..b1afdb1 --- /dev/null +++ b/docs/research/health-connect.md @@ -0,0 +1,273 @@ +# Recherche : intégrer les données Android Health Connect dans LifeTrack (auto-hébergé) + +> Document de recherche — module Santé/Fitness de LifeTrack. +> Date : 2026-08-13. Public : agents d'implémentation (backend FastAPI, frontend React, app Android éventuelle). +> Langue : prose en français, identifiants de code en anglais (convention projet). + +--- + +## 1. Résumé exécutif + +**Constat central : Health Connect est un magasin de données strictement local au téléphone ("on-device"). Il n'existe aucune API cloud officielle permettant à un serveur (auto-hébergé ou non) d'interroger les données Health Connect d'un utilisateur.** Le seul moyen d'amener ces données sur le serveur LifeTrack est qu'une application Android installée sur le téléphone lise Health Connect localement puis pousse les données vers notre backend (ou exporte des fichiers que l'on importe). + +**L'API REST Google Fit est morte** : inscriptions fermées depuis le 1er mai 2024, arrêt définitif courant 2026. Elle ne doit servir de base à rien dans LifeTrack. + +**Recommandation v1** (détaillée en section 8) : + +1. Construire côté LifeTrack un **endpoint d'ingestion REST générique et authentifié** (`POST /api/v1/ingest/...`), conçu pour recevoir des lots de mesures santé en JSON, avec stockage du payload brut + normalisation, idempotent (dédoublonnage par identifiant externe). +2. Utiliser comme pont v1 l'application open-source **health-connect-webhook** (Android, AGPL-3.0, disponible sur le Play Store), qui lit Health Connect en tâche de fond (WorkManager) et POSTe du JSON vers des URLs de webhook configurables — c'est exactement le modèle "push vers endpoint custom" voulu par le brief. Alternative plus lourde : **HCGateway** (serveur Flask+MongoDB à héberger en plus, moins bien aligné avec notre stack). +3. Prévoir en **fallback un import de fichiers CSV** : l'application payante **Health Sync** (licence unique ~4 €) exporte automatiquement les données Health Connect en CSV (et les séances en FIT/TCX/GPX) vers Google Drive ; LifeTrack fournit un importeur CSV correspondant via le framework de connecteurs. +4. **v2** : application companion Android minimale maison (Kotlin + Health Connect SDK + WorkManager) qui POSTe directement sur notre endpoint — fiabilité et contrôle maximum, effort modéré (3 à 10 jours). **v2/v3** : enregistrement direct des séances de tapis via **Web Bluetooth + FTMS** dans le frontend React (Chrome/Edge uniquement, HTTPS requis), pour s'affranchir de l'app FitShow. + +--- + +## 2. Health Connect : fonctionnement, capacités, limites + +### 2.1 Architecture : on-device uniquement, pas d'API cloud + +- Health Connect (HC) est une base de données chiffrée **stockée sur le téléphone**. Les apps santé (Samsung Health, Fitbit, Gadgetbridge, Foodvisor via Google Fit, etc.) y écrivent ; d'autres apps y lisent, **uniquement depuis le device**. +- La documentation officielle Google est explicite : HC est prévu pour les données "on-device" Android ; la « Google Health API » (cloud) est un produit distinct, successeur de la **Fitbit Web API** uniquement (comptes Fitbit/Google, accès soumis à validation Google) — **inutilisable pour un projet personnel auto-hébergé** et hors périmètre. +- Conséquence d'architecture pour LifeTrack : **un intermédiaire Android est obligatoire**. Le backend ne pourra jamais "aller chercher" les données ; il doit **recevoir** (push HTTP) ou **importer** (fichiers). +- Depuis Android 14, HC est **intégré au système** (Réglages > Sécurité et confidentialité > Health Connect). Sur Android 9–13, c'est une app APK à installer depuis le Play Store. SDK minimum : API 28 (Android 9). + +### 2.2 Types de données disponibles (Jetpack `androidx.health.connect.client.records`) + +Tous les besoins du module Santé/Fitness de LifeTrack sont couverts par des types HC standards. Correspondance à utiliser telle quelle dans le modèle de données : + +| Besoin LifeTrack | Record Health Connect | Permission Android | +|---|---|---| +| Pas quotidiens | `StepsRecord` | `android.permission.health.READ_STEPS` | +| Distance | `DistanceRecord` | `...READ_DISTANCE` | +| Calories actives brûlées | `ActiveCaloriesBurnedRecord` | `...READ_ACTIVE_CALORIES_BURNED` | +| Calories totales brûlées (TDEE observé) | `TotalCaloriesBurnedRecord` | `...READ_TOTAL_CALORIES_BURNED` | +| Métabolisme de base (BMR) | `BasalMetabolicRateRecord` | `...READ_BASAL_METABOLIC_RATE` | +| Séances de sport (tapis, etc.) | `ExerciseSessionRecord` (+ `SpeedRecord`, `PowerRecord`, `ElevationGainedRecord` associés) | `...READ_EXERCISE` (+ `READ_SPEED`, `READ_POWER`) | +| Poids | `WeightRecord` | `...READ_WEIGHT` | +| Masse grasse | `BodyFatRecord` | `...READ_BODY_FAT` | +| Masse maigre / osseuse / hydrique | `LeanBodyMassRecord`, `BoneMassRecord`, `BodyWaterMassRecord` | permissions dédiées | +| Taille | `HeightRecord` | `...READ_HEIGHT` | +| Fréquence cardiaque | `HeartRateRecord`, `RestingHeartRateRecord`, `HeartRateVariabilityRmssdRecord` | `...READ_HEART_RATE`, etc. | +| Sommeil | `SleepSessionRecord` (avec stages) | `...READ_SLEEP` | +| Apport calorique / macros (Foodvisor) | `NutritionRecord` | `...READ_NUTRITION` | +| Hydratation | `HydrationRecord` | `...READ_HYDRATION` | +| VO2 max, étages montés, etc. | `Vo2MaxRecord`, `FloorsClimbedRecord`, ... | permissions dédiées | + +Note Foodvisor : Foodvisor (Android) se synchronise avec **Google Fit** (réglage « connexion aux données de santé ») ; Google Fit écrit/lit la nutrition via Health Connect. La chaîne Foodvisor → Google Fit → Health Connect → pont → LifeTrack est donc **possible en théorie pour les calories ingérées** (`NutritionRecord`), mais fragile (dépend du maintien de l'app Google Fit, en fin de vie côté API ; des utilisateurs signalent des synchronisations Foodvisor→Fit capricieuses). À tester en priorité une fois le pont en place ; sinon, saisie manuelle / export Foodvisor en fallback. + +### 2.3 Permissions et restrictions importantes (impact direct sur la conception) + +- **Fenêtre historique de 30 jours** : par défaut, une app ne peut lire que les données datant d'au plus **30 jours avant la première autorisation**. Pour lire plus ancien, permission additionnelle `android.permission.health.READ_HEALTH_DATA_HISTORY` (`PERMISSION_READ_HEALTH_DATA_HISTORY`), disponible depuis les mises à jour 2025 du SDK (Jetpack en bêta depuis mars 2025). Conséquence : installer/configurer le pont **tôt** ; l'historique antérieur profond passera plutôt par des exports CSV. +- **Lecture en arrière-plan** : permission dédiée `android.permission.health.READ_HEALTH_DATA_IN_BACKGROUND`, indispensable pour un pont qui synchronise sans que l'app soit ouverte. Les apps pont citées la gèrent déjà ; une app maison doit la déclarer et la demander. +- **Rate limiting** : HC impose des quotas de lecture (par app, différents premier plan / arrière-plan). Conception à base de **sync incrémentale** obligatoire, pas de relecture complète à chaque cycle. +- **API de changements (differential changes)** : `getChanges(token)` fournit ajouts/modifications/suppressions depuis le dernier token — c'est le mécanisme officiel de sync incrémentale. Les tokens expirent (~30 jours) ; prévoir une resynchronisation de rattrapage si token expiré. +- **Distribution hors Play Store** : la validation Google (formulaire de déclaration des permissions santé) est une exigence **de publication sur le Play Store**, pas un prérequis technique de l'API. Une app companion **sideloadée** (APK debug/release signé localement) déclarant correctement ses permissions dans le manifest et l'intent de justification (`androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE` sur Android 13-, `android.intent.action.VIEW_PERMISSION_USAGE` + catégorie `android.intent.category.HEALTH_PERMISSIONS` sur 14+) fonctionne : l'utilisateur accorde les permissions dans l'UI Health Connect. Preuve empirique : HCGateway et d'autres ponts open-source se distribuent en APK GitHub et fonctionnent. Point de vigilance : certains guides tiers évoquent des contrôles d'app ID pour les apps Play ; pour un usage personnel sideloadé, aucun blocage connu, mais **à valider sur l'appareil cible dès le début du développement v2**. + +### 2.4 Ce que ça implique pour LifeTrack + +- Le backend doit exposer une **API d'ingestion push** (section 8) — jamais de « pull » possible. +- Les données arrivent par lots hétérogènes, avec doublons possibles (re-synchronisations) : l'ingestion doit être **idempotente**. +- Chaque record HC porte des métadonnées utiles à conserver : `metadata.id` (UUID unique HC), `clientRecordId`, `dataOrigin` (package de l'app source, ex. `com.sec.android.app.shealth`), horodatages début/fin avec offset de zone. LifeTrack stocke en UTC (convention projet) + `source_app`. + +--- + +## 3. API REST Google Fit : statut (chemin non viable) + +- **Dépréciation annoncée** avec Health Connect comme successeur ; **inscriptions fermées depuis le 1er mai 2024** (aucun nouveau projet ne peut obtenir l'accès OAuth aux scopes Fitness). +- **Arrêt de service courant 2026** ("supported until the end of 2026" selon la FAQ de migration ; la page officielle developers.google.com/fit dit "will be deprecated in 2026" sans jour précis). +- L'app mobile Google Fit elle-même est en fin de vie au profit de l'app Fitbit ; ne pas en faire une dépendance. +- **Verdict : à exclure totalement.** Même si un projet OAuth existant fonctionnait encore quelques mois, tout investissement serait perdu. Aucune ligne de code LifeTrack ne doit cibler l'API Google Fit. + +--- + +## 4. Applications pont existantes (Health Connect → serveur/fichier) + +### 4.1 health-connect-webhook (mcnaveen) — candidat recommandé v1 + +- **Dépôt** : https://github.com/mcnaveen/health-connect-webhook — Kotlin / Jetpack Compose / Material 3, licence **AGPL-3.0** (+ addendum commercial pour la redistribution en store). ~141 stars, ~161 commits, activement maintenu, distribué sur le **Play Store** (et App Store côté iOS). +- **Fonction** : lit Health Connect et **POSTe un JSON vers une ou plusieurs URLs de webhook** configurées par l'utilisateur. Exactement le modèle "push vers endpoint REST custom" du brief. +- **Mécanismes de sync** : (a) périodique via **WorkManager** (minimum 15 min, réglable), (b) horaires fixes via AlarmManager (défaut 08:00 et 21:00), (c) sync manuelle, (d) **serveur HTTP local optionnel** sur le téléphone (port 8787 par défaut) exposant un snapshot JSON en pull sur le LAN. +- **Payload** : `POST` avec `Content-Type: application/json; charset=utf-8`. Objet JSON unique : `timestamp` (génération du payload), `app_version`, puis un **tableau par type de données en snake_case** (ex. `steps`, `heart_rate`, `weight`, `exercise_sessions`, `sleep_sessions`, `nutrition`, `active_calories_burned`, `total_calories_burned`, `distance`...), tableaux omis si vides. Schémas de champs détaillés dans les fichiers `docs/webhook.md` et `docs/local-http.md` du dépôt (les récupérer au moment de l'implémentation de l'adaptateur pour figer le mapping exact). +- **Couverture** : **31 types de données**, incluant tout ce dont LifeTrack a besoin (steps, distance, calories actives/totales, exercise sessions, poids, masse grasse, BMR, FC, sommeil avec stages, nutrition, hydratation, VO2 max). +- **Fenêtre de lecture** : fenêtre glissante de **48 h** ; les syncs en arrière-plan sont incrémentales (données nouvelles depuis la dernière sync réussie). Retries : 3 tentatives avec backoff exponentiel, puis nouvelle tentative à la sync suivante. **Limite** : si le téléphone/app est inactif plus de 48 h, trou possible → couvert par le fallback CSV et par la réconciliation côté serveur. +- **Limite importante : pas d'en-tête d'authentification configurable.** La sécurité repose sur l'URL. Mitigation LifeTrack : jeton secret **dans l'URL** (ex. `https://lifetrack.example/api/v1/ingest/hc-webhook/`), token révocable, endpoint accessible uniquement en HTTPS (et idéalement seulement via VPN/LAN — déploiement domestique). Vérifier à l'implémentation si des en-têtes custom ont été ajoutés depuis. + +### 4.2 HCGateway (ShuchirJ) — alternative complète mais lourde + +- **Dépôt** : https://github.com/ShuchirJ/HCGateway — licence **GPL-3.0**, ~414 stars, développement actif ("API stability not guaranteed"). +- **Architecture** : app mobile **React Native** (Android 8+) + **serveur Python Flask + MongoDB** auto-hébergeable (Docker Compose fourni) + **Firebase** (notifications push, nécessaire seulement pour déclencher des écritures serveur→téléphone ; il faut alors builder l'APK soi-même avec son `google-services.json`). +- **Fonction** : sync **bidirectionnelle** — l'app envoie ~34/35 types de données HC vers le serveur toutes les **2 h** (réglable, service foreground persistant, sync ~15 min) ; API REST (login/signup, fetch par type — méthodes nommées `steps`, `heartRate`, `sleepSession`, `activeCaloriesBurned`, `weight`, `exerciseSession`, etc. — et push vers HC). Docs API : https://hcgateway.shuchir.dev/. +- **Sécurité** : mots de passe Argon2 ; données chiffrées **Fernet** au repos dans MongoDB (clé dérivée du hash utilisateur), déchiffrées à la volée lors des requêtes API. Collections `hcgateway_[user_id]` avec données chiffrées, horodatages début/fin, package d'origine, id unique. +- **Historique** : limité aussi par la fenêtre HC de 30 jours (issue GitHub #38 ouverte à ce sujet). +- **Évaluation pour LifeTrack** : fonctionne, mais impose **un deuxième backend + MongoDB + éventuellement Firebase** à côté de notre stack Postgres/FastAPI, et LifeTrack devrait **poller** l'API HCGateway (au lieu de recevoir un push). Intéressant seulement si health-connect-webhook s'avère défaillant. Une instance publique existe (`https://api.hcgateway.shuchir.dev/`) mais envoie les données santé chez un tiers — contraire à l'esprit auto-hébergé. + +### 4.3 Health Sync (appyhapps.nl) — le fallback fichiers de référence + +- App Android commerciale mature (https://healthsync.app) : essai 1 semaine, puis **licence à vie en achat unique (~4 €)** ou abonnement 6 mois (Withings seul nécessite un abonnement dédié). +- Synchronise entre plateformes (sources : Health Connect, Samsung Health, Fitbit, Garmin, Polar, Suunto, Huawei, Oura, Strava, fatsecret... ; destinations : Health Connect, Strava, Google Drive, etc.). +- **Fonction clé pour LifeTrack : export automatique vers Google Drive** — données santé (pas, FC, poids...) en **CSV** (fichiers jour courant / 7 jours / mois / 30 jours glissants), séances d'activité en **FIT, TCX, GPX, KML, CSV**. Tourne en arrière-plan sans intervention. +- Usage LifeTrack : l'utilisateur dépose les CSV dans l'UI d'import (ou un dossier synchronisé sur le serveur) ; le framework d'importeurs LifeTrack fournit un parseur `health_sync_csv`. Sert aussi à **rattraper l'historique profond** (au-delà des 30 jours HC) et les trous de sync. +- Fermé/propriétaire : formats CSV à rétro-ingénierer sur échantillons réels (colonnes stables, une ligne par mesure horodatée ; prévoir l'importeur tolérant : détection d'en-têtes + mapping configurable). + +### 4.4 Health Data Export (teqxnology / healthdataexport.com) + +- App d'export manuel/planifié : Apple Health, Health Connect, Google Fit → **CSV, JSON, PDF, Excel**. Utile ponctuellement pour un dump massif initial ; moins adaptée à une sync continue. Second choix derrière Health Sync pour le fallback fichiers. + +### 4.5 Home Assistant (app companion Android) + +- Depuis la version **2025.5**, l'app companion Home Assistant expose des **capteurs Health Connect** (pas, calories actives/totales, distance, sommeil, poids, FC... ; liste élargie par vagues de 6 capteurs, cf. release notes github.com/home-assistant/android). Flux : app santé → HC → capteurs HA. +- Limites : capteurs = **valeurs instantanées/agrégats du moment**, pas des séries historiques propres ; il faudrait ensuite extraire du recorder HA vers LifeTrack (REST API HA + long-lived token). Convoluté, granularité pauvre (pas de séances détaillées, pas de nutrition complète). **Pertinent uniquement si l'utilisateur exploite déjà Home Assistant** et seulement pour des métriques simples (pas quotidiens). Non retenu comme chemin principal. + +### 4.6 Gadgetbridge + +- Gadgetbridge (app FLOSS pour montres/bracelets) est une **source** Health Connect, pas un exporteur : depuis la 0.89.0, Réglages > External Integrations > Health Connect permet de pousser **Steps et Heartbeat** (types supportés à ce jour) vers HC, localement, sans cloud constructeur. +- Intérêt LifeTrack : si l'utilisateur passe un jour à une montre supportée par Gadgetbridge, ses données rejoignent HC puis LifeTrack via le pont existant — **aucun travail supplémentaire côté LifeTrack**. Gadgetbridge offre aussi ses propres exports (base SQLite, auto-export) mais ce n'est pas le sujet v1. + +### 4.7 Tableau comparatif des ponts + +| Solution | Type | Push vers REST custom ? | Auth | Types couverts | Coût | Maintenance/risque | +|---|---|---|---|---|---|---| +| health-connect-webhook | App open-source (AGPL) | **Oui (webhooks POST JSON)** | Token dans URL seulement | 31 | Gratuit | Actif ; projet jeune | +| HCGateway | App + serveur open-source (GPL) | Non (LifeTrack pollerait son API) | Login + tokens | ~34 | Gratuit | Actif ; stack Flask/MongoDB/Firebase en plus | +| Health Sync | App commerciale | Non (fichiers vers Drive) | n/a | Large | ~4 € une fois | Très mature | +| Health Data Export | App commerciale | Non (fichiers) | n/a | Large | Freemium | OK | +| Home Assistant companion | App open-source | Indirect (via HA) | Token HA | Partiel (capteurs) | Gratuit | Actif | +| Gadgetbridge | App open-source | Non (c'est une source HC) | n/a | Steps, FC | Gratuit | Très actif | + +--- + +## 5. FitShow (tapis de course) et la piste FTMS + +### 5.1 L'app FitShow : capacités et limites + +- FitShow (`com.fitshow` sur le Play Store) est l'app officielle des équipements embarquant le module Bluetooth **FitShow SmartBTM** (tapis, vélos, elliptiques, rameurs — marques low-cost/moyennes très répandues). Modes cartes, programmes, objectifs, podomètre. +- **iOS** : écrit pas et distance dans **Apple Health** (HealthKit) — sans objet pour nous. +- **Android** : **aucune intégration Health Connect ni Google Fit documentée**, **pas d'export Strava** (demandé par les utilisateurs, non implémenté), pas d'export de fichiers (TCX/GPX/FIT) connu. Compte cloud FitShow sans API publique. +- **Verdict : ne pas compter sur FitShow comme source de données.** Les séances tapis faites dans FitShow resteront enfermées. Trois contournements : (a) saisie manuelle de la séance dans LifeTrack (v1), (b) enregistrer la séance via une app qui écrit dans HC (ex. app de sport compatible FTMS, ou QZ ci-dessous), (c) **capter le tapis directement en BLE** (v2/v3, ci-dessous). + +### 5.2 Protocoles BLE : FS (propriétaire) vs FTMS (standard) + +- **FTMS** (Fitness Machine Service, Bluetooth SIG) est le standard BLE des machines de fitness : service `0x1826`, caractéristique **Treadmill Data `0x2ACD`** (notifications : vitesse instantanée en 0,01 km/h, distance totale, inclinaison en 0,1 %, calories, temps écoulé, FC si dispo — champs présents selon un bitfield de flags), Fitness Machine Control Point `0x2AD9` (contrôle vitesse/inclinaison), Fitness Machine Feature `0x2ACC`. Supporté par Zwift, Kinomap, Wahoo, Polar, etc. +- Les machines équipées FitShow parlent le **protocole propriétaire "FS"** ; **beaucoup de modèles récents exposent aussi FTMS** (les compatibilités Zwift/Kinomap l'attestent). À vérifier sur le tapis de l'utilisateur avec un scanner BLE (nRF Connect : chercher le service `0x1826`). Attention : le tapis n'accepte en général **qu'une connexion BLE à la fois** (FitShow app OU LifeTrack, pas les deux). +- **QZ (qdomyos-zwift)**, open-source (https://github.com/cagnulein/qdomyos-zwift), sait parler le protocole FS propriétaire de nombreux tapis et **re-exposer un device FTMS virtuel** ; il pousse aussi vers Strava/Peloton/Garmin. C'est le plan B si le tapis n'expose pas FTMS nativement. + +### 5.3 Web Bluetooth dans le frontend LifeTrack : faisable + +- **Oui, un frontend web peut enregistrer une séance de tapis en direct via Web Bluetooth + FTMS.** Des simulateurs de vélo open-source dans le navigateur le font déjà avec des trainers FTMS ; l'API `navigator.bluetooth.requestDevice({ filters: [{ services: [0x1826] }] })` puis abonnement aux notifications de `0x2ACD` suffit pour un tapis. +- **Contraintes** : + - Navigateurs : **Chrome/Edge (desktop et Android) uniquement** — pas Firefox ni Safari. Acceptable pour un outil personnel ; à documenter dans l'UI. + - **Contexte sécurisé requis** : HTTPS (ou `localhost`). Le déploiement domestique nginx devra servir en HTTPS (mkcert / CA locale / certificat Let's Encrypt si domaine) pour que le bouton "Connecter le tapis" fonctionne depuis un autre appareil que le serveur. + - Geste utilisateur requis pour l'appairage (pas de connexion silencieuse au chargement) ; reconnexion à gérer. +- **Design proposé (v2/v3)** : composant React `TreadmillRecorder` — connexion FTMS, échantillonnage ~1 Hz (vitesse, distance cumulée, inclinaison, kcal), graphe live ECharts, à l'arrêt POST de la séance vers `/api/v1/workouts` (durée, distance, vitesse moy/max, kcal, série de points). Valeur : remplace complètement FitShow pour le tapis et alimente directement le bilan énergétique. Effort : ~1-2 semaines avec l'UI. + +--- + +## 6. App companion Android maison (chemin v2 privilégié) + +### 6.1 Faisabilité et briques techniques + +- **SDK** : Jetpack `androidx.health.connect:connect-client` (bêta depuis mars 2025, stable pour les usages courants). Kotlin, `minSdk 28` (HC APK requis sur Android 9-13 ; natif sur 14+). +- **Manifest** : déclarer chaque permission `android.permission.health.READ_*` nécessaire + `READ_HEALTH_DATA_IN_BACKGROUND` + `READ_HEALTH_DATA_HISTORY` ; intent de justification : activité gérant `androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE` (Android 13-) et alias avec `android.intent.action.VIEW_PERMISSION_USAGE` + catégorie `android.intent.category.HEALTH_PERMISSIONS` (Android 14+), affichant la politique de confidentialité (une page statique en français suffit pour un usage personnel). +- **Sync** : `PeriodicWorkRequest` WorkManager (minimum Android : **15 min** ; 30-60 min suffisent pour LifeTrack), avec contrainte réseau. Première exécution : lecture complète 30 jours (ou plus avec la permission history) par `readRecords` paginé ; ensuite **sync incrémentale via `getChangesToken` / `getChanges`** (gère ajouts/modifs/suppressions, économise le quota de rate limiting). Stocker le token ; si expiré (~30 jours), refaire un rattrapage borné. +- **Envoi** : POST JSON (OkHttp/Ktor) vers l'endpoint d'ingestion LifeTrack avec **Bearer token** (vraie authentification, contrairement à health-connect-webhook) ; file de retry persistée (Room ou fichier) pour tolérer serveur éteint / hors LAN ; option "sync seulement en Wi-Fi domestique". +- **Distribution** : APK signé localement, sideloadé — **pas de compte développeur Google ni de revue Play nécessaire** pour un usage personnel (cf. 2.3, à valider sur l'appareil cible en tout début de v2). + +### 6.2 Estimation d'effort + +| Poste | Estimation | +|---|---| +| Squelette app (Compose, 2 écrans : config serveur/token, état de sync) | 1 jour | +| Intégration HC : permissions + lecture des ~10 types utiles | 1-2 jours | +| WorkManager + changes API + file de retry | 1-2 jours | +| Client HTTP + mapping JSON vers le schéma d'ingestion | 0,5-1 jour | +| Tests sur device réel, edge cases (Doze, OEM battery killers), polish | 1-3 jours | +| **Total** | **~3-5 jours** (dev Android expérimenté) à **~2 semaines** (montée en compétence incluse) | + +Risques spécifiques Android : optimisations batterie agressives des OEM (Xiaomi/Huawei/Samsung) pouvant tuer WorkManager → documenter l'exclusion de l'optimisation batterie ; quotas HC en arrière-plan → rester sur la changes API. + +--- + +## 7. Classement des chemins d'intégration (effort vs valeur) + +| # | Chemin | Effort | Valeur | Fiabilité | Version cible | +|---|---|---|---|---|---| +| 1 | **Endpoint d'ingestion REST générique LifeTrack** (prérequis de tout le reste) | Moyen (backend pur) | Très élevée | n/a | **v1** | +| 2 | **health-connect-webhook → endpoint LifeTrack** | Faible (adaptateur de payload) | Élevée (sync auto ~15 min, 31 types) | Moyenne+ (fenêtre 48 h, pas d'auth header) | **v1** | +| 3 | **Import CSV Health Sync (fallback + historique)** | Faible-moyen (parseurs CSV/TCX) | Élevée (rattrapage, robustesse) | Élevée | **v1** | +| 4 | Saisie manuelle poids/séances/nutrition | Déjà prévu (UI) | Élevée | Élevée | v1 | +| 5 | **App companion maison (HC SDK + WorkManager + Bearer)** | Moyen (3-10 j) | Très élevée (contrôle total, auth propre, >48 h, history) | Élevée | **v2** | +| 6 | **Web Bluetooth FTMS (enregistrement tapis dans le navigateur)** | Moyen (1-2 sem.) | Élevée (remplace FitShow, données riches) | Moyenne (Chrome/Edge + HTTPS + FTMS dispo sur le tapis) | **v2/v3** | +| 7 | HCGateway auto-hébergé | Moyen-élevé (2e stack serveur) | Moyenne (doublonne #2/#5) | Moyenne | plan B uniquement | +| 8 | Home Assistant companion → HA → LifeTrack | Moyen | Faible (granularité pauvre) | Moyenne | non retenu | +| 9 | Gadgetbridge (source HC si montre compatible) | Nul côté LifeTrack | Bonus | Élevée | opportuniste | +| 10 | Google Fit REST API | — | **Nulle (arrêt 2026, inscriptions fermées)** | — | **exclu** | + +--- + +## 8. Recommandation v1 détaillée + contrat d'ingestion proposé + +### 8.1 Périmètre v1 + +1. **API d'ingestion générique** (ci-dessous) + table de payloads bruts + normalisation vers les tables métier (`weight_measurements`, `daily_activity`, `workouts`, `nutrition_entries`, ...). +2. **Adaptateur health-connect-webhook** : endpoint dédié acceptant le format de cette app, token dans l'URL. +3. **Importeurs fichiers** via le framework de connecteurs : `health_sync_csv` (pas, poids, FC, calories), `tcx`/`gpx` (séances). Réutilisables pour tout autre export. +4. Documentation utilisateur (français) : installer health-connect-webhook depuis le Play Store, coller l'URL d'ingestion générée par LifeTrack (avec token), cocher les types de données, régler l'intervalle ; configurer Health Sync en secours. + +### 8.2 Contrat API d'ingestion (à implémenter tel quel) + +Principes : **push only, idempotent, tolérant, brut d'abord**. + +- `POST /api/v1/ingest/health` — endpoint canonique (utilisé par la future app companion v2 et tout client "propre"). + - Auth : `Authorization: Bearer ` (token d'ingestion par device, distinct du JWT de session, révocable, stocké haché). + - Corps : `{ "source": "companion-app", "device_id": "...", "records": [ { "type": "steps", "external_id": "", "start_time": "...Z", "end_time": "...Z", "value": {...}, "unit": "...", "origin_app": "com.sec.android.app.shealth" }, ... ] }`. + - Réponse : `{ "accepted": n, "duplicates": m, "rejected": [...] }` ; `207`-like sémantique, jamais d'échec global pour un record invalide. +- `POST /api/v1/ingest/hc-webhook/{ingest_token}` — **adaptateur health-connect-webhook** (l'app ne sait pas poser d'en-tête d'auth → token dans le chemin, transmis en HTTPS uniquement). Accepte le payload natif de l'app (objet avec `timestamp`, `app_version`, tableaux snake_case par type), le stocke brut, puis mappe les types connus vers le pipeline canonique. +- **Stockage brut systématique** : table `raw_ingest_payloads` (id, token/device, received_at UTC, source, payload JSONB, processing_status, error). Permet de rejouer la normalisation quand le mapping s'affine — crucial car le schéma exact des ponts tiers peut évoluer. +- **Idempotence / dédoublonnage** : contrainte unique `(user_id, record_type, external_id)` quand un id externe existe (UUID `metadata.id` HC, transmis par les ponts) ; sinon clé de repli = hash SHA-256 de `(record_type, start_time, end_time, origin_app, valeur canonique)`. Les re-syncs (fenêtre 48 h de health-connect-webhook, ré-imports CSV) deviennent inoffensives. +- **Fuseaux** : entrées horodatées ISO-8601 avec offset ; conversion et stockage **UTC** ; agrégats journaliers calculés en `Europe/Paris` côté requêtes/vues. +- Sécurité déploiement domestique : HTTPS obligatoire (nginx), rate limit simple sur les endpoints d'ingestion, tokens révocables depuis l'UI (page « Sources de données »), logs d'ingestion visibles dans l'UI pour diagnostiquer les trous de sync. + +### 8.3 Ordre de vérification à l'implémentation (sans nouvelle recherche produit) + +1. Figer le mapping exact des champs de health-connect-webhook depuis `docs/webhook.md` du dépôt (et/ou capturer un payload réel avec l'app pointée vers un endpoint de debug). +2. Générer des exports Health Sync réels (CSV jour/semaine/mois + un TCX de séance) et figer les parseurs sur ces échantillons. +3. Tester la chaîne Foodvisor → Google Fit → Health Connect → pont pour `NutritionRecord` ; si KO, la saisie calories reste manuelle en v1. +4. Scanner le tapis avec nRF Connect pour confirmer la présence du service FTMS `0x1826` (décide la faisabilité du chemin Web Bluetooth v2/v3 sans QZ). + +--- + +## 9. Risques et inconnues + +- **health-connect-webhook** : projet jeune ; schéma de payload non contractuel (d'où le stockage brut + adaptateur isolé) ; absence d'auth par en-tête (token en URL + HTTPS/VPN en mitigation) ; fenêtre 48 h (trous possibles, couverts par CSV). +- **Comportement OEM Android** (Doze, battery killers) : peut espacer les syncs de n'importe quel pont ou de l'app maison ; documenter l'exclusion d'optimisation batterie. +- **Fenêtre historique HC de 30 jours** : l'historique profond ne viendra jamais de HC sans `READ_HEALTH_DATA_HISTORY` (et jamais au-delà de ce que les apps sources ont écrit dans HC) → import CSV pour le passé. +- **Sideload + permissions HC sur l'appareil cible** : à valider empiriquement en tout début de v2 (aucun blocage connu, mais politique Google mouvante en 2025-2026). +- **FitShow** : silo confirmé côté Android ; la valeur du chemin FTMS dépend du matériel réel de l'utilisateur (présence du service `0x1826`). +- **Foodvisor → HC** : chaîne indirecte via Google Fit, app Google Fit en fin de vie ; fiabilité incertaine. +- **Écosystème mouvant** : Google migre l'écosystème (Fit → Health Connect / Google Health API) ; revalider les politiques HC (permissions, quotas) au démarrage de la v2 companion. + +--- + +## 10. Sources + +- Health Connect — guide de comparaison (on-device vs cloud) : https://developer.android.com/health-and-fitness/health-connect/comparison-guide +- Health Connect — démarrage : https://developer.android.com/health-and-fitness/health-connect/get-started +- Health Connect — synchronisation (changes API) : https://developer.android.com/health-and-fitness/health-connect/sync-data +- Health Connect — types de données et permissions : https://developer.android.com/health-and-fitness/health-connect/data-types +- Health Connect — lecture de données (fenêtre 30 jours) : https://developer.android.com/health-and-fitness/guides/health-connect/develop/read-data +- Health Connect — FAQ : https://developer.android.com/health-and-fitness/guides/health-connect/frequently-asked-questions +- Health Connect — rate limiting : https://developer.android.com/health-and-fitness/health-connect/rate-limiting +- Blog Android Developers (mars 2025) — SDK Jetpack bêta, background reads, history : https://android-developers.googleblog.com/2025/03/health-connect-jetpack-sdk-now-in-beta.html +- Android Authority — historical/background reads : https://www.androidauthority.com/health-connect-historical-background-reads-3443726/ +- Publication Play / déclaration santé : https://developer.android.com/health-and-fitness/health-connect/publish et https://support.google.com/googleplay/android-developer/answer/12991134 +- Google Fit — page officielle (dépréciation) : https://developers.google.com/fit +- Google Fit — FAQ migration : https://developer.android.com/health-and-fitness/health-connect/migration/fit/faq +- HCGateway : https://github.com/ShuchirJ/HCGateway/ (docs API : https://hcgateway.shuchir.dev/ ; issue 30 jours : https://github.com/ShuchirJ/HCGateway/issues/38) +- health-connect-webhook : https://github.com/mcnaveen/health-connect-webhook +- Health Sync : https://healthsync.app/about/ et https://play.google.com/store/apps/details?id=nl.appyhapps.healthsync +- Health Data Export : https://healthdataexport.com/ +- Home Assistant companion — capteurs Health Connect : https://github.com/home-assistant/android/releases/tag/2025.5.3 ; https://community.home-assistant.io/t/health-and-fitness-data-into-home-assistant-ha-companion-app-and-health-connect/905477 +- Gadgetbridge — intégration Health Connect : https://gadgetbridge.org/basics/integrations/health-connect/ ; https://gadgetbridge.org/blog/release-0_89_00/ +- FitShow (Play Store) : https://play.google.com/store/apps/details?id=com.fitshow ; (App Store, sync Apple Health) : https://apps.apple.com/us/app/fitshow-treadmill-workout/id1387360716 +- FTMS — présentation Decathlon Digital : https://medium.com/decathlondigital/take-control-of-your-fitness-machines-6588439aeeda ; intégration apps : https://www.fitscope.com/blog/bluetooth-ftms-integration-for-fitness-apps +- QZ (qdomyos-zwift) : https://github.com/cagnulein/qdomyos-zwift +- Foodvisor (Play Store, sync Google Fit) : https://play.google.com/store/apps/details?id=io.foodvisor.foodvisor diff --git a/docs/research/nutrition-sources.md b/docs/research/nutrition-sources.md new file mode 100644 index 0000000..a72f664 --- /dev/null +++ b/docs/research/nutrition-sources.md @@ -0,0 +1,298 @@ +# Recherche — Sources de données NUTRITION / CALORIES pour LifeTrack + +> Document de recherche destiné aux agents d'implémentation. Rédigé le 2026-08-13. +> Langue : prose en français, identifiants de code en anglais (convention projet). +> Périmètre : ingestion des données alimentaires (repas, calories, macros) dans le module Santé/Fitness. + +--- + +## 1. Résumé exécutif + +| Source | Accès | Verdict v1 | +|---|---|---| +| Foodvisor — export in-app / RGPD | CSV limité, sur demande, pas d'automatisation | **Importeur CSV "best effort" + procédure RGPD documentée** (import ponctuel d'historique) | +| Foodvisor — API consommateur | **N'existe pas** | Hors périmètre | +| Foodvisor — Vision API (analyse photo) | Produit entreprise, tarif non public, contrat commercial | **Non viable v1** ; garder un port d'extension `photo_analyzer` | +| Foodvisor — via Health Connect | Non confirmé (Foodvisor Android se synchronise à Google Fit, pas à Health Connect de façon documentée) | Chemin indirect **à tester** via Health Sync ; ne pas en dépendre | +| Health Connect `NutritionRecord` (bridge Android) | Complet, structuré, sur l'appareil uniquement (pas d'API cloud) | **OUI — endpoint REST d'ingestion + app/bridge Android** (stratégie cible) | +| Open Food Facts API | Gratuit, ouvert (ODbL), code-barres + recherche | **OUI — pilier de la saisie manuelle assistée** | +| CIQUAL (ANSES) | Téléchargement Excel/XML, licence ouverte | **OUI — table locale d'aliments génériques français** | +| MyFitnessPal / Cronometer / Yazio — exports CSV | Formats variés, semi-documentés | Importeurs CSV **optionnels** (framework générique, mapping par profil) | +| Saisie manuelle rapide | — | **OUI — socle indispensable** | + +**Stratégie v1 recommandée** : saisie manuelle rapide + recherche produit Open Food Facts (proxy backend + cache) + table CIQUAL locale pour les aliments génériques + framework d'importeurs CSV (Foodvisor, MyFitnessPal, Cronometer) + endpoint REST `POST /api/v1/ingest/nutrition` consommé par un bridge Health Connect. Un connecteur Foodvisor "direct" reste impossible aujourd'hui faute d'API ; on préserve la possibilité future via l'architecture connecteurs (section 8). + +--- + +## 2. Foodvisor (l'app utilisée par l'utilisateur) + +### 2.1 Ce que Foodvisor est (et n'est pas) + +Foodvisor (société française, éditeur de l'app `io.foodvisor.foodvisor` sur Android et `id1064020872` sur iOS) est une app grand public de comptage de calories par photo (IA de reconnaissance alimentaire), scan de code-barres et saisie manuelle, avec coaching payant. **Il n'existe aucune API consommateur** (pas d'OAuth, pas d'endpoint "mes repas", pas de webhook). Toute intégration "live" est donc exclue. + +### 2.2 Export de données utilisateur (in-app + RGPD) + +Constats issus de plusieurs sources (guides Nutrola 2025-2026, politique de confidentialité Foodvisor) : + +- **Export intégré** : dans l'app, `Réglages` → section `Compte` → entrée type « Demander mes données » / « Request my data » (l'intitulé et l'emplacement exacts varient selon les versions ; sur le tableau de bord web l'option est sous `Account` → `Privacy` → `Data`). L'export est **envoyé par e-mail**, généralement sous 24-72 h (le SLA de la politique de confidentialité autorise jusqu'à 30 jours). +- **Contenu de l'export** (archive ZIP contenant plusieurs CSV, ou CSV/PDF selon les versions) : + - objectifs et réglages en **CSV clé-valeur "à plat"** (objectif calorique, répartition des macros, métriques corporelles, niveau d'activité) ; + - **journal alimentaire** : une ligne par entrée avec horodatage, créneau de repas (petit-déjeuner/déjeuner/dîner/collation), nom de l'aliment, portion, macros calculées (protéines, glucides, lipides) et calories ; + - **totaux quotidiens** calories + macronutriments (parfois limités à une période récente et non à l'historique complet) ; + - **historique de poids** saisi manuellement (date + valeur, une ligne par pesée) ; + - aliments/recettes personnalisés avec leurs macros mais **rarement** la décomposition en ingrédients. +- **Ce qui manque** (confirmé par les guides de migration) : photos et scores de confiance IA, détails des scans code-barres (codes EAN), micronutriments au-delà des macros, horodatages fins au-delà du jour pour les totaux, notes/noms personnalisés, résumés de coaching. +- **Pièges de format observés** : horodatages « généralement ISO 8601 mais parfois en heure locale sans offset » ; encodage UTF-8 (Windows-1252 sur d'anciens exports) ; en-têtes potentiellement en français ou en anglais selon la langue du compte. **L'importeur devra être tolérant** (détection d'encodage, de délimiteur `,`/`;`, et mapping d'en-têtes bilingue). +- **Demande RGPD (article 15, droit d'accès + article 20, portabilité)** : Foodvisor est une société française ; contact **`data@foodvisor.io`** (contact officiel indiqué dans la politique de confidentialité pour l'exercice des droits d'accès, rectification, effacement, portabilité). Délai légal : **1 mois**, prolongeable de 2 mois pour les demandes complexes. Demander explicitement « l'intégralité de mon journal alimentaire au format structuré lisible par machine (CSV ou JSON), incluant les horodatages, portions et valeurs nutritionnelles » — cette voie contourne les limitations du bouton d'export intégré. + +**Conséquence pour LifeTrack** : l'import Foodvisor est un **import ponctuel de rattrapage d'historique** (one-shot ou trimestriel), pas un flux continu. L'importeur CSV Foodvisor doit être conçu comme un *profil de mapping* du framework d'import générique (section 7.3), avec un écran de prévisualisation/mapping de colonnes car le format exact n'est **pas documenté officiellement et peut changer**. Ne pas coder en dur des noms de colonnes : livrer un mapping par défaut + éditeur de mapping. + +### 2.3 Foodvisor Vision API (produit développeur) + +- Produit B2B de Foodvisor : détection d'aliments et calcul nutritionnel à partir d'une photo (items reconnus, calories, macros, estimation des portions). Page produit : `https://www.foodvisor.io/en/vision/` ; documentation : `https://vision.foodvisor.io/docs` (⚠️ lors de nos tests d'août 2026, ce sous-domaine **ne résout plus en DNS** — signe que le produit est peu maintenu ou réservé aux clients sous contrat). +- **Modèle d'accès : contrat commercial entreprise uniquement.** Pas d'inscription self-service, pas de tarif public, pas de free tier, pas de spécification OpenAPI publique ; « les détails d'endpoints et d'authentification sont partagés avec les clients » sous accord. Accès via le formulaire de contact commercial. +- **Verdict** : inutilisable pour un projet personnel auto-hébergé en v1. Si l'analyse de photos de repas devient une exigence, alternatives à évaluer plus tard (non recherchées en détail ici, à vérifier avant usage) : LogMeal API, Passio Nutrition-AI, ou un LLM multimodal généraliste. **Décision d'architecture** : définir côté backend une interface `PhotoAnalyzer` (entrée : image ; sortie : liste de `FoodItemCandidate` avec macros et confiance) pour brancher n'importe quel fournisseur plus tard, y compris Foodvisor Vision si un contrat devenait accessible. + +### 2.4 Foodvisor → Health Connect ? + +- Sur **iOS**, Foodvisor se synchronise avec **Apple Health** (non pertinent ici, utilisateur Android). +- Sur **Android**, la seule intégration documentée est **Google Fit** (import/export nutrition, exercice, poids vers l'app Google Fit ; des fils de support Google Fit confirment cette synchro et ses ratés). **Aucune mention publique de support Health Connect** par Foodvisor à date (recherches août 2026). +- ⚠️ Les **API Google Fit sont dépréciées** (annonce Google mai 2024, arrêt programmé, migration officielle vers Health Connect). Le chemin `Foodvisor → Google Fit` est donc fragile et sans avenir. +- **Chemin indirect à tester** (effort ~30 min, aucune dépendance de code) : l'app tierce **Health Sync** (`nl.appyhapps.healthsync`, payante ~3-4 €/an) sait recopier des données entre Google Fit et Health Connect pour certains types de données. À tester sur le téléphone de l'utilisateur : si les repas Foodvisor poussés dans Google Fit peuvent être recopiés en `NutritionRecord` Health Connect, alors le bridge Health Connect de LifeTrack (section 3) récupérera les données Foodvisor **sans aucun code spécifique Foodvisor**. Ne pas bloquer la v1 sur ce test ; c'est un bonus. + +--- + +## 3. Health Connect — type de données Nutrition (chemin cible) + +### 3.1 Principe et contrainte fondamentale + +Health Connect (Android 14+ intégré ; app système) est un magasin de données santé **sur l'appareil uniquement : il n'existe AUCUNE API cloud/REST côté serveur**. Pour amener les données dans LifeTrack, il faut une app Android (le « bridge » déjà prévu au projet) qui lit Health Connect via `androidx.health.connect:connect-client` et pousse vers l'endpoint REST authentifié de LifeTrack. + +### 3.2 `NutritionRecord` — champs exacts + +Classe : `androidx.health.connect.client.records.NutritionRecord` (miroir plateforme : `android.health.connect.datatypes.NutritionRecord`). Un enregistrement = un repas/une prise alimentaire sur un intervalle de temps. + +- **Temporalité** : `startTime`, `endTime` (Instant, UTC), `startZoneOffset`, `endZoneOffset` (nullable). +- **Identité** : `name` (String?, nom du repas/aliment), `mealType` (Int) avec valeurs : `MEAL_TYPE_UNKNOWN = 0`, `MEAL_TYPE_BREAKFAST = 1`, `MEAL_TYPE_LUNCH = 2`, `MEAL_TYPE_DINNER = 3`, `MEAL_TYPE_SNACK = 4`. +- **Énergie** : `energy`, `energyFromFat` — type `Energy` (exposer en kcal via `energy.inKilocalories`). +- **Nutriments** (tous de type `Mass`, à lire en grammes via `inGrams` ; tous nullables) : `protein`, `totalCarbohydrate`, `sugar`, `dietaryFiber`, `totalFat`, `saturatedFat`, `unsaturatedFat`, `monounsaturatedFat`, `polyunsaturatedFat`, `transFat`, `cholesterol`, `sodium`, `potassium`, `calcium`, `iron`, `magnesium`, `phosphorus`, `zinc`, `copper`, `manganese`, `selenium`, `iodine`, `chromium`, `molybdenum`, `chloride`, `caffeine`, `biotin`, `folate`, `folicAcid`, `niacin`, `pantothenicAcid`, `riboflavin`, `thiamin`, `vitaminA`, `vitaminB6`, `vitaminB12`, `vitaminC`, `vitaminD`, `vitaminE`, `vitaminK`. + - ⚠️ Les documentations Google confirment : **tous les nutriments sont en grammes** (y compris sodium — pas en mg — et les vitamines), l'énergie en kcal. Convertir côté bridge ou côté API, mais **stocker en unités SI cohérentes** (voir 7.2). +- **Métadonnées** (`metadata`) : `id` (UUID Health Connect, **clé de déduplication idéale**), `dataOrigin.packageName` (app source, ex. `com.myfitnesspal.android`, permet le champ `source_app`), `lastModifiedTime`, `clientRecordId`/`clientRecordVersion`, `recordingMethod`, `device`. +- **Agrégats disponibles** (API `aggregate`) : `NutritionRecord.ENERGY_TOTAL`, `PROTEIN_TOTAL`, `TOTAL_CARBOHYDRATE_TOTAL`, `TOTAL_FAT_TOTAL`, etc. — utile si le bridge veut pousser des totaux journaliers en plus des repas unitaires. Recommandation : **pousser les enregistrements unitaires** et laisser LifeTrack agréger. + +### 3.3 Permissions et limites côté bridge + +- Permission de lecture : `android.permission.health.READ_NUTRITION` (+ autres types déjà prévus : steps, calories, distance, poids, exercices). +- **Limite des 30 jours** : par défaut une app ne peut lire que les données antérieures de 30 jours max à la première autorisation. La permission `android.permission.health.READ_HEALTH_DATA_HISTORY` (introduite en 2024) lève cette limite — **à demander dès la v1 du bridge** pour récupérer l'historique complet. +- Lecture incrémentale : utiliser l'API **Changes / changement de jetons** (`getChangesToken` + `getChanges`) pour ne pousser que les deltas ; repli : requête par `TimeRangeFilter` depuis le dernier push réussi. +- Lecture en arrière-plan : permission `READ_HEALTH_DATA_IN_BACKGROUND` + WorkManager périodique (les bridges existants utilisent des périodes de 1-2 h). + +### 3.4 Bridges open source réutilisables (au lieu de tout écrire) + +- **HCGateway** (`github.com/ShuchirJ/HCGateway`) : bridge REST universel Health Connect ↔ serveur, app Android + serveur auto-hébergeable, sync bidirectionnelle, push toutes les ~2 h. Projet jeune (« API may change without notice ») mais **la partie app Android est une excellente base de code de référence** (lecture de tous les types de records dont nutrition, sérialisation JSON). +- **health-connect-webhook** (`github.com/mcnaveen/health-connect-webhook`) : app Android qui pousse les données Health Connect vers un webhook arbitraire — modèle exactement aligné avec notre endpoint d'ingestion générique. +- **Health Sync** (payant, closed source) : pour la recopie inter-apps sur le téléphone (cf. 2.4), pas pour pousser vers LifeTrack. + +**Recommandation** : concevoir l'endpoint d'ingestion LifeTrack (section 7.4) de façon à pouvoir accepter soit notre futur bridge maison, soit un HCGateway/webhook adapté, en gardant un schéma JSON proche du modèle `NutritionRecord`. + +--- + +## 4. Open Food Facts (OFF) — recherche produit + code-barres + +Base collaborative mondiale (>3 M produits, très bonne couverture des produits vendus en France). **Gratuit, sans clé d'API.** + +### 4.1 Endpoints + +- **Produit par code-barres** : + `GET https://world.openfoodfacts.org/api/v2/product/{barcode}` (JSON). Utiliser `https://fr.openfoodfacts.org/...` pour prioriser les libellés français. + Paramètre `fields` pour limiter la réponse, ex. : + `?fields=code,product_name,product_name_fr,brands,quantity,serving_size,serving_quantity,nutriments,nutriscore_grade,nova_group,ecoscore_grade,image_front_small_url,categories_tags,lang` +- **API v3** (courante, recommandée pour les nouvelles intégrations) : `GET /api/v3/product/{barcode}` — v2 reste supportée ; v0/v1 à éviter. +- **Recherche plein texte** : ⚠️ pas disponible dans l'API v2 serveur. Deux options : + - **Search-a-licious** (service de recherche officiel) : `https://search.openfoodfacts.org` (API documentée sur ce domaine, `GET /search?q=...&langs=fr&page_size=20`) — recommandé ; + - legacy : `GET https://fr.openfoodfacts.org/cgi/search.pl?search_terms=...&json=1` (lent, déprécié mais fonctionnel). +- **Recherche filtrée v2** (par catégorie/marque/nutriment, sans plein texte) : `GET /api/v2/search?categories_tags=...&fields=...`. +- **Environnement de staging** : `https://world.openfoodfacts.net` (HTTP Basic `off`/`off`) — utiliser pour les tests d'intégration automatisés. +- **SDK Python officiel** : paquet PyPI `openfoodfacts` (`openfoodfacts-python`) — utilisable directement dans l'API FastAPI, mais un simple client `httpx` suffit. + +### 4.2 Champs `nutriments` utiles (clés JSON exactes) + +Par 100 g : `energy-kcal_100g`, `energy_100g` (kJ), `proteins_100g`, `carbohydrates_100g`, `sugars_100g`, `fat_100g`, `saturated-fat_100g`, `fiber_100g`, `salt_100g`, `sodium_100g` (en g). Variantes par portion : suffixe `_serving` (ex. `energy-kcal_serving`) + `serving_size` (texte, ex. "30 g") et `serving_quantity` (nombre). Les champs peuvent être **absents ou incohérents** (données collaboratives) : toujours valider (`energy-kcal_100g` manquant → recalculer depuis kJ ÷ 4,184 ; contrôler que macros × facteurs Atwater ≈ énergie à ±20 %). + +### 4.3 Règles d'usage impératives + +- **User-Agent personnalisé obligatoire** : format `AppName/Version (ContactEmail)`, ex. `LifeTrack/1.0 (meejayproduction@gmail.com)` — sous peine d'être traité comme bot. +- **Rate limits (docs officielles, août 2026)** : **15 req/min/IP** pour les lectures produit, **10 req/min/IP** pour les recherches. Conséquences d'implémentation : (a) toutes les requêtes OFF passent par le **backend LifeTrack (proxy)**, jamais depuis le navigateur ; (b) **cache local** de chaque produit consulté (table `food_items`, cf. 7.2) — un produit scanné une fois ne re-sollicite plus OFF ; (c) throttle côté serveur (token bucket 10/min) + retry avec backoff sur 429. +- **Pas de scraping massif via l'API** : pour un besoin hors-ligne complet, utiliser les **exports intégraux** (CSV ~ plusieurs Go, JSONL, dump MongoDB, Parquet sur Hugging Face) — non nécessaire en v1. +- **Licence** : base sous **ODbL** (contenus sous DbCL, images CC-BY-SA). Pour un usage personnel auto-hébergé : afficher une attribution « Données produits : Open Food Facts (ODbL) » dans l'UI suffit largement ; l'obligation de partage à l'identique ne s'applique que si l'on redistribue une base dérivée. + +--- + +## 5. CIQUAL (ANSES) — table de composition française (aliments génériques) + +Complément indispensable d'OFF : OFF couvre les **produits industriels code-barrés**, CIQUAL couvre les **aliments génériques** (« Pomme, crue », « Baguette courante », « Poulet rôti, cuisse ») — exactement ce qu'il faut pour la saisie manuelle de plats maison. + +- **Éditeur** : ANSES (Agence nationale de sécurité sanitaire de l'alimentation). Site de consultation : `https://ciqual.anses.fr`. +- **Versions** : table **Ciqual 2020** (~3 185 aliments, ~60 constituants) ; une **table Ciqual 2025** a été publiée fin 2025 (~3 484 aliments, sucres individuels détaillés, fruits/légumes mis à jour incl. outre-mer). Prendre la 2025 si disponible au moment de l'implémentation, sinon 2020. +- **Téléchargement** : formats **Excel (.xls/.xlsx) et XML**, depuis `ciqual.anses.fr` (rubrique téléchargement) et sur **data.gouv.fr** : jeu de données « Table de composition nutritionnelle des aliments Ciqual » (`https://www.data.gouv.fr/datasets/table-de-composition-nutritionnelle-des-aliments-ciqual-2020`). La table 2025 est aussi déposée sur `entrepot.recherche.data.gouv.fr` (DOI `10.57745/RDMHWY`). Documentation PDF officielle du format Excel disponible sur le site Ciqual. +- **Licence** : open data (Licence Ouverte / Etalab 2.0 via data.gouv.fr) — usage libre avec mention de la source « ANSES — Table Ciqual ». +- **Structure du fichier Excel (à connaître pour l'importeur)** : + - une ligne par aliment : `alim_code` (code numérique stable), `alim_nom_fr` (libellé français), `alim_nom_eng`, groupes/sous-groupes (`alim_grp_code`, `alim_grp_nom_fr`, `alim_ssgrp_...`) ; + - une colonne par constituant, valeurs **pour 100 g**, libellés du type « Energie, Règlement UE N° 1169/2011 (kcal/100 g) », « Protéines, N x facteur de Jones (g/100 g) », « Glucides (g/100 g) », « Lipides (g/100 g) », « Sucres (g/100 g) », « Fibres alimentaires (g/100 g) », « Sel chlorure de sodium (g/100 g) », vitamines/minéraux ; + - **pièges de parsing** : séparateur décimal **virgule** ; valeurs non numériques `"-"` (non déterminé), `"traces"`, et bornes `"< 0,5"` — l'importeur doit normaliser (`traces` → 0, `< x` → x/2 ou 0 selon règle choisie, `-` → NULL) ; encodage à vérifier (exports historiques en Windows-1252/latin-1). +- **Intégration recommandée** : script one-shot `scripts/import_ciqual.py` qui charge le fichier Excel/XML dans la table `food_items` avec `source='ciqual'`, `source_id=alim_code`. Le fichier CIQUAL est mis dans un volume/dossier `data/` (préchargé dans l'image ou téléchargé au premier lancement par l'admin) — pas d'appel réseau au runtime. + +--- + +## 6. Exports CSV des autres apps de suivi alimentaire + +Objectif : le framework d'import CSV doit accepter les exports des apps majeures, pour migration d'historique ou si l'utilisateur change d'app un jour. + +### 6.1 MyFitnessPal + +- **Export "File Export" (Premium uniquement)** : Web → `Reports`/`Settings` → export par plage de dates ; production asynchrone (minutes à heures), lien envoyé par e-mail ; **3 CSV : Nutrition, Exercise, Progress**. Le CSV Nutrition est à la granularité **jour × repas** (Breakfast/Lunch/Dinner/Snacks), avec colonnes type : `Date`, `Meal`, `Calories`, `Fat (g)`, `Saturated Fat`, `Polyunsaturated Fat`, `Monounsaturated Fat`, `Trans Fat`, `Cholesterol`, `Sodium (mg)`, `Potassium`, `Carbohydrates (g)`, `Fiber`, `Sugar`, `Protein (g)`, `Vitamin A`, `Vitamin C`, `Calcium`, `Iron`, `Note` (⚠️ liste constatée sur des exports réels et outils communautaires, non documentée officiellement — **valider sur un échantillon réel** avant de figer le mapping ; d'où l'éditeur de mapping, 7.3). +- **Compte gratuit** : demande de téléchargement des données personnelles (RGPD/CCPA) via les réglages du compte — archive moins structurée. +- Il existe une lib Python non officielle `python-myfitnesspal` (scraping avec login) — fragile, **hors périmètre v1**. + +### 6.2 Cronometer + +- Export self-service (Web → `More`/`Account` → `Export Data`, plage de dates), **immédiat et gratuit**, en 5 fichiers CSV : `servings.csv` (une ligne par aliment consommé : `Day`, `Group` (repas), `Food Name`, `Amount`, puis ~70 colonnes de nutriments avec unités dans l'en-tête, ex. `Energy (kcal)`, `Protein (g)`, `Sodium (mg)`), `dailysummary.csv` (totaux/jour), `exercises.csv`, `biometrics.csv` (poids !), `notes.csv`. Le plus propre et le plus riche des trois — profil de mapping facile. + +### 6.3 Yazio + +- **Pas d'export CSV natif** : l'app ne propose qu'un PDF limité ; l'archive complète s'obtient par **demande RGPD** (siège à Erfurt, Allemagne ; réponse sous 1 mois ; en pratique ZIP de JSON/CSV). +- Outils open source non officiels : `github.com/aleksandr-bogdanov/yazio-exporter` (Python ; login e-mail/mot de passe sur l'API non officielle de Yazio, export JSON/CSV/SQLite : journal, aliments consommés, poids, exercices, eau, 40+ micronutriments) et `github.com/tobintax/yazio-csv-exporter`. Utilisables ponctuellement par l'utilisateur **en dehors** de LifeTrack ; LifeTrack se contente d'importer les CSV produits. + +### 6.4 Foodvisor (rappel) + +Voir 2.2 : ZIP de CSV (journal par entrée, totaux quotidiens, poids, réglages clé-valeur). Profil d'import dédié, tolérant (encodage, délimiteur, en-têtes FR/EN, horodatages sans offset → interpréter en Europe/Paris puis convertir en UTC). + +--- + +## 7. Stratégie d'ingestion nutrition v1 — recommandations concrètes + +### 7.1 Les quatre canaux v1 (ordre de priorité d'implémentation) + +1. **Saisie manuelle rapide** (UI français) : formulaire "repas" minimal — date/heure (défaut : maintenant, TZ Europe/Paris), type de repas (petit-déjeuner/déjeuner/dîner/collation), soit saisie libre kcal+macros, soit sélection d'un aliment (recherche locale `food_items` + OFF + CIQUAL) × quantité. Duplication d'un repas précédent ("répéter hier") = fonctionnalité à fort ROI. +2. **Recherche produit** : endpoint backend `GET /api/v1/foods/search?q=...` qui interroge (a) le cache local `food_items`, (b) CIQUAL local, (c) Open Food Facts (Search-a-licious + fallback code-barres direct), fusionne et met en cache. Scan de code-barres possible plus tard côté PWA (caméra) → `GET /api/v1/foods/barcode/{ean}`. +3. **Importeurs CSV** via le framework d'import générique du projet : profils `foodvisor`, `myfitnesspal`, `cronometer` (+ `generic_nutrition_csv` avec mapping manuel). Pipeline : upload → détection encodage/délimiteur → proposition de mapping (profil) → prévisualisation → validation → insertion idempotente. +4. **Endpoint d'ingestion REST** pour le bridge Health Connect (voir 7.4) — même endpoint générique que pour les pas/poids, avec le type `nutrition`. + +### 7.2 Modèle de données proposé (SQLAlchemy, PostgreSQL) + +Deux tables, séparant **référentiel d'aliments** et **journal** : + +```text +food_items # referential/cache of foods + id UUID PK + source TEXT NOT NULL -- 'off' | 'ciqual' | 'custom' + source_id TEXT -- OFF barcode | ciqual alim_code | NULL + name TEXT NOT NULL -- French label preferred + brand TEXT + energy_kcal_100g NUMERIC + protein_g_100g NUMERIC + carbs_g_100g NUMERIC + sugar_g_100g NUMERIC + fat_g_100g NUMERIC + sat_fat_g_100g NUMERIC + fiber_g_100g NUMERIC + salt_g_100g NUMERIC -- salt = sodium * 2.5 + serving_size_g NUMERIC + raw_payload JSONB -- full OFF/CIQUAL record + UNIQUE (source, source_id) + +nutrition_entries # the journal (one row = one food eaten OR one whole meal) + id UUID PK + user_id UUID FK + eaten_at TIMESTAMPTZ NOT NULL -- stored UTC + meal_type TEXT NOT NULL -- 'breakfast'|'lunch'|'dinner'|'snack'|'unknown' + label TEXT -- free-text name + food_item_id UUID FK NULL -- when picked from referential + quantity_g NUMERIC NULL + energy_kcal NUMERIC NOT NULL -- always denormalized at entry level + protein_g NUMERIC + carbs_g NUMERIC + sugar_g NUMERIC + fat_g NUMERIC + sat_fat_g NUMERIC + fiber_g NUMERIC + sodium_mg NUMERIC + source TEXT NOT NULL -- 'manual'|'import:foodvisor'|'import:mfp'|'import:cronometer'|'healthconnect' + source_record_id TEXT -- HC metadata.id, or import row hash + source_app TEXT -- HC dataOrigin.packageName + import_batch_id UUID FK NULL + UNIQUE (user_id, source, source_record_id) +``` + +- Unités stockées : **kcal, grammes, sodium en mg** (convention affichage FR) — conversions faites à l'ingestion (Health Connect fournit tout en g/kcal, cf. 3.2). +- Le bilan énergétique quotidien (kcal in) = `SUM(energy_kcal)` groupé par jour **en Europe/Paris** (conversion au moment de la requête, pas au stockage). +- **Déduplication inter-sources** : contrainte unique `(user_id, source, source_record_id)` pour l'idempotence intra-source ; pour l'inter-sources (ex. un repas présent à la fois dans le CSV Foodvisor et via Health Connect), règle v1 simple : l'UI signale les jours où deux sources se chevauchent (>1 source sur la même journée) et propose de masquer une source par plage de dates (`source_priority` par jour). Ne pas tenter de matching flou par entrée en v1. + +### 7.3 Framework d'import CSV — exigences pour la nutrition + +- Détection : BOM/UTF-8/Windows-1252, délimiteur `,`/`;`/tab, format de date (`YYYY-MM-DD`, `DD/MM/YYYY`, ISO 8601 avec/sans offset — sans offset ⇒ supposer Europe/Paris). +- Profils = fichiers de mapping déclaratifs (JSON/YAML versionnés dans le repo) : `column → field`, transformations (`comma_decimal`, `meal_type_map` FR/EN : `Petit-déjeuner→breakfast`, etc.), granularité (`per_food_row` pour Cronometer `servings.csv`, `per_meal_row` pour MFP, `per_entry_row` pour Foodvisor). +- Idempotence : `source_record_id = SHA-256(user_id + source + date + meal + label + kcal)` en absence d'ID natif ; ré-import du même fichier = 0 doublon. +- Chaque import crée un `import_batch` (annulable en bloc — indispensable vu l'instabilité des formats). + +### 7.4 Endpoint d'ingestion Health Connect (bridge) + +`POST /api/v1/ingest/nutrition` (JWT device token, scope `ingest`) — schéma aligné sur `NutritionRecord` : + +```json +{ + "records": [ + { + "source_record_id": "hc-uuid-from-metadata-id", + "source_app": "io.foodvisor.foodvisor", + "start_time": "2026-08-13T11:45:00Z", + "end_time": "2026-08-13T12:15:00Z", + "meal_type": "lunch", + "name": "Salade poulet", + "energy_kcal": 620.0, + "protein_g": 42.1, + "total_carbohydrate_g": 51.0, + "sugar_g": 8.2, + "total_fat_g": 24.3, + "saturated_fat_g": 6.1, + "dietary_fiber_g": 7.0, + "sodium_g": 1.1 + } + ] +} +``` + +Réponse : `{"accepted": n, "duplicates": m, "rejected": [...]}` — upsert sur `(user_id, 'healthconnect', source_record_id)`. Le même pattern (`/ingest/steps`, `/ingest/weight`, ...) sert tout le bridge Health Connect. Côté bridge : demander `READ_NUTRITION` + `READ_HEALTH_DATA_HISTORY`, sync incrémentale par Changes API, batch de 100-500 records. + +### 7.5 Garder un futur connecteur Foodvisor possible + +1. **Architecture connecteurs** : chaque source = classe `Connector` (métadonnées, capacités `file_import`/`api_pull`/`push`, mapping). `FoodvisorCsvConnector` (v1, `file_import`) et un éventuel `FoodvisorApiConnector` (si une API apparaît) partagent le même normaliseur `FoodvisorRecordNormalizer`. +2. **Interface `PhotoAnalyzer`** (cf. 2.3) : port d'extension pour Foodvisor Vision ou tout autre service d'analyse de photo. +3. **Chemin sans code** : si le test Health Sync (2.4) réussit, les données Foodvisor arrivent déjà par le canal Health Connect avec `source_app` identifiable. +4. **Veille** : re-vérifier périodiquement (a) l'apparition d'un support Health Connect dans l'app Foodvisor Android (fiche Play Store, permissions `android.permission.health.WRITE_NUTRITION`), (b) la résurrection de `vision.foodvisor.io`. + +--- + +## 8. Checklist pour les agents d'implémentation + +- [ ] Table `food_items` + import CIQUAL (script one-shot, gestion `traces`/`< x`/virgule décimale). +- [ ] Proxy OFF : client httpx avec `User-Agent: LifeTrack/1.0 (meejayproduction@gmail.com)`, cache DB, throttle 10 req/min, staging `.net` pour les tests. +- [ ] `GET /foods/search`, `GET /foods/barcode/{ean}` ; UI de saisie manuelle FR (types de repas : Petit-déjeuner, Déjeuner, Dîner, Collation). +- [ ] Framework import CSV + profils `cronometer` (le plus simple, commencer par lui), `myfitnesspal`, `foodvisor`, `generic_nutrition_csv` ; éditeur de mapping ; batches annulables. +- [ ] `POST /api/v1/ingest/nutrition` idempotent (+ doc French pour configurer HCGateway/webhook en attendant le bridge maison). +- [ ] Attributions UI : « Open Food Facts (ODbL) », « ANSES — Table Ciqual ». +- [ ] Doc utilisateur FR : procédure d'export Foodvisor in-app + modèle d'e-mail RGPD à `data@foodvisor.io`. + +## 9. Sources + +- Guide export Foodvisor (FR) : https://nutrola.app/fr/blog/how-to-export-data-from-foodvisor ; migration : https://nutrola.app/en/blog/migrating-from-foodvisor-how-to-import-data +- Politique de confidentialité Foodvisor : https://www.foodvisor.io/en/privacy-policy/ ; support privacy : https://foodvisor.zendesk.com/hc/en-us/categories/360002566060-Privacy +- Foodvisor Vision API : https://www.foodvisor.io/en/vision/ ; https://vision.foodvisor.io/docs (DNS KO août 2026) ; https://github.com/api-evangelist/foodvisor ; https://apis.io/providers/foodvisor/ +- Foodvisor ↔ Google Fit (fil support) : https://support.google.com/fit/thread/327566968 +- Health Connect `NutritionRecord` : https://developer.android.com/reference/androidx/health/connect/client/records/NutritionRecord ; https://developer.android.com/reference/android/health/connect/datatypes/NutritionRecord ; nutrition data types : https://developers.google.com/health/data-types/nutrition ; écriture : https://developer.android.com/health-and-fitness/health-connect/write-data +- Bridges : https://github.com/ShuchirJ/HCGateway ; https://github.com/mcnaveen/health-connect-webhook ; https://healthsync.app/ +- Open Food Facts API : https://openfoodfacts.github.io/openfoodfacts-server/api/ ; recherche : https://search.openfoodfacts.org +- CIQUAL : https://ciqual.anses.fr ; https://www.data.gouv.fr/datasets/table-de-composition-nutritionnelle-des-aliments-ciqual-2020 ; doc table 2025 : https://ciqual.anses.fr/cms/sites/default/files/inline-files/Table%20Ciqual%202025%20doc%20FR_2025_11_19.pdf ; dépôt 2025 : https://entrepot.recherche.data.gouv.fr/dataset.xhtml?persistentId=doi:10.57745/RDMHWY +- MyFitnessPal export : https://support.myfitnesspal.com/hc/en-us/articles/360032273352-Data-Export-FAQs ; https://quantifiedself.com/blog/access-export-myfitnesspal-data/ +- Cronometer export : https://support.cronometer.com/hc/en-us/articles/360018760151-Account-Settings ; forums : https://forums.cronometer.com/discussion/460/exporting-data +- Yazio : https://nutrola.app/en/blog/how-to-export-data-from-yazio ; https://github.com/aleksandr-bogdanov/yazio-exporter ; https://github.com/tobintax/yazio-csv-exporter