Initial import: LifeTrack v1 (santé, vape, finances)

Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de
pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et
finances personnelles avec import de relevés bancaires.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 10:48:57 +02:00
co-authored by Claude Opus 5
commit 93f0689c1e
273 changed files with 80746 additions and 0 deletions
+33
View File
@@ -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"]
+61
View File
@@ -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)
}
}
+13
View File
@@ -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;
+24
View File
@@ -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"]