diff --git a/.github/workflows/metabase-events.yml b/.github/workflows/metabase-events.yml
new file mode 100644
index 00000000000..2ab7457bfc4
--- /dev/null
+++ b/.github/workflows/metabase-events.yml
@@ -0,0 +1,113 @@
+name: Metabase events
+
+# Deux jobs :
+# - drift-check (PR) : fail si docs/events.md est desynchronise avec le code.
+# - wiki-sync (push sur dev/master) : regenere les docs et pousse le
+# glossaire sur le wiki GitHub (pages : Matomo-Events, Metabase-Materialized-Views,
+# Metabase-Dashboards, Metabase-Schema, Metabase-Models, Home).
+#
+# Prerequis wiki : le wiki doit avoir ete initialise au moins une fois
+# manuellement (creer la premiere page via l'UI GitHub).
+
+on:
+ push:
+ branches:
+ - dev
+ - master
+ paths:
+ - "packages/metabase/events/**"
+ - "packages/metabase/docs/**"
+ - "packages/code-du-travail-frontend/src/modules/**/*.ts"
+ - "packages/code-du-travail-frontend/src/modules/**/*.tsx"
+ - ".github/workflows/metabase-events.yml"
+ pull_request:
+ paths:
+ - "packages/metabase/**"
+ - "packages/code-du-travail-frontend/src/modules/**/*.ts"
+ - "packages/code-du-travail-frontend/src/modules/**/*.tsx"
+ - ".github/workflows/metabase-events.yml"
+ workflow_dispatch:
+
+concurrency:
+ group: metabase-events-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ drift-check:
+ name: Drift check (docs/events.md synchronise)
+ if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .node-version
+ - uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+ - run: pnpm install --filter @cdt/metabase --frozen-lockfile
+ - run: pnpm -F @cdt/metabase events:check
+
+ wiki-sync:
+ name: Sync docs Metabase vers le wiki
+ if: github.event_name == 'push'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .node-version
+ - uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+ - run: pnpm install --filter @cdt/metabase --frozen-lockfile
+ - run: pnpm -F @cdt/metabase events:docs
+
+ - name: Clone wiki repo
+ env:
+ WIKI_URL: https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.wiki.git
+ run: |
+ if ! git clone "$WIKI_URL" wiki 2>clone-err.log; then
+ echo "::warning::Impossible de cloner le wiki. Verifie qu'il est initialise (creer une premiere page via l'UI GitHub)."
+ cat clone-err.log
+ exit 1
+ fi
+
+ - name: Copy docs to wiki pages
+ run: |
+ cp packages/metabase/docs/events.md wiki/Matomo-Events.md
+ cp packages/metabase/docs/materialized-views.md wiki/Metabase-Materialized-Views.md
+ cp packages/metabase/docs/dashboards.md wiki/Metabase-Dashboards.md
+ cp packages/metabase/docs/schema.md wiki/Metabase-Schema.md
+ cp packages/metabase/docs/models.md wiki/Metabase-Models.md
+ cat > wiki/Home.md <<'EOF'
+ # Metabase & Events Matomo — CDTN
+
+ Ce wiki est **auto-genere** depuis `packages/metabase/` a chaque push sur `dev` / `master`.
+ Ne pas editer directement : modifier les fichiers source dans le repo.
+
+ ## Pages
+
+ - [Matomo-Events](./Matomo-Events) — glossaire des events emis par le frontend.
+ - [Metabase-Dashboards](./Metabase-Dashboards) — reference des dashboards et cartes.
+ - [Metabase-Materialized-Views](./Metabase-Materialized-Views) — vues materialisees.
+ - [Metabase-Models](./Metabase-Models) — modeles Metabase et patterns SQL.
+ - [Metabase-Schema](./Metabase-Schema) — schema de la DB OVH PG CDTN.
+ EOF
+
+ - name: Commit and push to wiki
+ working-directory: wiki
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add -A
+ if git diff --cached --quiet; then
+ echo "Aucun changement a pousser."
+ exit 0
+ fi
+ git commit -m "chore(metabase): sync docs depuis ${{ github.sha }}"
+ git push
diff --git a/.gitignore b/.gitignore
index c8b2d36e3d4..dbeeca8bfe4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,4 +13,5 @@ lerna-debug.log
packages/code-du-travail-modeles/bin
packages/code-du-travail-modeles/lib
token
-.lighthouseci
\ No newline at end of file
+.lighthouseci
+.mcp.json
\ No newline at end of file
diff --git a/.mcp.example.json b/.mcp.example.json
new file mode 100644
index 00000000000..679f67a33fa
--- /dev/null
+++ b/.mcp.example.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://modelcontextprotocol.io/schema/mcp.json",
+ "mcpServers": {
+ "metabase": {
+ "command": "npx",
+ "args": ["@easecloudio/mcp-metabase-server"],
+ "env": {
+ "METABASE_URL": "https://metabase-cdtn.fabrique.social.gouv.fr",
+ "METABASE_API_KEY": "your_api_key_here"
+ }
+ }
+ }
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000000..72ca60974de
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,32 @@
+# CLAUDE.md — Code du Travail Numerique
+
+Monorepo pnpm + lerna. Point d'entree pour les IA agents. Chaque package a son propre `CLAUDE.md` avec les regles specifiques.
+
+## Stack
+
+- **Node 24** (`.node-version`), **pnpm 10** (`packageManager`), **lerna 9**.
+- Packages dans `packages/*` — voir leur `CLAUDE.md` pour les details.
+
+## Commandes racine
+
+```bash
+pnpm install # install toutes les deps
+pnpm build # build tous les packages
+pnpm test # tests tous les packages
+pnpm lint # lint tous les packages
+pnpm format # prettier
+pnpm type-check # tsc
+```
+
+## Conventions
+
+- **Code** : voir [`BEST_PRACTICE.md`](BEST_PRACTICE.md).
+- **Commits / PR** : `feat:`, `fix:`, `chore:`, `docs:`. PR squashees sur `dev`, promues vers `master`.
+- **Branche** : forker depuis `dev`. `master` = production.
+- **Precommit** : `husky` + `lint-staged`.
+- **CI** : `.github/workflows/`.
+
+## Ressources
+
+- Site prod :
+- Issues :
diff --git a/packages/code-du-travail-frontend/CLAUDE.md b/packages/code-du-travail-frontend/CLAUDE.md
new file mode 100644
index 00000000000..98384d8b224
--- /dev/null
+++ b/packages/code-du-travail-frontend/CLAUDE.md
@@ -0,0 +1,42 @@
+# @cdt/frontend — consignes Claude
+
+App Next.js (App Router) du CDTN.
+
+## Stack
+
+- **Next.js** (App Router) — `app/`, `next.config.mjs`
+- **React** + **TypeScript strict**
+- **@codegouvfr/react-dsfr** — Design System de l'Etat
+- **Panda CSS** — `panda.config.ts`
+- **Zustand + Immer** — state des simulateurs (`modules/outils/*/store/`)
+- **Elasticsearch** — moteur de recherche (`src/api/modules/search/`)
+- **@socialgouv/matomo-next** — tracking (voir `packages/metabase/events/CLAUDE.md`)
+- **Sentry + OpenTelemetry** — monitoring
+- **Jest** + **Playwright** — tests unit / e2e
+- **Publicodes** (via `@socialgouv/modeles-social`) — moteur de regles simulateurs
+
+## Commandes
+
+```bash
+pnpm dev # serveur Next.js
+pnpm build # build
+pnpm test # tests
+pnpm test:e2e # Playwright
+pnpm lint
+pnpm type-check
+```
+
+## Architecture
+
+`src/modules//` :
+
+- `tracking.ts` / `tracking.tsx` → hooks `useXxxTracking()` wrappant `sendEvent` ou `push` de matomo-next.
+- `store/` (zustand), `components/`, `events/`, `__tests__/`.
+
+Voir [`BEST_PRACTICE.md`](../../BEST_PRACTICE.md) pour les conventions de nommage et de test.
+
+## Regles
+
+- **Pas de secret dans le code** (clefs API, tokens). Tout passe par `.env` (gitignore).
+- **Toujours passer par un hook `useXxxTracking()`** pour fire un event Matomo — jamais `sendEvent` direct dans un composant.
+- **Pour tout ajout/modif d'event Matomo** : voir `packages/metabase/events/CLAUDE.md` (le pipeline `events:check` bloque la PR en cas de drift).
diff --git a/packages/code-du-travail-modeles/CLAUDE.md b/packages/code-du-travail-modeles/CLAUDE.md
new file mode 100644
index 00000000000..f1e93252727
--- /dev/null
+++ b/packages/code-du-travail-modeles/CLAUDE.md
@@ -0,0 +1,37 @@
+# @socialgouv/modeles-social — consignes Claude
+
+Moteur de regles **publicodes** pour les simulateurs CDTN.
+
+## Stack
+
+- **Publicodes** (fichiers `.yaml` dans `src/modeles/` et `src/publicodes/`)
+- **TypeScript** — API programmatique
+- **Jest** — tests
+- **tsup** — bundler
+
+## Structure
+
+```
+src/
+├── __test__/ tests par theme (legal + CC specifiques)
+├── internal/ wrappers TS autour des engines publicodes
+├── modeles/ regles publicodes par simulateur
+├── publicodes/ wrappers haut niveau par simulateur
+└── index.ts exports publics
+```
+
+Voir [`BEST_PRACTICE.md`](BEST_PRACTICE.md) pour la structure des tests.
+
+## Commandes
+
+```bash
+pnpm -F @socialgouv/modeles-social test
+pnpm -F @socialgouv/modeles-social build
+```
+
+## Regles
+
+- **Pas de side-effect** : pas de reseau, pas de DOM, pas de tracking ici. Le package doit tourner cote serveur et client.
+- **Pas d'import de `@cdt/frontend`** (cycle) ni de `matomo-next`.
+- **Toute modification de regle** doit etre couverte par un test dans `src/__test__/`.
+- **Les references legales** (code du travail, CC) sont obligatoires dans chaque regle — validees par les `references.spec.ts`.
diff --git a/packages/code-du-travail-utils/CLAUDE.md b/packages/code-du-travail-utils/CLAUDE.md
new file mode 100644
index 00000000000..adf6d596355
--- /dev/null
+++ b/packages/code-du-travail-utils/CLAUDE.md
@@ -0,0 +1,18 @@
+# @socialgouv/cdtn-utils — consignes Claude
+
+Utilitaires partages **purs** (pas de side-effects) consommes par `@cdt/frontend` et `@socialgouv/modeles-social`.
+
+## Commandes
+
+```bash
+pnpm -F @socialgouv/cdtn-utils test
+pnpm -F @socialgouv/cdtn-utils build
+```
+
+## Regles
+
+- **100% pur** : pas de reseau, pas de DOM, pas de side-effect. Doit tourner cote serveur et client.
+- **Pas de dep lourde** : le bundle doit rester leger (importe partout).
+- **Pas d'import de `react`, `next`, `matomo-next`, `publicodes`** — casserait la reutilisabilite.
+- **Pas de metier specifique** (regles CC, etc.) : ca va dans `modeles-social` ou le frontend.
+- **Test unitaire obligatoire** pour chaque fonction exposee dans `index.ts`.
diff --git a/packages/metabase/CLAUDE.md b/packages/metabase/CLAUDE.md
new file mode 100644
index 00000000000..83e89e8959e
--- /dev/null
+++ b/packages/metabase/CLAUDE.md
@@ -0,0 +1,300 @@
+# @cdt/metabase — consignes Claude
+
+**Hub central** de tout ce qui concerne Metabase pour le CDTN :
+
+- definitions des vues materialisees (`sql/`) — source de verite git-tracked
+- glossaire auto-genere des events Matomo (`docs/events.md` + pipeline dans `events/`)
+- docs de reference (dashboards, cards, schema, patterns SQL) dans `docs/`
+- config MCP pour interroger l'instance Metabase (`.mcp.json` a la racine du repo)
+
+## Contexte
+
+Dashboards Metabase pour le CDTN trackant les KPI de personnalisation des contenus.
+
+- Issue principale : SocialGouv/code-du-travail-numerique#7199
+- Instance : `https://metabase-cdtn.fabrique.social.gouv.fr`
+- Database cible : `OVH PG CDTN` (id 4, PostgreSQL)
+
+### Commandes pour decouvrir l'etat courant
+
+Toutes les listes specifiques (cards, dashboards, MV, tables) sont **volatiles**. Ne les hardcode pas dans les docs : requete les en direct.
+
+```bash
+# Lister les databases Metabase (pour trouver l'ID de la DB CDTN)
+curl -s -H "X-API-Key: $METABASE_API_KEY" "$METABASE_URL/api/database" | jq '.data[] | {id, name, engine}'
+
+# Lister les dashboards
+curl -s -H "X-API-Key: $METABASE_API_KEY" "$METABASE_URL/api/dashboard" | jq '.[] | {id, name, collection_id}'
+
+# Inspecter une carte (SQL, parametres, visualisation)
+curl -s -H "X-API-Key: $METABASE_API_KEY" "$METABASE_URL/api/card/" | jq
+
+# Lister les vues materialisees sur la DB
+curl -s -X POST -H "X-API-Key: $METABASE_API_KEY" -H "Content-Type: application/json" \
+ -d '{"type":"native","database":4,"native":{"query":"SELECT matviewname FROM pg_matviews ORDER BY 1;"}}' \
+ "$METABASE_URL/api/dataset" | jq '.data.rows'
+
+# Verifier la fraicheur d'une MV
+curl -s -X POST -H "X-API-Key: $METABASE_API_KEY" -H "Content-Type: application/json" \
+ -d '{"type":"native","database":4,"native":{"query":"SELECT MAX(action_timestamp)::date AS max_date, CURRENT_DATE - MAX(action_timestamp)::date AS lag_days FROM metabase_model_106;"}}' \
+ "$METABASE_URL/api/dataset" | jq '.data.rows'
+```
+
+Via MCP (Claude Code, Cursor, Windsurf avec `.mcp.json` configure) : `list_databases`, `list_dashboards`, `list_cards`, `get_card`, `execute_query`.
+
+## Avant de toucher quoi que ce soit
+
+1. **Lire ce fichier** (CLAUDE.md) entierement : regles SQL, process card, anti-patterns, sources de donnees.
+2. **Lire `docs/materialized-views.md`** : source de verite des MV (schemas, definitions, ordre de refresh).
+3. **Pour les events Matomo** : lire `docs/events.md` (auto-genere). Si manquant ou stale → `pnpm -F @cdt/metabase events:docs`.
+
+## Regles absolues
+
+- **Ne jamais editer `docs/events.md` a la main.** Il est regenere par `events/generate-events-doc.ts`. Pour modifier la description d'un event → editer `events/events.metadata.yaml`.
+- **Toujours archiver** (pas supprimer) une carte obsolete : `POST /api/card/:id` avec `archived: true`. La carte va dans la corbeille Metabase et reste restaurable ; Metabase conserve en plus l'historique des revisions (onglet "History" de la carte). Pas besoin de backup local.
+- **Toujours verifier la fraicheur** des MV avant d'investiguer un "0 rows" (cf. §Process ci-dessous, point 2).
+- **Toujours mettre a jour la doc** quand on modifie une MV, une card ou le code qui fire des events (cf. §Process point 7).
+
+## Map du package
+
+| Chemin | Role | Maintenance |
+| ------------------------------- | -------------------------------------------------------- | -------------------------------------------- |
+| `CLAUDE.md` (ce fichier) | regles IA, process card, sources de donnees, conventions | manuel |
+| `README.md` | installation, MCP | manuel |
+| `docs/materialized-views.md` | definitions et patterns des MV | manuel (synchro avec `sql/`) |
+| `docs/dashboards.md` | reference dashboards et cartes | manuel |
+| `docs/models.md` | models Metabase et patterns SQL | manuel |
+| `docs/schema.md` | schema DB OVH PG CDTN | manuel |
+| `docs/events.md` | **glossaire exhaustif des events Matomo** | **AUTO-GENERE** — ne jamais editer a la main |
+| `events/events.metadata.yaml` | description metier des events | manuel (source pour `docs/events.md`) |
+| `events/events.extracted.json` | ground truth extraite du code frontend | auto-genere par `pnpm events:extract` |
+| `events/events.schema.ts` | types TS partages du pipeline events | manuel, rare |
+| `events/extract-events.ts` | AST scan des tracking.ts → `events.extracted.json` | manuel, rare |
+| `events/generate-events-doc.ts` | joint extracted + metadata → `docs/events.md` | manuel, rare |
+| `events/check-events-drift.ts` | drift check (precommit + CI) | manuel, rare |
+| `sql/` | DDL des MV custom | manuel, source de verite versionnee |
+
+Chaque sous-dossier a son propre `CLAUDE.md`. Pour "ou je trouve X", commencer par `CLAUDE.md` du dossier concerne.
+
+## Process avant TOUTE modification ou creation de carte
+
+> Regle absolue : **les requetes ad-hoc sur `metabase_model_106` (gros volume) sont lentes et fragiles**. Elles font systematiquement du Seq Scan. Toujours preferer une MV / un modele existant.
+
+1. **LIRE `docs/materialized-views.md` ET `docs/models.md` EN PREMIER**. Identifier la MV ou la card-modele dediee au cas d'usage avant d'ecrire une seule ligne de SQL.
+2. **Verifier la fraicheur des sources** (commande §"Commandes pour decouvrir l'etat courant" ci-dessus). Si la MV est en retard, signaler le besoin de refresh AVANT d'investiguer un "0 rows".
+3. **Si une MV existe pour ton cas d'usage** : l'utiliser. Si une etape ou un evenement manque, ajouter ce qui manque a la definition (`sql/mv_*.sql`) puis DROP/CREATE la MV - ne JAMAIS bypass la MV en attaquant `metabase_model_106` directement.
+4. **Si AUCUNE MV ne convient** : creer une nouvelle MV dediee dans `sql/mv_*.sql`, l'appliquer via `/api/dataset` (cf. tip ci-dessous), creer son index, puis pointer la carte dessus.
+5. **Pour remplacer une carte en prod** : creer la nouvelle version, la tester, puis **archiver l'ancienne via `archived: true`**. Ne jamais ecrire "par dessus" une carte en prod sans historique : l'onglet "History" de Metabase + la corbeille sont le canal de rollback.
+6. **Apres tout PUT sur une carte, RE-RUN la carte** (`POST /api/card/:id/query`) et verifier que `rows > 0` et que `cols` correspond aux colonnes attendues. Une carte qui parse mais retourne 0 ligne est souvent un probleme de fraicheur (point 2).
+7. **Apres toute modification d'une MV ou d'une carte, METTRE A JOUR LES DOCS** :
+ - `docs/materialized-views.md` (ajout/maj de section, schema, refresh)
+ - `docs/models.md` (chaines de cards, patterns)
+ - `docs/dashboards.md` (table des cartes par dashboard)
+ - `README.md` si la liste des dashboards/MV change
+
+### Tips API Metabase
+
+- **`POST /api/dataset` accepte les DDL** (DROP, CREATE, REFRESH MATERIALIZED VIEW, CREATE INDEX) **malgre une erreur "L'instruction Select n'a pas produit un ResultSet"**. La DDL est executee cote DB avant que Metabase ne plante en essayant de lire un ResultSet inexistant. Verifier le resultat avec un `SELECT` sur `pg_matviews` / `pg_indexes` / `SELECT MAX(...)`.
+- **`REFRESH MATERIALIZED VIEW` sur une grosse MV** peut depasser le timeout HTTP de `/api/dataset`. Dans ce cas il faut un acces direct psql ou une tache planifiee cote infra.
+- **PUT sur une carte avec parametres** (`type=date/single`, `required=true`) : bien synchroniser les `template-tags` du `dataset_query.native` avec le tableau `parameters` au niveau de la carte (meme `id`, meme `slug`).
+- **Archiver une carte** : `PUT /api/card/:id` avec `{"archived": true}`. Restauration via l'UI Metabase (Corbeille → Unarchive) ou `PUT` avec `archived: false`.
+- **Creer une carte** : `POST /api/card` avec `collection_id`, `name`, `display`, `dataset_query`, `visualization_settings`.
+
+### Convention "Custom date range" - parametres `date_debut` / `date_fin`
+
+Toute nouvelle carte qui presente une metrique sur une fenetre temporelle (taux, comptage, ratio) **doit** etre parametree avec deux dates pour permettre a l'utilisateur final de zoomer/comparer des periodes sans toucher au SQL.
+
+#### Pattern SQL
+
+```sql
+WITH visits AS (
+ SELECT *
+ FROM mv_xxx
+ WHERE jour >= {{date_debut}}
+ AND jour <= {{date_fin}}
+)
+SELECT ... FROM visits ...;
+```
+
+#### Pattern `template-tags` (dans `dataset_query.native`)
+
+```json
+"template-tags": {
+ "date_debut": {
+ "id": "-date-debut-001",
+ "name": "date_debut",
+ "display-name": "Date début",
+ "type": "date",
+ "required": true,
+ "default": ""
+ },
+ "date_fin": {
+ "id": "-date-fin-001",
+ "name": "date_fin",
+ "display-name": "Date fin",
+ "type": "date",
+ "required": true,
+ "default": ""
+ }
+}
+```
+
+#### Pattern `parameters` (au niveau de la carte)
+
+```json
+"parameters": [
+ {
+ "id": "-date-debut-001",
+ "type": "date/single",
+ "target": ["variable", ["template-tag", "date_debut"]],
+ "name": "Date début",
+ "slug": "date_debut",
+ "default": "",
+ "required": true
+ },
+ {
+ "id": "-date-fin-001",
+ "type": "date/single",
+ "target": ["variable", ["template-tag", "date_fin"]],
+ "name": "Date fin",
+ "slug": "date_fin",
+ "default": "",
+ "required": true
+ }
+]
+```
+
+#### Regles
+
+1. **Les `id` doivent etre uniques par carte** (prefixer avec un slug explicite, ex: `bounce-global-date-debut-001`). Si le meme `id` est reutilise sur plusieurs cartes, Metabase lit/ecrit dans la mauvaise carte au moment du PUT.
+2. **Les `id` doivent etre identiques entre `template-tags[X].id` et `parameters[X].id`** sur la meme carte.
+3. **`required: true`** : sinon la requete echoue avec "value missing" si l'utilisateur n'envoie pas le param.
+4. **Default = derniers 30 jours** par convention.
+5. **Ordre du SQL** : `WHERE jour >= {{date_debut}} AND jour <= {{date_fin}}` (inclusif des deux cotes). Eviter `BETWEEN` pour rester explicite.
+6. **Dashboard parameters** : pour un widget "range" combine, utiliser un parametre dashboard `date/range` mappe sur `date_debut` ET `date_fin`.
+
+### Anti-patterns
+
+- Ecrire `SELECT ... FROM metabase_model_106 WHERE pathname = '...' AND action_eventname IN (...)` sans avoir verifie qu'il n'existe pas deja une MV pour ces filtres -> **toujours lent**.
+- Ecraser une carte en prod sans archivage prealable de l'ancienne version.
+- Modifier une MV sans mettre a jour `materialized-views.md` ni `sql/mv_*.sql`.
+- Editer `docs/events.md` a la main (auto-genere).
+- Dupliquer l'etat courant (listes de cards, volumes, lag) dans les docs : ces chiffres drifent tous les jours. Preferer pointer vers une commande MCP / `curl`.
+
+## Regles de performance SQL
+
+1. **Toujours filtrer sur `action_timestamp`** - la table source est partitionnee hebdomadairement.
+2. **Preferer une MV dediee** plutot que `metabase_model_106` brut.
+3. **Utiliser `metabase_model_106`** UNIQUEMENT en dernier recours pour les 12 derniers mois (pathname, path_level2, path_level3 deja calcules).
+4. **Utiliser `visites_uniques`** pour les comptages de visites par page/mois.
+5. **Ne PAS utiliser `matomo_partitioned` directement** sauf si donnees > 1 an necessaires OU si on veut une MV temps-reel (cf. `mv_funnel_il_irc_visits`, `mv_bounce_contributions`).
+6. **`path_level2`** pour filtrer par type de contenu : `'contribution'` ou `'outils'`.
+7. **`month`** colonne pre-calculee au lieu de `DATE_TRUNC('month', action_timestamp)`.
+
+## Sources de donnees (vue d'ensemble)
+
+| Source | Type | Fenetre | Dependance |
+| --------------------------- | ------------------------------- | --------------------------- | -------------------- |
+| `matomo_partitioned` | table partitionnee hebdomadaire | illimitee | source brute |
+| `metabase_model_106` | MV | 12 derniers mois | `matomo_partitioned` |
+| `visites_uniques` | MV | 13 derniers mois | `metabase_model_106` |
+| `mv_perso_weekly` | MV hebdomadaire | periode couverte par source | `metabase_model_106` |
+| `mv_kpi_personnalisation` | MV (DROP/CREATE) | 12 derniers mois | `metabase_model_106` |
+| `mv_cc_non_traitees` | MV statique | 2025 | `matomo_partitioned` |
+| `mv_funnel_il_irc` | MV hebdomadaire | 12 derniers mois | `metabase_model_106` |
+| `mv_funnel_il_irc_visits` | MV par visite (temps reel) | 60 jours glissants | `matomo_partitioned` |
+| `mv_bounce_contributions` | MV par (visite, contribution) | 60 jours glissants | `matomo_partitioned` |
+| `commentaires_utilisateurs` | MV | 13 derniers mois | `matomo_partitioned` |
+
+> Les MV **independantes de `metabase_model_106`** (`mv_funnel_il_irc_visits`, `mv_bounce_contributions`, `commentaires_utilisateurs`, `mv_cc_non_traitees`) restent a jour meme si la MV source est figee. C'est volontaire : les cartes "temps reel" ne doivent pas etre bloquees par le retard de la MV source.
+
+Pour les volumes exacts et les schemas detailles : `docs/materialized-views.md`.
+Pour la liste des cartes qui consomment chaque MV : `docs/dashboards.md`.
+
+## Evenements Matomo
+
+> **Source de verite : `docs/events.md`** (auto-genere). Ne JAMAIS editer ce fichier a la main : il est reecrit par `pnpm -F @cdt/metabase events:docs`.
+
+### Pipeline
+
+```text
+packages/code-du-travail-frontend/src/modules/**/*.{ts,tsx} (sauf __tests__/)
+ | (AST scan via ts-morph)
+ | detecte :
+ | - sendEvent({ category, action, name? }) [custom events]
+ | - push(["trackEvent"|"trackSiteSearch"|...], ...) [events Matomo natifs]
+ | - _paq.push([...]) / paq.push([...]) [events + config]
+ v
+events/events.extracted.json ← ground truth technique (category, action, fichier:ligne)
+ +
+events/events.metadata.yaml ← description metier (label, trigger, KPI, dashboards, cards)
+ | (join + groupement par feature_group, avec tracking_method)
+ v
+docs/events.md ← glossaire lisible + section "Commandes Matomo (non-events)"
+```
+
+### Process "j'ajoute / modifie / supprime un event"
+
+1. Modifier le `sendEvent({...})` dans le frontend.
+2. `pnpm -F @cdt/metabase events:docs` → regarder si l'event apparait en "Orphelins" ou en "Metadata orpheline" dans `docs/events.md`.
+3. Editer `events/events.metadata.yaml` pour documenter le nouvel event (cle `":"`), ou supprimer la cle qui n'existe plus.
+4. Relancer `pnpm events:docs` jusqu'a obtenir 0 orphelin (ou des orphelins dynamiques documentes via wildcard `:*`).
+5. Commit `events.metadata.yaml`, `events.extracted.json`, `docs/events.md` ensemble.
+
+## Refresh des MV
+
+Planification cron **geree cote infra** (Kubernetes CronJob / `pg_cron`) — hors perimetre dev. Voir `docs/materialized-views.md` §"Ordre de refresh des MV" pour le detail.
+
+Resume rapide :
+
+```sql
+-- 1. MV source principale (lente, ~minutes - declenche la stale-trace de toutes les MV dependantes)
+REFRESH MATERIALIZED VIEW metabase_model_106;
+
+-- 2. mv_kpi_personnalisation : DROP + CREATE (schema fixe, voir sql/mv_kpi_personnalisation.sql)
+
+-- 3. MV simples (REFRESH, rapide)
+REFRESH MATERIALIZED VIEW visites_uniques;
+REFRESH MATERIALIZED VIEW mv_perso_weekly;
+REFRESH MATERIALIZED VIEW mv_funnel_il_irc; -- depend de metabase_model_106
+REFRESH MATERIALIZED VIEW mv_funnel_il_irc_visits; -- INDEPENDANT (source matomo_partitioned), cron quotidien
+REFRESH MATERIALIZED VIEW mv_bounce_contributions; -- INDEPENDANT (source matomo_partitioned), cron quotidien
+REFRESH MATERIALIZED VIEW commentaires_utilisateurs; -- INDEPENDANT
+```
+
+### Verifier la fraicheur
+
+```sql
+SELECT 'metabase_model_106' AS mv, MAX(action_timestamp)::date AS max_date,
+ CURRENT_DATE - MAX(action_timestamp)::date AS lag_days FROM metabase_model_106
+UNION ALL SELECT 'mv_funnel_il_irc_visits', MAX(jour), CURRENT_DATE - MAX(jour) FROM mv_funnel_il_irc_visits
+UNION ALL SELECT 'mv_bounce_contributions', MAX(jour), CURRENT_DATE - MAX(jour) FROM mv_bounce_contributions
+UNION ALL SELECT 'mv_funnel_il_irc', MAX(semaine), CURRENT_DATE - MAX(semaine) FROM mv_funnel_il_irc
+UNION ALL SELECT 'mv_perso_weekly', MAX(semaine), CURRENT_DATE - MAX(semaine) FROM mv_perso_weekly
+UNION ALL SELECT 'mv_kpi_personnalisation', MAX(month), CURRENT_DATE - MAX(month) FROM mv_kpi_personnalisation
+UNION ALL SELECT 'visites_uniques', MAX(month), CURRENT_DATE - MAX(month) FROM visites_uniques
+UNION ALL SELECT 'commentaires_utilisateurs', MAX(action_timestamp)::date, CURRENT_DATE - MAX(action_timestamp)::date FROM commentaires_utilisateurs
+ORDER BY lag_days DESC;
+```
+
+Une `lag_days > 7` (sauf pour les MV mensuelles) indique un cron casse cote infra.
+
+## Commandes clefs
+
+```bash
+# Regenerer docs/events.md depuis le code et events.metadata.yaml
+pnpm -F @cdt/metabase events:docs
+
+# Verifier que docs/events.md est synchronise (CI / precommit)
+pnpm -F @cdt/metabase events:check
+
+# Extraire seulement les events (JSON ground truth)
+pnpm -F @cdt/metabase events:extract
+```
+
+## Automatisation CI / Wiki
+
+- **Precommit** (`husky` -> `pnpm precommit` -> `lerna run precommit` -> `tsx events/check-events-drift.ts`) : bloque le commit si `docs/events.md` est desync.
+- **Workflow** : [`.github/workflows/metabase-events.yml`](../../.github/workflows/metabase-events.yml)
+ - `drift-check` (PR) : fail la PR si `docs/events.md` n'est pas a jour.
+ - `wiki-sync` (push sur `dev` / `master`) : regenere et pousse les docs vers le [wiki GitHub](https://github.com/SocialGouv/code-du-travail-numerique/wiki).
diff --git a/packages/metabase/README.md b/packages/metabase/README.md
new file mode 100644
index 00000000000..7a7ef70f77a
--- /dev/null
+++ b/packages/metabase/README.md
@@ -0,0 +1,197 @@
+# @cdt/metabase
+
+Dashboards Metabase et configuration MCP pour le CDTN.
+
+## Sommaire
+
+- [Installation](#installation)
+- [Serveur MCP](#serveur-mcp)
+- [Structure des fichiers](#structure-des-fichiers)
+- [Pipeline events Matomo](#pipeline-events-matomo)
+- [Dashboards et cartes](#dashboards-et-cartes)
+- [Refresh des MV](#refresh-des-mv)
+- [Issues](#issues)
+
+## Installation
+
+```bash
+pnpm install
+```
+
+## Serveur MCP
+
+Ce package fournit une integration MCP (Model Context Protocol) pour interroger l'instance Metabase CDTN depuis les agents IA (Claude Code, Claude Desktop, Cursor, Windsurf, etc.). Le serveur utilise [`@easecloudio/mcp-metabase-server`](https://www.npmjs.com/package/@easecloudio/mcp-metabase-server) pilote par `npx`.
+
+### Configuration locale
+
+Le fichier `.mcp.json` vit **a la racine du monorepo** (et pas dans `packages/metabase/`) pour que Claude Code et les autres outils MCP le detectent automatiquement. Il est **gitignore** au niveau racine (`.gitignore`, ligne `.mcp.json`). Chaque dev maintient sa propre copie.
+
+Si tu n'as pas encore de `.mcp.json` :
+
+```bash
+# depuis la racine du monorepo
+cp .mcp.example.json .mcp.json
+```
+
+Puis edite `.mcp.json` (racine) et remplace `your_api_key_here` par ta cle API Metabase. La cle se genere depuis Metabase : **Admin > Settings > Authentication > API Keys > Create API Key**.
+
+Structure attendue de `.mcp.json` :
+
+```json
+{
+ "$schema": "https://modelcontextprotocol.io/schema/mcp.json",
+ "mcpServers": {
+ "metabase": {
+ "command": "npx",
+ "args": ["@easecloudio/mcp-metabase-server"],
+ "env": {
+ "METABASE_URL": "https://metabase-cdtn.fabrique.social.gouv.fr",
+ "METABASE_API_KEY": "mb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ }
+ }
+ }
+}
+```
+
+> **Ne JAMAIS commiter `.mcp.json`** : il contient la cle API. Si tu vois `.mcp.json` apparaitre dans `git status`, verifie le `.gitignore` racine.
+
+### Activation
+
+`.mcp.json` est au format standard MCP, automatiquement detecte par les outils compatibles (Claude Code, Claude Desktop, Cursor, Windsurf). Aucune action manuelle n'est requise apres `cp` + edition.
+
+Si l'auto-detection ne fonctionne pas avec Claude Code :
+
+```bash
+claude mcp add metabase \
+ -e METABASE_URL=https://metabase-cdtn.fabrique.social.gouv.fr \
+ -e METABASE_API_KEY=ta_cle_api \
+ -- npx @easecloudio/mcp-metabase-server
+```
+
+### Variables d'environnement (alternative)
+
+Pour les scripts CLI directs (sans MCP), `.env` est lu via `source` :
+
+```bash
+source .env
+echo $METABASE_API_KEY
+```
+
+`.env` contient :
+
+```env
+METABASE_URL=https://metabase-cdtn.fabrique.social.gouv.fr
+METABASE_API_KEY=mb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+```
+
+`.env` est aussi gitignore au niveau du repo racine.
+
+### Interroger l'API directement (sans MCP)
+
+```bash
+source .env
+
+# Lister les dashboards
+curl -H "X-API-Key: $METABASE_API_KEY" \
+ "$METABASE_URL/api/dashboard"
+
+# Consulter une carte
+curl -H "X-API-Key: $METABASE_API_KEY" \
+ "$METABASE_URL/api/card/170"
+
+# Executer la requete d'une carte
+curl -X POST -H "X-API-Key: $METABASE_API_KEY" \
+ "$METABASE_URL/api/card/170/query"
+
+# Executer une requete native ad-hoc (incl. DDL DROP/CREATE - voir CLAUDE.md §Tips API Metabase)
+curl -X POST -H "X-API-Key: $METABASE_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"type":"native","database":4,"native":{"query":"SELECT 1"}}' \
+ "$METABASE_URL/api/dataset"
+```
+
+## Structure des fichiers
+
+| Fichier | Description |
+| ------------------------------- | ------------------------------------------------------------------------ |
+| `CLAUDE.md` | **Hub IA complet** : process cards, SQL, dashboards, events, refresh |
+| `../../.mcp.json` | Config MCP locale, **a la racine du repo** (gitignore) |
+| `../../.mcp.example.json` | Template de config MCP, **a la racine du repo** |
+| `.env` | Variables `METABASE_URL` / `METABASE_API_KEY` (gitignore) |
+| `docs/schema.md` | Schema de la base OVH PG CDTN (`matomo_partitioned`, etc.) |
+| `docs/materialized-views.md` | Definitions et patterns d'usage des vues materialisees |
+| `docs/events.md` | **Glossaire exhaustif des events Matomo (AUTO-GENERE)** |
+| `docs/models.md` | Modeles Metabase et patterns SQL optimises |
+| `docs/dashboards.md` | Reference de tous les dashboards et cartes |
+| `events/events.metadata.yaml` | Description **metier** des events (label, trigger, KPI, dashboards) |
+| `events/events.extracted.json` | Ground truth technique extraite depuis le code (auto) |
+| `events/events.schema.ts` | Types TS partages du pipeline events |
+| `events/extract-events.ts` | AST scan des `tracking.ts` → `events.extracted.json` |
+| `events/generate-events-doc.ts` | Join extracted + metadata → `docs/events.md` |
+| `events/check-events-drift.ts` | Exit 1 si `docs/events.md` est desynchronise (utilise en precommit + CI) |
+| `sql/` | SQL DDL des MV custom (source de verite git-tracked) |
+
+## Pipeline events Matomo
+
+Le package centralise le glossaire des events Matomo emis par le frontend CDTN. `docs/events.md` est **genere automatiquement** et ne doit jamais etre edite a la main.
+
+### Fonctionnement
+
+```text
+packages/code-du-travail-frontend/src/modules/**/*.{ts,tsx} (sauf __tests__/)
+ |
+ | events/extract-events.ts (AST via ts-morph)
+ | detecte sendEvent(), push(["trackEvent"|"trackSiteSearch"|...]), _paq.push()
+ v
+events/events.extracted.json (61 callsites events + matomo_config_calls)
+ +
+events/events.metadata.yaml (description metier a maintenir a la main)
+ |
+ | events/generate-events-doc.ts
+ v
+docs/events.md (groupe par feature_group, + sections Orphelins / Metadata orpheline / Commandes Matomo)
+```
+
+### Commandes
+
+```bash
+# Installer les deps (ts-morph, tsx, yaml)
+pnpm install
+
+# Regenerer docs/events.md
+pnpm -F @cdt/metabase events:docs
+
+# Verifier que docs/events.md est a jour (CI + precommit)
+pnpm -F @cdt/metabase events:check
+```
+
+### Workflow dev
+
+1. Dev ajoute un `sendEvent({ category, action, name? })` dans un `tracking.ts` du frontend.
+2. `pnpm -F @cdt/metabase events:docs` → l'event apparait dans `docs/events.md`, section "Orphelins".
+3. Dev documente l'event dans `events/events.metadata.yaml` (cle `":"`).
+4. Relance → l'event passe dans sa section metier.
+5. Le commit est bloque par husky si `events:check` echoue.
+
+Voir `events/CLAUDE.md` pour les details (schema + metadata + scripts du pipeline sont colocalises).
+
+## Dashboards et cartes
+
+La liste des dashboards et cartes maintenus vit dans [`docs/dashboards.md`](docs/dashboards.md). Pour l'etat en direct, interroger l'API :
+
+```bash
+source .env
+curl -s -H "X-API-Key: $METABASE_API_KEY" "$METABASE_URL/api/dashboard" | jq '.[] | {id, name, collection_id, archived}'
+```
+
+## Refresh des MV
+
+- Commandes detaillees et ordre de refresh : [`docs/materialized-views.md`](docs/materialized-views.md) §"Ordre de refresh des MV".
+- Consignes de process et regles d'archivage des cartes : [`CLAUDE.md`](CLAUDE.md) §"Refresh des MV".
+- Le cron lui-meme est gere cote infra (Kubernetes CronJob / `pg_cron`).
+
+## Issues
+
+- [#7199](https://github.com/SocialGouv/code-du-travail-numerique/issues/7199) - Personnalisation des contenus
+- [#7202](https://github.com/SocialGouv/code-du-travail-numerique/issues/7202) - Funnel IL/IRC
+- [#7136](https://github.com/SocialGouv/code-du-travail-numerique/issues/7136) - Taux de rebond contributions
diff --git a/packages/metabase/docs/CLAUDE.md b/packages/metabase/docs/CLAUDE.md
new file mode 100644
index 00000000000..a9671315cdf
--- /dev/null
+++ b/packages/metabase/docs/CLAUDE.md
@@ -0,0 +1,25 @@
+# docs/ — consignes Claude
+
+Documentation de reference pour humains et IA. Un seul fichier ici est auto-genere : `events.md`.
+
+## Fichiers
+
+| Fichier | Source | Maintenance |
+| ----------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
+| `events.md` | **AUTO-GENERE** par `events/generate-events-doc.ts` depuis `events/events.extracted.json` + `events/events.metadata.yaml`. | Ne jamais editer a la main. `pnpm -F @cdt/metabase events:docs` pour regenerer. |
+| `materialized-views.md` | Manuel. | A synchroniser avec `sql/mv_*.sql` a chaque modif de DDL. |
+| `dashboards.md` | Manuel. | A jour a chaque ajout / suppression de card ou de dashboard. |
+| `models.md` | Manuel. | Documente les "models" Metabase (cards sans dashboard, ex: card 106). |
+| `schema.md` | Manuel. | Documente le schema de la DB OVH PG CDTN (tables, partitions). |
+
+## Regles
+
+- **`events.md`** : toute modification est ecrasee au prochain run. Pour corriger la description d'un event → editer `events/events.metadata.yaml` et relancer `pnpm events:docs`.
+- **`materialized-views.md`** : source de verite pour l'architecture. Doit correspondre aux fichiers de `sql/`. Le header de chaque `mv_xxx.sql` pointe vers la section correspondante (`§N`).
+- **`dashboards.md`** : garder a jour la table des cards par dashboard. Quand tu ajoutes ou modifies une card → mettre a jour cette table ET la section "Cartes" du dashboard concerne dans `../CLAUDE.md`.
+- **Cross-references** : preferer des liens relatifs (`[materialized-views.md](./materialized-views.md)`) plutot que des chemins absolus.
+
+## Ne pas faire
+
+- Creer un nouveau fichier de doc pour un event : il doit etre dans `events.md` (auto) ou dans `../CLAUDE.md` si c'est une regle metier.
+- Dupliquer le contenu de `events/events.metadata.yaml` dans un autre fichier : c'est la source unique.
diff --git a/packages/metabase/docs/dashboards.md b/packages/metabase/docs/dashboards.md
new file mode 100644
index 00000000000..1b0939777e8
--- /dev/null
+++ b/packages/metabase/docs/dashboards.md
@@ -0,0 +1,159 @@
+# Dashboards et Collections
+
+## Dashboards
+
+| ID | Nom | Collection | Description |
+| --- | ------------------------------------------ | --------------------------- | --------------------------------- |
+| 1 | E-commerce Insights | Examples (2) | Sample Database |
+| 2 | Reproduction dashboard CDTN | (root) | Reproduction |
+| 7 | Completion des outils | Outils (16) | Taux de completion par simulateur |
+| 8 | General | General (29) | Dashboard general |
+| 16 | Contributions | Contributions (34) | Popularite et satisfaction |
+| 17 | Informations | Informations (38) | Popularite et satisfaction |
+| 18 | Modeles de documents | Modeles de documents (42) | Popularite et satisfaction |
+| 19 | Convention collectives | Convention collectives (46) | Popularite et satisfaction |
+| 20 | Indemnite rupture conventionnelle | Outils > IR (19) | Funnel et completion |
+| 21 | Indemnite licenciement | Outils > IL (20) | Funnel et completion |
+| 22 | Indemnite precarite | Outils > IP (23) | Funnel et completion |
+| 23 | Heures recherche emploi | Outils > HRE (24) | Funnel et completion |
+| 24 | Preavis demission | Outils > PD (22) | Funnel et completion |
+| 25 | Preavis depart retraite | Outils > PDR (25) | Funnel et completion |
+| 26 | Preavis licenciement | Outils > PL (21) | Funnel et completion |
+| 27 | Quoi de neuf | Quoi de neuf (73) | Popularite |
+| 28 | Infographies | Infographies (76) | Popularite et satisfaction |
+| 29 | Trouver convention collective | Outils > TCC (79) | Funnel CC |
+| 30 | IL - Satisfaction Top 100 | IL > Sat Top 100 (84) | Top 100 satisfaction |
+| 31 | RC - Satisfaction Top 100 | IR > Sat Top 100 (85) | Top 100 satisfaction |
+| 32 | Fantine_Investigation_2025 | CDTN (14) | Investigation |
+| 33 | Tableau de bord satisfaction utilisateur | CDTN (14) | Satisfaction globale |
+| 34 | Fantine - Completion outils | CDTN (14) | Completion |
+| 36 | **Personnalisation des contenus** | General (29) | **NOTRE DASHBOARD** |
+| 38 | KPI - Simulateurs "Demarches essentielles" | Demarches essentielles (89) | KPI satisfaction IL/RC |
+
+> Pour regenerer cette table automatiquement : `curl -s -H "X-API-Key: $METABASE_API_KEY" "$METABASE_URL/api/dashboard" | jq '.[] | {id, name, collection_id, archived}'`.
+
+---
+
+## Collections
+
+```
+Nos analyses (root)
+├── Examples (2)
+├── CDTN (14)
+│ ├── Outils (16)
+│ │ ├── Indemnite rupture conventionnelle (19)
+│ │ │ ├── Rapport mensuel (50)
+│ │ │ ├── Satisfaction (51)
+│ │ │ ├── Taux completion (53)
+│ │ │ └── Satisfaction Top 100 (85)
+│ │ ├── Indemnite licenciement (20)
+│ │ │ ├── Rapport mensuel (54)
+│ │ │ ├── Satisfaction (55)
+│ │ │ ├── Taux completion (56)
+│ │ │ └── Satisfaction Top 100 (84)
+│ │ ├── Preavis licenciement (21) → 69-71
+│ │ ├── Preavis demission (22) → 63-65
+│ │ ├── Indemnite precarite (23) → 57-59
+│ │ ├── Heures recherche emploi (24) → 60-62
+│ │ ├── Preavis depart retraite (25) → 66-68
+│ │ └── Trouver convention collective (79)
+│ │ ├── Popularite (81)
+│ │ ├── Taux completion (82)
+│ │ └── Satisfaction (80)
+│ ├── General (29)
+│ │ ├── Satisfaction (31)
+│ │ ├── Popularite (32)
+│ │ └── Rapport mensuel (33)
+│ ├── Contributions (34)
+│ │ ├── Popularite (35)
+│ │ ├── Rapport mensuel (36)
+│ │ ├── Satisfaction (37)
+│ │ └── Taux completion (83)
+│ ├── Informations (38) → 39-41
+│ ├── Modeles de documents (42) → 43-45
+│ ├── Convention collectives (46) → 47-49
+│ ├── Quoi de neuf (73) → 74-75
+│ └── Infographies (76) → 77-78
+└── Collections personnelles (4-10, 26, 72, 86-87, etc.)
+```
+
+---
+
+## Pattern par type de contenu
+
+Chaque type de contenu a le meme pattern de sous-collections :
+
+- **Popularite** : Visites uniques par mois, Top 10, Evolution
+- **Satisfaction** : Ratio avis, Commentaires, Raisons, Nuage
+- **Rapport mensuel** : Visites uniques par mois
+- **Taux completion** (Outils seulement) : Funnel par etapes
+
+---
+
+## Dashboard 36 - Cartes V2
+
+| Card ID | Nom | KPI | Display | Source |
+| ------- | ---------------------------------------------- | ----- | ------- | ----------------------- |
+| 435 | Personnalisation - Vue consolidee | KPI 1 | table | mv_kpi_personnalisation |
+| 436 | Renonciation - Taux global | KPI 3 | scalar | mv_kpi_personnalisation |
+| 437 | Personnalisation - Evolution 8 semaines | KPI 2 | line | mv_perso_weekly |
+| 438 | Personnalisation - Taux par contribution | KPI 1 | table | mv_kpi_personnalisation |
+| 439 | Personnalisation - Taux par simulateur | KPI 1 | table | mv_kpi_personnalisation |
+| 440 | Renonciation - Par contribution (V2) | KPI 3 | table | mv_kpi_personnalisation |
+| 441 | Parcours bloques - CC non traitee et pas de CC | KPI 4 | table | mv_kpi_personnalisation |
+| 442 | CC non traitees - Volume 2025 | KPI 5 | table | mv_cc_non_traitees |
+| 443 | Personnalisation - Vue agregee | KPI 1 | table | mv_kpi_personnalisation |
+| 444 | Renonciation - Par simulateur | KPI 3 | table | mv_kpi_personnalisation |
+
+### Cartes V1 archivees (IDs 427-434)
+
+Remplacees par les V2 (435-444). Les V1 ont ete **archivees via `PUT /api/card/:id` avec `archived: true`** : elles sont restaurables depuis la corbeille Metabase, et l'historique SQL est conserve dans l'onglet "History" de chaque carte. Elles utilisaient `matomo_partitioned` directement (performances insuffisantes).
+
+---
+
+## Dashboard 37 - Funnel IL/IRC _(archive)_
+
+Dashboard historique "Funnel de conversion pour l'IL et l'IRC" **archive** (corbeille Metabase). Les cartes 445, 446, 447 qui l'alimentaient sont elles aussi archivees. Conservees pour reference historique ; restaurables via l'UI Metabase si besoin.
+
+| Card ID | Nom | Display | Source | Statut |
+| ------- | ------------------------------------------------- | ------- | ---------------- | -------- |
+| 445 | Funnel conversion IL vs IRC - avant/apres refonte | table | mv_funnel_il_irc | archivee |
+| 446 | Taux conversion IL vs IRC - avant/apres refonte | table | mv_funnel_il_irc | archivee |
+| 447 | Evolution hebdomadaire taux conversion IL vs IRC | line | mv_funnel_il_irc | archivee |
+
+---
+
+## Cartes "Taux de rebond" Contributions (collection 88, #7136)
+
+Issue [#7136](https://github.com/SocialGouv/code-du-travail-numerique/issues/7136) : taux d'utilisateurs qui se rendent sur une contribution AVEC bouton generique et repartent sans aucune interaction (ni recherche CC, ni clic sur le bouton secondaire).
+
+Source : `mv_bounce_contributions` (par visite + contribution, temps reel via `matomo_partitioned`).
+
+Filtre : seules les contributions qui ont au moins un evenement `click_afficher_les_informations_générales` sur les 60 derniers jours sont incluses (= ~41 contributions sur ~2349 visitees).
+
+| Card ID | Collection | Nom | Display | Source |
+| ------- | ----------------------------------- | --------------------------------- | ------- | ----------------------- |
+| 450 | 88 (Contributions - Taux de rebond) | Taux de rebond - Global | scalar | mv_bounce_contributions |
+| 451 | 88 (Contributions - Taux de rebond) | Taux de rebond - Par contribution | table | mv_bounce_contributions |
+
+Les deux cards sont parametrees `date_debut` / `date_fin` (defaut = 30 derniers jours, modifiable via le widget Metabase ou via dashboard parameter).
+
+---
+
+## Cartes "Taux completion" IL et IRC (collections 56 et 53)
+
+Funnel cumulatif parametre par dates (`date_debut`, `date_fin`, defaut = 30 derniers jours).
+Source : `mv_funnel_il_irc_visits` (par visite, temps reel via `matomo_partitioned`).
+Logique : pour chaque etape N, on compte les visites qui ont vu l'etape N OU une etape ulterieure (funnel monotone par construction).
+
+| Card ID | Collection | Nom | Display | Source |
+| ------- | -------------------------- | ------------------------------------------ | ------- | ----------------------- |
+| 170 | 56 (IL - Taux completion) | Taux completion des etapes | bar | mv_funnel_il_irc_visits |
+| 448 | 56 (IL - Taux completion) | Taux completion des etapes (funnel - test) | funnel | mv_funnel_il_irc_visits |
+| 107 | 53 (IRC - Taux completion) | Taux completion des etapes | bar | mv_funnel_il_irc_visits |
+| 449 | 53 (IRC - Taux completion) | Taux completion des etapes (funnel - test) | funnel | mv_funnel_il_irc_visits |
+
+Etapes attendues (post refonte avril 2026) :
+`start` -> `info_cc` -> `infos` -> `anciennete` -> `absences` -> `salaires` -> `results`
+
+Voir `materialized-views.md` §7 pour les details du pattern et la justification du funnel cumulatif.
diff --git a/packages/metabase/docs/events.md b/packages/metabase/docs/events.md
new file mode 100644
index 00000000000..34243a9f35d
--- /dev/null
+++ b/packages/metabase/docs/events.md
@@ -0,0 +1,619 @@
+
+
+
+
+
+# Glossaire des Events Matomo
+
+Genere le **2026-04-22T10:01:51.889Z** depuis `packages/code-du-travail-frontend/src`.
+
+**Stats :** 61 callsites · 57 events uniques · **54 documentes** · **3 orphelins** · 7 metadata orphelines.
+
+> Cette page est le point d'entree unique pour comprendre les events trackes par le frontend CDTN et leur usage dans les dashboards Metabase. Chaque entree est extraite statiquement depuis le code TS puis enrichie avec la description metier maintenue dans `events/events.metadata.yaml`.
+
+## Sommaire
+
+- [cc-search](#cc-search) (11)
+- [contact](#contact) (2)
+- [contributions](#contributions) (3)
+- [documents](#documents) (1)
+- [enterprise](#enterprise) (3)
+- [feedback](#feedback) (4)
+- [header](#header) (3)
+- [home](#home) (2)
+- [navigation](#navigation) (1)
+- [recherche](#recherche) (11)
+- [share](#share) (1)
+- [simulateurs](#simulateurs) (12)
+- [Orphelins](#orphelins) (3)
+- [Metadata orpheline](#metadata-orpheline) (7)
+- [Commandes Matomo de configuration](#commandes-matomo-de-configuration-non-events) (9)
+
+---
+
+## cc-search
+
+### `cc_search_type_of_users` / ``
+
+Parcours CC dynamique (ternary : click_p1 / click_p2 / click_p3 / click_je_n_ai_pas_d_entreprise)
+
+- **Declenche par :** Resolu a runtime dans pushAgreementEvents.ts selon le parcours (`parcours!` variable). Voir les entrees specifiques ci-dessus pour chaque valeur.
+- **Metadata :** herite de `cc_search_type_of_users:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/outils/common/events/pushAgreementEvents.ts:44` (`pushAgreementEvents()`) — name: ``
+
+### `cc_search_type_of_users` / `click_je_n_ai_pas_d_entreprise`
+
+Declaration : pas d'entreprise
+
+- **Declenche par :** Clic sur 'Je n'ai pas d'entreprise' dans le parcours CC
+- **KPI :** Parcours bloques (KPI 4 dashboard 36)
+- **Dashboards :** #36
+- **Cartes :** #441
+- **MV source :** `mv_kpi_personnalisation`
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/enterprise/EnterpriseAgreementSearch/tracking.ts:52` (`emitNoEnterpriseClickEvent()`) — name: `Trouver sa convention collective`
+
+### `cc_search_type_of_users` / `click_p1`
+
+Parcours P1 : recherche par nom de CC
+
+- **Declenche par :** Clic sur l'option 'Je connais le nom de ma convention collective' dans le selecteur de parcours
+- **Callsites :** 2
+ - `packages/code-du-travail-frontend/src/modules/contributions/tracking.ts:61` (`emitClickP1()`) — name: ``
+ - `packages/code-du-travail-frontend/src/modules/convention-collective/tracking.ts:34` (`emitNavigateAgreementSearchEvent()`) — name: `Trouver sa convention collective`
+
+### `cc_search_type_of_users` / `click_p2`
+
+Parcours P2 : recherche par nom d'entreprise
+
+- **Declenche par :** Clic sur l'option 'Je connais le nom de mon entreprise' dans le selecteur de parcours
+- **Callsites :** 2
+ - `packages/code-du-travail-frontend/src/modules/contributions/tracking.ts:69` (`emitClickP2()`) — name: ``
+ - `packages/code-du-travail-frontend/src/modules/convention-collective/tracking.ts:42` (`emitNavigateEnterpriseSearchEvent()`) — name: `Trouver sa convention collective`
+
+### `cc_search_type_of_users` / `click_p3`
+
+Parcours P3 : renonciation CC
+
+- **Declenche par :** Clic sur l'option 'Je ne sais pas' / 'Je n'ai pas d'entreprise' - l'utilisateur saute l'etape CC
+- **KPI :** Renonciation (KPI 3 dashboard 36)
+- **Dashboards :** #36
+- **Cartes :** #436, #440, #444
+- **MV source :** `mv_kpi_personnalisation`
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/contributions/tracking.ts:77` (`emitClickP3()`) — name: ``
+
+### `cc_search_type_of_users` / `select_je_n_ai_pas_d_entreprise`
+
+Variante select : pas d'entreprise
+
+- **Declenche par :** Meme chose que click_je_n_ai_pas_d_entreprise mais depuis le composant select (event de repli)
+- **Notes :** Double-fired avec click_je_n_ai_pas_d_entreprise dans certains parcours
+- **Callsites :** 2
+ - `packages/code-du-travail-frontend/src/modules/enterprise/EnterpriseAgreementSearch/tracking.ts:59` (`emitNoEnterpriseSelectEvent()`) — name: `Trouver sa convention collective`
+ - `packages/code-du-travail-frontend/src/modules/outils/common/events/pushAgreementEvents.ts:75` (`pushAgreementEvents()`) — name: ``
+
+### `cc_select_p1` / ``
+
+CC selectionnee depuis le parcours P1
+
+- **Declenche par :** Apres avoir choisi une CC dans l'autocomplete P1
+- **Metadata :** herite de `cc_select_p1:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/convention-collective/tracking.ts:50` (`emitSelectEvent()`) — name: ``
+
+### `cc_select_p2` / ``
+
+CC selectionnee depuis le parcours P2 (via entreprise)
+
+- **Declenche par :** Apres avoir choisi une entreprise puis sa CC rattachee dans P2
+- **Metadata :** herite de `cc_select_p2:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/enterprise/EnterpriseAgreementSearch/tracking.ts:36` (`emitSelectEnterpriseAgreementEvent()`) — name: ``
+
+### `pagecc_searchcc` / ``
+
+Recherche par mots-cles dans le texte d'une CC sur Legifrance
+
+- **Declenche par :** Submit du formulaire 'Recherche dans la convention collective' sur une fiche CC. Ouvre legifrance.gouv.fr/search/kali dans un nouvel onglet. Le `action` est le titre court de la CC, le `name` est la query saisie.
+- **Notes :** Category fixe 'pagecc_searchcc', action = shortTitle de la CC (dynamique).
+- **Metadata :** herite de `pagecc_searchcc:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/convention-collective/LegiFranceSearch.tsx:24` (`LegiFranceSearch()`) — name: ``
+
+### `view_step_cc_search_p1` / `back_step_cc_search_p1`
+
+Retour etape P1 du parcours CC
+
+- **Declenche par :** Clic sur 'Precedent' apres avoir cherche une CC par nom (parcours P1)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/convention-collective/tracking.ts:58` (`emitPreviousEvent()`) — name: `Trouver sa convention collective`
+
+### `view_step_cc_search_p2` / `back_step_cc_search_p2`
+
+Retour etape P2 du parcours CC
+
+- **Declenche par :** Clic sur 'Precedent' apres avoir cherche une entreprise (parcours P2)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/enterprise/EnterpriseAgreementSearch/tracking.ts:44` (`emitPreviousEvent()`) — name: `Trouver sa convention collective`
+
+## contact
+
+### `contact` / `click_contact_sr_modale`
+
+Ouverture de la modale 'Besoin d'aide ?'
+
+- **Declenche par :** Clic sur le lien 'Contact' qui ouvre la modale de contact support
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/layout/footer/infos/tracking.ts:24` (`emitModalIsOpened()`) — name: ``
+
+### `contact` / `click_phone_number`
+
+Clic sur le numero de telephone dans le footer
+
+- **Declenche par :** Clic sur le lien 'tel:...' dans le footer
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/layout/footer/infos/tracking.ts:17` (`emitTrackNumber()`)
+
+## contributions
+
+### `contribution` / `click_afficher_les_informations_CC`
+
+Affichage de la reponse personnalisee sur une page contribution
+
+- **Declenche par :** Clic sur le bouton 'afficher les informations personnalisees' apres selection d'une CC TRAITEE
+- **KPI :** Personnalisation reussie (KPI 1 dashboard 36)
+- **Dashboards :** #36
+- **Cartes :** #435, #438, #443
+- **MV source :** `mv_kpi_personnalisation`
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/contributions/tracking.ts:37` (`emitDisplayAgreementContent()`) — name: ``
+
+### `contribution` / `click_afficher_les_informations_générales`
+
+Affichage des infos generales (sans personnalisation CC)
+
+- **Declenche par :** Clic sur le bouton 'afficher les informations generales' - utilisateur renonce a personnaliser sa reponse
+- **KPI :** Indicateur de rebond sur contributions (cartes 450/451)
+- **Dashboards :** #88
+- **Cartes :** #450, #451
+- **MV source :** `mv_bounce_contributions`
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/contributions/tracking.ts:53` (`emitDisplayGeneralContent()`) — name: ``
+
+### `contribution` / `click_afficher_les_informations_sans_CC`
+
+Affichage de la reponse generique (CC non traitee)
+
+- **Declenche par :** Clic sur le bouton 'afficher les informations' apres selection d'une CC NON TRAITEE
+- **KPI :** CC non traitee (KPI 4 dashboard 36)
+- **Dashboards :** #36
+- **Cartes :** #441
+- **MV source :** `mv_kpi_personnalisation`
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/contributions/tracking.ts:45` (`emitDisplayGenericContent()`) — name: ``
+
+## documents
+
+### `page_modeles_de_documents` / `type_CTRL_C`
+
+Copie d'un modele de courrier
+
+- **Declenche par :** L'utilisateur utilise Ctrl+C / Cmd+C pour copier le contenu d'un modele de courrier
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/modeles-de-courriers/tracking.ts:7` (`useModeleEvents()`) — name: ``
+
+## enterprise
+
+### `enterprise_search` / ``
+
+Recherche d'une entreprise
+
+- **Declenche par :** Saisie dans le champ de recherche d'entreprise (parcours P2)
+- **Metadata :** herite de `enterprise_search:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/enterprise/EnterpriseAgreementSearch/tracking.ts:14` (`emitEnterpriseAgreementSearchInputEvent()`) — name: ``
+
+### `enterprise_select` / ``
+
+Entreprise selectionnee
+
+- **Declenche par :** Selection d'une entreprise dans les suggestions
+- **Metadata :** herite de `enterprise_select:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/enterprise/EnterpriseAgreementSearch/tracking.ts:28` (`emitSelectEnterpriseEvent()`) — name: ``
+
+### `enterprise_select` / ``
+
+Entreprise selectionnee
+
+- **Declenche par :** Selection d'une entreprise dans les suggestions
+- **Metadata :** herite de `enterprise_select:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/outils/common/events/pushAgreementEvents.ts:51` (`pushAgreementEvents()`) — name: ``
+
+## feedback
+
+### `feedback` / `negative`
+
+Feedback negatif (peu clair / pas pertinent)
+
+- **Declenche par :** Clic sur le pouce rouge ou equivalent en bas de page
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/layout/feedback/tracking.ts:39` (`emitNegativeFeedback()`) — name: ``
+
+### `feedback` / `positive`
+
+Feedback positif (clair / pertinent)
+
+- **Declenche par :** Clic sur le pouce vert ou equivalent en bas de page
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/layout/feedback/tracking.ts:29` (`emitPositiveFeedback()`) — name: ``
+
+### `feedback_category` / ``
+
+Categorie de feedback negatif
+
+- **Declenche par :** Selection d'une raison apres un feedback negatif (infos fausses, pas claires, etc.)
+- **Metadata :** herite de `feedback_category:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/layout/feedback/tracking.ts:59` (`emitFeedbackCategory()`) — name: ``
+
+### `feedback_suggestion` / ``
+
+Soumission d'une suggestion libre
+
+- **Declenche par :** Texte libre envoye apres un feedback negatif
+- **Metadata :** herite de `feedback_suggestion:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/layout/feedback/tracking.ts:49` (`emitFeedbackSuggestion()`) — name: ``
+
+## header
+
+### `header_cc` / `cc_consult`
+
+Consultation d'une fiche CC depuis la modale globale
+
+- **Declenche par :** Clic sur 'Voir la fiche' d'une CC dans la modale de selection globale
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/convention-collective/AgreementSelectionModal/tracking.ts:33` (`emitConsultEvent()`) — name: ``
+
+### `header_cc` / `cc_select_processed | cc_select_unprocessed`
+
+Selection de CC depuis la modale globale (cc_select_processed / cc_select_unprocessed)
+
+- **Declenche par :** Selection d'une CC depuis la modale globale. Le `action` est `cc_select_processed` (CC traitee) ou `cc_select_unprocessed` (CC non traitee) selon la CC choisie.
+- **Notes :** Le code fire un ternaire (processed ? 'cc_select_processed' : 'cc_select_unprocessed') ce qui apparait en dynamic a l'extraction - d'ou le wildcard ici.
+- **Metadata :** herite de `header_cc:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/convention-collective/AgreementSelectionModal/tracking.ts:23` (`emitSelectEvent()`) — name: ``
+
+### `header_cc` / `open_modal`
+
+Ouverture de la modale de selection CC (header)
+
+- **Declenche par :** Clic sur la CC selectionnee dans le header, qui ouvre la modale de selection globale
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/convention-collective/AgreementSelectionModal/tracking.ts:16` (`emitOpenModalEvent()`)
+
+## home
+
+### `page_home` / ``
+
+Navigation depuis la homepage
+
+- **Declenche par :** Clic sur un des boutons / cartes de la page d'accueil
+- **Metadata :** herite de `page_home:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/home/tracking.ts:21` (`emitHomeClickButtonEvent()`)
+
+### `page_home` / `click_question_action`
+
+Navigation depuis la homepage
+
+- **Declenche par :** Clic sur un des boutons / cartes de la page d'accueil
+- **Metadata :** herite de `page_home:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/home/tracking.ts:28` (`emitQuestionActionEvent()`) — name: ``
+
+## navigation
+
+### `selectRelated` / ``
+
+Clic sur un contenu relie
+
+- **Declenche par :** Clic sur une carte 'Contenus relies' en bas d'une page
+- **Metadata :** herite de `selectRelated:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/common/tracking.ts:22` (`emitSelectRelated()`)
+
+## recherche
+
+### `_matomo_trackSiteSearch` / ``
+
+Event Matomo natif : recherche interne (trackSiteSearch)
+
+- **Declenche par :** Emis explicitement dans la modale Presearch via `push(['trackSiteSearch', query])`. Alimente le rapport 'Comportement > Recherche sur le site' dans Matomo (separe du report trackEvent).
+- **Notes :** Different d'un sendEvent : c'est l'API Matomo native pour le site search. Sur la page /recherche, trackSiteSearch est automatiquement appele par matomo-next (cf. config `searchKeyword: 'query'` dans MatomoAnalytics.tsx).
+- **Metadata :** herite de `_matomo_trackSiteSearch:*` (generique de categorie)
+- **Methode :** push:trackSiteSearch
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/recherche/tracking.ts:194` (`useSearchTracking()`) · `push:trackSiteSearch`
+
+### `nextResultPage` / ``
+
+Pagination des resultats de recherche
+
+- **Declenche par :** Clic sur 'Page suivante' dans les resultats
+- **Metadata :** herite de `nextResultPage:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/recherche/tracking.ts:105` (`useSearchTracking()`)
+
+### `search` / `clickSeeAllResults`
+
+Action sur la recherche full-text
+
+- **Declenche par :** Interaction avec la barre de recherche principale (submit, scroll resultats, etc.)
+- **Metadata :** herite de `search:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/recherche/tracking.ts:159` (`useSearchTracking()`) — name: ``
+
+### `search` / `fullsearch`
+
+Action sur la recherche full-text
+
+- **Declenche par :** Interaction avec la barre de recherche principale (submit, scroll resultats, etc.)
+- **Metadata :** herite de `search:*` (generique de categorie)
+- **Callsites :** 2
+ - `packages/code-du-travail-frontend/src/modules/recherche/tracking.ts:65` (`useSearchTracking()`) — name: ``
+ - `packages/code-du-travail-frontend/src/modules/recherche/tracking.ts:95` (`useSearchTracking()`) — name: ``
+
+### `search` / `presearch`
+
+Action sur la recherche full-text
+
+- **Declenche par :** Interaction avec la barre de recherche principale (submit, scroll resultats, etc.)
+- **Metadata :** herite de `search:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/recherche/tracking.ts:144` (`useSearchTracking()`) — name: ``
+
+### `search` / `selectPresearchResult`
+
+Action sur la recherche full-text
+
+- **Declenche par :** Interaction avec la barre de recherche principale (submit, scroll resultats, etc.)
+- **Metadata :** herite de `search:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/recherche/tracking.ts:182` (`useSearchTracking()`) — name: ``
+
+### `selectedSuggestion` / ``
+
+Clic sur une suggestion presearch
+
+- **Declenche par :** Clic sur un element de la liste presearch (suggestions rapides sous la barre)
+- **Metadata :** herite de `selectedSuggestion:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/recherche/tracking.ts:113` (`useSearchTracking()`) — name: ``
+
+### `selectResult` / ``
+
+Clic sur un resultat de recherche
+
+- **Declenche par :** Clic sur un item dans la liste des resultats de recherche
+- **Metadata :** herite de `selectResult:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/recherche/tracking.ts:46` (`useSearchTracking()`)
+
+### `selectResult` / ``
+
+Clic sur un resultat de recherche
+
+- **Declenche par :** Clic sur un item dans la liste des resultats de recherche
+- **Metadata :** herite de `selectResult:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/themes/tracking.ts:14` (`emitDocumentClickButtonEvent()`)
+
+### `widget_search` / `click_logo`
+
+Interaction widget de recherche
+
+- **Declenche par :** Clic logo / submit depuis le widget de recherche (integre site externe)
+- **Metadata :** herite de `widget_search:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/recherche/tracking.ts:123` (`useSearchTracking()`)
+
+### `widget_search` / `submit_search`
+
+Interaction widget de recherche
+
+- **Declenche par :** Clic logo / submit depuis le widget de recherche (integre site externe)
+- **Metadata :** herite de `widget_search:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/recherche/tracking.ts:130` (`useSearchTracking()`) — name: ``
+
+## share
+
+### `clic_share` / ``
+
+Partage de contenu
+
+- **Declenche par :** Clic sur un bouton de partage social (Facebook, Twitter, LinkedIn, email, WhatsApp, copy link)
+- **Metadata :** herite de `clic_share:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/common/tracking.ts:29` (`emitClickShare()`) — name: ``
+
+## simulateurs
+
+### `outil` / ``view_step_${TrackingAgreementSearchAction.AGREEMENT_SEARCH}``
+
+Event generique simulateur (cc_select / view_step / click_previous / click_print resolus dynamiquement)
+
+- **Declenche par :** Plusieurs emit functions (`pushAgreementEvents`, `SimulatorLayout`) fire des events 'outil' avec action construite a runtime (template `view_step_${title}`, ternaire cc_select_traitée / cc_select_non_traitée, etc.). Voir les entrees specifiques ci-dessus pour chaque valeur stable.
+- **Metadata :** herite de `outil:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/convention-collective/tracking.ts:26` (`emitViewStepEvent()`) — name: `start`
+
+### `outil` / ` | `
+
+Event generique simulateur (cc_select / view_step / click_previous / click_print resolus dynamiquement)
+
+- **Declenche par :** Plusieurs emit functions (`pushAgreementEvents`, `SimulatorLayout`) fire des events 'outil' avec action construite a runtime (template `view_step_${title}`, ternaire cc_select_traitée / cc_select_non_traitée, etc.). Voir les entrees specifiques ci-dessus pour chaque valeur stable.
+- **Metadata :** herite de `outil:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/outils/common/components/SimulatorLayout/tracking.ts:14` (`emitNextPreviousEvent()`) — name: ``
+
+### `outil` / `anciennete_plus_2_ans | anciennete_moins_2_ans`
+
+Event generique simulateur (cc_select / view_step / click_previous / click_print resolus dynamiquement)
+
+- **Declenche par :** Plusieurs emit functions (`pushAgreementEvents`, `SimulatorLayout`) fire des events 'outil' avec action construite a runtime (template `view_step_${title}`, ternaire cc_select_traitée / cc_select_non_traitée, etc.). Voir les entrees specifiques ci-dessus pour chaque valeur stable.
+- **Metadata :** herite de `outil:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/outils/preavis-retraite/steps/Seniority/store/store.ts:46` (`onNextStep()`)
+
+### `outil` / `cc_select_non_traitée`
+
+CC selectionnee mais NON TRAITEE par le simulateur
+
+- **Declenche par :** L'utilisateur a selectionne une CC qui n'a pas de traitement specifique dans le simulateur
+- **KPI :** CC non traitees (KPI 5 dashboard 36)
+- **Dashboards :** #36
+- **Cartes :** #442, #444
+- **MV source :** `mv_cc_non_traitees`
+- **Notes :** La version sans accent (cc_select_non_traitee) existait aussi historiquement.
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/contributions/tracking.ts:29` (`emitAgreementUntreatedEvent()`) — name: ``
+
+### `outil` / `cc_select_traitée`
+
+CC selectionnee et TRAITEE par le simulateur
+
+- **Declenche par :** L'utilisateur a selectionne une CC qui a un traitement personnalise dans le simulateur
+- **KPI :** Personnalisation reussie simulateur (KPI 1 dashboard 36)
+- **Dashboards :** #36
+- **Cartes :** #439, #443
+- **MV source :** `mv_kpi_personnalisation`
+- **Notes :** La version sans accent (cc_select_traitee) existait aussi historiquement - voir docs/events.md §Orphelins si drift.
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/contributions/tracking.ts:21` (`emitAgreementTreatedEvent()`) — name: ``
+
+### `outil` / `cc_select_traitée | cc_select_non_traitée`
+
+Event generique simulateur (cc_select / view_step / click_previous / click_print resolus dynamiquement)
+
+- **Declenche par :** Plusieurs emit functions (`pushAgreementEvents`, `SimulatorLayout`) fire des events 'outil' avec action construite a runtime (template `view_step_${title}`, ternaire cc_select_traitée / cc_select_non_traitée, etc.). Voir les entrees specifiques ci-dessus pour chaque valeur stable.
+- **Metadata :** herite de `outil:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/outils/common/events/pushAgreementEvents.ts:66` (`pushAgreementEvents()`) — name: ``
+
+### `outil` / `click_print`
+
+Impression du resultat du simulateur
+
+- **Declenche par :** Clic sur le bouton 'Imprimer' apres obtention d'un resultat
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/outils/common/components/SimulatorLayout/tracking.ts:24` (`emitPrintEvent()`) — name: ``
+
+### `outil` / `mise | depart`
+
+Event generique simulateur (cc_select / view_step / click_previous / click_print resolus dynamiquement)
+
+- **Declenche par :** Plusieurs emit functions (`pushAgreementEvents`, `SimulatorLayout`) fire des events 'outil' avec action construite a runtime (template `view_step_${title}`, ternaire cc_select_traitée / cc_select_non_traitée, etc.). Voir les entrees specifiques ci-dessus pour chaque valeur stable.
+- **Metadata :** herite de `outil:*` (generique de categorie)
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/outils/preavis-retraite/steps/OriginStep/store/store.ts:44` (`onNextStep()`)
+
+### `outil` / `view_step_Heures d'absence pour rechercher un emploi`
+
+Visualisation d'une etape du simulateur Heures recherche emploi
+
+- **Declenche par :** Navigation sur une etape du simulateur 'Heures d'absence pour rechercher un emploi' (start, info_cc, results, infos, user_blocked_info_cc).
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/outils/heures-recherche-emploi/events/useHeuresRechercheEmploiEventEmitter.tsx:11` (`useHeuresRechercheEmploiEventEmitter()`) — name: `user_blocked_info_cc`
+
+### `outil` / `view_step_Indemnité de licenciement`
+
+Visualisation d'une etape du simulateur IL
+
+- **Declenche par :** Chaque fois que l'utilisateur navigue sur une etape du simulateur 'Indemnite de licenciement'. Le `name` contient le slug de l'etape (start, info_cc, infos, anciennete, absences, salaires, results, results_ineligible).
+- **KPI :** Taux de completion des etapes (cartes 170, 448)
+- **Dashboards :** #37
+- **Cartes :** #170, #448
+- **MV source :** `mv_funnel_il_irc_visits`
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/outils/indemnite-licenciement/events/useIndemniteLicenciementEventEmitter.tsx:13` (`useIndemniteLicenciementEventEmitter()`) — name: `results_ineligible`
+
+### `outil` / `view_step_Indemnité de rupture conventionnelle`
+
+Visualisation d'une etape du simulateur IRC
+
+- **Declenche par :** Chaque fois que l'utilisateur navigue sur une etape du simulateur 'Indemnite de rupture conventionnelle'. Le `name` contient le slug de l'etape.
+- **KPI :** Taux de completion des etapes (cartes 107, 449)
+- **Dashboards :** #37
+- **Cartes :** #107, #449
+- **MV source :** `mv_funnel_il_irc_visits`
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/outils/indemnite-rupture-conventionnelle/events/useRuptureCoEventEmitter.tsx:13` (`useRuptureCoEventEmitter()`) — name: `results_ineligible`
+
+### `outil` / `view_step_Préavis de démission`
+
+Visualisation d'une etape du simulateur Preavis de demission
+
+- **Declenche par :** Navigation sur une etape du simulateur Preavis de demission (start, info_cc, user_blocked_info_cc, results, infos).
+- **Callsites :** 1
+ - `packages/code-du-travail-frontend/src/modules/outils/preavis-demission/events/usePreavisDemissionEventEmitter.tsx:11` (`usePreavisDemissionEventEmitter()`) — name: `user_blocked_info_cc`
+
+## Orphelins
+
+Events emis par le code **sans description metier** dans `events/events.metadata.yaml`.
+**Action requise :** completer la metadata pour chaque entree (cle `":"`) puis relancer `pnpm events:docs`.
+
+| category | action | callsites |
+| --- | --- | --- |
+| `` | `` | `packages/code-du-travail-frontend/src/modules/outils/common/events/pushAgreementEvents.ts:61` |
+| `` | `` | `packages/code-du-travail-frontend/src/modules/outils/indemnite-depart/feedback/tracking.tsx:42` |
+| `feedback_suggestion \| feedback_suggestion_rupture_co` | `` | `packages/code-du-travail-frontend/src/modules/outils/indemnite-depart/feedback/tracking.tsx:54` |
+
+## Metadata orpheline
+
+Entrees de `events/events.metadata.yaml` **sans callsite correspondant** dans le code. Deux cas possibles :
+
+1. L'event a ete supprime du code → supprimer l'entree de la metadata.
+2. L'event existe encore mais sous une autre category/action → corriger la cle.
+
+| cle | label | feature_group |
+| --- | --- | --- |
+| `feedback_simulateurs_rupture_co:*` | Feedback specifique au simulateur Rupture Conventionnelle | feedback |
+| `feedback_simulateurs:*` | Feedback specifique au simulateur Indemnite de licenciement | feedback |
+| `feedback_suggestion_rupture_co:*` | Suggestion texte libre simulateur RC | feedback |
+| `outil:anciennete_moins_2_ans` | Simulateur Preavis retraite - anciennete < 2 ans | simulateurs |
+| `outil:anciennete_plus_2_ans` | Simulateur Preavis retraite - anciennete > 2 ans | simulateurs |
+| `outil:depart` | Simulateur Preavis retraite - choix 'Depart volontaire' | simulateurs |
+| `outil:mise` | Simulateur Preavis retraite - choix 'Mise a la retraite' | simulateurs |
+
+## Commandes Matomo de configuration (non-events)
+
+Appels a `push([...])` ou `_paq.push([...])` qui configurent le tracker Matomo **sans emettre d'event**. Recenses ici pour completude : ils pilotent le consentement, les heatmaps, les A/B tests, le referrer, etc.
+
+> **Note** : `trackAppRouter({...})` dans `modules/config/MatomoAnalytics.tsx` initialise le tracker et emet automatiquement un `trackPageView` a chaque changement de route (pages SPA Next.js). Ce n'est pas liste ci-dessous car c'est un wrapper de haut niveau.
+
+| commande | args | fichier:ligne |
+| --- | --- | --- |
+| `AbTesting::create` | `` | `packages/code-du-travail-frontend/src/modules/config/initABTesting.ts:115` |
+| `forgetCookieConsentGiven` | _(aucun)_ | `packages/code-du-travail-frontend/src/modules/utils/consent.ts:90` |
+| `forgetUserOptOut` | _(aucun)_ | `packages/code-du-travail-frontend/src/modules/utils/consent.ts:86` |
+| `HeatmapSessionRecording::disable` | _(aucun)_ | `packages/code-du-travail-frontend/src/modules/utils/consent.ts:71` |
+| `HeatmapSessionRecording::enable` | _(aucun)_ | `packages/code-du-travail-frontend/src/modules/utils/consent.ts:69` |
+| `optUserOut` | _(aucun)_ | `packages/code-du-travail-frontend/src/modules/utils/consent.ts:89` |
+| `rememberCookieConsentGiven` | _(aucun)_ | `packages/code-du-travail-frontend/src/modules/utils/consent.ts:87` |
+| `setCookieSameSite` | `None` | `packages/code-du-travail-frontend/src/modules/config/MatomoAnalytics.tsx:51` |
+| `setReferrerUrl` | `` | `packages/code-du-travail-frontend/src/modules/config/MatomoAnalytics.tsx:47` |
diff --git a/packages/metabase/docs/materialized-views.md b/packages/metabase/docs/materialized-views.md
new file mode 100644
index 00000000000..61eb41e551f
--- /dev/null
+++ b/packages/metabase/docs/materialized-views.md
@@ -0,0 +1,632 @@
+
+
+
+# Vues Materialisees (Materialized Views)
+
+9 vues materialisees dans le schema `public` de la DB OVH PG CDTN.
+
+---
+
+## 1. `metabase_model_106` - Matomo Enriched
+
+**Role** : Cache pre-agrège des donnees Matomo avec colonnes calculees (`pathname`, `path_level2`, `path_level3`, `month`).
+
+**Donnees** : Derniere annee glissante (filtrage sur `action_timestamp >= date_trunc('month', CURRENT_DATE - interval '1 year')`).
+
+**Refresh** : `REFRESH MATERIALIZED VIEW metabase_model_106;`
+
+### Schema (15 colonnes)
+
+| Colonne | Type source | Calcul |
+| ---------------------- | ----------- | ------------------------------------------------------------- |
+| `action_id` | text | Direct |
+| `idvisit` | text | Direct |
+| `action_type` | text | Direct |
+| `action_eventcategory` | text | Direct |
+| `action_eventaction` | text | Direct |
+| `action_eventname` | text | Direct |
+| `action_eventvalue` | numeric | Direct |
+| `action_timestamp` | timestamptz | Direct |
+| `month` | date | `date_trunc('month', action_timestamp)::date` |
+| `action_url` | text | Direct |
+| `referrertype` | text | Direct |
+| `referrername` | text | Direct |
+| **`pathname`** | text | `substring(action_url, 'https?://[^/]+(/[^?#]*)')` |
+| **`path_level2`** | text | `split_part(pathname, '/', 2)` (ex: `contribution`, `outils`) |
+| **`path_level3`** | text | `split_part(pathname, '/', 3)` (ex: `heures-supplementaires`) |
+
+### Definition SQL
+
+```sql
+SELECT action_id,
+ idvisit,
+ action_type,
+ action_eventcategory,
+ action_eventaction,
+ action_eventname,
+ action_eventvalue,
+ action_timestamp,
+ (date_trunc('month', action_timestamp))::date AS month,
+ action_url,
+ referrertype,
+ referrername,
+ substring(action_url, 'https?://[^/]+(/[^?#]*)') AS pathname,
+ split_part(substring(action_url, 'https?://[^/]+(/[^?#]*)'), '/', 2) AS path_level2,
+ split_part(substring(action_url, 'https?://[^/]+(/[^?#]*)'), '/', 3) AS path_level3
+FROM matomo_partitioned
+WHERE action_timestamp >= date_trunc('month', CURRENT_DATE - interval '1 year');
+```
+
+### Quand l'utiliser
+
+- Requetes sur les 12 derniers mois
+- Besoin de `pathname` (plus propre que de parser `action_url`)
+- Besoin de `path_level2` / `path_level3` pour grouper par type de contenu
+- **Ne PAS utiliser** pour des donnees plus vieilles qu'1 an
+
+---
+
+## 2. `visites_uniques`
+
+**Role** : Liste dedoublonnee des visites par chemin et par mois.
+
+**Donnees** : Derniers ~13 mois.
+
+**Refresh** : `REFRESH MATERIALIZED VIEW visites_uniques;`
+
+### Schema (3 colonnes)
+
+| Colonne | Type | Description |
+| ---------- | ---- | ----------------- |
+| `idvisit` | text | ID visite unique |
+| `pathname` | text | Chemin de la page |
+| `month` | date | Mois (tronque) |
+
+### Definition SQL
+
+```sql
+SELECT DISTINCT idvisit, pathname, month
+FROM metabase_model_106
+WHERE month >= date_trunc('month', CURRENT_DATE - interval '1 year 1 month');
+```
+
+### Quand l'utiliser
+
+- Compter les visites uniques par page/mois
+- Calculer des taux (visites uniques qui atteignent une etape / total visites uniques)
+- Cross-join avec d'autres donnees pour des ratios
+
+---
+
+## 3. `commentaires_utilisateurs`
+
+**Role** : Feedbacks utilisateurs agrèges (suggestion + categorie + feedback en une ligne).
+
+**Donnees** : Derniers ~13 mois. Filtre sur `action_eventcategory` commencant par `feedback_`.
+
+**Refresh** : `REFRESH MATERIALIZED VIEW commentaires_utilisateurs;`
+
+### Schema (7 colonnes)
+
+| Colonne | Type | Description |
+| --------------------- | ----------- | ----------------------------------------------------------- |
+| `action_id` | text | ID de l'action |
+| `feedback_suggestion` | text | Texte du feedback |
+| `pathname` | text | Chemin de la page |
+| `feedback` | text | Action feedback (depuis `feedback` category) |
+| `feedback_category` | text | Categorie de feedback (depuis `feedback_category` category) |
+| `idvisit` | text | ID visite |
+| `action_timestamp` | timestamptz | Horodatage |
+
+### Definition SQL
+
+```sql
+WITH feedbacks AS (
+ SELECT action_id,
+ substring(action_url, 'https?://[^/]+(/[^?#]*)') AS pathname,
+ idvisit, action_timestamp, action_eventcategory, action_eventaction, action_type
+ FROM matomo_partitioned
+ WHERE split_part(action_eventcategory, '_', 1) = 'feedback'
+ AND action_timestamp >= date_trunc('month', CURRENT_DATE - interval '1 year 1 month')
+)
+SELECT action_id,
+ action_eventaction AS feedback_suggestion,
+ pathname,
+ (SELECT f.action_eventaction FROM feedbacks f
+ WHERE f.action_eventcategory = 'feedback'
+ AND f.idvisit = fs.idvisit
+ AND date_trunc('hour', f.action_timestamp) = date_trunc('hour', fs.action_timestamp)
+ LIMIT 1) AS feedback,
+ (SELECT f.action_eventaction FROM feedbacks f
+ WHERE f.action_eventcategory = 'feedback_category'
+ AND f.idvisit = fs.idvisit
+ AND date_trunc('hour', f.action_timestamp) = date_trunc('hour', fs.action_timestamp)
+ LIMIT 1) AS feedback_category,
+ idvisit, action_timestamp
+FROM feedbacks fs
+WHERE action_type = 'event' AND action_eventcategory = 'feedback_suggestion'
+ORDER BY action_timestamp DESC;
+```
+
+---
+
+## 4. `mv_kpi_personnalisation` - KPI Personnalisation (dediee)
+
+**Role** : Pre-agrege les evenements cibles de personnalisation par visite dedupliquee. Evite les Seq Scan sur `metabase_model_106` (53M lignes) pour les cartes du dashboard 36.
+
+**Donnees** : Derniere annee glissante (source : `metabase_model_106`).
+
+**Taille** : ~4.3M lignes, ~446 MB.
+
+**Refresh** : `DROP MATERIALIZED VIEW + CREATE` (non REFRESHABLE car schema fixe). Requete dans `sql/mv_kpi_personnalisation.sql`.
+
+**Indexes** :
+
+- `idx_mvkpi_month_type` : `(month, content_type)`
+- `idx_mvkpi_month_type_path` : `(month, content_type, path)`
+- `idx_mvkpi_idvisit` : `(idvisit)`
+
+**Cartes utilisees** : 435, 436, 438, 439, 440, 441, 443, 444 (KPI 1, 3, 4).
+
+### Schema (8 colonnes)
+
+| Colonne | Type | Description |
+| ------------------- | ------- | --------------------------------------------------- |
+| `month` | date | Mois de l'evenement |
+| `content_type` | text | `'contribution'`, `'simulateur'` ou `'cc_search'` |
+| `path` | text | Chemin (pathname ou action_eventname selon le type) |
+| `idvisit` | text | ID visite (deduplique par month/content_type/path) |
+| `is_perso` | boolean | L'utilisateur a obtenu une reponse personnalisee |
+| `is_cc_non_traitee` | boolean | L'utilisateur a rencontre une CC non traitee |
+| `is_pas_entreprise` | boolean | L'utilisateur a declare ne pas avoir d'entreprise |
+| `is_renonciation` | boolean | L'utilisateur a renonce a chercher sa CC (click_p3) |
+
+### Logique de deduplication
+
+Chaque ligne represente une visite unique (idvisit) pour un (month, content_type, path) donne. Les booleens sont calcules via `BOOL_OR` sur les evenements de la visite :
+
+- **contribution** : regroupe `click_afficher_les_informations_CC`, `click_afficher_les_informations_sans_CC`, `click_afficher_les_informations_generales`
+- **simulateur** : regroupe `cc_select_traitée`, `cc_select_non_traitée`
+- **cc_search** : regroupe `click_p1`, `click_p2`, `click_p3`, `click_je_n_ai_pas_d_entreprise`, `select_je_n_ai_pas_d_entreprise`
+
+### Definition SQL
+
+Voir `sql/mv_kpi_personnalisation.sql` pour la requete complete de creation.
+
+### Quand l'utiliser
+
+- Toutes les cartes du dashboard 36 sauf Evolution 8 semaines (437) et CC non traitees 2025 (442)
+- Calcul de taux de personnalisation, renonciation, parcours bloques
+- Requetes filtrees sur `month = (SELECT MAX(month) FROM mv_kpi_personnalisation)`
+
+---
+
+## 5. `mv_perso_weekly` - Evolution 8 semaines (dediee)
+
+**Role** : Pre-agrege les stats de personnalisation par semaine pour le graphique d'evolution (KPI 2).
+
+**Donnees** : Toute la periode couverte par `metabase_model_106`.
+
+**Taille** : ~100 lignes.
+
+**Refresh** : `REFRESH MATERIALIZED VIEW mv_perso_weekly;`
+
+**Cartes utilisees** : 437 (KPI 2 - Evolution 8 semaines).
+
+### Schema (4 colonnes)
+
+| Colonne | Type | Description |
+| --------------------- | ------ | --------------------------------------------------------------- |
+| `semaine` | date | Debut de semaine (`DATE_TRUNC('week', action_timestamp)::date`) |
+| `type` | text | `'contribution'` ou `'simulateur'` |
+| `total_visits` | bigint | Nombre de visites uniques |
+| `personalized_visits` | bigint | Nombre de visites personnalisees |
+
+### Definition SQL
+
+```sql
+CREATE MATERIALIZED VIEW mv_perso_weekly AS
+SELECT
+ DATE_TRUNC('week', action_timestamp)::date AS semaine,
+ CASE WHEN action_eventaction LIKE 'cc_select%' THEN 'simulateur' ELSE 'contribution' END AS type,
+ COUNT(DISTINCT idvisit) AS total_visits,
+ COUNT(DISTINCT CASE WHEN action_eventaction IN ('click_afficher_les_informations_CC', 'cc_select_traitée') THEN idvisit END) AS personalized_visits
+FROM metabase_model_106
+WHERE action_type = 'event'
+ AND action_eventaction IN (
+ 'click_afficher_les_informations_CC',
+ 'click_afficher_les_informations_sans_CC',
+ 'click_afficher_les_informations_générales',
+ 'cc_select_traitée',
+ 'cc_select_non_traitée'
+ )
+GROUP BY 1, 2;
+```
+
+---
+
+## 6. `mv_funnel_il_irc` - Funnel IL / IRC (dediee)
+
+**Role** : Pre-agrege par semaine, simulateur, etape les visites uniques sur les funnels Indemnite Licenciement (IL) et Indemnite Rupture Conventionnelle (IRC). Evite les Seq Scan sur `metabase_model_106` (53M lignes) pour les cartes des dashboards 37 et des sous-collections "Taux completion" (53 et 56).
+
+**Donnees** : Toute la periode couverte par `metabase_model_106` (12 derniers mois glissants).
+
+**Taille** : ~890 lignes, requetes < 200 ms (vs 188 s en attaquant `metabase_model_106` directement).
+
+**Refresh** : `REFRESH MATERIALIZED VIEW mv_funnel_il_irc;` (refresh simple, pas de DROP/CREATE necessaire).
+
+**Indexes** :
+
+- `idx_mv_funnel_il_irc_semaine` : `(semaine, simulateur, etape)`
+
+**Cartes utilisees** :
+
+- Dashboard 37 (Funnel IL/IRC, #7202) : cartes 445, 446, 447
+
+> Les cartes 170 et 107 (Taux completion IL/IRC, collections 56 et 53) **ne consomment plus cette MV** depuis le 2026-04-11. Elles utilisent `mv_funnel_il_irc_visits` (cf. §7) qui est independante de `metabase_model_106` et donc en temps reel.
+
+### Schema (4 colonnes)
+
+| Colonne | Type | Description |
+| ------------ | ------ | ------------------------------------------------------------------------------------- |
+| `semaine` | date | Debut de semaine ISO (`DATE_TRUNC('week', action_timestamp)::date`) |
+| `simulateur` | text | `'indemnite-licenciement'` ou `'indemnite-rupture-conventionnelle'` (= `path_level3`) |
+| `etape` | text | Nom de l'etape (= `action_eventname`) |
+| `visites` | bigint | Nombre de visites uniques (`COUNT(DISTINCT idvisit)`) |
+
+### Etapes trackees
+
+| Etape | Statut | Apparition |
+| -------------------- | ------ | -------------------------------------------------------------------------------------------------------------------- |
+| `start` | Active | Toujours |
+| `info_cc` | Active | Toujours |
+| `infos` | Active | Apres refonte du 2025-03-13 |
+| `anciennete` | Active | Toujours |
+| `absences` | Active | Deploye le **2026-03-13** (avant cette date, les visites n'emettent pas l'evenement) |
+| `salaires` | Active | Toujours |
+| `results` | Active | Toujours |
+| `results_ineligible` | Active | Toujours |
+| `contrat_travail` | Legacy | Avant refonte du 2025-03-13 uniquement, supprime du front mais conserve dans la MV pour les comparaisons historiques |
+
+> Toute modification de la liste des etapes (front + DB) implique de mettre a jour `sql/mv_funnel_il_irc.sql` puis de relancer un DROP/CREATE de la MV pour rafraichir le filtre `IN`.
+
+### Definition SQL
+
+Voir `sql/mv_funnel_il_irc.sql`.
+
+```sql
+SELECT
+ DATE_TRUNC('week', action_timestamp)::date AS semaine,
+ path_level3 AS simulateur,
+ action_eventname AS etape,
+ COUNT(DISTINCT idvisit) AS visites
+FROM metabase_model_106
+WHERE path_level3 IN ('indemnite-licenciement','indemnite-rupture-conventionnelle')
+ AND action_eventcategory = 'outil'
+ AND action_eventaction LIKE 'view_step_%'
+ AND action_eventname IN (
+ 'start','contrat_travail','info_cc',
+ 'anciennete','absences','salaires','infos','results','results_ineligible'
+ )
+GROUP BY 1, 2, 3;
+```
+
+### Quand l'utiliser
+
+- Toutes les cartes "Funnel IL/IRC" et "Taux completion" pour les simulateurs IL et IRC
+- Calcul de fenetres glissantes par semaine (4-5 semaines pour ~30 jours)
+- **Ne PAS attaquer `metabase_model_106`** pour ces simulateurs : utiliser cette MV
+
+### Pattern de fenetre glissante 30 jours
+
+```sql
+WHERE simulateur = 'indemnite-licenciement'
+ AND semaine >= DATE_TRUNC('week', CURRENT_DATE - INTERVAL '30 day')
+ AND semaine < DATE_TRUNC('week', CURRENT_DATE)
+```
+
+> Granularite hebdomadaire : la fenetre couvre 4 a 5 semaines completes selon la position dans la semaine courante. La precision exacte au jour pres n'est pas atteignable sans changer la granularite de la MV.
+
+---
+
+## 7. `mv_funnel_il_irc_visits` - Funnel IL/IRC par visite (dediee)
+
+**Role** : Pre-agrege une ligne PAR VISITE (idvisit) pour les funnels IL et IRC sur les 60 derniers jours, avec un flag booleen par etape. Permet aux cartes "Taux completion des etapes" (cards 170, 107, 448, 449) de calculer un funnel **cumulatif et monotone** sur une fenetre temporelle parametrable.
+
+**Pourquoi cette MV** :
+
+- `mv_funnel_il_irc` depend de `metabase_model_106` qui est rafraichi rarement (~mensuel) et donc systematiquement en retard pour une fenetre courante.
+- Cette MV tape directement `matomo_partitioned` (temps reel).
+- Une ligne par visite (avec flags par etape) permet une logique de funnel cumulatif robuste : "etape N atteinte" = "a fire l'evenement de l'etape N OU d'une etape ulterieure". Cette logique reste monotone meme quand des evenements manquent (deploiement recent d'une nouvelle etape, ad blocker, deep-link, etape conditionnellement masquee).
+
+**Donnees** : 60 derniers jours, source : `matomo_partitioned`.
+
+**Taille** : ~400-500k lignes (60j x ~3-5k visites/jour x 2 simulateurs).
+
+**Refresh** : `REFRESH MATERIALIZED VIEW mv_funnel_il_irc_visits;` -- a planifier en cron quotidien.
+
+**Indexes** :
+
+- `idx_mv_funnel_il_irc_visits_jour` : `(simulateur, jour)`
+
+**Cartes utilisees** :
+
+- 170 : Taux completion des etapes (IL, bar) - collection 56
+- 107 : Taux completion des etapes (IRC, bar) - collection 53
+- 448 : Taux completion des etapes (IL, funnel) - collection 56
+- 449 : Taux completion des etapes (IRC, funnel) - collection 53
+
+### Schema (10 colonnes)
+
+| Colonne | Type | Description |
+| -------------- | ---- | ------------------------------------------------------------------- |
+| `idvisit` | text | ID de la visite Matomo |
+| `simulateur` | text | `'indemnite-licenciement'` ou `'indemnite-rupture-conventionnelle'` |
+| `jour` | date | Date du premier evenement de la visite sur le simulateur |
+| `s_start` | bool | A vu l'etape Introduction (`view_step_*` name=`start`) |
+| `s_info_cc` | bool | A vu l'etape Convention collective |
+| `s_infos` | bool | A vu l'etape Informations (peut etre conditionnellement masquee) |
+| `s_anciennete` | bool | A vu l'etape Anciennete |
+| `s_absences` | bool | A vu l'etape Absences (ajoutee le 2026-03-13) |
+| `s_salaires` | bool | A vu l'etape Salaires (peut etre conditionnellement masquee) |
+| `s_results` | bool | A vu l'etape Indemnite (resultat) |
+
+### Definition SQL
+
+Voir `sql/mv_funnel_il_irc_visits.sql`.
+
+```sql
+SELECT
+ idvisit,
+ CASE
+ WHEN action_url LIKE '%/outils/indemnite-licenciement%' THEN 'indemnite-licenciement'
+ WHEN action_url LIKE '%/outils/indemnite-rupture-conventionnelle%' THEN 'indemnite-rupture-conventionnelle'
+ END AS simulateur,
+ MIN(action_timestamp)::date AS jour,
+ BOOL_OR(action_eventname = 'start') AS s_start,
+ BOOL_OR(action_eventname = 'info_cc') AS s_info_cc,
+ BOOL_OR(action_eventname = 'infos') AS s_infos,
+ BOOL_OR(action_eventname = 'anciennete') AS s_anciennete,
+ BOOL_OR(action_eventname = 'absences') AS s_absences,
+ BOOL_OR(action_eventname = 'salaires') AS s_salaires,
+ BOOL_OR(action_eventname = 'results') AS s_results
+FROM matomo_partitioned
+WHERE action_timestamp >= CURRENT_DATE - INTERVAL '60 day'
+ AND action_timestamp < CURRENT_DATE
+ AND action_type = 'event'
+ AND action_eventcategory = 'outil'
+ AND action_eventaction LIKE 'view_step_%'
+ AND action_eventname IN ('start','info_cc','infos','anciennete','absences','salaires','results')
+ AND (action_url LIKE '%/outils/indemnite-licenciement%'
+ OR action_url LIKE '%/outils/indemnite-rupture-conventionnelle%')
+GROUP BY 1, 2;
+```
+
+### Pattern funnel cumulatif parametre
+
+```sql
+WITH visits AS (
+ SELECT * FROM mv_funnel_il_irc_visits
+ WHERE simulateur = 'indemnite-licenciement'
+ AND s_start = true
+ AND jour >= {{date_debut}}
+ AND jour <= {{date_fin}}
+),
+counts(action_eventname, idx, nombre) AS (
+ SELECT 'start', 1, COUNT(*) FROM visits
+ UNION ALL SELECT 'info_cc', 2, COUNT(*) FILTER (WHERE s_info_cc OR s_infos OR s_anciennete OR s_absences OR s_salaires OR s_results) FROM visits
+ UNION ALL SELECT 'infos', 3, COUNT(*) FILTER (WHERE s_infos OR s_anciennete OR s_absences OR s_salaires OR s_results) FROM visits
+ UNION ALL SELECT 'anciennete', 4, COUNT(*) FILTER (WHERE s_anciennete OR s_absences OR s_salaires OR s_results) FROM visits
+ UNION ALL SELECT 'absences', 5, COUNT(*) FILTER (WHERE s_absences OR s_salaires OR s_results) FROM visits
+ UNION ALL SELECT 'salaires', 6, COUNT(*) FILTER (WHERE s_salaires OR s_results) FROM visits
+ UNION ALL SELECT 'results', 7, COUNT(*) FILTER (WHERE s_results) FROM visits
+)
+SELECT c.action_eventname, c.nombre,
+ ROUND(100.0 * c.nombre / NULLIF((SELECT nombre FROM counts WHERE idx=1), 0))::int AS ratio
+FROM counts c ORDER BY c.idx;
+```
+
+### Quand l'utiliser vs `mv_funnel_il_irc`
+
+| Cas d'usage | Utiliser |
+| ---------------------------------------------------------------- | ------------------------- |
+| Funnel temps reel parametre par dates (cards 170, 107, 448, 449) | `mv_funnel_il_irc_visits` |
+| Funnel hebdomadaire historique (12 mois) | `mv_funnel_il_irc` |
+| Comparaison avant/apres une refonte (dashboard 37) | `mv_funnel_il_irc` |
+
+### Pourquoi un funnel cumulatif et pas un comptage par evenement
+
+Un comptage par evenement (`COUNT visites WHERE s_etape`) suppose que tous les evenements sont fires de facon fiable. En pratique, ce n'est pas le cas :
+
+- Une nouvelle etape (ex: `absences` deployee le 2026-03-13) n'a pas d'evenements pour les visites pre-deploiement -> le comptage strict d'`absences` est plus bas que celui de `salaires`/`results` sur les fenetres a cheval sur le deploiement.
+- Une etape conditionnellement masquee (`infos`, `salaires`) n'emet pas d'evenement quand elle est masquee -> son comptage est plus bas que prevu.
+- Un user qui refresh la page sur une etape avancee ne re-fire pas les evenements des etapes precedentes.
+
+La logique cumulative ("a vu l'etape N OU une etape ulterieure") evite ces faux negatifs en faisant l'hypothese (raisonnable) qu'une visite qui a atteint l'etape N+1 a forcement franchi l'etape N. Le funnel devient strictement monotone par construction.
+
+---
+
+## 8. `mv_bounce_contributions` - Taux de rebond contributions (dediee)
+
+**Role** : Pre-agrege une ligne par couple (visite, contribution visitee) pour le calcul du taux de rebond des contributions du CDTN. Permet aux cartes "Taux de rebond" (450, 451) de calculer le pourcentage de visites qui arrivent sur une contribution avec bouton "afficher les informations generales" et qui repartent **sans aucune interaction**. Issue #7136.
+
+**Pourquoi cette MV** :
+
+- Source `matomo_partitioned` directement (temps reel, **independant** de `metabase_model_106` et de son retard).
+- Granularite par (idvisit, pathname) avec un flag booleen par type d'interaction -> permet de calculer le rebond avec une seule requete simple sur une fenetre temporelle parametrable.
+- Filtre optimise sur `action_url LIKE '%/contribution/%'` pour eviter de scanner toute la table.
+
+**Donnees** : 60 derniers jours, source : `matomo_partitioned`.
+
+**Taille** : ~1.5M lignes (60j x ~10k visites/jour x ~3 contributions visitees/visite en moyenne).
+
+**Refresh** : `REFRESH MATERIALIZED VIEW mv_bounce_contributions;` -- a planifier en cron quotidien.
+
+**Indexes** :
+
+- `idx_mv_bounce_contributions_jour` : `(jour, pathname)`
+- `idx_mv_bounce_contributions_path` : `(pathname)`
+
+**Cartes utilisees** :
+
+- 450 : Taux de rebond - Global (scalar) - collection 88
+- 451 : Taux de rebond - Par contribution (table) - collection 88
+
+### Schema (9 colonnes)
+
+| Colonne | Type | Description |
+| ------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `idvisit` | text | ID de la visite Matomo |
+| `pathname` | text | Chemin de la contribution (`/contribution/...`, query string strippe) |
+| `jour` | date | Date du premier evenement de la visite sur cette contribution |
+| `has_pageview` | bool | A vu la page (`action_type = 'action'`) |
+| `has_interaction` | bool | A fait au moins une interaction (toute combinaison des autres flags) |
+| `has_click_generic` | bool | A clique sur "afficher les informations generales" |
+| `has_click_cc` | bool | A clique sur "afficher les informations CC" (parcours personnalise reussi) |
+| `has_click_sans_cc` | bool | A clique sur "afficher les informations sans ma CC" |
+| `has_cc_search` | bool | A interagi avec la recherche de CC (`cc_search`, `cc_select_*`, `click_p1/p2/p3`, `enterprise_*`, `je_n_ai_pas_d_entreprise`) |
+
+### Definition SQL
+
+Voir `sql/mv_bounce_contributions.sql`.
+
+```sql
+WITH contrib_actions AS (
+ SELECT
+ idvisit,
+ substring(action_url, 'https?://[^/]+(/[^?#]*)') AS pathname,
+ DATE_TRUNC('day', action_timestamp)::date AS jour,
+ action_type, action_eventcategory, action_eventaction
+ FROM matomo_partitioned
+ WHERE action_timestamp >= CURRENT_DATE - INTERVAL '60 day'
+ AND action_timestamp < CURRENT_DATE
+ AND action_url LIKE '%/contribution/%'
+ AND substring(action_url, 'https?://[^/]+(/[^?#]*)') LIKE '/contribution/%'
+)
+SELECT
+ idvisit, pathname, MIN(jour) AS jour,
+ BOOL_OR(action_type = 'action') AS has_pageview,
+ BOOL_OR(action_type = 'event' AND (
+ action_eventcategory IN ('contribution','cc_search','cc_search_type_of_users','cc_select_p1','cc_select_p2','enterprise_search','enterprise_select')
+ OR action_eventaction IN ('click_p1','click_p2','click_p3','click_je_n_ai_pas_d_entreprise','select_je_n_ai_pas_d_entreprise',
+ 'click_afficher_les_informations_CC','click_afficher_les_informations_sans_CC',
+ 'click_afficher_les_informations_générales','click_afficher_les_informations_generales')
+ )) AS has_interaction,
+ BOOL_OR(action_eventaction IN ('click_afficher_les_informations_générales','click_afficher_les_informations_generales')) AS has_click_generic,
+ BOOL_OR(action_eventaction = 'click_afficher_les_informations_CC') AS has_click_cc,
+ BOOL_OR(action_eventaction = 'click_afficher_les_informations_sans_CC') AS has_click_sans_cc,
+ BOOL_OR(
+ action_eventcategory IN ('cc_search','cc_search_type_of_users','cc_select_p1','cc_select_p2','enterprise_search','enterprise_select')
+ OR action_eventaction IN ('click_p1','click_p2','click_p3','click_je_n_ai_pas_d_entreprise','select_je_n_ai_pas_d_entreprise')
+ ) AS has_cc_search
+FROM contrib_actions
+GROUP BY idvisit, pathname;
+```
+
+### Pattern : taux de rebond global (filtre = contributions avec bouton generique)
+
+```sql
+WITH contribs_avec_generique AS (
+ SELECT DISTINCT pathname FROM mv_bounce_contributions WHERE has_click_generic
+),
+filtered AS (
+ SELECT v.* FROM mv_bounce_contributions v
+ INNER JOIN contribs_avec_generique g ON g.pathname = v.pathname
+ WHERE v.has_pageview
+ AND v.jour >= {{date_debut}}
+ AND v.jour <= {{date_fin}}
+)
+SELECT
+ COUNT(*) AS total_visites,
+ COUNT(*) FILTER (WHERE NOT has_interaction) AS bounces,
+ ROUND(100.0 * COUNT(*) FILTER (WHERE NOT has_interaction) / NULLIF(COUNT(*), 0), 1) AS taux_rebond
+FROM filtered;
+```
+
+### Pourquoi filtrer sur les contributions avec bouton generique
+
+Sur ~2349 contributions visitees, **41 seulement** ont un bouton "afficher les informations generales" (donc une reponse non-personnalisee disponible). Les autres sont CC-specifiques (ex: `/contribution/3248-quel-est-le-salaire-minimum`) et ne peuvent pas etre "rebondies" au sens du KPI : le user n'a aucun choix autre que de chercher sa CC.
+
+Le filtre `pathname IN (SELECT DISTINCT pathname FROM mv_bounce_contributions WHERE has_click_generic)` identifie automatiquement ces 41 contributions en se basant sur l'historique d'evenements (toute contribution qui a fire au moins une fois `click_afficher_les_informations_générales` dans la fenetre 60j).
+
+> Si une nouvelle contribution avec bouton generique est ajoutee mais n'a pas encore de clic dans la fenetre, elle sera **manquee** par le filtre. Le filtre est probabiliste mais robuste apres quelques jours d'usage.
+
+### Definition du "rebond"
+
+Une visite est consideree comme un rebond sur une contribution si :
+
+1. Elle a vu la page (`has_pageview = true`) sur une contribution avec bouton generique
+2. ET elle n'a fait **aucune** interaction (`has_interaction = false`) :
+ - pas de recherche CC
+ - pas de clic sur le bouton secondaire generique
+ - pas de clic sur les informations CC ou sans-CC
+
+Voir issue [#7136](https://github.com/SocialGouv/code-du-travail-numerique/issues/7136) pour le contexte produit.
+
+---
+
+## 9. `mv_cc_non_traitees` - CC non traitees 2025 (dediee)
+
+**Role** : Pre-agrege les selections de CC non traitees par nom de CC pour l'annee 2025.
+
+**Donnees** : Annee 2025 (source : `matomo_partitioned`).
+
+**Taille** : ~800 lignes, ~80 KB.
+
+**Refresh** : Statique (donnees 2025). Pas besoin de rafraichir.
+
+**Cartes utilisees** : 442 (KPI 5 - CC non traitees 2025).
+
+### Schema (3 colonnes)
+
+| Colonne | Type | Description |
+| ----------------- | ------ | ---------------------------------- |
+| `cc_name` | text | Nom/ID de la convention collective |
+| `nb_utilisateurs` | bigint | Nombre d'utilisateurs distincts |
+| `nb_selections` | bigint | Nombre total de selections |
+
+### Definition SQL
+
+```sql
+CREATE MATERIALIZED VIEW mv_cc_non_traitees AS
+SELECT action_eventname AS cc_name,
+ COUNT(DISTINCT idvisit) AS nb_utilisateurs,
+ COUNT(*) AS nb_selections
+FROM matomo_partitioned
+WHERE action_type = 'event' AND action_eventcategory = 'outil'
+ AND action_eventaction = 'cc_select_non_traitée'
+ AND action_eventname IS NOT NULL AND action_eventname != ''
+ AND action_timestamp >= '2025-01-01' AND action_timestamp < '2026-01-01'
+GROUP BY action_eventname;
+```
+
+---
+
+## Ordre de refresh des MV
+
+```sql
+-- 1. MV source principale (recompute les donnees brutes)
+REFRESH MATERIALIZED VIEW metabase_model_106;
+
+-- 2. MV dediees qui dependent de metabase_model_106
+-- mv_kpi_personnalisation : DROP + CREATE (voir sql/mv_kpi_personnalisation.sql)
+DROP MATERIALIZED VIEW IF EXISTS mv_kpi_personnalisation;
+-- ... coller la requete CREATE depuis 06_mv_kpi_personnalisation.sql ...
+
+-- 3. MV simples (REFRESH)
+REFRESH MATERIALIZED VIEW visites_uniques;
+REFRESH MATERIALIZED VIEW mv_perso_weekly;
+REFRESH MATERIALIZED VIEW mv_funnel_il_irc;
+REFRESH MATERIALIZED VIEW mv_funnel_il_irc_visits;
+REFRESH MATERIALIZED VIEW mv_bounce_contributions;
+REFRESH MATERIALIZED VIEW commentaires_utilisateurs;
+-- mv_cc_non_traitees est statique, pas de refresh necessaire
+```
+
+---
+
+## Autres vues (test)
+
+- `test_view_2` : `SELECT 1` (test)
+- `mv_test_bidon` : Table de test avec valeurs statiques
diff --git a/packages/metabase/docs/models.md b/packages/metabase/docs/models.md
new file mode 100644
index 00000000000..96267600e19
--- /dev/null
+++ b/packages/metabase/docs/models.md
@@ -0,0 +1,353 @@
+# Modeles Metabase et Patterns Optimises
+
+## Modeles Metabase (100 modeles / datasets)
+
+### Arborescence des collections (76 collections)
+
+```
+root (Nos analyses)
+├── 2: Examples (cards Sample Database)
+├── 14: CDTN
+│ ├── 16: Outils
+│ │ ├── 19: Indemnite rupture conventionnelle
+│ │ │ ├── 50: Rapport mensuel
+│ │ │ ├── 51: Satisfaction
+│ │ │ ├── 53: Taux completion
+│ │ │ └── 85: Satisfaction Top 100
+│ │ ├── 20: Indemnite licenciement
+│ │ │ ├── 54: Rapport mensuel
+│ │ │ ├── 55: Satisfaction
+│ │ │ ├── 56: Taux completion
+│ │ │ └── 84: Satisfaction Top 100
+│ │ ├── 21: Preavis licenciement (69-71)
+│ │ ├── 22: Preavis demission (63-65)
+│ │ ├── 23: Indemnite precarite (57-59)
+│ │ ├── 24: Heures recherche emploi (60-62)
+│ │ ├── 25: Preavis depart retraite (66-68)
+│ │ └── 79: Trouver convention collective
+│ │ ├── 80: Satisfaction
+│ │ ├── 81: Popularite
+│ │ └── 82: Taux completion
+│ ├── 29: General
+│ │ ├── 31: Satisfaction
+│ │ ├── 32: Popularite
+│ │ └── 33: Rapport mensuel
+│ ├── 34: Contributions
+│ │ ├── 35: Popularite
+│ │ ├── 36: Rapport mensuel
+│ │ ├── 37: Satisfaction
+│ │ └── 83: Taux completion
+│ ├── 38: Informations (39-41)
+│ ├── 42: Modeles de documents (43-45)
+│ ├── 46: Convention collectives (47-49)
+│ ├── 73: Quoi de neuf (74-75)
+│ └── 76: Infographies (77-78)
+└── Collections personnelles (4-10, 26, 72, 86-87, etc.)
+```
+
+---
+
+## Modeles principaux (datasets)
+
+### Modele racine : `matomo enriched` (Card 106)
+
+- **Collection** : CDTN (14)
+- **Base** : Vue materialisee `metabase_model_106`
+- **Colonnes** : idvisit, action_type, action_eventcategory, action_eventaction, action_eventname, action_eventvalue, action_timestamp, action_url, referrertype, referrername, pathname
+- **Utilise comme source** par la plupart des dashboards
+
+```sql
+SELECT idvisit, action_type, action_eventcategory, action_eventaction,
+ action_eventname, action_eventvalue, action_timestamp,
+ action_url, referrertype, referrername, pathname
+FROM metabase_model_106;
+```
+
+### Modele secondaire : `Visites uniques par chemin et par mois` (Card 151)
+
+- **Collection** : Rapport mensuel (33)
+- **Base** : Vue materialisee `visites_uniques`
+- **Utilise par** : Card 69, 339, et toutes les cards "Visites uniques par mois"
+
+```sql
+SELECT * FROM visites_uniques;
+```
+
+### Modele : `Nombre Visites uniques par mois` (Card 339)
+
+- **Collection** : CDTN (14)
+- **Source** : Card 151 (`visites_uniques`)
+
+```sql
+SELECT month, pathname, COUNT(DISTINCT idvisit) AS visit_count
+FROM {{#151-visites-uniques-par-chemin-et-par-mois}}
+WHERE month >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '3 month')
+GROUP BY month, pathname
+ORDER BY visit_count DESC;
+```
+
+### Modele : `Top pages` (Card 69)
+
+- **Collection** : Popularite (32)
+- **Source** : Card 151 (`visites_uniques`)
+
+```sql
+WITH top AS (
+ SELECT pathname,
+ COUNT(*) FILTER (WHERE month = DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 month')) AS one_month_ago_visits
+ FROM {{#151-visites-uniques-par-chemin-et-par-mois}}
+ GROUP BY pathname ORDER BY one_month_ago_visits DESC LIMIT 10
+)
+SELECT v.pathname, v.month, COUNT(*) AS visits
+FROM {{#151-visites-uniques-par-chemin-et-par-mois}} AS v
+INNER JOIN top t ON t.pathname = v.pathname
+WHERE v.month >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '3 month')
+GROUP BY v.pathname, v.month;
+```
+
+### Modele : `Evolution par chemin` (Card 341)
+
+- **Collection** : Contributions - Popularite (35)
+- **Source** : Card 339
+
+```sql
+WITH list AS (
+ SELECT pathname,
+ sum(visit_count) FILTER (WHERE month = DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 month')) AS one_month_ago_visits,
+ sum(visit_count) FILTER (WHERE month = DATE_TRUNC('month', CURRENT_DATE - INTERVAL '2 month')) AS two_month_ago_visits
+ FROM {{#339-nombre-visites-uniques-par-mois}}
+ GROUP BY pathname
+)
+SELECT pathname, (one_month_ago_visits - two_month_ago_visits) * 100.0 / two_month_ago_visits AS ratio
+FROM list WHERE two_month_ago_visits > 0 AND one_month_ago_visits > 0;
+```
+
+### Modele : `ratio_feedback_par_page` (Card 75)
+
+- **Collection** : General (29)
+- **Source** : Directement `metabase_model_106`
+
+```sql
+SELECT pathname,
+ COUNT(CASE WHEN action_eventaction = 'positive' THEN 1 END) -
+ COUNT(CASE WHEN action_eventaction = 'negative' THEN 1 END) AS satisfaction,
+ COUNT(CASE WHEN action_eventaction = 'positive' THEN 1 END) AS nb_positive,
+ COUNT(CASE WHEN action_eventaction = 'negative' THEN 1 END) AS nb_negative
+FROM metabase_model_106
+WHERE action_type = 'event' AND action_eventcategory = 'feedback'
+ AND action_timestamp >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 month')
+ AND action_timestamp < DATE_TRUNC('month', CURRENT_DATE)
+GROUP BY pathname;
+```
+
+### Modele : `Ratio avis utilisateurs` (Card 193)
+
+- **Collection** : Satisfaction (31)
+- **Source** : `metabase_model_106`
+
+```sql
+SELECT DATE_TRUNC('month', action_timestamp) AS month,
+ CASE WHEN COUNT(CASE WHEN action_eventaction = 'negative' THEN 1 END) = 0 THEN 100
+ ELSE (COUNT(CASE WHEN action_eventaction = 'positive' THEN 1 END)::FLOAT /
+ COUNT(CASE WHEN action_eventaction = ANY(ARRAY['negative', 'positive']) THEN 1 END)) * 100
+ END AS pourcentage_positive_negative
+FROM metabase_model_106
+WHERE action_type = 'event' AND action_eventcategory = 'feedback'
+ AND action_timestamp >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '2 month')
+GROUP BY DATE_TRUNC('month', action_timestamp);
+```
+
+### Modele : Funnel Trouver CC - `Nombre visites par etapes P1` (Card 374)
+
+- **Collection** : Trouver convention collective - Taux completion (82)
+- **Source** : `metabase_model_106`
+- **Utilise** : `path_level2`, `path_level3` pour filtrer
+
+```sql
+WITH list AS (
+ SELECT DISTINCT idvisit,
+ CASE WHEN action_eventcategory = 'cc_search_type_of_users' THEN action_eventaction ELSE action_eventcategory END AS action,
+ month
+ FROM metabase_model_106
+ WHERE path_level2 = 'outils' AND path_level3 = 'convention-collective'
+ AND action_type = 'event'
+ AND (action_eventaction = 'click_p1' OR action_eventcategory = 'cc_select_p1')
+ AND month >= date_trunc('month', CURRENT_DATE - INTERVAL '2 month')
+)
+SELECT action, month, COUNT(*) AS nombre FROM list GROUP BY action, month;
+```
+
+### Modele : Funnel Trouver CC P2 (Card 376)
+
+```sql
+WITH list AS (
+ SELECT idvisit,
+ array_agg(CASE WHEN action_eventaction = 'click_p2' THEN 'click_p2' ELSE action_eventcategory END) AS actions, MONTH
+ FROM metabase_model_106
+ WHERE path_level2 = 'outils' AND path_level3 = 'convention-collective'
+ AND action_type = 'event'
+ AND (action_eventaction = 'click_p2' OR action_eventcategory IN ('cc_select_p2', 'enterprise_search', 'enterprise_select'))
+ AND MONTH >= date_trunc('month', CURRENT_DATE - INTERVAL '2 month')
+ GROUP BY idvisit, MONTH
+),
+filtered_list AS (
+ SELECT DISTINCT idvisit, unnest(actions) AS action, MONTH FROM list WHERE 'click_p2' = any(actions)
+)
+SELECT action, MONTH, COUNT(*) AS visits FROM filtered_list GROUP BY action, MONTH;
+```
+
+### Modele : Funnel Contributions P2 (Card 379)
+
+```sql
+WITH list AS (
+ SELECT idvisit,
+ array_agg(DISTINCT CASE WHEN action_eventaction IN ('click_p2', 'cc_select_traitee') THEN action_eventaction ELSE action_eventcategory END) AS actions,
+ MONTH
+ FROM metabase_model_106
+ WHERE path_level2 = 'contribution' AND action_type = 'event'
+ AND (action_eventaction IN ('click_p2', 'cc_select_traitee') OR action_eventcategory IN ('cc_select_p2', 'enterprise_select', 'enterprise_search'))
+ AND MONTH >= date_trunc('month', CURRENT_DATE - INTERVAL '2 month')
+ GROUP BY idvisit, month
+),
+filtered_list AS (
+ SELECT idvisit, unnest(actions) AS action, MONTH FROM list WHERE 'click_p2' = any(actions)
+)
+SELECT action, MONTH, COUNT(*) AS visits FROM filtered_list GROUP BY action, MONTH;
+```
+
+---
+
+## Modeles par simulateur (pattern "visites par etapes")
+
+Chaque simulateur a historiquement le meme pattern de 3 cards en chaine :
+
+| Simulateur | Visites etapes | Nombre etapes | Taux completion |
+| ----------------------- | -------------- | ------------- | ----------------------------------- |
+| Indemnite licenciement | 168 | 169 | **170** (migre -> mv_funnel_il_irc) |
+| Indemnite precarite | 174 | 180 | 186 |
+| Indemnite rupture conv. | 105 | 167 | **107** (migre -> mv_funnel_il_irc) |
+| Preavis demission | 172 | 178 | 182 |
+| Preavis depart retraite | 176 | 177 | 184 |
+| Preavis licenciement | 173 | 181 | 183 |
+| Heures recherche emploi | 175 | 179 | 185 |
+| Rupture conv. (autre) | 189 | 191 | - |
+| Rupture conv. (autre 2) | 187 | 192 | - |
+| Rupture conv. (autre 3) | 188 | 190 | - |
+
+Pattern historique (encore actif pour les autres simulateurs) :
+
+```
+Card 106 (matomo enriched) -> Card 168 (filtre pathname) -> Card 169 (GROUP BY) -> Card 170 (ratio)
+```
+
+> **IL et IRC** : les cartes de taux completion (170 et 107) ne suivent plus la chaine 168/169 ni 105/167/190/191/192. Elles attaquent directement la MV `mv_funnel_il_irc` (voir `materialized-views.md`) avec une fenetre glissante de 30 jours. Les cards intermediaires (168, 169, 105, 167, 187, 188, 189, 190, 191, 192) restent presentes mais sont **orphelines** ; ne pas les utiliser dans de nouvelles cartes - ajouter les nouvelles etapes a la MV plutot que de les ressusciter.
+
+---
+
+## Patterns de requetes optimises
+
+### Pattern 1 : Chaine de cards (3 niveaux)
+
+```
+Card 106 (matomo enriched)
+ -> Card 168 (visites par etapes - filtre pathname + events)
+ -> Card 169 (nombre visites par etapes - GROUP BY)
+ -> Card 170 (taux completion - ratio / start)
+```
+
+**Avantage** : Chaque card est un cache reutilisable. Metabase peut cacher les resultats intermediaires.
+
+### Pattern 2 : Requete directe sur `matomo_partitioned` avec filtres temporels
+
+Pour les nouvelles cards (funnels, taux de conversion) ou les requetes sur plus d'1 an.
+
+```sql
+SELECT ... FROM matomo_partitioned
+WHERE action_timestamp >= DATE '2026-02-16'
+ AND action_eventcategory = 'outil'
+ AND action_eventaction = 'view_step_Indemnité de licenciement'
+GROUP BY idvisit, DATE_TRUNC('week', action_timestamp);
+```
+
+### Pattern 3 : Funnel avec CASE WHEN MAX
+
+Pour les taux de completion par simulateur.
+
+```sql
+WITH funnel_steps AS (
+ SELECT idvisit,
+ MAX(CASE WHEN action_eventname = 'start' THEN 1 ELSE 0 END) AS reached_start,
+ MAX(CASE WHEN action_eventname = 'results' THEN 1 ELSE 0 END) AS reached_result
+ FROM matomo_partitioned
+ WHERE action_timestamp BETWEEN DATE '2026-01-15' AND DATE '2026-02-15'
+ AND action_eventcategory = 'outil'
+ AND action_url LIKE '%indemnite-licenciement%'
+ AND NOT (action_eventaction LIKE 'click_previous%')
+ GROUP BY idvisit
+)
+SELECT 'start' AS etape, SUM(reached_start) FROM funnel_steps WHERE reached_start = 1
+UNION ALL SELECT 'results', SUM(reached_result) FROM funnel_steps WHERE reached_start = 1;
+```
+
+### Pattern 4 : Funnel CC avec array_agg
+
+Pour les parcours multi-chemins (Trouver CC, Contributions).
+
+```sql
+WITH list AS (
+ SELECT idvisit,
+ array_agg(DISTINCT ...) AS actions, MONTH
+ FROM metabase_model_106
+ WHERE path_level2 = 'contribution' AND ...
+ GROUP BY idvisit, month
+),
+filtered_list AS (
+ SELECT idvisit, unnest(actions) AS action, MONTH
+ FROM list WHERE 'click_p2' = any(actions)
+)
+SELECT action, MONTH, COUNT(*) FROM filtered_list GROUP BY action, MONTH;
+```
+
+---
+
+## Reference Card Template Tags
+
+```
+{{#106-matomo-enriched}} -> Modele principal
+{{#151-visites-uniques-par-chemin-et-par-mois}} -> Visites uniques
+{{#339-nombre-visites-uniques-par-mois}} -> Comptage visites
+{{#169-indemnite-licenciement-nombre-visites-par-etapes}} -> Etapes IL
+{{#379-nombre-visites-par-etapes-p2}} -> Funnel Contributions P2
+{{#376-nombre-visites-par-etapes-p2}} -> Funnel Trouver CC P2
+```
+
+---
+
+## Optimisation Performance
+
+### Regles cles
+
+1. **Toujours filtrer sur `action_timestamp`** - PostgreSQL n'interroge que les partitions pertinentes
+2. **Utiliser `metabase_model_106`** pour les 12 derniers mois (pathname deja calcule, 1 an de data)
+3. **Utiliser `visites_uniques`** pour les comptages de visites par page/mois (deja dedoublonne)
+4. **Limiter les `COUNT(DISTINCT idvisit)`** - cher sur de gros volumes
+5. **Grouper par `action_eventcategory` + `action_eventaction`** avant les jointures
+6. **Chaines de cards** pour les sous-requetes reutilisables (Metabase met en cache)
+7. **`path_level2` / `path_level3`** du modele pour filtrer par type de contenu sans regexp
+
+### Quand utiliser quoi
+
+| Source | Periode | Performance | Colonnes speciales |
+| --------------------------- | ---------------- | ------------------- | ----------------------------------------- |
+| `metabase_model_106` | 12 derniers mois | Rapide (MV) | pathname, path_level2, path_level3, month |
+| `visites_uniques` | 13 derniers mois | Tres rapide | idvisit dedoublonne par pathname/mois |
+| `commentaires_utilisateurs` | 13 derniers mois | Tres rapide | feedback joint avec feedback_category |
+| `matomo_partitioned` | Toute periode | OK avec filtre date | action_url (a parser) |
+
+### Refresher les vues materialisees
+
+```sql
+REFRESH MATERIALIZED VIEW metabase_model_106;
+REFRESH MATERIALIZED VIEW commentaires_utilisateurs;
+REFRESH MATERIALIZED VIEW visites_uniques;
+```
diff --git a/packages/metabase/docs/schema.md b/packages/metabase/docs/schema.md
new file mode 100644
index 00000000000..6a669a39f08
--- /dev/null
+++ b/packages/metabase/docs/schema.md
@@ -0,0 +1,87 @@
+# Base de donnees OVH PG CDTN (ID: 4)
+
+## Connexion
+
+- **URL Metabase** : `https://metabase-cdtn.fabrique.social.gouv.fr`
+- **Database Metabase ID** : `4` (OVH PG CDTN)
+- **Engine** : PostgreSQL
+
+---
+
+## Table principale : `matomo_partitioned`
+
+Table partitionnee hebdomadairement. La table parent `matomo_partitioned` unionise toutes les partitions automatiquement avec filtre sur `action_timestamp`.
+
+### Colonnes (39 colonnes)
+
+| Colonne | Type | Description |
+| ----------------------------- | ----------- | ---------------------------------------- |
+| `action_id` | text | ID unique de l'action |
+| `idsite` | text | ID du site Matomo |
+| `idvisit` | text | ID de visite (utilisateur session) |
+| `actions` | text | Nombre d'actions dans la visite |
+| `country` | text | Pays du visiteur |
+| `region` | text | Region |
+| `city` | text | Ville |
+| `operatingsystemname` | text | OS |
+| `devicemodel` | text | Modele appareil |
+| `devicebrand` | text | Marque appareil |
+| `visitduration` | text | Duree de visite |
+| `dayssincefirstvisit` | text | Jours depuis 1ere visite |
+| `visitortype` | text | Type visiteur (new/returning) |
+| `sitename` | text | Nom du site |
+| `userid` | text | ID utilisateur |
+| `serverdateprettyfirstaction` | date | Date premiere action |
+| **`action_type`** | text | Type d'action (`event`, page view, etc.) |
+| **`action_eventcategory`** | text | Categorie de l'evenement |
+| **`action_eventaction`** | text | Action de l'evenement |
+| **`action_eventname`** | text | Nom de l'evenement |
+| **`action_eventvalue`** | numeric | Valeur de l'evenement |
+| `action_timespent` | text | Temps passe sur l'action |
+| **`action_timestamp`** | timestamptz | Horodatage de l'action |
+| `usercustomproperties` | json | Proprietes custom utilisateur |
+| `usercustomdimensions` | json | Dimensions custom |
+| `dimension1` - `dimension10` | text | Dimensions Matomo (custom) |
+| **`action_url`** | text | URL complete de l'action |
+| `sitesearchkeyword` | text | Mot-cle de recherche |
+| `action_title` | text | Titre de la page |
+| `visitorid` | text | ID visiteur |
+| `referrertype` | text | Type de referent |
+| `referrername` | text | Nom du referent |
+| `resolution` | text | Resolution ecran |
+
+### Colonnes cles pour les requetes
+
+- **`action_type`** : toujours filtrer sur `'event'` pour les evenements tracks
+- **`action_eventcategory`** + **`action_eventaction`** + **`action_eventname`** : identifient l'evenement
+- **`action_url`** : contient l'URL complete (`https://code.travail.gouv.fr/...`)
+- **`action_timestamp`** : toujours filtrer avec un range de dates pour la performance
+- **`idvisit`** : identifiant de session utilisateur (pour COUNT DISTINCT)
+
+### Extraction du chemin depuis action_url
+
+```sql
+-- Extraire le pathname sans query params
+regexp_replace(
+ regexp_replace(action_url, 'https://code.travail.gouv.fr(/[^?]+).*', '\1'),
+ 'https://code.travail.gouv.fr', ''
+)
+```
+
+---
+
+## Partitions hebdomadaires
+
+80 partitions de `matomo_partitioned_2024w40` a `matomo_partitioned_2026w15`.
+
+Format : `matomo_partitioned_YYYYwWW`
+
+- Les partitions sont automatiquement unions par la table parent `matomo_partitioned`
+- Toujours filtrer sur `action_timestamp` pour que PostgreSQL n'interroge que les partitions pertinentes
+- Pour des requetes tres lourdes, cibler directement une partition (ex: `matomo_partitioned_2026w14`)
+
+---
+
+## Table brute : `matomo` (ID DB Metabase: 3)
+
+Base Matomo directe, meme schema que `matomo_partitioned` mais sans partitions. Moins performante.
diff --git a/packages/metabase/events/CLAUDE.md b/packages/metabase/events/CLAUDE.md
new file mode 100644
index 00000000000..dfe9e66ad0b
--- /dev/null
+++ b/packages/metabase/events/CLAUDE.md
@@ -0,0 +1,111 @@
+# events/ — consignes Claude
+
+**Pipeline complet** du glossaire d'events Matomo du CDTN : schema, metadata metier, donnees extraites, et les 3 scripts qui font tourner le tout.
+
+## Contenu du dossier
+
+| Fichier | Role | Maintenance |
+| ------------------------ | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
+| `events.schema.ts` | Types TS partages (`ExtractedEvent`, `EventMetadata`, `MatomoConfigCall`, `EventsExtraction`). | Manuel, rare. |
+| `events.metadata.yaml` | Description **metier** de chaque event (label, trigger, KPI, dashboards, cards). | **Manuel**. A maintenir a chaque ajout/renommage d'event. |
+| `events.extracted.json` | Ground truth **technique** extraite du code (category, action, fichier:ligne, tracking_method). | **Auto-genere** par `extract-events.ts`. Tracke en git pour voir les diffs en PR. |
+| `extract-events.ts` | Scanne les fichiers du frontend via ts-morph, ecrit `events.extracted.json`. | Manuel, rare. |
+| `generate-events-doc.ts` | Joint extracted + metadata → `../docs/events.md`. | Manuel, rare. |
+| `check-events-drift.ts` | Rejoue le pipeline en memoire et compare. Exit 1 si drift. Utilise en precommit + CI. | Manuel, rare. |
+
+## Pipeline
+
+```
+packages/code-du-travail-frontend/src/modules/**/*.{ts,tsx} (sauf __tests__/)
+ | extract-events.ts (AST ts-morph)
+ | Detecte :
+ | - sendEvent({ category, action, name? })
+ | - push(["trackEvent"|"trackSiteSearch"|"trackPageView"|"trackGoal"|"trackLink"|"trackContentImpression"|"trackContentInteraction", ...])
+ | - _paq.push([...]) / paq.push([...])
+ | Range les autres push([cmd, ...]) (setReferrerUrl, AbTesting::create, HeatmapSessionRecording::*, opt-out, etc.) dans `matomo_config_calls` (non-events).
+ v
+events/events.extracted.json
+ +
+events/events.metadata.yaml
+ | generate-events-doc.ts (join par ":" avec fallback ":*")
+ v
+packages/metabase/docs/events.md
+```
+
+## Commandes (definies dans `../package.json`)
+
+```bash
+pnpm -F @cdt/metabase events:extract # extract-events.ts seul
+pnpm -F @cdt/metabase events:docs # extract + generate
+pnpm -F @cdt/metabase events:check # drift check (precommit + CI)
+```
+
+## Workflow "j'ajoute un nouvel event"
+
+1. Dev ajoute un `sendEvent({ category, action, name? })` dans un fichier du frontend (n'importe ou dans `src/modules/**`, pas seulement dans un `tracking.ts`).
+2. `pnpm -F @cdt/metabase events:docs`.
+3. Le script extrait l'event et l'ajoute a `events.extracted.json`. Comme il n'a pas encore de description metier, il apparait dans la section **"Orphelins"** de `docs/events.md`.
+4. Dev ajoute une entree dans `events.metadata.yaml` avec la cle `":"` (ou un wildcard `":*"` si tous les events d'une categorie partagent la meme description).
+5. Dev relance `pnpm events:docs` → l'event passe de "Orphelins" a sa section `feature_group`.
+6. Dev commit `events.metadata.yaml`, `events.extracted.json`, `docs/events.md` dans la meme PR. Le precommit (husky → `events:check`) bloque sinon.
+
+## Workflow "je renomme un event"
+
+1. Dev modifie le `sendEvent` et / ou son enum associe.
+2. `pnpm events:docs` → l'ancienne cle apparait dans "Metadata orpheline", la nouvelle soit normalement (si metadata.yaml a suivi) soit dans "Orphelins".
+3. Dev corrige `events.metadata.yaml` (renomme la cle) et relance.
+
+## Workflow "je supprime un event"
+
+1. Dev retire le `sendEvent` du code.
+2. `pnpm events:docs` → l'ancienne cle apparait dans "Metadata orpheline".
+3. Dev supprime l'entree de `events.metadata.yaml` (ou la garde avec `deprecated: true` + `notes:` pour historique).
+4. Dev relance et commit.
+
+## Regles pour `events.metadata.yaml`
+
+- **Cle** : `":"` entre guillemets (certains actions contiennent des caracteres speciaux comme `é`).
+- **Champs obligatoires** : `label_fr`, `trigger`, `feature_group`.
+- **Champs optionnels** : `kpi`, `dashboards`, `cards`, `mv_source`, `since`, `deprecated`, `notes`.
+- **Wildcard** `":*"` : utilise pour couvrir en bulk tous les events d'une categorie (ex: `"search:*"` → une seule entree couvre tous les actions de `search`). Le wildcard est un **fallback** : une cle exacte prend toujours le dessus.
+- **`feature_group`** : groupe de regroupement dans `docs/events.md`. Valeurs recommandees : `contributions`, `cc-search`, `simulateurs`, `feedback`, `recherche`, `home`, `share`, `contact`, `documents`, `header`, `navigation`, `enterprise`. Ne pas multiplier les groupes (vise la lisibilite de la TOC).
+
+## Regles pour les scripts (`extract-events.ts`, `generate-events-doc.ts`, `check-events-drift.ts`)
+
+- **Deterministe** : la sortie doit etre stable entre runs (tri par `(category, action, file, line)`). Sinon drift check echoue en boucle.
+- **Pas de dependance cyclique** : les scripts n'importent que depuis `events.schema.ts` et les deps listees dans `../package.json` (`ts-morph`, `tsx`, `yaml`, `@types/node`).
+- **Ne pas importer depuis `../../code-du-travail-frontend`** : on **parse** les fichiers source avec ts-morph, on ne les **execute** pas.
+- **Fail-safe** : si un `sendEvent(...)` a un `category` ou `action` dynamique non-resolu, l'event est liste dans `unresolved` de `events.extracted.json` (pas en silence).
+
+### Resolution des enums
+
+`extract-events.ts` construit une map globale `EnumName.Member -> "valeur"` en scannant tous les fichiers charges. Pour resoudre une reference comme `TrackingContributionCategory.TOOL`, le script cherche les 2 derniers segments (ce qui gere aussi les acces namespaces comme `analytics.MatomoBaseEvent.OUTIL`). Si un nouveau pattern apparait (enum local anonyme, object literal), ajouter un cas dans `resolveExpression`.
+
+### Debug rapide
+
+```bash
+pnpm -F @cdt/metabase events:extract
+cat packages/metabase/events/events.extracted.json | jq '.events | length'
+cat packages/metabase/events/events.extracted.json | jq '.unresolved'
+cat packages/metabase/events/events.extracted.json | jq '.matomo_config_calls'
+```
+
+## Exemple de metadata
+
+```yaml
+"contribution:click_afficher_les_informations_CC":
+ label_fr: "Affichage de la reponse personnalisee sur une page contribution"
+ trigger: "Clic sur le bouton 'afficher les informations personnalisees' apres selection d'une CC TRAITEE"
+ kpi: "Personnalisation reussie (KPI 1 dashboard 36)"
+ dashboards: [36]
+ cards: [435, 438, 443]
+ mv_source: "mv_kpi_personnalisation"
+ feature_group: "contributions"
+```
+
+## Ne pas faire
+
+- Editer `events.extracted.json` a la main : il est reecrit a chaque run.
+- Ajouter une entree `events.metadata.yaml` pour un event qui n'existe pas encore dans le code : elle apparaitra en "Metadata orpheline" et polluera la CI.
+- Ajouter dans les scripts des regex de "nom d'event connu" : le but est que le script soit l'autorite. Si un event n'apparait pas, c'est que le code ne le fire pas → corriger le code, pas le script.
+- Ajouter des dependances runtime (le package `@cdt/metabase` reste dev-only).
diff --git a/packages/metabase/events/check-events-drift.ts b/packages/metabase/events/check-events-drift.ts
new file mode 100644
index 00000000000..b9fac4f030c
--- /dev/null
+++ b/packages/metabase/events/check-events-drift.ts
@@ -0,0 +1,80 @@
+// check-events-drift.ts
+// ----------------------------------------------------------------------------
+// Re-lance extract + generate en memoire et compare le resultat avec les
+// fichiers actuellement committes (docs/events.md et events/events.extracted.json).
+// Si un diff existe → exit 1 avec un message explicite.
+//
+// Utilise en CI et dans precommit pour garantir que docs/events.md reste
+// synchronise avec l'etat du code.
+// ----------------------------------------------------------------------------
+
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { fileURLToPath } from "node:url";
+import { spawnSync } from "node:child_process";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const METABASE_DIR = path.resolve(__dirname, "..");
+const DOCS_EVENTS = path.join(METABASE_DIR, "docs/events.md");
+const EXTRACTED = path.join(METABASE_DIR, "events/events.extracted.json");
+
+function snapshot(file: string): string {
+ return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
+}
+
+const beforeDocs = snapshot(DOCS_EVENTS);
+const beforeExtracted = snapshot(EXTRACTED);
+
+// Les timestamps `generated_at` et "Genere le ..." changent a chaque run
+// meme si rien d'autre n'a bouge. On les neutralise avant comparaison.
+function normalize(content: string): string {
+ return content
+ .replace(/"generated_at":\s*"[^"]+"/g, '"generated_at":"__NORMALIZED__"')
+ .replace(/Genere le \*\*[^*]+\*\*/g, "Genere le **__NORMALIZED__**");
+}
+
+function run(script: string) {
+ const r = spawnSync("npx", ["tsx", path.join(__dirname, script)], {
+ cwd: METABASE_DIR,
+ stdio: "inherit",
+ env: process.env,
+ });
+ if (r.status !== 0) {
+ console.error(`[check-events-drift] ${script} a echoue`);
+ process.exit(r.status ?? 1);
+ }
+}
+
+run("extract-events.ts");
+run("generate-events-doc.ts");
+
+const afterDocs = snapshot(DOCS_EVENTS);
+const afterExtracted = snapshot(EXTRACTED);
+
+let drift = false;
+if (normalize(beforeDocs) !== normalize(afterDocs)) {
+ console.error(
+ "[check-events-drift] docs/events.md est DESYNCHRONISE avec le code."
+ );
+ drift = true;
+}
+if (normalize(beforeExtracted) !== normalize(afterExtracted)) {
+ console.error(
+ "[check-events-drift] events/events.extracted.json est DESYNCHRONISE avec le code."
+ );
+ drift = true;
+}
+
+if (drift) {
+ // Restaurer l'etat avant pour laisser le dev decider quand regenerer
+ // (evite un 'files modified' surprise en CI)
+ if (beforeDocs) fs.writeFileSync(DOCS_EVENTS, beforeDocs);
+ if (beforeExtracted) fs.writeFileSync(EXTRACTED, beforeExtracted);
+ console.error("");
+ console.error(
+ "=> Lance `pnpm -F @cdt/metabase events:docs` pour regenerer, puis commit."
+ );
+ process.exit(1);
+}
+
+console.log("[check-events-drift] OK, docs/events.md et events.extracted.json sont a jour.");
diff --git a/packages/metabase/events/events.extracted.json b/packages/metabase/events/events.extracted.json
new file mode 100644
index 00000000000..060626a2b9d
--- /dev/null
+++ b/packages/metabase/events/events.extracted.json
@@ -0,0 +1,855 @@
+{
+ "generated_at": "2026-04-22T10:01:51.889Z",
+ "scan_root": "packages/code-du-travail-frontend/src",
+ "total_callsites": 61,
+ "unique_events": 57,
+ "unresolved_callsites": 0,
+ "events": [
+ {
+ "category": "_matomo_trackSiteSearch",
+ "action": "",
+ "name_pattern": null,
+ "emit_function": "useSearchTracking",
+ "file": "packages/code-du-travail-frontend/src/modules/recherche/tracking.ts",
+ "line": 194,
+ "enum_refs": [],
+ "tracking_method": "push:trackSiteSearch"
+ },
+ {
+ "category": "",
+ "action": "",
+ "name_pattern": "`idcc${values.selected.num}`",
+ "emit_function": "pushAgreementEvents",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/common/events/pushAgreementEvents.ts",
+ "line": 61,
+ "enum_refs": [],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "",
+ "action": "",
+ "name_pattern": "",
+ "emit_function": "trackFeedback",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/indemnite-depart/feedback/tracking.tsx",
+ "line": 42,
+ "enum_refs": [],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "cc_search_type_of_users",
+ "action": "",
+ "name_pattern": "",
+ "emit_function": "pushAgreementEvents",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/common/events/pushAgreementEvents.ts",
+ "line": 44,
+ "enum_refs": [
+ "MatomoSearchAgreementCategory.AGREEMENT_SEARCH_TYPE_OF_USERS"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "cc_search_type_of_users",
+ "action": "click_je_n_ai_pas_d_entreprise",
+ "name_pattern": "Trouver sa convention collective",
+ "emit_function": "emitNoEnterpriseClickEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/enterprise/EnterpriseAgreementSearch/tracking.ts",
+ "line": 52,
+ "enum_refs": [
+ "TrackingAgreementSearchAction.AGREEMENT_SEARCH",
+ "TrackingAgreementSearchAction.CLICK_NO_COMPANY",
+ "TrackingAgreementSearchCategory.CC_SEARCH_TYPE_OF_USERS"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "cc_search_type_of_users",
+ "action": "click_p1",
+ "name_pattern": "",
+ "emit_function": "emitClickP1",
+ "file": "packages/code-du-travail-frontend/src/modules/contributions/tracking.ts",
+ "line": 61,
+ "enum_refs": [
+ "TrackingAgreementSearchAction.CLICK_P1",
+ "TrackingContributionCategory.CC_SEARCH_TYPE_OF_USERS"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "cc_search_type_of_users",
+ "action": "click_p1",
+ "name_pattern": "Trouver sa convention collective",
+ "emit_function": "emitNavigateAgreementSearchEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/convention-collective/tracking.ts",
+ "line": 34,
+ "enum_refs": [
+ "TrackingAgreementSearchAction.AGREEMENT_SEARCH",
+ "TrackingAgreementSearchAction.CLICK_P1",
+ "TrackingAgreementSearchCategory.CC_SEARCH_TYPE_OF_USERS"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "cc_search_type_of_users",
+ "action": "click_p2",
+ "name_pattern": "",
+ "emit_function": "emitClickP2",
+ "file": "packages/code-du-travail-frontend/src/modules/contributions/tracking.ts",
+ "line": 69,
+ "enum_refs": [
+ "TrackingAgreementSearchAction.CLICK_P2",
+ "TrackingContributionCategory.CC_SEARCH_TYPE_OF_USERS"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "cc_search_type_of_users",
+ "action": "click_p2",
+ "name_pattern": "Trouver sa convention collective",
+ "emit_function": "emitNavigateEnterpriseSearchEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/convention-collective/tracking.ts",
+ "line": 42,
+ "enum_refs": [
+ "TrackingAgreementSearchAction.AGREEMENT_SEARCH",
+ "TrackingAgreementSearchAction.CLICK_P2",
+ "TrackingAgreementSearchCategory.CC_SEARCH_TYPE_OF_USERS"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "cc_search_type_of_users",
+ "action": "click_p3",
+ "name_pattern": "",
+ "emit_function": "emitClickP3",
+ "file": "packages/code-du-travail-frontend/src/modules/contributions/tracking.ts",
+ "line": 77,
+ "enum_refs": [
+ "TrackingAgreementSearchAction.CLICK_P3",
+ "TrackingContributionCategory.CC_SEARCH_TYPE_OF_USERS"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "cc_search_type_of_users",
+ "action": "select_je_n_ai_pas_d_entreprise",
+ "name_pattern": "Trouver sa convention collective",
+ "emit_function": "emitNoEnterpriseSelectEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/enterprise/EnterpriseAgreementSearch/tracking.ts",
+ "line": 59,
+ "enum_refs": [
+ "TrackingAgreementSearchAction.AGREEMENT_SEARCH",
+ "TrackingAgreementSearchAction.SELECT_NO_COMPANY",
+ "TrackingAgreementSearchCategory.CC_SEARCH_TYPE_OF_USERS"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "cc_search_type_of_users",
+ "action": "select_je_n_ai_pas_d_entreprise",
+ "name_pattern": "",
+ "emit_function": "pushAgreementEvents",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/common/events/pushAgreementEvents.ts",
+ "line": 75,
+ "enum_refs": [
+ "MatomoSearchAgreementCategory.AGREEMENT_SEARCH_TYPE_OF_USERS",
+ "MatomoSimulatorEvent.SELECT_NO_COMPANY"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "cc_select_p1",
+ "action": "",
+ "name_pattern": "",
+ "emit_function": "emitSelectEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/convention-collective/tracking.ts",
+ "line": 50,
+ "enum_refs": [
+ "TrackingAgreementSearchCategory.CC_SELECT_P1"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "cc_select_p2",
+ "action": "",
+ "name_pattern": "",
+ "emit_function": "emitSelectEnterpriseAgreementEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/enterprise/EnterpriseAgreementSearch/tracking.ts",
+ "line": 36,
+ "enum_refs": [
+ "TrackingAgreementSearchCategory.CC_SELECT_P2"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "clic_share",
+ "action": "",
+ "name_pattern": "",
+ "emit_function": "emitClickShare",
+ "file": "packages/code-du-travail-frontend/src/modules/common/tracking.ts",
+ "line": 29,
+ "enum_refs": [
+ "CommonCategory.CLICK_SHARE"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "contact",
+ "action": "click_contact_sr_modale",
+ "name_pattern": "",
+ "emit_function": "emitModalIsOpened",
+ "file": "packages/code-du-travail-frontend/src/modules/layout/footer/infos/tracking.ts",
+ "line": 24,
+ "enum_refs": [
+ "MatomoNeedMoreInfoEventSecondary.CONTACT",
+ "MatomoNeedMoreInfoEventTertiary.CLICK_CONTACT_MODAL"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "contact",
+ "action": "click_phone_number",
+ "name_pattern": null,
+ "emit_function": "emitTrackNumber",
+ "file": "packages/code-du-travail-frontend/src/modules/layout/footer/infos/tracking.ts",
+ "line": 17,
+ "enum_refs": [
+ "MatomoNeedMoreInfoEventSecondary.CONTACT",
+ "MatomoNeedMoreInfoEventTertiary.CLICK_PHONE_NUMBER"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "contribution",
+ "action": "click_afficher_les_informations_CC",
+ "name_pattern": "",
+ "emit_function": "emitDisplayAgreementContent",
+ "file": "packages/code-du-travail-frontend/src/modules/contributions/tracking.ts",
+ "line": 37,
+ "enum_refs": [
+ "TrackingAgreementSearchAction.CLICK_DISPLAY_AGREEMENT_CONTENT",
+ "TrackingContributionCategory.CONTRIBUTION"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "contribution",
+ "action": "click_afficher_les_informations_générales",
+ "name_pattern": "",
+ "emit_function": "emitDisplayGeneralContent",
+ "file": "packages/code-du-travail-frontend/src/modules/contributions/tracking.ts",
+ "line": 53,
+ "enum_refs": [
+ "TrackingAgreementSearchAction.CLICK_DISPLAY_GENERAL_CONTENT",
+ "TrackingContributionCategory.CONTRIBUTION"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "contribution",
+ "action": "click_afficher_les_informations_sans_CC",
+ "name_pattern": "",
+ "emit_function": "emitDisplayGenericContent",
+ "file": "packages/code-du-travail-frontend/src/modules/contributions/tracking.ts",
+ "line": 45,
+ "enum_refs": [
+ "TrackingAgreementSearchAction.CLICK_DISPLAY_GENERIC_CONTENT",
+ "TrackingContributionCategory.CONTRIBUTION"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "enterprise_search",
+ "action": "",
+ "name_pattern": "",
+ "emit_function": "emitEnterpriseAgreementSearchInputEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/enterprise/EnterpriseAgreementSearch/tracking.ts",
+ "line": 14,
+ "enum_refs": [
+ "JSON.stringify",
+ "TrackingAgreementSearchCategory.ENTERPRISE_SEARCH"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "enterprise_select",
+ "action": "",
+ "name_pattern": "",
+ "emit_function": "emitSelectEnterpriseEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/enterprise/EnterpriseAgreementSearch/tracking.ts",
+ "line": 28,
+ "enum_refs": [
+ "JSON.stringify",
+ "TrackingAgreementSearchCategory.CC_ENTERPRISE_SELECT"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "enterprise_select",
+ "action": "",
+ "name_pattern": "",
+ "emit_function": "pushAgreementEvents",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/common/events/pushAgreementEvents.ts",
+ "line": 51,
+ "enum_refs": [
+ "JSON.stringify",
+ "MatomoSearchAgreementCategory.ENTERPRISE_SELECT"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "feedback",
+ "action": "negative",
+ "name_pattern": "",
+ "emit_function": "emitNegativeFeedback",
+ "file": "packages/code-du-travail-frontend/src/modules/layout/feedback/tracking.ts",
+ "line": 39,
+ "enum_refs": [
+ "FeedbackActionEvent.NEGATIVE",
+ "FeedbackCategoryEvent.FEEDBACK"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "feedback",
+ "action": "positive",
+ "name_pattern": "",
+ "emit_function": "emitPositiveFeedback",
+ "file": "packages/code-du-travail-frontend/src/modules/layout/feedback/tracking.ts",
+ "line": 29,
+ "enum_refs": [
+ "FeedbackActionEvent.POSITIVE",
+ "FeedbackCategoryEvent.FEEDBACK"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "feedback_category",
+ "action": "",
+ "name_pattern": "",
+ "emit_function": "emitFeedbackCategory",
+ "file": "packages/code-du-travail-frontend/src/modules/layout/feedback/tracking.ts",
+ "line": 59,
+ "enum_refs": [
+ "FeedbackCategoryEvent.FEEDBACK_CATEGORY"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "feedback_suggestion",
+ "action": "",
+ "name_pattern": "",
+ "emit_function": "emitFeedbackSuggestion",
+ "file": "packages/code-du-travail-frontend/src/modules/layout/feedback/tracking.ts",
+ "line": 49,
+ "enum_refs": [
+ "FeedbackCategoryEvent.FEEDBACK_SUGGESTION"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "feedback_suggestion | feedback_suggestion_rupture_co",
+ "action": "",
+ "name_pattern": "",
+ "emit_function": "trackFeedbackText",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/indemnite-depart/feedback/tracking.tsx",
+ "line": 54,
+ "enum_refs": [],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "header_cc",
+ "action": "cc_consult",
+ "name_pattern": "",
+ "emit_function": "emitConsultEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/convention-collective/AgreementSelectionModal/tracking.ts",
+ "line": 33,
+ "enum_refs": [
+ "TrackingHeaderAgreementAction.CC_CONSULT",
+ "TrackingHeaderAgreementCategory.HEADER_CC"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "header_cc",
+ "action": "cc_select_processed | cc_select_unprocessed",
+ "name_pattern": "",
+ "emit_function": "emitSelectEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/convention-collective/AgreementSelectionModal/tracking.ts",
+ "line": 23,
+ "enum_refs": [
+ "TrackingHeaderAgreementAction.CC_TREATED",
+ "TrackingHeaderAgreementAction.CC_UNTREATED",
+ "TrackingHeaderAgreementCategory.HEADER_CC"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "header_cc",
+ "action": "open_modal",
+ "name_pattern": null,
+ "emit_function": "emitOpenModalEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/convention-collective/AgreementSelectionModal/tracking.ts",
+ "line": 16,
+ "enum_refs": [
+ "TrackingHeaderAgreementAction.OPEN_MODAL",
+ "TrackingHeaderAgreementCategory.HEADER_CC"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "nextResultPage",
+ "action": "",
+ "name_pattern": null,
+ "emit_function": "useSearchTracking",
+ "file": "packages/code-du-travail-frontend/src/modules/recherche/tracking.ts",
+ "line": 105,
+ "enum_refs": [
+ "MatomoSearchCategory.NEXT_RESULT_PAGE"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "outil",
+ "action": "`view_step_${TrackingAgreementSearchAction.AGREEMENT_SEARCH}`",
+ "name_pattern": "start",
+ "emit_function": "emitViewStepEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/convention-collective/tracking.ts",
+ "line": 26,
+ "enum_refs": [
+ "TrackingAgreementSearchAction.AGREEMENT_SEARCH"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "outil",
+ "action": " | ",
+ "name_pattern": "",
+ "emit_function": "emitNextPreviousEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/common/components/SimulatorLayout/tracking.ts",
+ "line": 14,
+ "enum_refs": [
+ "MatomoActionEvent.CLICK_PREVIOUS",
+ "MatomoActionEvent.VIEW_STEP",
+ "MatomoBaseEvent.OUTIL"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "outil",
+ "action": "anciennete_plus_2_ans | anciennete_moins_2_ans",
+ "name_pattern": null,
+ "emit_function": "onNextStep",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/preavis-retraite/steps/Seniority/store/store.ts",
+ "line": 46,
+ "enum_refs": [
+ "MatomoBaseEvent.OUTIL",
+ "MatomoRetirementEvent.ANCIENNETE_MOINS_2_ANS",
+ "MatomoRetirementEvent.ANCIENNETE_PLUS_2_ANS"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "outil",
+ "action": "cc_select_non_traitée",
+ "name_pattern": "",
+ "emit_function": "emitAgreementUntreatedEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/contributions/tracking.ts",
+ "line": 29,
+ "enum_refs": [
+ "MatomoAgreementEvent.CC_UNTREATED",
+ "TrackingContributionCategory.TOOL"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "outil",
+ "action": "cc_select_traitée",
+ "name_pattern": "",
+ "emit_function": "emitAgreementTreatedEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/contributions/tracking.ts",
+ "line": 21,
+ "enum_refs": [
+ "MatomoAgreementEvent.CC_TREATED",
+ "TrackingContributionCategory.TOOL"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "outil",
+ "action": "cc_select_traitée | cc_select_non_traitée",
+ "name_pattern": "",
+ "emit_function": "pushAgreementEvents",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/common/events/pushAgreementEvents.ts",
+ "line": 66,
+ "enum_refs": [
+ "MatomoAgreementEvent.CC_TREATED",
+ "MatomoAgreementEvent.CC_UNTREATED",
+ "MatomoBaseEvent.OUTIL"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "outil",
+ "action": "click_print",
+ "name_pattern": "",
+ "emit_function": "emitPrintEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/common/components/SimulatorLayout/tracking.ts",
+ "line": 24,
+ "enum_refs": [
+ "MatomoBaseEvent.OUTIL",
+ "MatomoSimulatorEvent.CLICK_PRINT"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "outil",
+ "action": "mise | depart",
+ "name_pattern": null,
+ "emit_function": "onNextStep",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/preavis-retraite/steps/OriginStep/store/store.ts",
+ "line": 44,
+ "enum_refs": [
+ "MatomoBaseEvent.OUTIL",
+ "MatomoRetirementEvent.DEPART_RETRAITE",
+ "MatomoRetirementEvent.MISE_RETRAITE"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "outil",
+ "action": "view_step_Heures d'absence pour rechercher un emploi",
+ "name_pattern": "user_blocked_info_cc",
+ "emit_function": "useHeuresRechercheEmploiEventEmitter",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/heures-recherche-emploi/events/useHeuresRechercheEmploiEventEmitter.tsx",
+ "line": 11,
+ "enum_refs": [
+ "MatomoActionEvent.HEURES_RECHERCHE_EMPLOI",
+ "MatomoAgreementEvent.CC_BLOCK_USER",
+ "TrackingContributionCategory.TOOL"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "outil",
+ "action": "view_step_Indemnité de licenciement",
+ "name_pattern": "results_ineligible",
+ "emit_function": "useIndemniteLicenciementEventEmitter",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/indemnite-licenciement/events/useIndemniteLicenciementEventEmitter.tsx",
+ "line": 13,
+ "enum_refs": [
+ "MatomoActionEvent.INDEMNITE_LICENCIEMENT",
+ "MatomoBaseEvent.OUTIL",
+ "MatomoSimulatorEvent.STEP_RESULT_INELIGIBLE"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "outil",
+ "action": "view_step_Indemnité de rupture conventionnelle",
+ "name_pattern": "results_ineligible",
+ "emit_function": "useRuptureCoEventEmitter",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/indemnite-rupture-conventionnelle/events/useRuptureCoEventEmitter.tsx",
+ "line": 13,
+ "enum_refs": [
+ "MatomoActionEvent.RUPTURE_CONVENTIONNELLE",
+ "MatomoBaseEvent.OUTIL",
+ "MatomoSimulatorEvent.STEP_RESULT_INELIGIBLE"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "outil",
+ "action": "view_step_Préavis de démission",
+ "name_pattern": "user_blocked_info_cc",
+ "emit_function": "usePreavisDemissionEventEmitter",
+ "file": "packages/code-du-travail-frontend/src/modules/outils/preavis-demission/events/usePreavisDemissionEventEmitter.tsx",
+ "line": 11,
+ "enum_refs": [
+ "MatomoActionEvent.PREAVIS_DEMISSION",
+ "MatomoAgreementEvent.CC_BLOCK_USER",
+ "TrackingContributionCategory.TOOL"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "page_home",
+ "action": "",
+ "name_pattern": null,
+ "emit_function": "emitHomeClickButtonEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/home/tracking.ts",
+ "line": 21,
+ "enum_refs": [
+ "MatomoHomeCategory.PAGE_HOME"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "page_home",
+ "action": "click_question_action",
+ "name_pattern": "",
+ "emit_function": "emitQuestionActionEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/home/tracking.ts",
+ "line": 28,
+ "enum_refs": [
+ "MatomoHomeCategory.PAGE_HOME",
+ "MatomoHomeEvent.CLICK_DE_LA_QUESTION_A_LACTION"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "page_modeles_de_documents",
+ "action": "type_CTRL_C",
+ "name_pattern": "",
+ "emit_function": "useModeleEvents",
+ "file": "packages/code-du-travail-frontend/src/modules/modeles-de-courriers/tracking.ts",
+ "line": 7,
+ "enum_refs": [
+ "MatomoActionEvent.TYPE_CTRL_C",
+ "MatomoBaseEvent.PAGE_MODELS"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "pagecc_searchcc",
+ "action": "",
+ "name_pattern": "",
+ "emit_function": "LegiFranceSearch",
+ "file": "packages/code-du-travail-frontend/src/modules/convention-collective/LegiFranceSearch.tsx",
+ "line": 24,
+ "enum_refs": [],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "search",
+ "action": "clickSeeAllResults",
+ "name_pattern": "",
+ "emit_function": "useSearchTracking",
+ "file": "packages/code-du-travail-frontend/src/modules/recherche/tracking.ts",
+ "line": 159,
+ "enum_refs": [
+ "MatomoSearchAction.CLICK_SEE_ALL_RESULTS",
+ "MatomoSearchCategory.SEARCH"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "search",
+ "action": "fullsearch",
+ "name_pattern": "",
+ "emit_function": "useSearchTracking",
+ "file": "packages/code-du-travail-frontend/src/modules/recherche/tracking.ts",
+ "line": 65,
+ "enum_refs": [
+ "MatomoSearchAction.FULL_SEARCH",
+ "MatomoSearchCategory.SEARCH"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "search",
+ "action": "fullsearch",
+ "name_pattern": "",
+ "emit_function": "useSearchTracking",
+ "file": "packages/code-du-travail-frontend/src/modules/recherche/tracking.ts",
+ "line": 95,
+ "enum_refs": [
+ "MatomoSearchAction.FULL_SEARCH",
+ "MatomoSearchCategory.SEARCH"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "search",
+ "action": "presearch",
+ "name_pattern": "",
+ "emit_function": "useSearchTracking",
+ "file": "packages/code-du-travail-frontend/src/modules/recherche/tracking.ts",
+ "line": 144,
+ "enum_refs": [
+ "MatomoSearchAction.PRESEARCH",
+ "MatomoSearchCategory.SEARCH"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "search",
+ "action": "selectPresearchResult",
+ "name_pattern": "",
+ "emit_function": "useSearchTracking",
+ "file": "packages/code-du-travail-frontend/src/modules/recherche/tracking.ts",
+ "line": 182,
+ "enum_refs": [
+ "MatomoSearchAction.SELECT_PRESEARCH_RESULT",
+ "MatomoSearchCategory.SEARCH"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "selectedSuggestion",
+ "action": "",
+ "name_pattern": "",
+ "emit_function": "useSearchTracking",
+ "file": "packages/code-du-travail-frontend/src/modules/recherche/tracking.ts",
+ "line": 113,
+ "enum_refs": [
+ "MatomoSearchCategory.SELECTED_SUGGESTION"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "selectRelated",
+ "action": "",
+ "name_pattern": null,
+ "emit_function": "emitSelectRelated",
+ "file": "packages/code-du-travail-frontend/src/modules/common/tracking.ts",
+ "line": 22,
+ "enum_refs": [
+ "CommonCategory.SELECTED_RELATED",
+ "JSON.stringify"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "selectResult",
+ "action": "",
+ "name_pattern": null,
+ "emit_function": "useSearchTracking",
+ "file": "packages/code-du-travail-frontend/src/modules/recherche/tracking.ts",
+ "line": 46,
+ "enum_refs": [
+ "JSON.stringify",
+ "MatomoSearchCategory.SELECT_RESULT"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "selectResult",
+ "action": "",
+ "name_pattern": null,
+ "emit_function": "emitDocumentClickButtonEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/themes/tracking.ts",
+ "line": 14,
+ "enum_refs": [
+ "JSON.stringify",
+ "MatomoThemeCategory.SELECT_RESULT"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "view_step_cc_search_p1",
+ "action": "back_step_cc_search_p1",
+ "name_pattern": "Trouver sa convention collective",
+ "emit_function": "emitPreviousEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/convention-collective/tracking.ts",
+ "line": 58,
+ "enum_refs": [
+ "TrackingAgreementSearchAction.AGREEMENT_SEARCH",
+ "TrackingAgreementSearchAction.BACK_STEP_P1",
+ "TrackingAgreementSearchCategory.VIEW_STEP_CC_SEARCH_P1"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "view_step_cc_search_p2",
+ "action": "back_step_cc_search_p2",
+ "name_pattern": "Trouver sa convention collective",
+ "emit_function": "emitPreviousEvent",
+ "file": "packages/code-du-travail-frontend/src/modules/enterprise/EnterpriseAgreementSearch/tracking.ts",
+ "line": 44,
+ "enum_refs": [
+ "TrackingAgreementSearchAction.AGREEMENT_SEARCH",
+ "TrackingAgreementSearchAction.BACK_STEP_P2",
+ "TrackingAgreementSearchCategory.VIEW_STEP_CC_SEARCH_P2"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "widget_search",
+ "action": "click_logo",
+ "name_pattern": null,
+ "emit_function": "useSearchTracking",
+ "file": "packages/code-du-travail-frontend/src/modules/recherche/tracking.ts",
+ "line": 123,
+ "enum_refs": [
+ "MatomoBaseEvent.WIDGET_SEARCH",
+ "MatomoWidgetEvent.CLICK_LOGO"
+ ],
+ "tracking_method": "sendEvent"
+ },
+ {
+ "category": "widget_search",
+ "action": "submit_search",
+ "name_pattern": "",
+ "emit_function": "useSearchTracking",
+ "file": "packages/code-du-travail-frontend/src/modules/recherche/tracking.ts",
+ "line": 130,
+ "enum_refs": [
+ "MatomoBaseEvent.WIDGET_SEARCH",
+ "MatomoWidgetEvent.SUBMIT_SEARCH"
+ ],
+ "tracking_method": "sendEvent"
+ }
+ ],
+ "unresolved": [],
+ "matomo_config_calls": [
+ {
+ "command": "AbTesting::create",
+ "args": [
+ ""
+ ],
+ "file": "packages/code-du-travail-frontend/src/modules/config/initABTesting.ts",
+ "line": 115
+ },
+ {
+ "command": "forgetCookieConsentGiven",
+ "args": [],
+ "file": "packages/code-du-travail-frontend/src/modules/utils/consent.ts",
+ "line": 90
+ },
+ {
+ "command": "forgetUserOptOut",
+ "args": [],
+ "file": "packages/code-du-travail-frontend/src/modules/utils/consent.ts",
+ "line": 86
+ },
+ {
+ "command": "HeatmapSessionRecording::disable",
+ "args": [],
+ "file": "packages/code-du-travail-frontend/src/modules/utils/consent.ts",
+ "line": 71
+ },
+ {
+ "command": "HeatmapSessionRecording::enable",
+ "args": [],
+ "file": "packages/code-du-travail-frontend/src/modules/utils/consent.ts",
+ "line": 69
+ },
+ {
+ "command": "optUserOut",
+ "args": [],
+ "file": "packages/code-du-travail-frontend/src/modules/utils/consent.ts",
+ "line": 89
+ },
+ {
+ "command": "rememberCookieConsentGiven",
+ "args": [],
+ "file": "packages/code-du-travail-frontend/src/modules/utils/consent.ts",
+ "line": 87
+ },
+ {
+ "command": "setCookieSameSite",
+ "args": [
+ "None"
+ ],
+ "file": "packages/code-du-travail-frontend/src/modules/config/MatomoAnalytics.tsx",
+ "line": 51
+ },
+ {
+ "command": "setReferrerUrl",
+ "args": [
+ ""
+ ],
+ "file": "packages/code-du-travail-frontend/src/modules/config/MatomoAnalytics.tsx",
+ "line": 47
+ }
+ ]
+}
diff --git a/packages/metabase/events/events.metadata.yaml b/packages/metabase/events/events.metadata.yaml
new file mode 100644
index 00000000000..be9aa55eb13
--- /dev/null
+++ b/packages/metabase/events/events.metadata.yaml
@@ -0,0 +1,364 @@
+# events.metadata.yaml
+# ------------------------------------------------------------------
+# Description metier des events Matomo emis par @cdt/frontend.
+# Fichier MAINTENU A LA MAIN. Les events techniques (category/action/fichier/ligne)
+# sont extraits automatiquement par events/extract-events.ts dans events.extracted.json.
+# Ce fichier decrit le POURQUOI (quand est-il declenche, a quel KPI il contribue).
+#
+# Cle : ":"
+# Si un event apparait dans events.extracted.json mais n'a pas d'entree ici,
+# il sera liste dans la section "Orphelins" de docs/events.md (a completer).
+#
+# Voir events/CLAUDE.md pour le workflow d'ajout.
+# ------------------------------------------------------------------
+
+# =====================
+# Contributions (/contribution/*)
+# =====================
+
+"contribution:click_afficher_les_informations_CC":
+ label_fr: "Affichage de la reponse personnalisee sur une page contribution"
+ trigger: "Clic sur le bouton 'afficher les informations personnalisees' apres selection d'une CC TRAITEE"
+ kpi: "Personnalisation reussie (KPI 1 dashboard 36)"
+ dashboards: [36]
+ cards: [435, 438, 443]
+ mv_source: "mv_kpi_personnalisation"
+ feature_group: "contributions"
+
+"contribution:click_afficher_les_informations_sans_CC":
+ label_fr: "Affichage de la reponse generique (CC non traitee)"
+ trigger: "Clic sur le bouton 'afficher les informations' apres selection d'une CC NON TRAITEE"
+ kpi: "CC non traitee (KPI 4 dashboard 36)"
+ dashboards: [36]
+ cards: [441]
+ mv_source: "mv_kpi_personnalisation"
+ feature_group: "contributions"
+
+"contribution:click_afficher_les_informations_générales":
+ label_fr: "Affichage des infos generales (sans personnalisation CC)"
+ trigger: "Clic sur le bouton 'afficher les informations generales' - utilisateur renonce a personnaliser sa reponse"
+ kpi: "Indicateur de rebond sur contributions (cartes 450/451)"
+ dashboards: [88]
+ cards: [450, 451]
+ mv_source: "mv_bounce_contributions"
+ feature_group: "contributions"
+
+"cc_search_type_of_users:click_p1":
+ label_fr: "Parcours P1 : recherche par nom de CC"
+ trigger: "Clic sur l'option 'Je connais le nom de ma convention collective' dans le selecteur de parcours"
+ feature_group: "cc-search"
+
+"cc_search_type_of_users:click_p2":
+ label_fr: "Parcours P2 : recherche par nom d'entreprise"
+ trigger: "Clic sur l'option 'Je connais le nom de mon entreprise' dans le selecteur de parcours"
+ feature_group: "cc-search"
+
+"cc_search_type_of_users:click_p3":
+ label_fr: "Parcours P3 : renonciation CC"
+ trigger: "Clic sur l'option 'Je ne sais pas' / 'Je n'ai pas d'entreprise' - l'utilisateur saute l'etape CC"
+ kpi: "Renonciation (KPI 3 dashboard 36)"
+ dashboards: [36]
+ cards: [436, 440, 444]
+ mv_source: "mv_kpi_personnalisation"
+ feature_group: "cc-search"
+
+"cc_search_type_of_users:click_je_n_ai_pas_d_entreprise":
+ label_fr: "Declaration : pas d'entreprise"
+ trigger: "Clic sur 'Je n'ai pas d'entreprise' dans le parcours CC"
+ kpi: "Parcours bloques (KPI 4 dashboard 36)"
+ dashboards: [36]
+ cards: [441]
+ mv_source: "mv_kpi_personnalisation"
+ feature_group: "cc-search"
+
+"cc_search_type_of_users:select_je_n_ai_pas_d_entreprise":
+ label_fr: "Variante select : pas d'entreprise"
+ trigger: "Meme chose que click_je_n_ai_pas_d_entreprise mais depuis le composant select (event de repli)"
+ feature_group: "cc-search"
+ notes: "Double-fired avec click_je_n_ai_pas_d_entreprise dans certains parcours"
+
+"cc_search_type_of_users:*":
+ label_fr: "Parcours CC dynamique (ternary : click_p1 / click_p2 / click_p3 / click_je_n_ai_pas_d_entreprise)"
+ trigger: "Resolu a runtime dans pushAgreementEvents.ts selon le parcours (`parcours!` variable). Voir les entrees specifiques ci-dessus pour chaque valeur."
+ feature_group: "cc-search"
+
+"view_step_cc_search_p1:back_step_cc_search_p1":
+ label_fr: "Retour etape P1 du parcours CC"
+ trigger: "Clic sur 'Precedent' apres avoir cherche une CC par nom (parcours P1)"
+ feature_group: "cc-search"
+
+"view_step_cc_search_p2:back_step_cc_search_p2":
+ label_fr: "Retour etape P2 du parcours CC"
+ trigger: "Clic sur 'Precedent' apres avoir cherche une entreprise (parcours P2)"
+ feature_group: "cc-search"
+
+"cc_select_p1:*":
+ label_fr: "CC selectionnee depuis le parcours P1"
+ trigger: "Apres avoir choisi une CC dans l'autocomplete P1"
+ feature_group: "cc-search"
+
+"cc_select_p2:*":
+ label_fr: "CC selectionnee depuis le parcours P2 (via entreprise)"
+ trigger: "Apres avoir choisi une entreprise puis sa CC rattachee dans P2"
+ feature_group: "cc-search"
+
+# =====================
+# Simulateurs / Outils (/outils/*)
+# =====================
+
+"outil:cc_select_traitée":
+ label_fr: "CC selectionnee et TRAITEE par le simulateur"
+ trigger: "L'utilisateur a selectionne une CC qui a un traitement personnalise dans le simulateur"
+ kpi: "Personnalisation reussie simulateur (KPI 1 dashboard 36)"
+ dashboards: [36]
+ cards: [439, 443]
+ mv_source: "mv_kpi_personnalisation"
+ feature_group: "simulateurs"
+ notes: "La version sans accent (cc_select_traitee) existait aussi historiquement - voir docs/events.md §Orphelins si drift."
+
+"outil:cc_select_non_traitée":
+ label_fr: "CC selectionnee mais NON TRAITEE par le simulateur"
+ trigger: "L'utilisateur a selectionne une CC qui n'a pas de traitement specifique dans le simulateur"
+ kpi: "CC non traitees (KPI 5 dashboard 36)"
+ dashboards: [36]
+ cards: [442, 444]
+ mv_source: "mv_cc_non_traitees"
+ feature_group: "simulateurs"
+ notes: "La version sans accent (cc_select_non_traitee) existait aussi historiquement."
+
+"outil:view_step_Indemnité de licenciement":
+ label_fr: "Visualisation d'une etape du simulateur IL"
+ trigger: "Chaque fois que l'utilisateur navigue sur une etape du simulateur 'Indemnite de licenciement'. Le `name` contient le slug de l'etape (start, info_cc, infos, anciennete, absences, salaires, results, results_ineligible)."
+ kpi: "Taux de completion des etapes (cartes 170, 448)"
+ dashboards: [37]
+ cards: [170, 448]
+ mv_source: "mv_funnel_il_irc_visits"
+ feature_group: "simulateurs"
+
+"outil:view_step_Indemnité de rupture conventionnelle":
+ label_fr: "Visualisation d'une etape du simulateur IRC"
+ trigger: "Chaque fois que l'utilisateur navigue sur une etape du simulateur 'Indemnite de rupture conventionnelle'. Le `name` contient le slug de l'etape."
+ kpi: "Taux de completion des etapes (cartes 107, 449)"
+ dashboards: [37]
+ cards: [107, 449]
+ mv_source: "mv_funnel_il_irc_visits"
+ feature_group: "simulateurs"
+
+"outil:view_step_Préavis de démission":
+ label_fr: "Visualisation d'une etape du simulateur Preavis de demission"
+ trigger: "Navigation sur une etape du simulateur Preavis de demission (start, info_cc, user_blocked_info_cc, results, infos)."
+ feature_group: "simulateurs"
+
+"outil:view_step_Heures d'absence pour rechercher un emploi":
+ label_fr: "Visualisation d'une etape du simulateur Heures recherche emploi"
+ trigger: "Navigation sur une etape du simulateur 'Heures d'absence pour rechercher un emploi' (start, info_cc, results, infos, user_blocked_info_cc)."
+ feature_group: "simulateurs"
+
+"outil:click_print":
+ label_fr: "Impression du resultat du simulateur"
+ trigger: "Clic sur le bouton 'Imprimer' apres obtention d'un resultat"
+ feature_group: "simulateurs"
+
+"outil:*":
+ label_fr: "Event generique simulateur (cc_select / view_step / click_previous / click_print resolus dynamiquement)"
+ trigger: "Plusieurs emit functions (`pushAgreementEvents`, `SimulatorLayout`) fire des events 'outil' avec action construite a runtime (template `view_step_${title}`, ternaire cc_select_traitée / cc_select_non_traitée, etc.). Voir les entrees specifiques ci-dessus pour chaque valeur stable."
+ feature_group: "simulateurs"
+
+# Simulateur Preavis retraite : events emis depuis les stores zustand
+# (cf. outils/preavis-retraite/steps/*/store.ts). Les actions sont ternaires
+# sur l'input utilisateur donc la cle extraite est "outil:mise | depart", etc.
+
+"outil:mise":
+ label_fr: "Simulateur Preavis retraite - choix 'Mise a la retraite'"
+ trigger: "A l'etape 'Origine du depart' du simulateur Preavis de retraite, l'utilisateur a choisi 'Mise a la retraite' (decision employeur)."
+ feature_group: "simulateurs"
+ notes: "Runtime-only : ternaire `originDepart === 'mise-retraite' ? MISE_RETRAITE : DEPART_RETRAITE` dans OriginStep/store.ts. MatomoRetirementEvent.MISE_RETRAITE = 'mise'."
+
+"outil:depart":
+ label_fr: "Simulateur Preavis retraite - choix 'Depart volontaire'"
+ trigger: "A l'etape 'Origine du depart' du simulateur Preavis de retraite, l'utilisateur a choisi 'Depart volontaire' (decision salarie)."
+ feature_group: "simulateurs"
+ notes: "Runtime-only : voir outil:mise. MatomoRetirementEvent.DEPART_RETRAITE = 'depart'."
+
+"outil:anciennete_plus_2_ans":
+ label_fr: "Simulateur Preavis retraite - anciennete > 2 ans"
+ trigger: "A l'etape 'Anciennete' du simulateur Preavis de retraite, l'utilisateur a declare avoir plus de 2 ans d'anciennete."
+ feature_group: "simulateurs"
+ notes: "Runtime-only : ternaire `moreThanXYears === 'oui' ? ANCIENNETE_PLUS_2_ANS : ANCIENNETE_MOINS_2_ANS` dans Seniority/store.ts."
+
+"outil:anciennete_moins_2_ans":
+ label_fr: "Simulateur Preavis retraite - anciennete < 2 ans"
+ trigger: "A l'etape 'Anciennete' du simulateur Preavis de retraite, l'utilisateur a declare avoir moins de 2 ans d'anciennete."
+ feature_group: "simulateurs"
+ notes: "Runtime-only : voir outil:anciennete_plus_2_ans."
+
+# =====================
+# Recherche Legifrance (LegiFranceSearch.tsx)
+# =====================
+
+"pagecc_searchcc:*":
+ label_fr: "Recherche par mots-cles dans le texte d'une CC sur Legifrance"
+ trigger: "Submit du formulaire 'Recherche dans la convention collective' sur une fiche CC. Ouvre legifrance.gouv.fr/search/kali dans un nouvel onglet. Le `action` est le titre court de la CC, le `name` est la query saisie."
+ feature_group: "cc-search"
+ notes: "Category fixe 'pagecc_searchcc', action = shortTitle de la CC (dynamique)."
+
+# =====================
+# Modale selection CC (header)
+# =====================
+
+"header_cc:open_modal":
+ label_fr: "Ouverture de la modale de selection CC (header)"
+ trigger: "Clic sur la CC selectionnee dans le header, qui ouvre la modale de selection globale"
+ feature_group: "header"
+
+"header_cc:cc_consult":
+ label_fr: "Consultation d'une fiche CC depuis la modale globale"
+ trigger: "Clic sur 'Voir la fiche' d'une CC dans la modale de selection globale"
+ feature_group: "header"
+
+"header_cc:*":
+ label_fr: "Selection de CC depuis la modale globale (cc_select_processed / cc_select_unprocessed)"
+ trigger: "Selection d'une CC depuis la modale globale. Le `action` est `cc_select_processed` (CC traitee) ou `cc_select_unprocessed` (CC non traitee) selon la CC choisie."
+ feature_group: "header"
+ notes: "Le code fire un ternaire (processed ? 'cc_select_processed' : 'cc_select_unprocessed') ce qui apparait en dynamic a l'extraction - d'ou le wildcard ici."
+
+# =====================
+# Entreprise
+# =====================
+
+"enterprise_search:*":
+ label_fr: "Recherche d'une entreprise"
+ trigger: "Saisie dans le champ de recherche d'entreprise (parcours P2)"
+ feature_group: "enterprise"
+
+"enterprise_select:*":
+ label_fr: "Entreprise selectionnee"
+ trigger: "Selection d'une entreprise dans les suggestions"
+ feature_group: "enterprise"
+
+# =====================
+# Recherche
+# =====================
+
+"search:*":
+ label_fr: "Action sur la recherche full-text"
+ trigger: "Interaction avec la barre de recherche principale (submit, scroll resultats, etc.)"
+ feature_group: "recherche"
+
+"selectedSuggestion:*":
+ label_fr: "Clic sur une suggestion presearch"
+ trigger: "Clic sur un element de la liste presearch (suggestions rapides sous la barre)"
+ feature_group: "recherche"
+
+"selectResult:*":
+ label_fr: "Clic sur un resultat de recherche"
+ trigger: "Clic sur un item dans la liste des resultats de recherche"
+ feature_group: "recherche"
+
+"nextResultPage:*":
+ label_fr: "Pagination des resultats de recherche"
+ trigger: "Clic sur 'Page suivante' dans les resultats"
+ feature_group: "recherche"
+
+"widget_search:*":
+ label_fr: "Interaction widget de recherche"
+ trigger: "Clic logo / submit depuis le widget de recherche (integre site externe)"
+ feature_group: "recherche"
+
+"_matomo_trackSiteSearch:*":
+ label_fr: "Event Matomo natif : recherche interne (trackSiteSearch)"
+ trigger: "Emis explicitement dans la modale Presearch via `push(['trackSiteSearch', query])`. Alimente le rapport 'Comportement > Recherche sur le site' dans Matomo (separe du report trackEvent)."
+ feature_group: "recherche"
+ notes: "Different d'un sendEvent : c'est l'API Matomo native pour le site search. Sur la page /recherche, trackSiteSearch est automatiquement appele par matomo-next (cf. config `searchKeyword: 'query'` dans MatomoAnalytics.tsx)."
+
+# =====================
+# Feedback
+# =====================
+
+"feedback:positive":
+ label_fr: "Feedback positif (clair / pertinent)"
+ trigger: "Clic sur le pouce vert ou equivalent en bas de page"
+ feature_group: "feedback"
+
+"feedback:negative":
+ label_fr: "Feedback negatif (peu clair / pas pertinent)"
+ trigger: "Clic sur le pouce rouge ou equivalent en bas de page"
+ feature_group: "feedback"
+
+"feedback_category:*":
+ label_fr: "Categorie de feedback negatif"
+ trigger: "Selection d'une raison apres un feedback negatif (infos fausses, pas claires, etc.)"
+ feature_group: "feedback"
+
+"feedback_suggestion:*":
+ label_fr: "Soumission d'une suggestion libre"
+ trigger: "Texte libre envoye apres un feedback negatif"
+ feature_group: "feedback"
+
+"feedback_simulateurs:*":
+ label_fr: "Feedback specifique au simulateur Indemnite de licenciement"
+ trigger: "Note ou texte libre sur la page de resultat du simulateur IL. Runtime-only : la category est passee en parametre a `trackFeedback(event, feedback, category)` dans `outils/indemnite-depart/feedback/tracking.tsx` (entree statique `:` dans les orphelins)."
+ feature_group: "feedback"
+ notes: "Runtime-only : category = EVENT_CATEGORY.indemniteLicenciement, action = EVENT_ACTION.{GLOBAL,EASINESS,QUESTION_CLARITY,RESULT_CLARITY,SUGGESTION}, name = FEEDBACK_RESULT.{1..5 ou pas_bien/moyen/très_bien/etc.}"
+
+"feedback_simulateurs_rupture_co:*":
+ label_fr: "Feedback specifique au simulateur Rupture Conventionnelle"
+ trigger: "Note ou texte libre sur la page de resultat du simulateur RC. Meme mecanisme que feedback_simulateurs (trackFeedback) avec category = EVENT_CATEGORY.ruptureConventionnelle."
+ feature_group: "feedback"
+ notes: "Runtime-only : voir note de feedback_simulateurs:*"
+
+"feedback_suggestion_rupture_co:*":
+ label_fr: "Suggestion texte libre simulateur RC"
+ trigger: "Texte libre envoye apres un feedback negatif sur le simulateur RC. Emis par `trackFeedbackText(text, url, category)` dans `outils/indemnite-depart/feedback/tracking.tsx`."
+ feature_group: "feedback"
+ notes: "Runtime-only : category = EVENT_SUGGESTION.ruptureConventionnelle, action = texte libre saisi par l'utilisateur"
+
+# =====================
+# Home
+# =====================
+
+"page_home:*":
+ label_fr: "Navigation depuis la homepage"
+ trigger: "Clic sur un des boutons / cartes de la page d'accueil"
+ feature_group: "home"
+
+# =====================
+# Partage (share)
+# =====================
+
+"clic_share:*":
+ label_fr: "Partage de contenu"
+ trigger: "Clic sur un bouton de partage social (Facebook, Twitter, LinkedIn, email, WhatsApp, copy link)"
+ feature_group: "share"
+
+# =====================
+# Contact
+# =====================
+
+"contact:click_phone_number":
+ label_fr: "Clic sur le numero de telephone dans le footer"
+ trigger: "Clic sur le lien 'tel:...' dans le footer"
+ feature_group: "contact"
+
+"contact:click_contact_sr_modale":
+ label_fr: "Ouverture de la modale 'Besoin d'aide ?'"
+ trigger: "Clic sur le lien 'Contact' qui ouvre la modale de contact support"
+ feature_group: "contact"
+
+# =====================
+# Modeles de documents
+# =====================
+
+"page_modeles_de_documents:type_CTRL_C":
+ label_fr: "Copie d'un modele de courrier"
+ trigger: "L'utilisateur utilise Ctrl+C / Cmd+C pour copier le contenu d'un modele de courrier"
+ feature_group: "documents"
+
+# =====================
+# selectRelated
+# =====================
+
+"selectRelated:*":
+ label_fr: "Clic sur un contenu relie"
+ trigger: "Clic sur une carte 'Contenus relies' en bas d'une page"
+ feature_group: "navigation"
diff --git a/packages/metabase/events/events.schema.ts b/packages/metabase/events/events.schema.ts
new file mode 100644
index 00000000000..ee2fdd1639f
--- /dev/null
+++ b/packages/metabase/events/events.schema.ts
@@ -0,0 +1,57 @@
+// Types partages pour le pipeline d'events Matomo.
+// - ExtractedEvent : ground truth issue de l'AST des tracking.ts (auto)
+// - EventMetadata : description metier maintenue a la main dans events.metadata.yaml
+// - MergedEvent : jointure des deux, consommee par generate-events-doc.ts
+
+export type ExtractedEvent = {
+ category: string;
+ action: string;
+ name_pattern: string | null;
+ emit_function: string | null;
+ file: string;
+ line: number;
+ enum_refs: string[];
+ // "sendEvent" : appel a sendEvent({ category, action, name }) de @socialgouv/matomo-next
+ // "push:" : appel a push([command, ...args]) ou _paq.push([command, ...args])
+ // ou paq.push([command, ...args])
+ tracking_method: string;
+};
+
+// Commande Matomo de configuration (non-event) detectee dans un push([cmd, ...]).
+// Exemples : setReferrerUrl, setCookieSameSite, AbTesting::create, optUserOut,
+// HeatmapSessionRecording::enable, etc.
+export type MatomoConfigCall = {
+ command: string;
+ args: string[];
+ file: string;
+ line: number;
+};
+
+export type EventMetadata = {
+ label_fr: string;
+ trigger: string;
+ kpi?: string;
+ dashboards?: number[];
+ cards?: number[];
+ mv_source?: string;
+ feature_group: string;
+ since?: string;
+ deprecated?: boolean;
+ notes?: string;
+};
+
+export type MergedEvent = ExtractedEvent & {
+ metadata: EventMetadata | null;
+};
+
+export type EventsExtraction = {
+ generated_at: string;
+ scan_root: string;
+ total_callsites: number;
+ unique_events: number;
+ events: ExtractedEvent[];
+ matomo_config_calls?: MatomoConfigCall[];
+};
+
+export const EVENT_KEY = (e: { category: string; action: string }) =>
+ `${e.category}:${e.action}`;
diff --git a/packages/metabase/events/extract-events.ts b/packages/metabase/events/extract-events.ts
new file mode 100644
index 00000000000..255edba0582
--- /dev/null
+++ b/packages/metabase/events/extract-events.ts
@@ -0,0 +1,369 @@
+// extract-events.ts
+// ----------------------------------------------------------------------------
+// Scanne les fichiers tracking.{ts,tsx} et events/*.{ts,tsx} du frontend pour
+// extraire TOUS les appels a sendEvent({ category, action, name? }) et les
+// serialise dans events/events.extracted.json (source de verite technique).
+//
+// Les references a des membres d'enum (ex: TrackingContributionCategory.TOOL)
+// sont resolues statiquement en scannant tous les enums exportes par les
+// memes fichiers + packages/code-du-travail-frontend/src/modules/analytics.
+//
+// Voir events/CLAUDE.md pour le contrat complet.
+// ----------------------------------------------------------------------------
+
+import { Project, Node, SyntaxKind } from "ts-morph";
+import type { ObjectLiteralExpression } from "ts-morph";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const METABASE_DIR = path.resolve(__dirname, "..");
+const REPO_ROOT = path.resolve(METABASE_DIR, "..", "..");
+const FRONTEND_SRC = path.resolve(
+ REPO_ROOT,
+ "packages/code-du-travail-frontend/src"
+);
+
+// Scanne TOUT src/modules/**/*.{ts,tsx} sauf les tests/specs.
+// Raison : sendEvent peut etre appele depuis n'importe quel module (ex: stores
+// zustand dans `outils/preavis-retraite/steps/*/store.ts`, composants inline
+// comme `LegiFranceSearch.tsx`), pas seulement dans les fichiers "tracking.ts".
+const GLOB_PATTERNS = [
+ path.join(FRONTEND_SRC, "modules/**/*.ts"),
+ path.join(FRONTEND_SRC, "modules/**/*.tsx"),
+ `!${path.join(FRONTEND_SRC, "modules/**/__tests__/**")}`,
+ `!${path.join(FRONTEND_SRC, "modules/**/*.test.ts")}`,
+ `!${path.join(FRONTEND_SRC, "modules/**/*.test.tsx")}`,
+ `!${path.join(FRONTEND_SRC, "modules/**/*.spec.ts")}`,
+ `!${path.join(FRONTEND_SRC, "modules/**/*.spec.tsx")}`,
+ `!${path.join(FRONTEND_SRC, "modules/**/*.stories.ts")}`,
+ `!${path.join(FRONTEND_SRC, "modules/**/*.stories.tsx")}`,
+];
+
+type ExtractedEvent = {
+ category: string;
+ action: string;
+ name_pattern: string | null;
+ emit_function: string | null;
+ file: string;
+ line: number;
+ enum_refs: string[];
+ tracking_method: string;
+};
+
+type MatomoConfigCall = {
+ command: string;
+ args: string[];
+ file: string;
+ line: number;
+};
+
+// Commandes Matomo qui PRODUISENT un event (a documenter dans events.md).
+// Les autres pushes sont de la config (setReferrerUrl, AbTesting::create,
+// HeatmapSessionRecording, opt-out, etc.) -> liste a part.
+const EVENT_COMMANDS = new Set([
+ "trackEvent",
+ "trackSiteSearch",
+ "trackPageView",
+ "trackGoal",
+ "trackLink",
+ "trackContentImpression",
+ "trackContentInteraction",
+]);
+
+// Heuristique : ces noms d'expression sont consideres comme un push Matomo.
+// Les autres `.push()` (Array.prototype.push) sont filtrees par la structure
+// de l'argument (ArrayLiteralExpression avec StringLiteral commande en tete).
+const MATOMO_PUSH_CALLEES = new Set([
+ "push",
+ "_paq.push",
+ "window._paq.push",
+ "paq.push",
+]);
+
+const project = new Project({
+ compilerOptions: {
+ allowJs: false,
+ jsx: 4 /* ReactJSX */,
+ target: 99 /* ESNext */,
+ module: 99 /* ESNext */,
+ moduleResolution: 2 /* NodeJs */,
+ esModuleInterop: true,
+ skipLibCheck: true,
+ },
+ skipFileDependencyResolution: true,
+ skipAddingFilesFromTsConfig: true,
+});
+
+for (const pattern of GLOB_PATTERNS) {
+ project.addSourceFilesAtPaths(pattern);
+}
+
+const loadedFiles = project.getSourceFiles();
+if (loadedFiles.length === 0) {
+ console.error(
+ "[extract-events] Aucun fichier trouve. Verifier les patterns de glob."
+ );
+ process.exit(1);
+}
+
+// Build global enum value map: "EnumName.Member" -> "value"
+const enumMap = new Map();
+
+for (const sf of loadedFiles) {
+ for (const decl of sf.getEnums()) {
+ const enumName = decl.getName();
+ for (const member of decl.getMembers()) {
+ const value = member.getValue();
+ if (typeof value === "string") {
+ enumMap.set(`${enumName}.${member.getName()}`, value);
+ }
+ }
+ }
+}
+
+function resolveExpression(node: Node): string | null {
+ if (
+ node.getKind() === SyntaxKind.StringLiteral ||
+ node.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral
+ ) {
+ return (node as unknown as { getLiteralValue(): string }).getLiteralValue();
+ }
+ if (Node.isPropertyAccessExpression(node)) {
+ const fullText = node.getText();
+ // Handle namespaced: foo.bar.EnumName.MEMBER -> take last two segments
+ const parts = fullText.split(".");
+ if (parts.length >= 2) {
+ const shortKey = parts.slice(-2).join(".");
+ const resolved = enumMap.get(shortKey);
+ if (resolved !== undefined) return resolved;
+ }
+ return `<${fullText}>`;
+ }
+ if (Node.isTemplateExpression(node)) {
+ // Template with expressions: give a readable pattern
+ return node.getText();
+ }
+ if (Node.isIdentifier(node)) {
+ return `<${node.getText()}>`;
+ }
+ if (Node.isCallExpression(node)) {
+ const text = node.getText();
+ return `<${text.length > 80 ? text.slice(0, 77) + "..." : text}>`;
+ }
+ if (Node.isConditionalExpression(node)) {
+ const whenTrue = resolveExpression(node.getWhenTrue());
+ const whenFalse = resolveExpression(node.getWhenFalse());
+ return `${whenTrue ?? "?"} | ${whenFalse ?? "?"}`;
+ }
+ return `<${node.getText().slice(0, 80)}>`;
+}
+
+function resolveProperty(
+ obj: ObjectLiteralExpression,
+ propName: string
+): string | null {
+ const prop = obj.getProperty(propName);
+ if (!prop) return null;
+ if (Node.isPropertyAssignment(prop)) {
+ const initializer = prop.getInitializer();
+ if (!initializer) return null;
+ return resolveExpression(initializer);
+ }
+ if (Node.isShorthandPropertyAssignment(prop)) {
+ return `<${prop.getName()}>`;
+ }
+ return null;
+}
+
+function findContainingFunctionName(node: Node): string | null {
+ let cur: Node | undefined = node.getParent();
+ while (cur) {
+ if (Node.isArrowFunction(cur) || Node.isFunctionExpression(cur)) {
+ const parent = cur.getParent();
+ if (Node.isVariableDeclaration(parent)) {
+ return parent.getName();
+ }
+ if (Node.isPropertyAssignment(parent)) {
+ return parent.getName();
+ }
+ }
+ if (Node.isFunctionDeclaration(cur)) {
+ const name = cur.getName();
+ if (name) return name;
+ }
+ cur = cur.getParent();
+ }
+ return null;
+}
+
+function getEnumRefs(obj: ObjectLiteralExpression): string[] {
+ const refs = new Set();
+ obj.forEachDescendant((d) => {
+ if (Node.isPropertyAccessExpression(d)) {
+ const text = d.getText();
+ // heuristic: PascalCase.UPPER_OR_CAMEL
+ if (/^[A-Z][A-Za-z0-9]*\.[A-Za-z_][A-Za-z0-9_]*$/.test(text)) {
+ refs.add(text);
+ }
+ }
+ });
+ return [...refs].sort((a, b) => a.localeCompare(b));
+}
+
+const events: ExtractedEvent[] = [];
+const unresolvedCalls: { file: string; line: number; reason: string }[] = [];
+const configCalls: MatomoConfigCall[] = [];
+
+function isMatomoPushCallee(exprText: string): boolean {
+ if (MATOMO_PUSH_CALLEES.has(exprText)) return true;
+ // Gere `something._paq.push`
+ return exprText.endsWith("._paq.push");
+}
+
+for (const sf of loadedFiles) {
+ sf.forEachDescendant((node) => {
+ if (!Node.isCallExpression(node)) return;
+ const expr = node.getExpression();
+ const exprText = expr.getText();
+ const line = node.getStartLineNumber();
+ const relFile = path.relative(REPO_ROOT, sf.getFilePath());
+
+ // ---- Cas 1 : sendEvent({ category, action, name }) ----
+ if (exprText === "sendEvent" || exprText.endsWith(".sendEvent")) {
+ const args = node.getArguments();
+ if (args.length === 0) return;
+ const firstArg = args[0];
+ if (!Node.isObjectLiteralExpression(firstArg)) return;
+
+ const category = resolveProperty(firstArg, "category");
+ const action = resolveProperty(firstArg, "action");
+ const namePattern = resolveProperty(firstArg, "name");
+
+ if (!category || !action) {
+ unresolvedCalls.push({
+ file: relFile,
+ line,
+ reason: `Missing ${!category ? "category" : ""}${
+ !category && !action ? "+" : ""
+ }${!action ? "action" : ""}`,
+ });
+ return;
+ }
+
+ events.push({
+ category,
+ action,
+ name_pattern: namePattern,
+ emit_function: findContainingFunctionName(node),
+ file: relFile,
+ line,
+ enum_refs: getEnumRefs(firstArg),
+ tracking_method: "sendEvent",
+ });
+ return;
+ }
+
+ // ---- Cas 2 : push([cmd, ...args]) ou _paq.push([cmd, ...args]) ----
+ if (!isMatomoPushCallee(exprText)) return;
+
+ const args = node.getArguments();
+ if (args.length === 0) return;
+ const firstArg = args[0];
+ if (!Node.isArrayLiteralExpression(firstArg)) return;
+
+ const elements = firstArg.getElements();
+ if (elements.length === 0) return;
+ const cmdNode = elements[0];
+ // Le premier element doit etre un StringLiteral pour qu'on sache que c'est
+ // un push Matomo (et pas un Array.prototype.push quelconque).
+ if (cmdNode.getKind() !== SyntaxKind.StringLiteral) return;
+ const cmdValue = (
+ cmdNode as unknown as { getLiteralValue(): string }
+ ).getLiteralValue();
+
+ if (EVENT_COMMANDS.has(cmdValue)) {
+ // Event Matomo : on le normalise en category/action pour le docs pipeline.
+ const method = `push:${cmdValue}`;
+ let category: string;
+ let action: string;
+ let namePattern: string | null = null;
+
+ if (cmdValue === "trackEvent") {
+ category = elements[1] ? resolveExpression(elements[1]) ?? "" : "";
+ action = elements[2] ? resolveExpression(elements[2]) ?? "" : "";
+ namePattern = elements[3] ? resolveExpression(elements[3]) : null;
+ } else {
+ // Pour les autres events (trackSiteSearch, trackPageView, trackGoal, etc.)
+ // on utilise une pseudo-category "_matomo:" et on met le 1er arg en action.
+ // Utiliser `_` comme separateur (pas `:`) pour que le split
+ // category:action en aval ne confonde pas _matomo:cmd:action.
+ category = `_matomo_${cmdValue}`;
+ action = elements[1] ? resolveExpression(elements[1]) ?? "" : "";
+ namePattern = elements[2] ? resolveExpression(elements[2]) : null;
+ }
+
+ events.push({
+ category,
+ action,
+ name_pattern: namePattern,
+ emit_function: findContainingFunctionName(node),
+ file: relFile,
+ line,
+ enum_refs: [],
+ tracking_method: method,
+ });
+ } else {
+ // Commande de configuration (setReferrerUrl, AbTesting::create, opt-out, etc.)
+ const argStrings = elements
+ .slice(1)
+ .map((e) => {
+ const v = resolveExpression(e);
+ return v ?? e.getText().slice(0, 80);
+ });
+ configCalls.push({
+ command: cmdValue,
+ args: argStrings,
+ file: relFile,
+ line,
+ });
+ }
+ });
+}
+
+events.sort(
+ (a, b) =>
+ a.category.localeCompare(b.category) ||
+ a.action.localeCompare(b.action) ||
+ a.file.localeCompare(b.file) ||
+ a.line - b.line
+);
+
+const uniqueKeys = new Set(events.map((e) => `${e.category}:${e.action}`));
+
+configCalls.sort(
+ (a, b) =>
+ a.command.localeCompare(b.command) ||
+ a.file.localeCompare(b.file) ||
+ a.line - b.line
+);
+
+const output = {
+ generated_at: new Date().toISOString(),
+ scan_root: path.relative(REPO_ROOT, FRONTEND_SRC),
+ total_callsites: events.length,
+ unique_events: uniqueKeys.size,
+ unresolved_callsites: unresolvedCalls.length,
+ events,
+ unresolved: unresolvedCalls,
+ matomo_config_calls: configCalls,
+};
+
+const outputPath = path.join(METABASE_DIR, "events/events.extracted.json");
+fs.writeFileSync(outputPath, JSON.stringify(output, null, 2) + "\n");
+
+console.log(
+ `[extract-events] ${events.length} callsites events, ${uniqueKeys.size} events uniques, ${unresolvedCalls.length} non-resolus, ${configCalls.length} commandes config`
+);
+console.log(`[extract-events] Ecrit: ${path.relative(REPO_ROOT, outputPath)}`);
diff --git a/packages/metabase/events/generate-events-doc.ts b/packages/metabase/events/generate-events-doc.ts
new file mode 100644
index 00000000000..31473edeae8
--- /dev/null
+++ b/packages/metabase/events/generate-events-doc.ts
@@ -0,0 +1,381 @@
+// generate-events-doc.ts
+// ----------------------------------------------------------------------------
+// Joint events/events.extracted.json (ground truth technique) et
+// events/events.metadata.yaml (description metier) et produit docs/events.md
+// groupé par feature_group, avec sections "Orphelins" (code sans metadata) et
+// "Metadata orpheline" (metadata sans code correspondant).
+//
+// Fichier auto-genere : la bannière en tete de docs/events.md doit dissuader
+// l'edition manuelle. check-events-drift.ts valide que le fichier est a jour.
+// ----------------------------------------------------------------------------
+
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { fileURLToPath } from "node:url";
+import * as YAML from "yaml";
+import type { EventMetadata } from "./events.schema.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const METABASE_DIR = path.resolve(__dirname, "..");
+const EXTRACTED_PATH = path.join(METABASE_DIR, "events/events.extracted.json");
+const METADATA_PATH = path.join(METABASE_DIR, "events/events.metadata.yaml");
+const OUTPUT_PATH = path.join(METABASE_DIR, "docs/events.md");
+
+type ExtractedEvent = {
+ category: string;
+ action: string;
+ name_pattern: string | null;
+ emit_function: string | null;
+ file: string;
+ line: number;
+ enum_refs: string[];
+ tracking_method: string;
+};
+
+type MatomoConfigCall = {
+ command: string;
+ args: string[];
+ file: string;
+ line: number;
+};
+
+type Extraction = {
+ generated_at: string;
+ scan_root: string;
+ total_callsites: number;
+ unique_events: number;
+ unresolved_callsites: number;
+ events: ExtractedEvent[];
+ unresolved: { file: string; line: number; reason: string }[];
+ matomo_config_calls?: MatomoConfigCall[];
+};
+
+if (!fs.existsSync(EXTRACTED_PATH)) {
+ console.error(
+ `[generate-events-doc] Introuvable: ${EXTRACTED_PATH}. Lance d'abord 'pnpm events:extract'.`
+ );
+ process.exit(1);
+}
+
+const extraction: Extraction = JSON.parse(fs.readFileSync(EXTRACTED_PATH, "utf8"));
+const metadataRaw = fs.existsSync(METADATA_PATH)
+ ? fs.readFileSync(METADATA_PATH, "utf8")
+ : "";
+const metadata: Record =
+ YAML.parse(metadataRaw) ?? {};
+
+function lookupMetadata(
+ category: string,
+ action: string
+): EventMetadata | null {
+ const exactKey = `${category}:${action}`;
+ if (metadata[exactKey]) return metadata[exactKey];
+ const wildKey = `${category}:*`;
+ if (metadata[wildKey]) return metadata[wildKey];
+ return null;
+}
+
+// Regrouper les callsites par event unique (category:action)
+const byEvent = new Map();
+for (const e of extraction.events) {
+ const key = `${e.category}:${e.action}`;
+ const arr = byEvent.get(key) ?? [];
+ arr.push(e);
+ byEvent.set(key, arr);
+}
+
+type DocumentedEvent = {
+ category: string;
+ action: string;
+ metadata: EventMetadata;
+ callsites: ExtractedEvent[];
+ matched_wildcard: boolean;
+};
+
+const documented: DocumentedEvent[] = [];
+const orphanEvents: ExtractedEvent[][] = [];
+
+for (const [key, callsites] of byEvent) {
+ const colonIdx = key.indexOf(":");
+ const category = key.slice(0, colonIdx);
+ const action = key.slice(colonIdx + 1);
+ const meta = lookupMetadata(category, action);
+ if (meta) {
+ documented.push({
+ category,
+ action,
+ metadata: meta,
+ callsites,
+ matched_wildcard: !metadata[`${category}:${action}`],
+ });
+ } else {
+ orphanEvents.push(callsites);
+ }
+}
+
+// Metadata sans event correspondant
+const extractedKeys = new Set(byEvent.keys());
+const extractedCategories = new Set(
+ [...byEvent.keys()].map((k) => k.split(":")[0])
+);
+const metadataOrphans: { key: string; meta: EventMetadata }[] = [];
+for (const [key, meta] of Object.entries(metadata)) {
+ if (key.endsWith(":*")) {
+ const cat = key.slice(0, -2);
+ if (!extractedCategories.has(cat)) metadataOrphans.push({ key, meta });
+ } else if (!extractedKeys.has(key)) {
+ metadataOrphans.push({ key, meta });
+ }
+}
+
+// Regrouper par feature_group
+const byGroup = new Map();
+for (const ev of documented) {
+ const group = ev.metadata.feature_group ?? "uncategorized";
+ const arr = byGroup.get(group) ?? [];
+ arr.push(ev);
+ byGroup.set(group, arr);
+}
+
+function anchor(s: string): string {
+ return s
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "");
+}
+
+function escapePipe(s: string): string {
+ return s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
+}
+
+const lines: string[] = [];
+
+lines.push(
+ ""
+);
+lines.push(
+ ""
+);
+lines.push(
+ ""
+);
+lines.push(
+ ""
+);
+lines.push("");
+lines.push("# Glossaire des Events Matomo");
+lines.push("");
+lines.push(
+ `Genere le **${extraction.generated_at}** depuis \`${extraction.scan_root}\`.`
+);
+lines.push("");
+lines.push(
+ `**Stats :** ${extraction.total_callsites} callsites · ${extraction.unique_events} events uniques · **${documented.length} documentes** · **${orphanEvents.length} orphelins** · ${metadataOrphans.length} metadata orphelines.`
+);
+lines.push("");
+lines.push(
+ "> Cette page est le point d'entree unique pour comprendre les events trackes par le frontend CDTN et leur usage dans les dashboards Metabase. Chaque entree est extraite statiquement depuis le code TS puis enrichie avec la description metier maintenue dans `events/events.metadata.yaml`."
+);
+lines.push("");
+lines.push("## Sommaire");
+lines.push("");
+for (const group of [...byGroup.keys()].sort((a, b) => a.localeCompare(b))) {
+ lines.push(
+ `- [${group}](#${anchor(group)}) (${byGroup.get(group)!.length})`
+ );
+}
+if (orphanEvents.length > 0) {
+ lines.push(`- [Orphelins](#orphelins) (${orphanEvents.length})`);
+}
+if (metadataOrphans.length > 0) {
+ lines.push(
+ `- [Metadata orpheline](#metadata-orpheline) (${metadataOrphans.length})`
+ );
+}
+const totalConfigCalls = extraction.matomo_config_calls?.length ?? 0;
+if (totalConfigCalls > 0) {
+ lines.push(
+ `- [Commandes Matomo de configuration](#commandes-matomo-de-configuration-non-events) (${totalConfigCalls})`
+ );
+}
+if (extraction.unresolved_callsites > 0) {
+ lines.push(
+ `- [Callsites non-resolus](#callsites-non-resolus) (${extraction.unresolved_callsites})`
+ );
+}
+lines.push("");
+lines.push("---");
+lines.push("");
+
+for (const group of [...byGroup.keys()].sort((a, b) => a.localeCompare(b))) {
+ lines.push(`## ${group}`);
+ lines.push("");
+ const events = byGroup
+ .get(group)!
+ .sort(
+ (a, b) =>
+ a.category.localeCompare(b.category) ||
+ a.action.localeCompare(b.action)
+ );
+ for (const ev of events) {
+ lines.push(`### \`${ev.category}\` / \`${ev.action}\``);
+ lines.push("");
+ lines.push(ev.metadata.label_fr);
+ lines.push("");
+ lines.push(`- **Declenche par :** ${ev.metadata.trigger}`);
+ if (ev.metadata.kpi) lines.push(`- **KPI :** ${ev.metadata.kpi}`);
+ if (ev.metadata.dashboards?.length) {
+ lines.push(
+ `- **Dashboards :** ${ev.metadata.dashboards
+ .map((d) => `#${d}`)
+ .join(", ")}`
+ );
+ }
+ if (ev.metadata.cards?.length) {
+ lines.push(
+ `- **Cartes :** ${ev.metadata.cards.map((c) => `#${c}`).join(", ")}`
+ );
+ }
+ if (ev.metadata.mv_source) {
+ lines.push(`- **MV source :** \`${ev.metadata.mv_source}\``);
+ }
+ if (ev.metadata.since) {
+ lines.push(`- **Depuis :** ${ev.metadata.since}`);
+ }
+ if (ev.metadata.deprecated) {
+ lines.push(`- **Deprecated :** oui`);
+ }
+ if (ev.metadata.notes) {
+ lines.push(`- **Notes :** ${ev.metadata.notes}`);
+ }
+ if (ev.matched_wildcard) {
+ lines.push(
+ `- **Metadata :** herite de \`${ev.category}:*\` (generique de categorie)`
+ );
+ }
+ const methods = new Set(ev.callsites.map((c) => c.tracking_method));
+ if (methods.size === 1 && methods.has("sendEvent")) {
+ // default case, pas besoin d'alourdir
+ } else {
+ lines.push(
+ `- **Methode :** ${[...methods].sort((a, b) => a.localeCompare(b)).join(", ")}`
+ );
+ }
+ lines.push(
+ `- **Callsites :** ${ev.callsites.length}`
+ );
+ for (const c of ev.callsites) {
+ const name = c.name_pattern ? ` — name: \`${c.name_pattern}\`` : "";
+ const fn = c.emit_function ? ` (\`${c.emit_function}()\`)` : "";
+ const method =
+ c.tracking_method !== "sendEvent" ? ` · \`${c.tracking_method}\`` : "";
+ lines.push(` - \`${c.file}:${c.line}\`${fn}${method}${name}`);
+ }
+ lines.push("");
+ }
+}
+
+if (orphanEvents.length > 0) {
+ lines.push("## Orphelins");
+ lines.push("");
+ lines.push(
+ "Events emis par le code **sans description metier** dans `events/events.metadata.yaml`."
+ );
+ lines.push(
+ "**Action requise :** completer la metadata pour chaque entree (cle `\":\"`) puis relancer `pnpm events:docs`."
+ );
+ lines.push("");
+ lines.push("| category | action | callsites |");
+ lines.push("| --- | --- | --- |");
+ const sorted = orphanEvents.sort((a, b) =>
+ `${a[0].category}:${a[0].action}`.localeCompare(
+ `${b[0].category}:${b[0].action}`
+ )
+ );
+ for (const callsites of sorted) {
+ const e = callsites[0];
+ const sites = callsites
+ .map((c) => `\`${c.file}:${c.line}\``)
+ .join("
");
+ lines.push(
+ `| \`${escapePipe(e.category)}\` | \`${escapePipe(e.action)}\` | ${sites} |`
+ );
+ }
+ lines.push("");
+}
+
+if (metadataOrphans.length > 0) {
+ lines.push("## Metadata orpheline");
+ lines.push("");
+ lines.push(
+ "Entrees de `events/events.metadata.yaml` **sans callsite correspondant** dans le code. Deux cas possibles :"
+ );
+ lines.push("");
+ lines.push(
+ "1. L'event a ete supprime du code → supprimer l'entree de la metadata."
+ );
+ lines.push(
+ "2. L'event existe encore mais sous une autre category/action → corriger la cle."
+ );
+ lines.push("");
+ lines.push("| cle | label | feature_group |");
+ lines.push("| --- | --- | --- |");
+ for (const { key, meta } of metadataOrphans.sort((a, b) =>
+ a.key.localeCompare(b.key)
+ )) {
+ lines.push(
+ `| \`${escapePipe(key)}\` | ${escapePipe(meta.label_fr)} | ${escapePipe(
+ meta.feature_group
+ )} |`
+ );
+ }
+ lines.push("");
+}
+
+const configCalls = extraction.matomo_config_calls ?? [];
+if (configCalls.length > 0) {
+ lines.push("## Commandes Matomo de configuration (non-events)");
+ lines.push("");
+ lines.push(
+ "Appels a `push([...])` ou `_paq.push([...])` qui configurent le tracker Matomo **sans emettre d'event**. Recenses ici pour completude : ils pilotent le consentement, les heatmaps, les A/B tests, le referrer, etc."
+ );
+ lines.push("");
+ lines.push(
+ "> **Note** : `trackAppRouter({...})` dans `modules/config/MatomoAnalytics.tsx` initialise le tracker et emet automatiquement un `trackPageView` a chaque changement de route (pages SPA Next.js). Ce n'est pas liste ci-dessous car c'est un wrapper de haut niveau."
+ );
+ lines.push("");
+ lines.push("| commande | args | fichier:ligne |");
+ lines.push("| --- | --- | --- |");
+ for (const cc of configCalls) {
+ const argsStr = cc.args.length > 0
+ ? cc.args.map((a) => `\`${escapePipe(a)}\``).join(", ")
+ : "_(aucun)_";
+ lines.push(
+ `| \`${escapePipe(cc.command)}\` | ${argsStr} | \`${cc.file}:${cc.line}\` |`
+ );
+ }
+ lines.push("");
+}
+
+if (extraction.unresolved_callsites > 0) {
+ lines.push("## Callsites non-resolus");
+ lines.push("");
+ lines.push(
+ "Appels a `sendEvent` dont `extract-events.ts` n'a pas pu resoudre statiquement `category` ou `action` (ex: variable dynamique)."
+ );
+ lines.push("");
+ lines.push("| fichier:ligne | raison |");
+ lines.push("| --- | --- |");
+ for (const u of extraction.unresolved) {
+ lines.push(`| \`${u.file}:${u.line}\` | ${escapePipe(u.reason)} |`);
+ }
+ lines.push("");
+}
+
+fs.writeFileSync(OUTPUT_PATH, lines.join("\n"));
+console.log(
+ `[generate-events-doc] Ecrit: ${path.relative(
+ path.resolve(METABASE_DIR, "..", ".."),
+ OUTPUT_PATH
+ )} (${documented.length} documentes, ${orphanEvents.length} orphelins, ${metadataOrphans.length} metadata orphelines)`
+);
diff --git a/packages/metabase/package.json b/packages/metabase/package.json
new file mode 100644
index 00000000000..753d5332444
--- /dev/null
+++ b/packages/metabase/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@cdt/metabase",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "description": "Dashboards Metabase, pipeline d'events Matomo et configuration MCP pour le CDTN",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/SocialGouv/code-du-travail-numerique.git",
+ "directory": "packages/metabase"
+ },
+ "scripts": {
+ "events:extract": "tsx events/extract-events.ts",
+ "events:docs": "tsx events/extract-events.ts && tsx events/generate-events-doc.ts",
+ "events:check": "tsx events/check-events-drift.ts",
+ "precommit": "tsx events/check-events-drift.ts"
+ },
+ "devDependencies": {
+ "@types/node": "~22.10.2",
+ "ts-morph": "~24.0.0",
+ "tsx": "~4.19.2",
+ "yaml": "~2.6.1"
+ },
+ "engines": {
+ "node": ">= 24"
+ }
+}
diff --git a/packages/metabase/sql/CLAUDE.md b/packages/metabase/sql/CLAUDE.md
new file mode 100644
index 00000000000..2be0da83945
--- /dev/null
+++ b/packages/metabase/sql/CLAUDE.md
@@ -0,0 +1,60 @@
+# sql/ — consignes Claude
+
+DDL des vues materialisees de la DB OVH PG CDTN (ID 4). **Source de verite versionnee** : tout changement de schema doit passer par une modification de ces fichiers.
+
+## Convention de nommage
+
+`mv_.sql` — un fichier par MV. Le nom du fichier doit matcher le nom de la MV en DB.
+
+## Header obligatoire
+
+Chaque fichier commence par un bloc de commentaires avec :
+
+```sql
+-- mv_xxx.sql
+-- Source de verite : docs/materialized-views.md §N
+-- Role :
+-- Source :
+-- Taille : <~ cardinalite>
+-- Refresh :
+-- Cartes :
+```
+
+## Regles
+
+1. **Toute modification d'une MV** :
+ - Modifier `sql/mv_xxx.sql` (la DDL canonique)
+ - Mettre a jour la section correspondante de `docs/materialized-views.md`
+ - Mettre a jour `CLAUDE.md` §Refresh des MV si la frequence change
+ - Appliquer via `/api/dataset` (voir `CLAUDE.md` §"Tips API Metabase") OU psql direct
+ - Verifier via `SELECT * FROM pg_matviews WHERE matviewname = 'mv_xxx';`
+
+2. **Pour les MV avec `DROP + CREATE`** (`mv_kpi_personnalisation`) :
+ - Lister les index dans le fichier SQL (le CASCADE du DROP les supprime aussi)
+ - Tester sur un environnement de dev si possible
+ - Backup du SELECT COUNT(\*) avant / apres
+
+3. **Ne PAS** ajouter de REFRESH dans ces fichiers : le refresh est execute separement cote infra (cf. `../CLAUDE.md` §Refresh des MV).
+
+## Ordre de dependance
+
+```
+matomo_partitioned (table source)
+ -> metabase_model_106
+ -> visites_uniques
+ -> mv_perso_weekly
+ -> mv_funnel_il_irc
+ -> mv_kpi_personnalisation (DROP/CREATE)
+ -> mv_funnel_il_irc_visits (INDEPENDANT, source = matomo_partitioned)
+ -> mv_bounce_contributions (INDEPENDANT, source = matomo_partitioned)
+ -> commentaires_utilisateurs (INDEPENDANT, source = matomo_partitioned)
+ -> mv_cc_non_traitees (STATIQUE, source = matomo_partitioned)
+```
+
+Voir `docs/materialized-views.md` §"Ordre de refresh des MV" pour la sequence de commandes.
+
+## Ne pas faire
+
+- Modifier la DDL sans mettre a jour la doc.
+- Hard-coder des dates (preferer `CURRENT_DATE - INTERVAL 'N day'`).
+- Creer une MV en dehors de ce folder (la DDL doit etre tracee en git).
diff --git a/packages/metabase/sql/mv_bounce_contributions.sql b/packages/metabase/sql/mv_bounce_contributions.sql
new file mode 100644
index 00000000000..b83eaaab67f
--- /dev/null
+++ b/packages/metabase/sql/mv_bounce_contributions.sql
@@ -0,0 +1,57 @@
+-- mv_bounce_contributions.sql
+-- Source de verite : docs/materialized-views.md §8
+-- Role : une ligne par (visite, contribution) pour le calcul du taux de rebond
+-- des contributions. Source matomo_partitioned, INDEPENDANTE de
+-- metabase_model_106 -> temps reel.
+-- Refresh : REFRESH MATERIALIZED VIEW mv_bounce_contributions;
+-- Cartes consommatrices : voir docs/dashboards.md §"Taux de rebond Contributions".
+
+CREATE MATERIALIZED VIEW IF NOT EXISTS mv_bounce_contributions AS
+WITH contrib_actions AS (
+ SELECT
+ idvisit,
+ substring(action_url, 'https?://[^/]+(/[^?#]*)') AS pathname,
+ DATE_TRUNC('day', action_timestamp)::date AS jour,
+ action_type, action_eventcategory, action_eventaction
+ FROM matomo_partitioned
+ WHERE action_timestamp >= CURRENT_DATE - INTERVAL '60 day'
+ AND action_timestamp < CURRENT_DATE
+ AND action_url LIKE '%/contribution/%'
+ AND substring(action_url, 'https?://[^/]+(/[^?#]*)') LIKE '/contribution/%'
+)
+SELECT
+ idvisit, pathname, MIN(jour) AS jour,
+ BOOL_OR(action_type = 'action') AS has_pageview,
+ BOOL_OR(action_type = 'event' AND (
+ action_eventcategory IN ('contribution','cc_search','cc_search_type_of_users',
+ 'cc_select_p1','cc_select_p2',
+ 'enterprise_search','enterprise_select')
+ OR action_eventaction IN (
+ 'click_p1','click_p2','click_p3',
+ 'click_je_n_ai_pas_d_entreprise','select_je_n_ai_pas_d_entreprise',
+ 'click_afficher_les_informations_CC',
+ 'click_afficher_les_informations_sans_CC',
+ 'click_afficher_les_informations_générales','click_afficher_les_informations_generales'
+ )
+ )) AS has_interaction,
+ BOOL_OR(action_eventaction IN (
+ 'click_afficher_les_informations_générales','click_afficher_les_informations_generales'
+ )) AS has_click_generic,
+ BOOL_OR(action_eventaction = 'click_afficher_les_informations_CC') AS has_click_cc,
+ BOOL_OR(action_eventaction = 'click_afficher_les_informations_sans_CC') AS has_click_sans_cc,
+ BOOL_OR(
+ action_eventcategory IN ('cc_search','cc_search_type_of_users',
+ 'cc_select_p1','cc_select_p2',
+ 'enterprise_search','enterprise_select')
+ OR action_eventaction IN (
+ 'click_p1','click_p2','click_p3',
+ 'click_je_n_ai_pas_d_entreprise','select_je_n_ai_pas_d_entreprise'
+ )
+ ) AS has_cc_search
+FROM contrib_actions
+GROUP BY idvisit, pathname;
+
+CREATE INDEX IF NOT EXISTS idx_mv_bounce_contributions_jour
+ ON mv_bounce_contributions (jour, pathname);
+CREATE INDEX IF NOT EXISTS idx_mv_bounce_contributions_path
+ ON mv_bounce_contributions (pathname);
diff --git a/packages/metabase/sql/mv_cc_non_traitees.sql b/packages/metabase/sql/mv_cc_non_traitees.sql
new file mode 100644
index 00000000000..b5a9123ced2
--- /dev/null
+++ b/packages/metabase/sql/mv_cc_non_traitees.sql
@@ -0,0 +1,17 @@
+-- mv_cc_non_traitees.sql
+-- Source de verite : docs/materialized-views.md §9
+-- Role : pre-agrege les selections de CC non traitees par nom de CC pour 2025.
+-- Source : matomo_partitioned.
+-- Refresh : STATIQUE (donnees 2025, pas de refresh prevu).
+-- Cartes consommatrices : voir docs/dashboards.md §"Dashboard Personnalisation" (KPI 5).
+
+CREATE MATERIALIZED VIEW IF NOT EXISTS mv_cc_non_traitees AS
+SELECT action_eventname AS cc_name,
+ COUNT(DISTINCT idvisit) AS nb_utilisateurs,
+ COUNT(*) AS nb_selections
+FROM matomo_partitioned
+WHERE action_type = 'event' AND action_eventcategory = 'outil'
+ AND action_eventaction = 'cc_select_non_traitée'
+ AND COALESCE(action_eventname, '') <> ''
+ AND action_timestamp >= '2025-01-01' AND action_timestamp < '2026-01-01'
+GROUP BY action_eventname;
diff --git a/packages/metabase/sql/mv_commentaires_utilisateurs.sql b/packages/metabase/sql/mv_commentaires_utilisateurs.sql
new file mode 100644
index 00000000000..46d5e265ed7
--- /dev/null
+++ b/packages/metabase/sql/mv_commentaires_utilisateurs.sql
@@ -0,0 +1,32 @@
+-- mv_commentaires_utilisateurs.sql
+-- Source de verite : docs/materialized-views.md §3
+-- Role : feedbacks utilisateurs agreges (suggestion + categorie + feedback par ligne).
+-- Source : matomo_partitioned (independant de metabase_model_106).
+-- Refresh : REFRESH MATERIALIZED VIEW commentaires_utilisateurs;
+
+CREATE MATERIALIZED VIEW IF NOT EXISTS commentaires_utilisateurs AS
+WITH feedbacks AS (
+ SELECT action_id,
+ substring(action_url, 'https?://[^/]+(/[^?#]*)') AS pathname,
+ idvisit, action_timestamp, action_eventcategory, action_eventaction, action_type
+ FROM matomo_partitioned
+ WHERE split_part(action_eventcategory, '_', 1) = 'feedback'
+ AND action_timestamp >= date_trunc('month', CURRENT_DATE - interval '1 year 1 month')
+)
+SELECT action_id,
+ action_eventaction AS feedback_suggestion,
+ pathname,
+ (SELECT f.action_eventaction FROM feedbacks f
+ WHERE f.action_eventcategory = 'feedback'
+ AND f.idvisit = fs.idvisit
+ AND date_trunc('hour', f.action_timestamp) = date_trunc('hour', fs.action_timestamp)
+ LIMIT 1) AS feedback,
+ (SELECT f.action_eventaction FROM feedbacks f
+ WHERE f.action_eventcategory = 'feedback_category'
+ AND f.idvisit = fs.idvisit
+ AND date_trunc('hour', f.action_timestamp) = date_trunc('hour', fs.action_timestamp)
+ LIMIT 1) AS feedback_category,
+ idvisit, action_timestamp
+FROM feedbacks fs
+WHERE action_type = 'event' AND action_eventcategory = 'feedback_suggestion'
+ORDER BY action_timestamp DESC;
diff --git a/packages/metabase/sql/mv_funnel_il_irc.sql b/packages/metabase/sql/mv_funnel_il_irc.sql
new file mode 100644
index 00000000000..d35848b52d1
--- /dev/null
+++ b/packages/metabase/sql/mv_funnel_il_irc.sql
@@ -0,0 +1,28 @@
+-- mv_funnel_il_irc.sql
+-- Source de verite : docs/materialized-views.md §6
+-- Role : pre-agrege par (semaine, simulateur, etape) les visites uniques sur
+-- les funnels IL et IRC. Usage historique (12 mois), donc base sur
+-- metabase_model_106 (stale si metabase_model_106 n'est pas rafraichi).
+-- Refresh : REFRESH MATERIALIZED VIEW mv_funnel_il_irc;
+-- Cartes consommatrices : voir docs/dashboards.md §"Funnel IL/IRC" (dashboard
+-- de comparaison avant/apres refonte). Pour les cartes temps reel, voir
+-- mv_funnel_il_irc_visits.
+
+CREATE MATERIALIZED VIEW IF NOT EXISTS mv_funnel_il_irc AS
+SELECT
+ DATE_TRUNC('week', action_timestamp)::date AS semaine,
+ path_level3 AS simulateur,
+ action_eventname AS etape,
+ COUNT(DISTINCT idvisit) AS visites
+FROM metabase_model_106
+WHERE path_level3 IN ('indemnite-licenciement','indemnite-rupture-conventionnelle')
+ AND action_eventcategory = 'outil'
+ AND action_eventaction LIKE 'view_step_%'
+ AND action_eventname IN (
+ 'start','contrat_travail','info_cc',
+ 'anciennete','absences','salaires','infos','results','results_ineligible'
+ )
+GROUP BY 1, 2, 3;
+
+CREATE INDEX IF NOT EXISTS idx_mv_funnel_il_irc_semaine
+ ON mv_funnel_il_irc (semaine, simulateur, etape);
diff --git a/packages/metabase/sql/mv_funnel_il_irc_visits.sql b/packages/metabase/sql/mv_funnel_il_irc_visits.sql
new file mode 100644
index 00000000000..20b68090f40
--- /dev/null
+++ b/packages/metabase/sql/mv_funnel_il_irc_visits.sql
@@ -0,0 +1,37 @@
+-- mv_funnel_il_irc_visits.sql
+-- Source de verite : docs/materialized-views.md §7
+-- Role : une ligne PAR VISITE (idvisit) pour les funnels IL et IRC sur les 60j
+-- derniers, avec un flag booleen par etape. Source matomo_partitioned,
+-- INDEPENDANTE de metabase_model_106 -> temps reel.
+-- Refresh : REFRESH MATERIALIZED VIEW mv_funnel_il_irc_visits;
+-- Cartes consommatrices : voir docs/dashboards.md §"Taux completion IL/IRC"
+-- (cumulative funnel, fenetre parametrable).
+
+CREATE MATERIALIZED VIEW IF NOT EXISTS mv_funnel_il_irc_visits AS
+SELECT
+ idvisit,
+ CASE
+ WHEN action_url LIKE '%/outils/indemnite-licenciement%' THEN 'indemnite-licenciement'
+ WHEN action_url LIKE '%/outils/indemnite-rupture-conventionnelle%' THEN 'indemnite-rupture-conventionnelle'
+ END AS simulateur,
+ MIN(action_timestamp)::date AS jour,
+ BOOL_OR(action_eventname = 'start') AS s_start,
+ BOOL_OR(action_eventname = 'info_cc') AS s_info_cc,
+ BOOL_OR(action_eventname = 'infos') AS s_infos,
+ BOOL_OR(action_eventname = 'anciennete') AS s_anciennete,
+ BOOL_OR(action_eventname = 'absences') AS s_absences,
+ BOOL_OR(action_eventname = 'salaires') AS s_salaires,
+ BOOL_OR(action_eventname = 'results') AS s_results
+FROM matomo_partitioned
+WHERE action_timestamp >= CURRENT_DATE - INTERVAL '60 day'
+ AND action_timestamp < CURRENT_DATE
+ AND action_type = 'event'
+ AND action_eventcategory = 'outil'
+ AND action_eventaction LIKE 'view_step_%'
+ AND action_eventname IN ('start','info_cc','infos','anciennete','absences','salaires','results')
+ AND (action_url LIKE '%/outils/indemnite-licenciement%'
+ OR action_url LIKE '%/outils/indemnite-rupture-conventionnelle%')
+GROUP BY 1, 2;
+
+CREATE INDEX IF NOT EXISTS idx_mv_funnel_il_irc_visits_jour
+ ON mv_funnel_il_irc_visits (simulateur, jour);
diff --git a/packages/metabase/sql/mv_kpi_personnalisation.sql b/packages/metabase/sql/mv_kpi_personnalisation.sql
new file mode 100644
index 00000000000..fd82162e8d3
--- /dev/null
+++ b/packages/metabase/sql/mv_kpi_personnalisation.sql
@@ -0,0 +1,97 @@
+-- mv_kpi_personnalisation.sql
+-- Source de verite : docs/materialized-views.md §4
+-- Role : pre-agrege les evenements cibles de personnalisation par visite dedupliquee.
+-- Evite les Seq Scan sur metabase_model_106 pour les cartes du dashboard
+-- "Personnalisation des contenus".
+-- Source : metabase_model_106 (derniere annee glissante).
+-- Refresh : DROP + CREATE (schema fixe, pas de REFRESH).
+-- Cartes consommatrices : voir docs/dashboards.md §"Dashboard Personnalisation".
+--
+-- PROCEDURE DE REFRESH
+-- --------------------
+-- 1. Lancer REFRESH MATERIALIZED VIEW metabase_model_106 (long).
+-- 2. Sauvegarder SELECT COUNT(*) FROM mv_kpi_personnalisation (controle avant/apres).
+-- 3. Executer ce fichier (DROP + CREATE).
+-- 4. Verifier SELECT COUNT(*) FROM mv_kpi_personnalisation (proche de l'avant).
+-- Les index sont recrees automatiquement en fin de fichier.
+
+DROP MATERIALIZED VIEW IF EXISTS mv_kpi_personnalisation CASCADE;
+
+CREATE MATERIALIZED VIEW mv_kpi_personnalisation AS
+WITH events_bruts AS (
+ SELECT
+ month,
+ action_eventcategory,
+ action_eventaction,
+ action_eventname,
+ pathname,
+ path_level2,
+ path_level3,
+ idvisit,
+ -- Type de contenu
+ CASE
+ WHEN path_level2 = 'contribution' THEN 'contribution'
+ WHEN action_eventcategory = 'outil' AND action_eventaction IN ('cc_select_traitée', 'cc_select_non_traitée') THEN 'simulateur'
+ WHEN action_eventcategory = 'cc_search_type_of_users' THEN 'cc_search'
+ ELSE NULL
+ END AS content_type,
+ -- Path unique par type
+ CASE
+ WHEN path_level2 = 'contribution' THEN pathname
+ WHEN action_eventcategory = 'outil' THEN path_level3
+ WHEN action_eventcategory = 'cc_search_type_of_users' THEN 'global'
+ ELSE NULL
+ END AS path
+ FROM metabase_model_106
+ WHERE action_type = 'event'
+ AND (
+ -- Contributions
+ (path_level2 = 'contribution' AND action_eventaction IN (
+ 'click_afficher_les_informations_CC',
+ 'click_afficher_les_informations_sans_CC',
+ 'click_afficher_les_informations_générales',
+ 'click_afficher_les_informations_generales'
+ ))
+ -- Simulateurs
+ OR (action_eventcategory = 'outil' AND action_eventaction IN (
+ 'cc_select_traitée', 'cc_select_non_traitée',
+ 'cc_select_traitee', 'cc_select_non_traitee'
+ ))
+ -- CC search
+ OR (action_eventcategory = 'cc_search_type_of_users' AND action_eventaction IN (
+ 'click_p1','click_p2','click_p3',
+ 'click_je_n_ai_pas_d_entreprise','select_je_n_ai_pas_d_entreprise'
+ ))
+ )
+)
+SELECT
+ month,
+ content_type,
+ path,
+ idvisit,
+ BOOL_OR(
+ action_eventaction IN (
+ 'click_afficher_les_informations_CC',
+ 'cc_select_traitée', 'cc_select_traitee'
+ )
+ ) AS is_perso,
+ BOOL_OR(
+ action_eventaction IN (
+ 'click_afficher_les_informations_sans_CC',
+ 'cc_select_non_traitée', 'cc_select_non_traitee'
+ )
+ ) AS is_cc_non_traitee,
+ BOOL_OR(
+ action_eventaction IN (
+ 'click_je_n_ai_pas_d_entreprise', 'select_je_n_ai_pas_d_entreprise'
+ )
+ ) AS is_pas_entreprise,
+ BOOL_OR(action_eventaction = 'click_p3') AS is_renonciation
+FROM events_bruts
+WHERE content_type IS NOT NULL
+GROUP BY month, content_type, path, idvisit;
+
+-- Indexes
+CREATE INDEX IF NOT EXISTS idx_mvkpi_month_type ON mv_kpi_personnalisation (month, content_type);
+CREATE INDEX IF NOT EXISTS idx_mvkpi_month_type_path ON mv_kpi_personnalisation (month, content_type, path);
+CREATE INDEX IF NOT EXISTS idx_mvkpi_idvisit ON mv_kpi_personnalisation (idvisit);
diff --git a/packages/metabase/sql/mv_metabase_model_106.sql b/packages/metabase/sql/mv_metabase_model_106.sql
new file mode 100644
index 00000000000..c8efdcd41e0
--- /dev/null
+++ b/packages/metabase/sql/mv_metabase_model_106.sql
@@ -0,0 +1,33 @@
+-- mv_metabase_model_106.sql
+-- Source de verite : docs/materialized-views.md §1
+-- Role : cache pre-agrege des donnees Matomo avec colonnes calculees
+-- (pathname, path_level2, path_level3, month).
+-- Donnees : derniere annee glissante (filtre sur action_timestamp).
+-- Refresh : REFRESH MATERIALIZED VIEW metabase_model_106;
+-- !! Attention : cette MV est TRES lente a refresh (~minutes a heures) et
+-- plusieurs autres MV dependent d'elle. Cf. ../CLAUDE.md §"Refresh des MV"
+-- et ../docs/materialized-views.md §"Ordre de refresh des MV".
+
+-- NOTE : cette MV est egalement exposee cote Metabase par une carte "model".
+-- La definition ci-dessous est la requete equivalente, fournie ici pour
+-- tracabilite et recreation manuelle si besoin. Ne pas la DROP/CREATE sans
+-- concertation.
+
+CREATE MATERIALIZED VIEW IF NOT EXISTS metabase_model_106 AS
+SELECT action_id,
+ idvisit,
+ action_type,
+ action_eventcategory,
+ action_eventaction,
+ action_eventname,
+ action_eventvalue,
+ action_timestamp,
+ (date_trunc('month', action_timestamp))::date AS month,
+ action_url,
+ referrertype,
+ referrername,
+ substring(action_url, 'https?://[^/]+(/[^?#]*)') AS pathname,
+ split_part(substring(action_url, 'https?://[^/]+(/[^?#]*)'), '/', 2) AS path_level2,
+ split_part(substring(action_url, 'https?://[^/]+(/[^?#]*)'), '/', 3) AS path_level3
+FROM matomo_partitioned
+WHERE action_timestamp >= date_trunc('month', CURRENT_DATE - interval '1 year');
diff --git a/packages/metabase/sql/mv_perso_weekly.sql b/packages/metabase/sql/mv_perso_weekly.sql
new file mode 100644
index 00000000000..475f788aca6
--- /dev/null
+++ b/packages/metabase/sql/mv_perso_weekly.sql
@@ -0,0 +1,27 @@
+-- mv_perso_weekly.sql
+-- Source de verite : docs/materialized-views.md §5
+-- Role : pre-agrege les stats de personnalisation par semaine pour le graphique
+-- d'evolution (KPI 2 du dashboard "Personnalisation des contenus").
+-- Source : metabase_model_106.
+-- Refresh : REFRESH MATERIALIZED VIEW mv_perso_weekly;
+-- Cartes consommatrices : voir docs/dashboards.md §"Dashboard Personnalisation" (KPI 2).
+
+CREATE MATERIALIZED VIEW IF NOT EXISTS mv_perso_weekly AS
+SELECT
+ DATE_TRUNC('week', action_timestamp)::date AS semaine,
+ CASE WHEN action_eventaction LIKE 'cc_select%' THEN 'simulateur' ELSE 'contribution' END AS type,
+ COUNT(DISTINCT idvisit) AS total_visits,
+ COUNT(DISTINCT CASE
+ WHEN action_eventaction IN ('click_afficher_les_informations_CC', 'cc_select_traitée')
+ THEN idvisit
+ END) AS personalized_visits
+FROM metabase_model_106
+WHERE action_type = 'event'
+ AND action_eventaction IN (
+ 'click_afficher_les_informations_CC',
+ 'click_afficher_les_informations_sans_CC',
+ 'click_afficher_les_informations_générales',
+ 'cc_select_traitée',
+ 'cc_select_non_traitée'
+ )
+GROUP BY 1, 2;
diff --git a/packages/metabase/sql/mv_visites_uniques.sql b/packages/metabase/sql/mv_visites_uniques.sql
new file mode 100644
index 00000000000..c318ef696c6
--- /dev/null
+++ b/packages/metabase/sql/mv_visites_uniques.sql
@@ -0,0 +1,11 @@
+-- mv_visites_uniques.sql
+-- Source de verite : docs/materialized-views.md §2
+-- Role : liste dedoublonnee des visites par chemin et par mois.
+-- Source : metabase_model_106 (donc stale si metabase_model_106 n'est pas rafraichi).
+-- Refresh : REFRESH MATERIALIZED VIEW visites_uniques;
+-- Usage : compter les visites uniques par page/mois, calculer des taux.
+
+CREATE MATERIALIZED VIEW IF NOT EXISTS visites_uniques AS
+SELECT DISTINCT idvisit, pathname, month
+FROM metabase_model_106
+WHERE month >= date_trunc('month', CURRENT_DATE - interval '1 year 1 month');
diff --git a/packages/metabase/tsconfig.json b/packages/metabase/tsconfig.json
new file mode 100644
index 00000000000..746b74c8734
--- /dev/null
+++ b/packages/metabase/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "es2024",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "lib": ["es2024"],
+ "types": ["node"],
+ "strict": true,
+ "noImplicitAny": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "allowImportingTsExtensions": true,
+ "noEmit": true
+ },
+ "include": ["events/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9792baa9e2c..bf6e0908891 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -195,7 +195,7 @@ importers:
version: 0.3.1(@testing-library/dom@10.4.1)
tsup:
specifier: ^8.5.1
- version: 8.5.1(@swc/core@1.15.11(@swc/helpers@0.5.18))(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2)
+ version: 8.5.1(@swc/core@1.15.11(@swc/helpers@0.5.18))(postcss@8.5.6)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
typescript:
specifier: ^5.9.3
version: 5.9.3
@@ -304,6 +304,21 @@ importers:
specifier: ^8.54.0
version: 8.54.0(eslint@9.39.2)(typescript@5.9.3)
+ packages/metabase:
+ devDependencies:
+ '@types/node':
+ specifier: ~22.10.2
+ version: 22.10.10
+ ts-morph:
+ specifier: ~24.0.0
+ version: 24.0.0
+ tsx:
+ specifier: ~4.19.2
+ version: 4.19.4
+ yaml:
+ specifier: ~2.6.1
+ version: 2.6.1
+
packages:
'@adobe/css-tools@4.4.4':
@@ -2637,6 +2652,9 @@ packages:
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
+ '@ts-morph/common@0.25.0':
+ resolution: {integrity: sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==}
+
'@ts-morph/common@0.28.1':
resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==}
@@ -2738,6 +2756,9 @@ packages:
'@types/node@17.0.45':
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
+ '@types/node@22.10.10':
+ resolution: {integrity: sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==}
+
'@types/node@24.10.10':
resolution: {integrity: sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow==}
@@ -7316,6 +7337,9 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+ ts-morph@24.0.0:
+ resolution: {integrity: sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==}
+
ts-morph@27.0.2:
resolution: {integrity: sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==}
@@ -7364,6 +7388,11 @@ packages:
typescript:
optional: true
+ tsx@4.19.4:
+ resolution: {integrity: sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
tuf-js@4.1.0:
resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==}
engines: {node: ^20.17.0 || >=22.9.0}
@@ -7464,6 +7493,9 @@ packages:
undefsafe@2.0.5:
resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==}
+ undici-types@6.20.0:
+ resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
+
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -7743,6 +7775,11 @@ packages:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
+ yaml@2.6.1:
+ resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==}
+ engines: {node: '>= 14'}
+ hasBin: true
+
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
@@ -10319,6 +10356,12 @@ snapshots:
'@tokenizer/token@0.3.0': {}
+ '@ts-morph/common@0.25.0':
+ dependencies:
+ minimatch: 9.0.5
+ path-browserify: 1.0.1
+ tinyglobby: 0.2.15
+
'@ts-morph/common@0.28.1':
dependencies:
minimatch: 10.1.1
@@ -10438,6 +10481,10 @@ snapshots:
'@types/node@17.0.45': {}
+ '@types/node@22.10.10':
+ dependencies:
+ undici-types: 6.20.0
+
'@types/node@24.10.10':
dependencies:
undici-types: 7.16.0
@@ -12021,7 +12068,7 @@ snapshots:
eslint: 9.39.2
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.2))(eslint@9.39.2)
- eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2)
+ eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2)
eslint-plugin-react: 7.37.5(eslint@9.39.2)
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2)
@@ -12054,7 +12101,7 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2)
+ eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2)
transitivePeerDependencies:
- supports-color
@@ -12068,7 +12115,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2):
+ eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -14254,7 +14301,7 @@ snapshots:
proc-log: 5.0.0
semver: 7.7.2
tar: 7.5.7
- tinyglobby: 0.2.12
+ tinyglobby: 0.2.15
which: 5.0.0
transitivePeerDependencies:
- supports-color
@@ -14777,11 +14824,12 @@ snapshots:
dependencies:
postcss: 8.5.6
- postcss-load-config@6.0.1(postcss@8.5.6)(yaml@2.8.2):
+ postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.19.4)(yaml@2.8.2):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
postcss: 8.5.6
+ tsx: 4.19.4
yaml: 2.8.2
postcss-merge-rules@7.0.7(postcss@8.5.6):
@@ -15825,6 +15873,11 @@ snapshots:
ts-interface-checker@0.1.13: {}
+ ts-morph@24.0.0:
+ dependencies:
+ '@ts-morph/common': 0.25.0
+ code-block-writer: 13.0.3
+
ts-morph@27.0.2:
dependencies:
'@ts-morph/common': 0.28.1
@@ -15853,7 +15906,7 @@ snapshots:
tslib@2.8.1: {}
- tsup@8.5.1(@swc/core@1.15.11(@swc/helpers@0.5.18))(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2):
+ tsup@8.5.1(@swc/core@1.15.11(@swc/helpers@0.5.18))(postcss@8.5.6)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2):
dependencies:
bundle-require: 5.1.0(esbuild@0.27.2)
cac: 6.7.14
@@ -15864,7 +15917,7 @@ snapshots:
fix-dts-default-cjs-exports: 1.0.1
joycon: 3.1.1
picocolors: 1.1.1
- postcss-load-config: 6.0.1(postcss@8.5.6)(yaml@2.8.2)
+ postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.19.4)(yaml@2.8.2)
resolve-from: 5.0.0
rollup: 4.57.1
source-map: 0.7.6
@@ -15882,6 +15935,13 @@ snapshots:
- tsx
- yaml
+ tsx@4.19.4:
+ dependencies:
+ esbuild: 0.25.12
+ get-tsconfig: 4.13.1
+ optionalDependencies:
+ fsevents: 2.3.3
+
tuf-js@4.1.0:
dependencies:
'@tufjs/models': 4.1.0
@@ -15987,6 +16047,8 @@ snapshots:
undefsafe@2.0.5: {}
+ undici-types@6.20.0: {}
+
undici-types@7.16.0: {}
undici@6.23.0: {}
@@ -16313,6 +16375,8 @@ snapshots:
yallist@5.0.0: {}
+ yaml@2.6.1: {}
+
yaml@2.8.2: {}
yargs-parser@20.2.9: {}