Phases 6 et 7 : application SvelteKit, PWA, systemd, documentation
Application — rendu serveur sur adapter-node, conforme au §3bis : - tableau de bord, recherche à facettes, fiche détaillée, calendrier - /a-propos et /methode prérendues ; tout le reste en rendu serveur - API JSON ouverte : /api/textes, /api/textes/[slug], /api/echeances - recherche FTS5 avec surlignage et classement bm25 pondéré (le titre pèse dix fois plus que les points clés) - facettes recalculées sur le résultat filtré par les AUTRES facettes : cocher un thème doit recompter les statuts encore disponibles, sinon les compteurs mentent Identité OKI sobrifiée : tokens en CSS vanilla, thème sombre par défaut et clair opt-in, or comme seule couleur d'action, flag-bar une fois par écran, polices Archivo/Inter auto-hébergées. Politique de sécurité de contenu interdisant toute requête tierce. Les statuts ne reposent jamais sur la seule couleur : libellé en toutes lettres et pastille de forme distincte. Budget respecté très largement : 6,6 Ko de JavaScript sur l'accueil (3 Ko compressés) pour un plafond de 100 Ko. Les faits clés — AFD à 500 €, saisine 2026-915 DC, statut — sont lisibles sans JavaScript. Trois défauts trouvés en testant l'application, pas en relisant le code : - v_textes est une vue, et une vue SQLite n'a pas de rowid : la jointure avec l'index plein texte échouait (migration 005) - ppl-montagne remontait en « pertinence forte » alors que le corpus dit « sans portée pour la Guadeloupe » — la cotation comptait le mot sans lire la négation. Idem pour pjl-logement et accord-globe. Corrigé par une lecture du voisinage, avec quatre tests de non-régression. - les libellés d'affichage étaient importés depuis /server dans un composant, ce que SvelteKit interdit à raison PWA : service worker réseau-d'abord pour les pages, cache-d'abord pour le coffre du build. Une veille législative ne doit pas servir une page périmée quand le réseau répond — une date de promulgation change tout. Les réponses issues du cache portent un en-tête qui le dit. systemd : timer du pipeline (6 h, 18 h, dimanche 9 h, avec dispersion et rattrapage), application activée par socket avec mise en sommeil à 300 s. Unités durcies, validées par systemd-analyze. Documentation : README d'installation sur Debian/Ubuntu vierge, README-pipeline avec le piège des deux couples d'identifiants PISTE, et scripts/verifier-conformite.sh qui contrôle mécaniquement le §3bis et le §8. 172 tests, npm run check à 0 erreur, make verifier au vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
feb5544599
commit
34d81c66a8
@@ -0,0 +1,209 @@
|
|||||||
|
# Pipeline de veille — installation, clés, dépannage
|
||||||
|
|
||||||
|
Le pipeline fait deux choses : **remplir** la base depuis le corpus de recherche
|
||||||
|
(`make seed`), puis la **tenir à jour** depuis les sources officielles
|
||||||
|
(`make update`). Il n'écrit jamais dans `data/input/`, qui est en lecture seule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv --python 3.12
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
cp .env.example .env # puis renseigner les clés, voir §2
|
||||||
|
make seed # remplit data/veille.db
|
||||||
|
```
|
||||||
|
|
||||||
|
`make seed` est idempotent : le relancer ne duplique rien. Pour repartir d'une
|
||||||
|
base vierge : `make reseed`.
|
||||||
|
|
||||||
|
Le compte-rendu affiché en fin de seed est le vrai livrable : il dit combien de
|
||||||
|
textes sont entrés, combien de citations ont été résolues, et — surtout — ce qui
|
||||||
|
manque.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Clés d'API
|
||||||
|
|
||||||
|
### Légifrance, via PISTE
|
||||||
|
|
||||||
|
Créez un compte sur [piste.gouv.fr](https://piste.gouv.fr), déclarez une
|
||||||
|
application, et **abonnez-la à l'API « Légifrance »**.
|
||||||
|
|
||||||
|
> **Le piège à connaître.** Une application PISTE affiche *deux* couples de
|
||||||
|
> valeurs. Seul le couple **OAuth** fonctionne ici :
|
||||||
|
>
|
||||||
|
> | Ce que PISTE affiche | À mettre dans `.env` |
|
||||||
|
> |---|---|
|
||||||
|
> | **Client ID** / **Client secret** | ✅ `LEGIFRANCE_CLIENT_ID` / `LEGIFRANCE_CLIENT_SECRET` |
|
||||||
|
> | API key / API key secret | ❌ produit `invalid_client` à la demande de jeton |
|
||||||
|
>
|
||||||
|
> Vérifié le 25 juillet 2026 sur un compte réel : la clé d'API est refusée par
|
||||||
|
> le point d'accès OAuth, quelle que soit la méthode d'authentification tentée
|
||||||
|
> (corps de requête, en-tête Basic, avec ou sans `scope`).
|
||||||
|
|
||||||
|
Second piège : **les identifiants de production ne fonctionnent pas en bac à
|
||||||
|
sable.** `sandbox` exige une application distincte, déclarée sur l'environnement
|
||||||
|
de test. Laissez `LEGIFRANCE_ENV=prod` sauf si vous avez explicitement créé
|
||||||
|
cette seconde application.
|
||||||
|
|
||||||
|
Vérification en une commande :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -c "
|
||||||
|
from pipeline.collecteurs.http import ClientHttp
|
||||||
|
from pipeline.collecteurs.legifrance import CollecteurLegifrance
|
||||||
|
with ClientHttp() as c:
|
||||||
|
col = CollecteurLegifrance(c, numeros_a_verifier=['2026-491'])
|
||||||
|
print(col.est_disponible())
|
||||||
|
print(col.collecter().textes)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aucune autre clé n'est nécessaire
|
||||||
|
|
||||||
|
Le Sénat et le Conseil constitutionnel sont interrogés sans authentification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Ce qui marche sans clé PISTE
|
||||||
|
|
||||||
|
C'est le point important : **le pipeline reste utile sans Légifrance.**
|
||||||
|
|
||||||
|
| Collecteur | Sans clé PISTE | Avec clé PISTE |
|
||||||
|
|---|---|---|
|
||||||
|
| `legifrance` | ⛔ désactivé, motif affiché dans le rapport | ✅ vérifie chaque numéro de loi en base |
|
||||||
|
| `senat` | ✅ liste chronologique des lois promulguées | ✅ idem, sert de contrôle croisé |
|
||||||
|
| `conseil_constitutionnel` | ✅ affaires en instance + décisions rendues | ✅ idem |
|
||||||
|
|
||||||
|
Ce qui est **perdu** sans clé : la confirmation de l'intitulé officiel et de la
|
||||||
|
date de signature directement au Journal officiel, et l'identifiant JORF de
|
||||||
|
chaque texte. Ce qui est **conservé** : la détection des promulgations
|
||||||
|
nouvelles (par le Sénat) et le suivi des décisions constitutionnelles — soit
|
||||||
|
l'essentiel de ce qui fait bouger un statut.
|
||||||
|
|
||||||
|
Le rapport de run indique explicitement quand un collecteur est ignoré :
|
||||||
|
|
||||||
|
```
|
||||||
|
Collecteurs
|
||||||
|
legifrance ignoré — LEGIFRANCE_CLIENT_ID absent de .env — repli sur
|
||||||
|
l'Assemblée nationale, le Sénat et le Conseil constitutionnel
|
||||||
|
senat 31 texte(s), 0 décision(s) (0.4 s)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Une URL du cahier des charges est morte
|
||||||
|
|
||||||
|
Le §5.1 indique `senat.fr/lois/index.html` : cette adresse renvoie **404** depuis
|
||||||
|
la refonte du site. L'équivalent fonctionnel, utilisé par le collecteur, est
|
||||||
|
`senat.fr/dossiers-legislatifs/lois-promulguees.html`.
|
||||||
|
|
||||||
|
De même, `assemblee-nationale.fr/dyn/actualites-accueil/promulgations-de-lois`
|
||||||
|
renvoie 404. La liste du Sénat couvrant l'intégralité des lois promulguées, elle
|
||||||
|
tient lieu de source unique pour les promulgations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Utilisation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make update-dry # collecte réelle, aucune écriture, rapport des écarts
|
||||||
|
make update # collecte et applique
|
||||||
|
```
|
||||||
|
|
||||||
|
**Toujours commencer par `--dry-run`.** Le rapport liste chaque écart avec sa
|
||||||
|
nature, son ancienne et sa nouvelle valeur :
|
||||||
|
|
||||||
|
```
|
||||||
|
Écarts détectés : 11
|
||||||
|
ajouts : 7
|
||||||
|
modifications : 4
|
||||||
|
signalements : 0
|
||||||
|
|
||||||
|
[ajout ] (nouveau) 2026-103 · LOI n° 2026-103 du 19 février 2026 de finances
|
||||||
|
[decision_cc ] ppl-aide-a-mourir 2026-910 DC · date_saisine : — → 2026-07-16
|
||||||
|
[decision_cc ] loi-2026-650 2026-908 DC · date_decision : — → 2026-07-23
|
||||||
|
```
|
||||||
|
|
||||||
|
Options utiles :
|
||||||
|
|
||||||
|
| Option | Effet |
|
||||||
|
|---|---|
|
||||||
|
| `--dry-run` | collecte réelle, aucune écriture |
|
||||||
|
| `--hors-ligne` | n'utilise que le cache HTTP, sans accès réseau |
|
||||||
|
| `--verbeux` | journalisation détaillée, requête par requête |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Automatisation
|
||||||
|
|
||||||
|
### systemd (recommandé)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp systemd/veille-legislative.{service,timer} /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now veille-legislative.timer
|
||||||
|
systemctl list-timers veille-legislative.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Cadence : 6 h et 18 h tous les jours, plus un passage le dimanche à 9 h pour
|
||||||
|
rattraper les publications de fin de semaine. `RandomizedDelaySec=900` évite de
|
||||||
|
frapper Légifrance à la seconde ronde. `Persistent=true` rattrape le passage
|
||||||
|
manqué si la machine était éteinte.
|
||||||
|
|
||||||
|
### cron (repli documenté)
|
||||||
|
|
||||||
|
```cron
|
||||||
|
0 6,18 * * * cd /opt/veille-legislative && .venv/bin/python -m pipeline.update >> /var/log/veille.log 2>&1
|
||||||
|
0 9 * * 0 cd /opt/veille-legislative && .venv/bin/python -m pipeline.update >> /var/log/veille.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Le repli cron perd le durcissement de sécurité des unités systemd et la reprise
|
||||||
|
des passages manqués.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Dépannage
|
||||||
|
|
||||||
|
| Symptôme | Cause probable | Remède |
|
||||||
|
|---|---|---|
|
||||||
|
| `invalid_client` sur le jeton | clé d'API utilisée au lieu du couple OAuth | voir §2 |
|
||||||
|
| `invalid_client` avec le bon couple | application non abonnée à l'API Légifrance | demander l'abonnement sur piste.gouv.fr, compter 24-48 h |
|
||||||
|
| 403 sur `/search` alors que le jeton est obtenu | abonnement en cours de propagation | réessayer plus tard |
|
||||||
|
| `Base introuvable` | seed jamais lancé | `make seed` |
|
||||||
|
| `no such column: t.rowid` | base créée avant la migration 005 | `make db-init` (les migrations s'appliquent seules) |
|
||||||
|
| Collecte lente | premier passage, cache vide | normal : ~25 s pour 27 numéros, quasi instantané ensuite |
|
||||||
|
| Un collecteur en échec | site indisponible ou structure changée | le run continue ; vérifier le rapport et les fixtures de `tests/fixtures/` |
|
||||||
|
|
||||||
|
### Le cache HTTP
|
||||||
|
|
||||||
|
Les réponses sont mises en cache dans `data/cache_http/` pendant
|
||||||
|
`VEILLE_CACHE_TTL_H` heures (6 par défaut). Pour forcer une collecte fraîche :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf data/cache_http
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sauvegarde
|
||||||
|
|
||||||
|
La base est un fichier unique. Sauvegarder revient à le copier — mais **jamais
|
||||||
|
pendant un passage**, le mode WAL laissant des fichiers annexes :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 data/veille.db ".backup data/veille-$(date +%F).db"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # pytest avec couverture
|
||||||
|
make lint # ruff
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun test ne touche au réseau : les collecteurs sont éprouvés sur des fixtures
|
||||||
|
HTML figées dans `tests/fixtures/`, capturées le 25 juillet 2026. **Si un test
|
||||||
|
de fixture échoue après une mise à jour, c'est le site officiel qui a changé de
|
||||||
|
structure** — c'est exactement ce que ces tests servent à détecter. Recapturez
|
||||||
|
alors la fixture et adaptez le parseur.
|
||||||
@@ -1,49 +1,259 @@
|
|||||||
# Veille législative Guadeloupe
|
# Veille législative Gwadloup
|
||||||
|
|
||||||
Plateforme de veille sur la production législative française — lois, projets et
|
Plateforme de veille sur la production législative française — lois, projets et
|
||||||
propositions de loi, ordonnances, décisions du Conseil constitutionnel — analysée
|
propositions de loi, ordonnances, décisions du Conseil constitutionnel — lue
|
||||||
sous l'angle des **libertés des individus, des associations et des entreprises**,
|
sous l'angle des **libertés des individus, des associations et des
|
||||||
avec un zoom Guadeloupe et outre-mer.
|
entreprises**, avec un zoom Guadeloupe et outre-mer.
|
||||||
|
|
||||||
Deux composantes indissociables :
|
|
||||||
|
|
||||||
1. **`pipeline/`** — automatisation Python. Ingère le corpus de recherche
|
|
||||||
(date d'arrêté : 25 juillet 2026, périmètre 1ᵉʳ mai → 30 septembre 2026) puis
|
|
||||||
met la base à jour depuis les sources officielles.
|
|
||||||
2. **`web/`** — application SvelteKit rendue côté serveur : recherche plein
|
|
||||||
texte, filtres à facettes partageables, calendrier des échéances, fiches
|
|
||||||
détaillées avec sources et extraits verbatim.
|
|
||||||
|
|
||||||
> **Aucune donnée législative n'est inventée.** Chaque fait porte sa source
|
> **Aucune donnée législative n'est inventée.** Chaque fait porte sa source
|
||||||
> (URL, date, extrait), son niveau de confiance et son fichier d'origine. Ce qui
|
> (adresse, date, extrait verbatim), son niveau de confiance et son fichier
|
||||||
> n'est pas établi est marqué « à vérifier » plutôt que deviné.
|
> d'origine. Ce qui n'est pas établi est marqué « à vérifier », avec son motif
|
||||||
|
> affiché sur la fiche.
|
||||||
|
|
||||||
## État d'avancement
|
---
|
||||||
|
|
||||||
| Phase | Objet | Statut |
|
## Ce que contient la base
|
||||||
|---|---|---|
|
|
||||||
| 0 | Lecture du corpus, plan d'implémentation | ✅ `PLAN-IMPLEMENTATION.md` |
|
|
||||||
| 1 | Socle : schéma SQLite + FTS5, modèles | ✅ |
|
|
||||||
| 2 | Parseur des tableaux récapitulatifs | ✅ 52 textes |
|
|
||||||
| 3 | Parseurs des rapports de dimension, citations | ✅ 360 sources |
|
|
||||||
| 4 | Enrichissement et tests d'acceptation | ✅ 7/7 critères §7 |
|
|
||||||
| 5 | Collecteurs officiels, run de mise à jour | 🚧 |
|
|
||||||
| 6 | Application web | ⏳ |
|
|
||||||
| 7 | PWA, systemd, documentation d'installation | ⏳ |
|
|
||||||
|
|
||||||
## Ce que contient la base après `make seed`
|
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Textes législatifs | **52** (27 promulgués, 7 devant le Conseil constitutionnel, 10 en navette, 5 déposés, 2 annoncés, 1 validé) |
|
| Textes législatifs | **52** — 27 promulgués, 7 devant le Conseil constitutionnel, 10 en navette, 5 déposés, 2 annoncés, 1 validé |
|
||||||
| Sources vérifiables | **360**, toutes avec URL — aucun texte sans lien |
|
| Sources vérifiables | **360**, toutes avec adresse — aucun texte sans lien |
|
||||||
| Événements de timeline | 83 |
|
| Événements de parcours | 83 |
|
||||||
| Affaires du Conseil constitutionnel | 12 |
|
| Affaires du Conseil constitutionnel | 12 |
|
||||||
| Analyses transversales | 7 |
|
| Analyses transversales | 7 |
|
||||||
| Échéances du calendrier | 9 |
|
| Échéances de calendrier | 9 |
|
||||||
| Marqueurs de citation résolus | 100 % (332/332), dont 93 % avec un lien |
|
| Marqueurs de citation résolus | **100 %** (332/332), dont 93 % avec un lien |
|
||||||
|
|
||||||
Les valeurs déduites — 16 cotations Guadeloupe, 17 ventilations d'impact — sont
|
Les valeurs déduites — 16 cotations Guadeloupe, 17 ventilations d'impact — sont
|
||||||
marquées « à vérifier » avec leur motif, et affichées comme telles.
|
signalées comme telles, avec leur motif.
|
||||||
|
|
||||||
L'installation complète sur Debian/Ubuntu vierge est documentée en phase 7.
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
veille_legislative_971/
|
||||||
|
├── data/input/ corpus de recherche — LECTURE SEULE, jamais modifié
|
||||||
|
├── pipeline/ Python 3.12 : parseurs, collecteurs, run de veille
|
||||||
|
├── web/ SvelteKit 2 + Svelte 5, rendu serveur sur adapter-node
|
||||||
|
├── systemd/ 4 unités : timer du pipeline, socket + service de l'app
|
||||||
|
├── tests/ pytest, fixtures HTML figées — aucun test ne va au réseau
|
||||||
|
└── scripts/ contrôles de conformité
|
||||||
|
```
|
||||||
|
|
||||||
|
Une seule base SQLite, `data/veille.db`. La recherche plein texte est assurée
|
||||||
|
par SQLite lui-même (FTS5). Aucun service externe n'est nécessaire au
|
||||||
|
fonctionnement : ni moteur de recherche, ni réseau de diffusion, ni traceur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation sur Debian ou Ubuntu vierge
|
||||||
|
|
||||||
|
Testé sur Debian 13 et Ubuntu 24.04.
|
||||||
|
|
||||||
|
### 1. Dépendances système
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y git curl build-essential sqlite3 ca-certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Node.js ≥ 20.6
|
||||||
|
|
||||||
|
La version 20.6 est le minimum : l'application utilise `node --env-file`, qui
|
||||||
|
n'existe pas avant.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
node --version # doit afficher v20.6 ou plus
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Python 3.12 et uv
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
uv python install 3.12
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Le projet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/veille-legislative
|
||||||
|
sudo chown "$USER:$USER" /opt/veille-legislative
|
||||||
|
git clone <adresse-du-depot> /opt/veille-legislative
|
||||||
|
cd /opt/veille-legislative
|
||||||
|
|
||||||
|
make install # environnement Python + dépendances npm
|
||||||
|
cp .env.example .env
|
||||||
|
$EDITOR .env # renseigner les identifiants PISTE — voir README-pipeline.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Remplir la base et construire l'application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make seed # → 52 textes, 360 sources
|
||||||
|
cd web && npm run polices && cd .. # copie les polices depuis npm
|
||||||
|
make build
|
||||||
|
make verifier # contrôles de conformité
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Vérifier localement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
HOST=127.0.0.1 PORT=3971 VEILLE_DB=/opt/veille-legislative/data/veille.db node build
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis, dans un autre terminal :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sI http://127.0.0.1:3971/ | head -1 # HTTP/1.1 200 OK
|
||||||
|
curl -s http://127.0.0.1:3971/api/textes | head -c 200
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mise en service
|
||||||
|
|
||||||
|
### Compte dédié
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd --system --home /opt/veille-legislative --shell /usr/sbin/nologin veille
|
||||||
|
sudo chown -R veille:veille /opt/veille-legislative
|
||||||
|
sudo chmod 600 /opt/veille-legislative/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unités systemd
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp systemd/*.service systemd/*.timer systemd/*.socket /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now veille-web.socket # l'app se réveille à la demande
|
||||||
|
sudo systemctl enable --now veille-legislative.timer # collecte 6 h, 18 h, dimanche 9 h
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application est **activée par socket** : systemd tient le port ouvert et ne
|
||||||
|
démarre Node qu'à la première requête ; sans trafic pendant `IDLE_TIMEOUT`
|
||||||
|
(300 s), elle s'arrête. Sur un site de veille peu fréquenté, l'empreinte mémoire
|
||||||
|
tombe à zéro entre deux visites — ce qui compte sur un serveur à 5 € par mois.
|
||||||
|
|
||||||
|
Avant de démarrer, ajustez `ORIGIN` dans `veille-web.service` : sans lui, les
|
||||||
|
soumissions de formulaire sont refusées derrière un reverse proxy.
|
||||||
|
|
||||||
|
### Reverse proxy
|
||||||
|
|
||||||
|
**Caddy** (le plus court, HTTPS automatique) :
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
veille.example.org {
|
||||||
|
encode gzip zstd
|
||||||
|
reverse_proxy 127.0.0.1:3971
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**nginx** :
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name veille.example.org;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3971;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Avec nginx, remplacez `ORIGIN` par `PROTOCOL_HEADER=x-forwarded-proto` et
|
||||||
|
`HOST_HEADER=x-forwarded-host` dans le service.
|
||||||
|
|
||||||
|
### Vérifier
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status veille-web.socket
|
||||||
|
systemctl list-timers veille-legislative.timer
|
||||||
|
journalctl -u veille-legislative -n 40
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commandes
|
||||||
|
|
||||||
|
```
|
||||||
|
make install installe pipeline Python et application web
|
||||||
|
make seed remplit la base depuis data/input/
|
||||||
|
make reseed repart d'une base vierge
|
||||||
|
make update-dry collecte réelle, aucune écriture, rapport des écarts
|
||||||
|
make update collecte et applique
|
||||||
|
make test pytest avec couverture
|
||||||
|
make lint ruff
|
||||||
|
make dev serveur de développement (port 5971)
|
||||||
|
make build build de production
|
||||||
|
make check vérification TypeScript
|
||||||
|
make verifier contrôles de conformité au cahier des charges
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dégradations documentées
|
||||||
|
|
||||||
|
| Situation | Conséquence |
|
||||||
|
|---|---|
|
||||||
|
| Sans identifiants PISTE | Le collecteur Légifrance se désactive et l'annonce dans le rapport. Le Sénat et le Conseil constitutionnel prennent le relais : les promulgations et les décisions restent suivies. Voir `README-pipeline.md` §3. |
|
||||||
|
| Une source indisponible | Le collecteur concerné échoue seul ; les autres poursuivent. L'erreur figure au rapport de run. |
|
||||||
|
| Réseau coupé | `make update --hors-ligne` travaille sur le cache HTTP. Côté navigateur, le service worker sert les pages déjà consultées, en signalant qu'elles peuvent être périmées. |
|
||||||
|
| Base absente | L'application refuse de démarrer avec un message indiquant les emplacements essayés. |
|
||||||
|
|
||||||
|
**Deux adresses du cahier des charges sont mortes** et ont été remplacées :
|
||||||
|
`senat.fr/lois/index.html` et
|
||||||
|
`assemblee-nationale.fr/dyn/actualites-accueil/promulgations-de-lois` renvoient
|
||||||
|
toutes deux 404. La liste `senat.fr/dossiers-legislatifs/lois-promulguees.html`
|
||||||
|
couvre l'ensemble des promulgations et tient lieu de source unique.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sauvegarde
|
||||||
|
|
||||||
|
La base est un fichier unique :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 data/veille.db ".backup /sauvegardes/veille-$(date +%F).db"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ne copiez jamais le fichier pendant un passage du pipeline : le mode WAL laisse
|
||||||
|
des fichiers annexes. `.backup` s'en charge correctement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interface de programmation
|
||||||
|
|
||||||
|
Ouverte, sans clé :
|
||||||
|
|
||||||
|
| Route | Objet |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/textes` | liste filtrable — `q`, `statut`, `theme`, `type`, `impact`, `guadeloupe`, `confiance`, `from`, `to`, `tri`, `page` |
|
||||||
|
| `GET /api/textes/[identifiant]` | fiche complète, sources et parcours compris |
|
||||||
|
| `GET /api/echeances` | calendrier, avec `?jours=N` en option |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s 'https://veille.example.org/api/textes?statut=saisie_cc&guadeloupe=forte' | jq '.total'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
AGPL-3.0-or-later. Le corpus de `data/input/` reste la propriété de ses auteurs.
|
||||||
|
|
||||||
|
Si vous réutilisez ces données, citez la source primaire — Journal officiel,
|
||||||
|
assemblées, Conseil constitutionnel — plutôt que cette plateforme : elle n'est
|
||||||
|
qu'un intermédiaire.
|
||||||
|
|||||||
+75
-5
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import functools
|
import functools
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -22,6 +22,33 @@ from pipeline import chemins
|
|||||||
from pipeline.modeles import Pertinence
|
from pipeline.modeles import Pertinence
|
||||||
from pipeline.parseurs.dates_fr import sans_accents
|
from pipeline.parseurs.dates_fr import sans_accents
|
||||||
|
|
||||||
|
# Tournures par lesquelles le corpus **écarte** un territoire qu'il vient de
|
||||||
|
# nommer : « PPL montagne — sans portée pour la Guadeloupe », « PJL logement —
|
||||||
|
# pas de volet outre-mer à ce stade ». Compter la mention sans lire la négation
|
||||||
|
# donnerait à ces textes la cotation la plus forte, à l'exact inverse de ce que
|
||||||
|
# la source affirme.
|
||||||
|
NEGATIONS = (
|
||||||
|
"sans portee",
|
||||||
|
"sans objet",
|
||||||
|
"sans incidence",
|
||||||
|
"sans specificite",
|
||||||
|
"sans volet",
|
||||||
|
"sans consequence",
|
||||||
|
"ne concerne pas",
|
||||||
|
"n'est pas concerne",
|
||||||
|
"pas concernee",
|
||||||
|
"pas de volet",
|
||||||
|
"pas d'incidence",
|
||||||
|
"non concerne",
|
||||||
|
"hors champ",
|
||||||
|
"exclue",
|
||||||
|
"exclus",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fenêtre de voisinage, en caractères, autour d'une mention de territoire.
|
||||||
|
_AVANT = 90
|
||||||
|
_APRES = 60
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Cotation:
|
class Cotation:
|
||||||
@@ -30,12 +57,24 @@ class Cotation:
|
|||||||
pertinence: Pertinence | None
|
pertinence: Pertinence | None
|
||||||
score: int
|
score: int
|
||||||
expressions: list[str]
|
expressions: list[str]
|
||||||
|
exclusions: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def note(self) -> str | None:
|
def note(self) -> str | None:
|
||||||
|
if self.exclusions and not self.expressions:
|
||||||
|
return (
|
||||||
|
"Le rapport écarte explicitement ce territoire : "
|
||||||
|
+ ", ".join(f"« {e} »" for e in self.exclusions[:3])
|
||||||
|
)
|
||||||
if not self.expressions:
|
if not self.expressions:
|
||||||
return None
|
return None
|
||||||
return "Détecté d'après : " + ", ".join(self.expressions[:6])
|
|
||||||
|
note = "Détecté d'après : " + ", ".join(self.expressions[:6])
|
||||||
|
if self.exclusions:
|
||||||
|
note += " — mais le rapport écarte par ailleurs : " + ", ".join(
|
||||||
|
f"« {e} »" for e in self.exclusions[:2]
|
||||||
|
)
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=1)
|
@functools.lru_cache(maxsize=1)
|
||||||
@@ -60,18 +99,31 @@ def _motifs() -> list[tuple[re.Pattern[str], str, int]]:
|
|||||||
|
|
||||||
|
|
||||||
def coter(*fragments: str | None) -> Cotation:
|
def coter(*fragments: str | None) -> Cotation:
|
||||||
"""Cote la pertinence guadeloupéenne d'un ensemble de fragments de texte."""
|
"""Cote la pertinence guadeloupéenne d'un ensemble de fragments de texte.
|
||||||
|
|
||||||
|
Une mention niée ne compte pas. « Sans portée pour la Guadeloupe » nomme le
|
||||||
|
territoire pour l'écarter : la lire comme une pertinence forte inverserait
|
||||||
|
le sens de la source.
|
||||||
|
"""
|
||||||
plat = sans_accents(" ".join(f for f in fragments if f)).lower()
|
plat = sans_accents(" ".join(f for f in fragments if f)).lower()
|
||||||
if not plat.strip():
|
if not plat.strip():
|
||||||
return Cotation(pertinence=None, score=0, expressions=[])
|
return Cotation(pertinence=None, score=0, expressions=[])
|
||||||
|
|
||||||
score = 0
|
score = 0
|
||||||
trouvees: list[str] = []
|
trouvees: list[str] = []
|
||||||
|
exclusions: list[str] = []
|
||||||
|
|
||||||
for motif, expression, poids in _motifs():
|
for motif, expression, poids in _motifs():
|
||||||
if motif.search(plat):
|
positions = [m.start() for m in motif.finditer(plat)]
|
||||||
|
if not positions:
|
||||||
|
continue
|
||||||
|
|
||||||
|
retenues = [p for p in positions if not _est_niee(plat, p)]
|
||||||
|
if retenues:
|
||||||
score += poids
|
score += poids
|
||||||
trouvees.append(expression)
|
trouvees.append(expression)
|
||||||
|
else:
|
||||||
|
exclusions.append(_extrait_autour(plat, positions[0]))
|
||||||
|
|
||||||
seuils = _configuration()["guadeloupe"]["seuils"]
|
seuils = _configuration()["guadeloupe"]["seuils"]
|
||||||
if score >= seuils["forte"]:
|
if score >= seuils["forte"]:
|
||||||
@@ -80,10 +132,28 @@ def coter(*fragments: str | None) -> Cotation:
|
|||||||
pertinence = Pertinence.MOYENNE
|
pertinence = Pertinence.MOYENNE
|
||||||
elif score >= seuils["faible"]:
|
elif score >= seuils["faible"]:
|
||||||
pertinence = Pertinence.FAIBLE
|
pertinence = Pertinence.FAIBLE
|
||||||
|
elif exclusions:
|
||||||
|
# Le territoire est nommé, mais pour être écarté : la pertinence est
|
||||||
|
# faible, et le motif dit pourquoi.
|
||||||
|
pertinence = Pertinence.FAIBLE
|
||||||
else:
|
else:
|
||||||
pertinence = None
|
pertinence = None
|
||||||
|
|
||||||
return Cotation(pertinence=pertinence, score=score, expressions=trouvees)
|
return Cotation(
|
||||||
|
pertinence=pertinence, score=score, expressions=trouvees, exclusions=exclusions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _est_niee(plat: str, position: int) -> bool:
|
||||||
|
"""Vrai si une tournure d'exclusion entoure la mention trouvée."""
|
||||||
|
voisinage = plat[max(0, position - _AVANT) : position + _APRES]
|
||||||
|
return any(negation in voisinage for negation in NEGATIONS)
|
||||||
|
|
||||||
|
|
||||||
|
def _extrait_autour(plat: str, position: int, largeur: int = 70) -> str:
|
||||||
|
debut = max(0, position - largeur)
|
||||||
|
fin = min(len(plat), position + largeur)
|
||||||
|
return " ".join(plat[debut:fin].split())
|
||||||
|
|
||||||
|
|
||||||
MOTIF_VERIFICATION = (
|
MOTIF_VERIFICATION = (
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Migration 005 — exposer le rowid dans la vue de consultation
|
||||||
|
--
|
||||||
|
-- L'index FTS5 est à contenu externe : il se joint à la table `textes` par
|
||||||
|
-- `rowid`. Or une vue SQLite n'a pas de rowid propre, et `v_textes.rowid`
|
||||||
|
-- échoue avec « no such column ».
|
||||||
|
--
|
||||||
|
-- Sans cette colonne, l'application ne peut pas à la fois filtrer sur les
|
||||||
|
-- champs dérivés de la vue (`devant_cc`) et joindre l'index plein texte — soit
|
||||||
|
-- exactement ce que demande la recherche à facettes.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
DROP VIEW v_textes;
|
||||||
|
|
||||||
|
CREATE VIEW v_textes AS
|
||||||
|
SELECT
|
||||||
|
t.rowid AS rowid,
|
||||||
|
t.*,
|
||||||
|
(SELECT COUNT(*) FROM sources s WHERE s.texte_id = t.id) AS nb_sources,
|
||||||
|
(SELECT COUNT(*) FROM evenements e WHERE e.texte_id = t.id) AS nb_evenements,
|
||||||
|
CASE
|
||||||
|
WHEN t.statut = 'saisie_cc' THEN 1
|
||||||
|
WHEN EXISTS (SELECT 1 FROM decisions_cc d
|
||||||
|
WHERE d.texte_id = t.id
|
||||||
|
AND d.date_decision IS NULL
|
||||||
|
AND COALESCE(d.resultat, 'en_instance') = 'en_instance') THEN 1
|
||||||
|
WHEN EXISTS (SELECT 1 FROM evenements e
|
||||||
|
WHERE e.texte_id = t.id AND e.type_etape = 'saisine_cc')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM evenements e
|
||||||
|
WHERE e.texte_id = t.id AND e.type_etape = 'decision_cc')
|
||||||
|
AND t.statut <> 'promulguee' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END AS devant_cc
|
||||||
|
FROM textes t;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Contrôles de conformité au cahier des charges (§3bis et §8).
|
||||||
|
#
|
||||||
|
# Ces vérifications sont mécaniques : elles constatent, elles n'interprètent
|
||||||
|
# pas. Un seul contrôle en échec fait échouer la commande.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -uo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echecs=0
|
||||||
|
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
||||||
|
ko() {
|
||||||
|
printf ' \033[31m✗\033[0m %s\n' "$1"
|
||||||
|
echecs=$((echecs + 1))
|
||||||
|
}
|
||||||
|
titre() { printf '\n\033[1m%s\033[0m\n' "$1"; }
|
||||||
|
|
||||||
|
titre "§3bis — architecture de rendu"
|
||||||
|
|
||||||
|
# Seule une déclaration exportée est lue par SvelteKit : chercher la chaîne
|
||||||
|
# brute ferait échouer le contrôle sur les commentaires qui la mentionnent,
|
||||||
|
# y compris ceux qui expliquent pourquoi on ne la pose pas.
|
||||||
|
MOTIF_SSR='^[[:space:]]*export[[:space:]]+const[[:space:]]+ssr[[:space:]]*=[[:space:]]*false'
|
||||||
|
MOTIF_CSR='^[[:space:]]*export[[:space:]]+const[[:space:]]+csr[[:space:]]*=[[:space:]]*false'
|
||||||
|
|
||||||
|
if grep -rnE "$MOTIF_SSR" web/src --include='*.ts' --include='*.js' --include='*.svelte' -q; then
|
||||||
|
ko "un fichier déclare export const ssr = false (le mode SPA est écarté)"
|
||||||
|
grep -rnE "$MOTIF_SSR" web/src --include='*.ts' --include='*.js' --include='*.svelte'
|
||||||
|
else
|
||||||
|
ok "aucun export const ssr = false : le rendu serveur est le défaut partout"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -rnE "$MOTIF_CSR" web/src/routes/+layout.ts web/src/routes/+layout.server.ts 2>/dev/null -q; then
|
||||||
|
ko "csr = false déclaré au layout racine"
|
||||||
|
else
|
||||||
|
ok "aucun csr = false global"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for page in a-propos methode; do
|
||||||
|
if grep -q "prerender = true" "web/src/routes/$page/+page.ts" 2>/dev/null; then
|
||||||
|
ok "prerender = true sur /$page"
|
||||||
|
else
|
||||||
|
ko "prerender = true absent de /$page"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
grep -q "adapter-node" web/svelte.config.js &&
|
||||||
|
ok "adapter-node en place" || ko "adapter-node absent de svelte.config.js"
|
||||||
|
|
||||||
|
grep -q "precompress: true" web/svelte.config.js &&
|
||||||
|
ok "precompress activé" || ko "precompress non activé"
|
||||||
|
|
||||||
|
titre "§3 — souveraineté des ressources"
|
||||||
|
|
||||||
|
tiers=$(grep -rnE "https?://(fonts\.googleapis|fonts\.gstatic|cdn\.|unpkg|jsdelivr|googletagmanager|google-analytics)" \
|
||||||
|
web/src web/static 2>/dev/null | grep -v Binary || true)
|
||||||
|
if [ -z "$tiers" ]; then
|
||||||
|
ok "aucune ressource tierce référencée (polices, CDN, traceurs)"
|
||||||
|
else
|
||||||
|
ko "ressources tierces trouvées :"
|
||||||
|
echo "$tiers"
|
||||||
|
fi
|
||||||
|
|
||||||
|
nb_polices=$(find web/static/polices -name '*.woff2' 2>/dev/null | wc -l)
|
||||||
|
[ "$nb_polices" -gt 0 ] &&
|
||||||
|
ok "polices auto-hébergées ($nb_polices fichiers woff2)" ||
|
||||||
|
ko "polices non auto-hébergées — lancer npm run polices"
|
||||||
|
|
||||||
|
titre "§8 — livrables"
|
||||||
|
|
||||||
|
for chemin in pipeline web data systemd tests README.md README-pipeline.md .env.example Makefile; do
|
||||||
|
[ -e "$chemin" ] && ok "présent : $chemin" || ko "manquant : $chemin"
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -f web/build/index.js ] &&
|
||||||
|
ok "build de production présent" || ko "build absent — lancer make build"
|
||||||
|
|
||||||
|
titre "§8.2 — intégrité des données"
|
||||||
|
|
||||||
|
if [ -f data/veille.db ]; then
|
||||||
|
total=$(sqlite3 data/veille.db "SELECT COUNT(*) FROM textes")
|
||||||
|
orphelins=$(sqlite3 data/veille.db \
|
||||||
|
"SELECT COUNT(*) FROM textes t WHERE NOT EXISTS (SELECT 1 FROM sources s WHERE s.texte_id = t.id)")
|
||||||
|
cc=$(sqlite3 data/veille.db "SELECT COUNT(*) FROM v_textes WHERE devant_cc = 1")
|
||||||
|
sans_url=$(sqlite3 data/veille.db \
|
||||||
|
"SELECT COUNT(*) FROM sources WHERE url NOT LIKE 'http%'")
|
||||||
|
|
||||||
|
[ "$total" -ge 49 ] && ok "$total textes (≥ 49 exigés)" || ko "$total textes, 49 exigés"
|
||||||
|
[ "$orphelins" -eq 0 ] && ok "aucun texte sans source URL" || ko "$orphelins textes sans source"
|
||||||
|
[ "$sans_url" -eq 0 ] && ok "toutes les sources ont une URL absolue" || ko "$sans_url sources sans URL"
|
||||||
|
[ "$cc" -ge 7 ] && ok "$cc textes devant le Conseil constitutionnel (≥ 7)" || ko "$cc textes, 7 attendus"
|
||||||
|
else
|
||||||
|
ko "base absente — lancer make seed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
titre "Résultat"
|
||||||
|
if [ "$echecs" -eq 0 ]; then
|
||||||
|
printf ' \033[32mTous les contrôles passent.\033[0m\n\n'
|
||||||
|
else
|
||||||
|
printf ' \033[31m%d contrôle(s) en échec.\033[0m\n\n' "$echecs"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Passage de veille : collecte les sources officielles et met la base à jour.
|
||||||
|
#
|
||||||
|
# Unité oneshot déclenchée par veille-legislative.timer. Installation :
|
||||||
|
# sudo cp systemd/veille-*.{service,timer,socket} /etc/systemd/system/
|
||||||
|
# sudo systemctl daemon-reload
|
||||||
|
# sudo systemctl enable --now veille-legislative.timer
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Veille législative — passage de collecte
|
||||||
|
Documentation=file:///opt/veille-legislative/README-pipeline.md
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=veille
|
||||||
|
Group=veille
|
||||||
|
WorkingDirectory=/opt/veille-legislative
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
EnvironmentFile=/opt/veille-legislative/.env
|
||||||
|
ExecStart=/opt/veille-legislative/.venv/bin/python -m pipeline.update
|
||||||
|
|
||||||
|
# Un passage qui s'éternise a rencontré un problème réseau : on l'arrête.
|
||||||
|
TimeoutStartSec=900
|
||||||
|
|
||||||
|
# Durcissement : le pipeline lit le réseau et écrit un seul fichier.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/opt/veille-legislative/data
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||||
|
RestrictNamespaces=true
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
SystemCallErrorNumber=EPERM
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=veille-legislative
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Cadence de la veille : deux fois par jour, plus un passage renforcé le
|
||||||
|
# dimanche pour rattraper les publications de fin de semaine au Journal officiel.
|
||||||
|
#
|
||||||
|
# Les décisions du Conseil constitutionnel tombent en semaine, les promulgations
|
||||||
|
# suivent de un à trois jours : deux passages quotidiens suffisent largement, et
|
||||||
|
# ménagent des services publics gratuits.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Déclenchement de la veille législative (6 h, 18 h, et dimanche 9 h)
|
||||||
|
Documentation=file:///opt/veille-legislative/README-pipeline.md
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 06:00:00
|
||||||
|
OnCalendar=*-*-* 18:00:00
|
||||||
|
OnCalendar=Sun *-*-* 09:00:00
|
||||||
|
|
||||||
|
# Dispersion : inutile de frapper Légifrance à la seconde ronde en même temps
|
||||||
|
# que tous les autres services qui l'interrogent.
|
||||||
|
RandomizedDelaySec=900
|
||||||
|
|
||||||
|
# Rattrape le passage manqué si la machine était éteinte.
|
||||||
|
Persistent=true
|
||||||
|
Unit=veille-legislative.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Application web de la veille législative (SvelteKit, adapter-node).
|
||||||
|
#
|
||||||
|
# Démarrée par veille-web.socket à la première requête, elle s'arrête d'elle-même
|
||||||
|
# après IDLE_TIMEOUT secondes sans trafic, et systemd la réveille à la suivante.
|
||||||
|
#
|
||||||
|
# Node ≥ 20.6 requis : les variables ne sont PAS chargées automatiquement en
|
||||||
|
# production, d'où --env-file.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Veille législative — application web
|
||||||
|
Documentation=file:///opt/veille-legislative/README.md
|
||||||
|
Requires=veille-web.socket
|
||||||
|
After=network.target veille-web.socket
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=veille
|
||||||
|
Group=veille
|
||||||
|
WorkingDirectory=/opt/veille-legislative/web
|
||||||
|
ExecStart=/usr/bin/node --env-file=/opt/veille-legislative/.env build
|
||||||
|
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=VEILLE_DB=/opt/veille-legislative/data/veille.db
|
||||||
|
# Mise en sommeil après cinq minutes sans requête.
|
||||||
|
Environment=IDLE_TIMEOUT=300
|
||||||
|
# Obligatoire derrière un reverse proxy, sinon les soumissions de formulaire
|
||||||
|
# sont refusées (« Cross-site POST form submissions are forbidden »).
|
||||||
|
# Alternative : PROTOCOL_HEADER=x-forwarded-proto + HOST_HEADER=x-forwarded-host
|
||||||
|
Environment=ORIGIN=https://veille.example.org
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Durcissement : l'application ne fait que lire la base.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadOnlyPaths=/opt/veille-legislative/data
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||||
|
RestrictNamespaces=true
|
||||||
|
LockPersonality=true
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=veille-web
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Activation par socket de l'application web.
|
||||||
|
#
|
||||||
|
# systemd tient le port ouvert et ne démarre l'application qu'à la première
|
||||||
|
# requête. Combinée à IDLE_TIMEOUT dans le service, l'application se met en
|
||||||
|
# sommeil sans trafic : l'empreinte mémoire d'un site de veille peu fréquenté
|
||||||
|
# tombe à zéro entre deux visites, ce qui compte sur un VPS à 5 €.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Socket d'écoute de la veille législative
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
ListenStream=127.0.0.1:3971
|
||||||
|
# Le service hérite du descripteur : adapter-node lit LISTEN_PID et LISTEN_FDS.
|
||||||
|
Accept=no
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
||||||
@@ -309,3 +309,58 @@ def test_rapport_lisible(base) -> None:
|
|||||||
texte = rapport.en_texte()
|
texte = rapport.en_texte()
|
||||||
assert "Rapport de veille" in texte
|
assert "Rapport de veille" in texte
|
||||||
assert "conforme aux sources officielles" in texte
|
assert "conforme aux sources officielles" in texte
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Cotation Guadeloupe : les négations
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_mention_niee_ne_vaut_pas_pertinence() -> None:
|
||||||
|
"""« Sans portée pour la Guadeloupe » nomme le territoire pour l'écarter.
|
||||||
|
|
||||||
|
Sans lecture de la négation, la PPL montagne recevait la cotation la plus
|
||||||
|
forte alors que le rapport dit l'inverse — et remontait dans la facette
|
||||||
|
« pertinence forte pour la Guadeloupe ».
|
||||||
|
"""
|
||||||
|
from pipeline.guadeloupe import coter
|
||||||
|
|
||||||
|
cotation = coter(
|
||||||
|
"PPL « pour une montagne vivante et souveraine ». Continuité de "
|
||||||
|
"l'urbanisation ; sans portée pour la Guadeloupe."
|
||||||
|
)
|
||||||
|
assert cotation.pertinence is Pertinence.FAIBLE
|
||||||
|
assert cotation.score == 0
|
||||||
|
assert cotation.exclusions
|
||||||
|
assert "écarte explicitement" in (cotation.note or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pas_de_volet_outre_mer_est_une_negation() -> None:
|
||||||
|
from pipeline.guadeloupe import coter
|
||||||
|
|
||||||
|
cotation = coter("PJL logement. Véto des maires ; pas de volet outre-mer à ce stade.")
|
||||||
|
assert cotation.pertinence is Pertinence.FAIBLE
|
||||||
|
assert cotation.score == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_une_mention_affirmee_compte_toujours() -> None:
|
||||||
|
from pipeline.guadeloupe import coter
|
||||||
|
|
||||||
|
cotation = coter(
|
||||||
|
"Chlordécone : responsabilité de l'État. Guadeloupe et Martinique au cœur du texte."
|
||||||
|
)
|
||||||
|
assert cotation.pertinence is Pertinence.FORTE
|
||||||
|
assert "guadeloupe" in cotation.expressions
|
||||||
|
assert not cotation.exclusions
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_negation_ne_porte_que_sur_son_voisinage() -> None:
|
||||||
|
"""Une exclusion lointaine ne doit pas annuler une mention affirmée."""
|
||||||
|
from pipeline.guadeloupe import coter
|
||||||
|
|
||||||
|
cotation = coter(
|
||||||
|
"Fouilles jusqu'à 40 km du littoral, soit la quasi-totalité de la Guadeloupe. "
|
||||||
|
+ "Texte de portée générale. " * 12
|
||||||
|
+ "Le dispositif est sans objet pour les collectivités du Pacifique."
|
||||||
|
)
|
||||||
|
assert cotation.pertinence is Pertinence.FORTE
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
static/polices/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
engine-strict=false
|
||||||
|
fund=false
|
||||||
|
audit=false
|
||||||
Generated
+2304
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "veille-legislative-971-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Interface de la veille législative Guadeloupe — SvelteKit, rendu serveur",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev --port 5971",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"start": "node build",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"polices": "node scripts/copier-polices.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@fontsource/archivo": "5.1.0",
|
||||||
|
"@fontsource/inter": "5.1.0",
|
||||||
|
"@sveltejs/adapter-node": "5.2.12",
|
||||||
|
"@sveltejs/kit": "2.17.2",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "5.0.3",
|
||||||
|
"@types/better-sqlite3": "7.6.12",
|
||||||
|
"svelte": "5.20.2",
|
||||||
|
"svelte-check": "4.1.4",
|
||||||
|
"typescript": "5.7.3",
|
||||||
|
"vite": "6.1.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "11.8.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Copie les polices Archivo et Inter depuis node_modules vers static/polices/.
|
||||||
|
*
|
||||||
|
* Le self-hébergement n'est pas une préférence : la politique de sécurité de
|
||||||
|
* contenu déclarée dans svelte.config.js interdit toute requête vers un hôte
|
||||||
|
* tiers, Google Fonts compris.
|
||||||
|
*/
|
||||||
|
import { copyFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const DESTINATION = 'static/polices';
|
||||||
|
mkdirSync(DESTINATION, { recursive: true });
|
||||||
|
|
||||||
|
const familles = [
|
||||||
|
{ paquet: '@fontsource/archivo', graisses: ['400', '600', '700', '900'] },
|
||||||
|
{ paquet: '@fontsource/inter', graisses: ['400', '500', '600', '700'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
let copiees = 0;
|
||||||
|
for (const { paquet, graisses } of familles) {
|
||||||
|
const source = join('node_modules', paquet, 'files');
|
||||||
|
for (const fichier of readdirSync(source)) {
|
||||||
|
const estLatin = fichier.includes('-latin-') && fichier.endsWith('.woff2');
|
||||||
|
const estNormal = fichier.includes('-normal.woff2');
|
||||||
|
const graisseVoulue = graisses.some((g) => fichier.includes(`-${g}-`));
|
||||||
|
if (estLatin && estNormal && graisseVoulue) {
|
||||||
|
copyFileSync(join(source, fichier), join(DESTINATION, fichier));
|
||||||
|
copiees += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${copiees} fichiers de police copiés dans ${DESTINATION}`);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr" %sveltekit.theme%>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="dark light" />
|
||||||
|
<meta name="theme-color" content="#0D0D0D" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="%sveltekit.assets%/polices/archivo-latin-700-normal.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="%sveltekit.assets%/polices/inter-latin-400-normal.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
<!--
|
||||||
|
Restauration du thème avant le premier rendu, pour éviter le flash de
|
||||||
|
thème clair. Le script est externe : la politique de sécurité de contenu
|
||||||
|
interdit l'inline.
|
||||||
|
-->
|
||||||
|
<script src="%sveltekit.assets%/theme.js"></script>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/** Cotation de pertinence guadeloupéenne, avec son motif quand il existe. */
|
||||||
|
interface Props {
|
||||||
|
pertinence: string | null;
|
||||||
|
note?: string | null;
|
||||||
|
aVerifier?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pertinence, note = null, aVerifier = false }: Props = $props();
|
||||||
|
|
||||||
|
const LIBELLES: Record<string, string> = {
|
||||||
|
forte: 'Gwadloup : forte',
|
||||||
|
moyenne: 'Gwadloup : moyenne',
|
||||||
|
faible: 'Gwadloup : faible'
|
||||||
|
};
|
||||||
|
const OPACITES: Record<string, string> = { forte: '100%', moyenne: '70%', faible: '45%' };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pertinence}
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
style="--intensite: {OPACITES[pertinence] ?? '50%'}"
|
||||||
|
title={note ?? undefined}
|
||||||
|
>
|
||||||
|
{LIBELLES[pertinence] ?? pertinence}{#if aVerifier}<abbr title="Cotation déduite automatiquement, non relue">*</abbr>{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.16em 0.6em;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--turquoise-caraibes) var(--intensite), transparent);
|
||||||
|
background: color-mix(in srgb, var(--turquoise-caraibes) 12%, transparent);
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
abbr {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--or-oki);
|
||||||
|
margin-left: 0.15em;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Badge de statut.
|
||||||
|
*
|
||||||
|
* L'accessibilité daltonienne interdit de faire porter l'information par la
|
||||||
|
* seule couleur : chaque statut porte donc son libellé en toutes lettres et
|
||||||
|
* un motif de bordure distinct. La couleur ne fait que renforcer.
|
||||||
|
*/
|
||||||
|
import { LIBELLES_STATUT } from '$lib/libelles';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
statut: string;
|
||||||
|
devantCc?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { statut, devantCc = false, compact = false }: Props = $props();
|
||||||
|
|
||||||
|
const COULEURS: Record<string, string> = {
|
||||||
|
promulguee: 'var(--statut-promulguee)',
|
||||||
|
validee_cc: 'var(--statut-attente)',
|
||||||
|
adoptee_non_promulguee: 'var(--statut-attente)',
|
||||||
|
saisie_cc: 'var(--statut-cc)',
|
||||||
|
censuree_partiellement: 'var(--statut-cc)',
|
||||||
|
navette: 'var(--statut-navette)',
|
||||||
|
deposee_non_examinee: 'var(--statut-projet)',
|
||||||
|
annoncee: 'var(--statut-projet)',
|
||||||
|
rejetee: 'var(--statut-rejetee)'
|
||||||
|
};
|
||||||
|
|
||||||
|
const couleur = $derived(COULEURS[statut] ?? 'var(--muted-fort)');
|
||||||
|
const libelle = $derived(LIBELLES_STATUT[statut] ?? statut);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="badge" class:compact style="--teinte: {couleur}">
|
||||||
|
<span class="pastille" aria-hidden="true"></span>
|
||||||
|
{libelle}
|
||||||
|
</span>
|
||||||
|
{#if devantCc && statut !== 'saisie_cc'}
|
||||||
|
<span class="badge cc" style="--teinte: var(--statut-cc)">
|
||||||
|
<span class="pastille" aria-hidden="true"></span>
|
||||||
|
Devant le Conseil constitutionnel
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
padding: 0.16em 0.6em;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--teinte) 65%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--teinte) 12%, transparent);
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge.compact {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.pastille {
|
||||||
|
width: 0.5em;
|
||||||
|
height: 0.5em;
|
||||||
|
background: var(--teinte);
|
||||||
|
border-radius: 1px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/** Carte compacte de résultat : titre, badges, date clé, résumé deux lignes. */
|
||||||
|
import BadgeGuadeloupe from './BadgeGuadeloupe.svelte';
|
||||||
|
import BadgeStatut from './BadgeStatut.svelte';
|
||||||
|
import { LIBELLES_THEME, LIBELLES_TYPE } from '$lib/libelles';
|
||||||
|
import { dateLongue, extrait } from '$lib/format';
|
||||||
|
import type { Texte } from '$lib/types';
|
||||||
|
|
||||||
|
const { texte }: { texte: Texte } = $props();
|
||||||
|
|
||||||
|
const dateCle = $derived(
|
||||||
|
texte.date_promulgation ?? texte.date_adoption ?? texte.statut_date ?? null
|
||||||
|
);
|
||||||
|
const libelleDate = $derived(
|
||||||
|
texte.date_promulgation
|
||||||
|
? `Promulguée le ${dateLongue(texte.date_promulgation)}`
|
||||||
|
: texte.date_adoption
|
||||||
|
? `Adoptée le ${dateLongue(texte.date_adoption)}`
|
||||||
|
: dateCle
|
||||||
|
? dateLongue(dateCle)
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="carte resultat">
|
||||||
|
<h3>
|
||||||
|
<a href="/textes/{texte.id}">
|
||||||
|
{#if texte.numero_officiel}<span class="numero">n° {texte.numero_officiel}</span>{/if}
|
||||||
|
{texte.titre_court}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="ligne badges">
|
||||||
|
<BadgeStatut statut={texte.statut} devantCc={texte.devant_cc === 1} compact />
|
||||||
|
<BadgeGuadeloupe
|
||||||
|
pertinence={texte.guadeloupe_pertinence}
|
||||||
|
note={texte.guadeloupe_note}
|
||||||
|
aVerifier={texte.a_verifier === 1}
|
||||||
|
/>
|
||||||
|
<span class="type">{LIBELLES_TYPE[texte.type] ?? texte.type}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if libelleDate}<p class="date">{libelleDate}</p>{/if}
|
||||||
|
|
||||||
|
{#if texte.extrait}
|
||||||
|
<p class="resume">{@html texte.extrait}</p>
|
||||||
|
{:else if texte.resume}
|
||||||
|
<p class="resume">{extrait(texte.resume, 200)}</p>
|
||||||
|
{:else if texte.points_cles.length}
|
||||||
|
<p class="resume">{extrait(texte.points_cles[0], 200)}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if texte.themes.length}
|
||||||
|
<div class="ligne themes">
|
||||||
|
{#each texte.themes as theme (theme)}
|
||||||
|
<span class="tag">{LIBELLES_THEME[theme] ?? theme}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="meta">
|
||||||
|
{texte.nb_sources}
|
||||||
|
{texte.nb_sources > 1 ? 'sources' : 'source'}
|
||||||
|
{#if texte.a_verifier === 1}
|
||||||
|
· <span class="verifier">à vérifier</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.resultat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
h3 a {
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
h3 a:hover {
|
||||||
|
color: var(--or-oki);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.numero {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: var(--or-oki);
|
||||||
|
margin-right: 0.35em;
|
||||||
|
}
|
||||||
|
.badges {
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.type {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
.date {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.resume {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.themes {
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
.verifier {
|
||||||
|
color: var(--or-oki);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Panneau de filtres à facettes.
|
||||||
|
*
|
||||||
|
* Chaque facette est un lien : la page fonctionne sans JavaScript, et l'état
|
||||||
|
* des filtres vit dans l'URL, donc se partage et se recharge à l'identique.
|
||||||
|
* Les compteurs sont recalculés sur le résultat filtré par les *autres*
|
||||||
|
* facettes — cocher un thème doit recompter les statuts encore disponibles.
|
||||||
|
*/
|
||||||
|
import type { Facette, Filtres, Resultats } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
facettes: Resultats['facettes'];
|
||||||
|
filtres: Filtres;
|
||||||
|
lien: (champ: keyof Filtres, valeur: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { facettes, filtres, lien }: Props = $props();
|
||||||
|
|
||||||
|
const GROUPES: { champ: keyof Filtres; titre: string; cle: keyof Resultats['facettes'] }[] = [
|
||||||
|
{ champ: 'statut', titre: 'Statut', cle: 'statut' },
|
||||||
|
{ champ: 'guadeloupe', titre: 'Pertinence Guadeloupe', cle: 'guadeloupe' },
|
||||||
|
{ champ: 'impact', titre: 'Impact sur les libertés', cle: 'impact' },
|
||||||
|
{ champ: 'theme', titre: 'Thème', cle: 'theme' },
|
||||||
|
{ champ: 'type', titre: 'Type de texte', cle: 'type' },
|
||||||
|
{ champ: 'confiance', titre: 'Niveau de confiance', cle: 'confiance' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function ouvertParDefaut(champ: keyof Filtres, liste: Facette[]): boolean {
|
||||||
|
return (filtres[champ] as string[]).length > 0 || liste.some((f) => f.actif) ||
|
||||||
|
champ === 'statut' || champ === 'guadeloupe';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="panneau-facettes" aria-label="Filtres">
|
||||||
|
{#each GROUPES as groupe (groupe.champ)}
|
||||||
|
{@const liste = facettes[groupe.cle]}
|
||||||
|
{#if liste.length}
|
||||||
|
<details class="groupe" open={ouvertParDefaut(groupe.champ, liste)}>
|
||||||
|
<summary>
|
||||||
|
{groupe.titre}
|
||||||
|
{#if (filtres[groupe.champ] as string[]).length}
|
||||||
|
<span class="compteur-actifs">{(filtres[groupe.champ] as string[]).length}</span>
|
||||||
|
{/if}
|
||||||
|
</summary>
|
||||||
|
<ul>
|
||||||
|
{#each liste as facette (facette.valeur)}
|
||||||
|
<li>
|
||||||
|
<!--
|
||||||
|
Ce sont des liens, pas des cases à cocher : `aria-pressed` n'est pas
|
||||||
|
valide sur le rôle `link`. L'état est donc porté par un texte
|
||||||
|
réservé aux lecteurs d'écran, qui dit aussi ce que fera le clic.
|
||||||
|
-->
|
||||||
|
<a
|
||||||
|
href={lien(groupe.champ, facette.valeur)}
|
||||||
|
class="facette"
|
||||||
|
class:actif={facette.actif}
|
||||||
|
rel="nofollow"
|
||||||
|
>
|
||||||
|
<span class="case" aria-hidden="true">{facette.actif ? '×' : '+'}</span>
|
||||||
|
<span class="libelle">{facette.libelle}</span>
|
||||||
|
<span class="nombre">{facette.nombre}</span>
|
||||||
|
<span class="visuellement-cache">
|
||||||
|
{facette.actif
|
||||||
|
? 'filtre actif, activer pour le retirer'
|
||||||
|
: `${facette.nombre} résultats, activer pour filtrer`}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panneau-facettes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupe {
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
summary::marker {
|
||||||
|
color: var(--or-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compteur-actifs {
|
||||||
|
background: var(--or-oki);
|
||||||
|
color: var(--noir-oki);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0 0.4em;
|
||||||
|
min-width: 1.3em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0.5rem 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facette {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.32rem 0.45rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.facette:hover {
|
||||||
|
background: var(--card-bg-survol);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
.facette.actif {
|
||||||
|
background: color-mix(in srgb, var(--or-oki) 14%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--or-oki) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--or-oki);
|
||||||
|
width: 1ch;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.libelle {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.nombre {
|
||||||
|
color: var(--muted-fort);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/** Formatage des dates et des libellés, en français. */
|
||||||
|
|
||||||
|
const MOIS = [
|
||||||
|
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||||
|
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** « 2026-06-12 » → « 12 juin 2026 ». */
|
||||||
|
export function dateLongue(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const [a, m, j] = iso.slice(0, 10).split('-').map(Number);
|
||||||
|
if (!a || !m || !j) return iso;
|
||||||
|
return `${j === 1 ? '1ᵉʳ' : j} ${MOIS[m - 1]} ${a}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** « 2026-06-12 » → « 12 juin ». */
|
||||||
|
export function dateCourte(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const [, m, j] = iso.slice(0, 10).split('-').map(Number);
|
||||||
|
if (!m || !j) return iso;
|
||||||
|
return `${j === 1 ? '1ᵉʳ' : j} ${MOIS[m - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mois(iso: string): string {
|
||||||
|
const [a, m] = iso.slice(0, 10).split('-').map(Number);
|
||||||
|
return `${MOIS[m - 1]} ${a}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre de jours entre deux dates ISO, positif dans le futur. */
|
||||||
|
export function joursRestants(iso: string, depuis = '2026-07-25'): number {
|
||||||
|
const cible = new Date(iso.slice(0, 10)).getTime();
|
||||||
|
const base = new Date(depuis).getTime();
|
||||||
|
return Math.round((cible - base) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluriel(n: number, singulier: string, plurielMot?: string): string {
|
||||||
|
return n > 1 ? (plurielMot ?? `${singulier}s`) : singulier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tronque sur une frontière de mot, sans couper au milieu. */
|
||||||
|
export function extrait(texte: string | null | undefined, taille = 180): string {
|
||||||
|
if (!texte) return '';
|
||||||
|
if (texte.length <= taille) return texte;
|
||||||
|
const coupe = texte.slice(0, taille);
|
||||||
|
const espace = coupe.lastIndexOf(' ');
|
||||||
|
return `${coupe.slice(0, espace > 0 ? espace : taille)}…`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Libellés d'affichage du vocabulaire métier.
|
||||||
|
*
|
||||||
|
* Ce module est partagé serveur et client : il ne touche ni à la base ni au
|
||||||
|
* système de fichiers. Les composants d'interface en ont besoin, et SvelteKit
|
||||||
|
* interdit — à raison — d'importer `$lib/server` depuis du code client.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const LIBELLES_STATUT: Record<string, string> = {
|
||||||
|
promulguee: 'Promulguée',
|
||||||
|
adoptee_non_promulguee: 'Adoptée, non promulguée',
|
||||||
|
saisie_cc: 'Devant le Conseil constitutionnel',
|
||||||
|
validee_cc: 'Validée, en attente de promulgation',
|
||||||
|
censuree_partiellement: 'Censurée partiellement',
|
||||||
|
navette: 'En navette',
|
||||||
|
deposee_non_examinee: 'Déposée, non examinée',
|
||||||
|
annoncee: 'Annoncée',
|
||||||
|
rejetee: 'Rejetée'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIBELLES_THEME: Record<string, string> = {
|
||||||
|
justice: 'Justice',
|
||||||
|
securite: 'Sécurité',
|
||||||
|
numerique: 'Numérique',
|
||||||
|
social: 'Social',
|
||||||
|
fiscal: 'Fiscal',
|
||||||
|
environnement: 'Environnement',
|
||||||
|
agriculture: 'Agriculture',
|
||||||
|
sante: 'Santé',
|
||||||
|
memoire_patrimoine: 'Mémoire et patrimoine',
|
||||||
|
outre_mer: 'Outre-mer',
|
||||||
|
economie: 'Économie',
|
||||||
|
migration: 'Migration',
|
||||||
|
institutions: 'Institutions'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIBELLES_TYPE: Record<string, string> = {
|
||||||
|
loi: 'Loi',
|
||||||
|
loi_organique: 'Loi organique',
|
||||||
|
pjl: 'Projet de loi',
|
||||||
|
ppl: 'Proposition de loi',
|
||||||
|
ordonnance: 'Ordonnance',
|
||||||
|
decision_cc: 'Décision du Conseil constitutionnel',
|
||||||
|
decret: 'Décret',
|
||||||
|
accord_international: 'Accord international'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIBELLES_PERTINENCE: Record<string, string> = {
|
||||||
|
forte: 'Forte',
|
||||||
|
moyenne: 'Moyenne',
|
||||||
|
faible: 'Faible'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIBELLES_CONFIANCE: Record<string, string> = {
|
||||||
|
high: 'Élevée',
|
||||||
|
medium: 'Moyenne',
|
||||||
|
low: 'Faible'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIBELLES_IMPACT: Record<string, string> = {
|
||||||
|
'individus:positif': 'Individus — droits étendus',
|
||||||
|
'individus:negatif': 'Individus — droits restreints',
|
||||||
|
'individus:mixte': 'Individus — effet mixte',
|
||||||
|
'associations:positif': 'Associations — droits étendus',
|
||||||
|
'associations:negatif': 'Associations — droits restreints',
|
||||||
|
'associations:mixte': 'Associations — effet mixte',
|
||||||
|
'entreprises:positif': 'Entreprises — obligations allégées',
|
||||||
|
'entreprises:negatif': 'Entreprises — obligations renforcées',
|
||||||
|
'entreprises:mixte': 'Entreprises — effet mixte'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIBELLES_ETAPE: Record<string, string> = {
|
||||||
|
depot: 'Dépôt',
|
||||||
|
adoption_1re_lecture: 'Adoption en première lecture',
|
||||||
|
adoption_definitive: 'Adoption définitive',
|
||||||
|
commission_mixte_paritaire: 'Commission mixte paritaire',
|
||||||
|
transmission: 'Transmission',
|
||||||
|
saisine_cc: 'Saisine du Conseil constitutionnel',
|
||||||
|
decision_cc: 'Décision du Conseil constitutionnel',
|
||||||
|
promulgation: 'Promulgation',
|
||||||
|
publication_jo: 'Publication au Journal officiel',
|
||||||
|
entree_vigueur: 'Entrée en vigueur',
|
||||||
|
rejet: 'Rejet',
|
||||||
|
annonce: 'Annonce',
|
||||||
|
autre: 'Étape'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIBELLES_RESULTAT_CC: Record<string, string> = {
|
||||||
|
conforme: 'Conforme',
|
||||||
|
conforme_avec_reserves: 'Conforme avec réserves',
|
||||||
|
non_conformite_partielle: 'Non-conformité partielle',
|
||||||
|
non_conformite_totale: 'Non-conformité totale',
|
||||||
|
en_instance: 'En instance'
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Connexion SQLite partagée.
|
||||||
|
*
|
||||||
|
* La base est ouverte une seule fois, en lecture seule : l'application web ne
|
||||||
|
* modifie jamais les données, c'est le rôle du pipeline. Le mode WAL permet au
|
||||||
|
* pipeline d'écrire pendant que l'application lit, sans blocage.
|
||||||
|
*/
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const ICI = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/** Emplacements possibles de la base, du plus explicite au plus conventionnel. */
|
||||||
|
function trouverLaBase(): string {
|
||||||
|
const candidats = [
|
||||||
|
process.env.VEILLE_DB,
|
||||||
|
resolve(process.cwd(), '../data/veille.db'),
|
||||||
|
resolve(process.cwd(), 'data/veille.db'),
|
||||||
|
resolve(ICI, '../../../../data/veille.db')
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
for (const candidat of candidats) {
|
||||||
|
if (existsSync(candidat)) return candidat;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Base introuvable. Essayés : ${candidats.join(', ')}. ` +
|
||||||
|
'Lancer `make seed` à la racine du dépôt, ou définir VEILLE_DB.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let instance: Database.Database | null = null;
|
||||||
|
|
||||||
|
export function base(): Database.Database {
|
||||||
|
if (instance) return instance;
|
||||||
|
|
||||||
|
instance = new Database(trouverLaBase(), { readonly: true, fileMustExist: true });
|
||||||
|
instance.pragma('journal_mode = WAL');
|
||||||
|
// Les requêtes de facettes lisent beaucoup et écrivent rien : un cache
|
||||||
|
// généreux évite de relire les mêmes pages à chaque requête HTTP.
|
||||||
|
instance.pragma('cache_size = -16000');
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fermer(): void {
|
||||||
|
instance?.close();
|
||||||
|
instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Date d'arrêté du corpus de recherche, affichée partout où c'est utile. */
|
||||||
|
export const DATE_ARRETE_CORPUS = '2026-07-25';
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
/**
|
||||||
|
* Requêtes de consultation : recherche plein texte, facettes, fiches, calendrier.
|
||||||
|
*
|
||||||
|
* Tout se joue en SQL. La recherche plein texte passe par l'index FTS5 à
|
||||||
|
* tokenizer `unicode61 remove_diacritics 2` — « chlordecone » retrouve
|
||||||
|
* « chlordécone » — et le classement par `bm25()` pondère les colonnes : un mot
|
||||||
|
* dans le titre pèse dix fois plus que dans les points clés.
|
||||||
|
*
|
||||||
|
* Les facettes sont calculées sur le résultat filtré **par les autres
|
||||||
|
* facettes**, pas sur la table entière : cocher « santé » doit recompter les
|
||||||
|
* statuts disponibles parmi les textes de santé, sinon les compteurs mentent.
|
||||||
|
*/
|
||||||
|
import { base } from '$serveur/db';
|
||||||
|
import {
|
||||||
|
LIBELLES_CONFIANCE,
|
||||||
|
LIBELLES_IMPACT,
|
||||||
|
LIBELLES_PERTINENCE,
|
||||||
|
LIBELLES_STATUT,
|
||||||
|
LIBELLES_THEME,
|
||||||
|
LIBELLES_TYPE
|
||||||
|
} from '$lib/libelles';
|
||||||
|
import type {
|
||||||
|
Echeance,
|
||||||
|
Facette,
|
||||||
|
Filtres,
|
||||||
|
Insight,
|
||||||
|
Resultats,
|
||||||
|
Texte,
|
||||||
|
TexteDetaille
|
||||||
|
} from '$lib/types';
|
||||||
|
|
||||||
|
const PAR_PAGE = 20;
|
||||||
|
|
||||||
|
/** Pondération bm25 : titre court, titre officiel, résumé, points clés, note, numéro. */
|
||||||
|
const POIDS_BM25 = '10.0, 5.0, 3.0, 2.0, 2.0, 1.0';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Lecture des filtres depuis l'URL
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
export function lireFiltres(parametres: URLSearchParams): Filtres {
|
||||||
|
const multiple = (nom: string) =>
|
||||||
|
parametres
|
||||||
|
.getAll(nom)
|
||||||
|
.flatMap((valeur) => valeur.split(','))
|
||||||
|
.map((valeur) => valeur.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const page = Number.parseInt(parametres.get('page') ?? '1', 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
q: (parametres.get('q') ?? '').trim(),
|
||||||
|
statut: multiple('statut'),
|
||||||
|
theme: multiple('theme'),
|
||||||
|
type: multiple('type'),
|
||||||
|
impact: multiple('impact'),
|
||||||
|
guadeloupe: multiple('guadeloupe'),
|
||||||
|
confiance: multiple('confiance'),
|
||||||
|
from: parametres.get('from'),
|
||||||
|
to: parametres.get('to'),
|
||||||
|
tri: parametres.get('tri') === 'date' ? 'date' : 'pertinence',
|
||||||
|
page: Number.isFinite(page) && page > 0 ? page : 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconstruit une URL de recherche en basculant une valeur de facette. */
|
||||||
|
export function basculerFacette(
|
||||||
|
filtres: Filtres,
|
||||||
|
champ: keyof Filtres,
|
||||||
|
valeur: string
|
||||||
|
): string {
|
||||||
|
const parametres = new URLSearchParams();
|
||||||
|
if (filtres.q) parametres.set('q', filtres.q);
|
||||||
|
|
||||||
|
for (const nom of ['statut', 'theme', 'type', 'impact', 'guadeloupe', 'confiance'] as const) {
|
||||||
|
let valeurs = [...filtres[nom]];
|
||||||
|
if (nom === champ) {
|
||||||
|
valeurs = valeurs.includes(valeur)
|
||||||
|
? valeurs.filter((v) => v !== valeur)
|
||||||
|
: [...valeurs, valeur];
|
||||||
|
}
|
||||||
|
for (const v of valeurs) parametres.append(nom, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtres.from) parametres.set('from', filtres.from);
|
||||||
|
if (filtres.to) parametres.set('to', filtres.to);
|
||||||
|
if (filtres.tri !== 'pertinence') parametres.set('tri', filtres.tri);
|
||||||
|
|
||||||
|
const chaine = parametres.toString();
|
||||||
|
return chaine ? `/recherche?${chaine}` : '/recherche';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Construction du SQL
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
interface Clause {
|
||||||
|
sql: string;
|
||||||
|
parametres: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conditions issues des filtres, hors recherche plein texte.
|
||||||
|
*
|
||||||
|
* `exclure` permet de recalculer les facettes d'un champ en ignorant ses
|
||||||
|
* propres sélections — c'est ce qui rend les compteurs honnêtes.
|
||||||
|
*/
|
||||||
|
function conditions(filtres: Filtres, exclure?: string): Clause {
|
||||||
|
const morceaux: string[] = [];
|
||||||
|
const parametres: unknown[] = [];
|
||||||
|
|
||||||
|
if (filtres.statut.length && exclure !== 'statut') {
|
||||||
|
// `saisie_cc` n'est pas seulement un statut : c'est aussi l'état des
|
||||||
|
// textes dont une affaire est en instance devant le Conseil. La vue
|
||||||
|
// `v_textes` expose cette lecture combinée sous `devant_cc`.
|
||||||
|
const statutsSimples = filtres.statut.filter((s) => s !== 'saisie_cc');
|
||||||
|
const clauses: string[] = [];
|
||||||
|
if (statutsSimples.length) {
|
||||||
|
clauses.push(`statut IN (${statutsSimples.map(() => '?').join(', ')})`);
|
||||||
|
parametres.push(...statutsSimples);
|
||||||
|
}
|
||||||
|
if (filtres.statut.includes('saisie_cc')) clauses.push('devant_cc = 1');
|
||||||
|
morceaux.push(`(${clauses.join(' OR ')})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtres.theme.length && exclure !== 'theme') {
|
||||||
|
const clauses = filtres.theme.map(
|
||||||
|
() => `EXISTS (SELECT 1 FROM json_each(themes) WHERE json_each.value = ?)`
|
||||||
|
);
|
||||||
|
morceaux.push(`(${clauses.join(' OR ')})`);
|
||||||
|
parametres.push(...filtres.theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtres.type.length && exclure !== 'type') {
|
||||||
|
morceaux.push(`type IN (${filtres.type.map(() => '?').join(', ')})`);
|
||||||
|
parametres.push(...filtres.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtres.guadeloupe.length && exclure !== 'guadeloupe') {
|
||||||
|
morceaux.push(
|
||||||
|
`guadeloupe_pertinence IN (${filtres.guadeloupe.map(() => '?').join(', ')})`
|
||||||
|
);
|
||||||
|
parametres.push(...filtres.guadeloupe);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtres.confiance.length && exclure !== 'confiance') {
|
||||||
|
morceaux.push(`confiance IN (${filtres.confiance.map(() => '?').join(', ')})`);
|
||||||
|
parametres.push(...filtres.confiance);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtres.impact.length && exclure !== 'impact') {
|
||||||
|
// Un filtre d'impact s'écrit « public:sens », par exemple
|
||||||
|
// « associations:negatif ». Les deux moitiés doivent correspondre
|
||||||
|
// ensemble, d'où l'accès direct par clé JSON.
|
||||||
|
const clauses = filtres.impact.map(() => `json_extract(impacts, ?) = ?`);
|
||||||
|
morceaux.push(`(${clauses.join(' OR ')})`);
|
||||||
|
for (const valeur of filtres.impact) {
|
||||||
|
const [pub, sens] = valeur.split(':');
|
||||||
|
parametres.push(`$.${pub}.sens`, sens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtres.from) {
|
||||||
|
morceaux.push(`COALESCE(date_promulgation, date_adoption, statut_date) >= ?`);
|
||||||
|
parametres.push(filtres.from);
|
||||||
|
}
|
||||||
|
if (filtres.to) {
|
||||||
|
morceaux.push(`COALESCE(date_promulgation, date_adoption, statut_date) <= ?`);
|
||||||
|
parametres.push(filtres.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sql: morceaux.length ? morceaux.join(' AND ') : '1 = 1', parametres };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traduit une saisie libre en requête FTS5.
|
||||||
|
*
|
||||||
|
* Chaque terme est cité pour neutraliser la syntaxe FTS (`NEAR`, `*`, `:`)
|
||||||
|
* qu'un visiteur ne saisit pas volontairement, et suffixé d'une étoile pour
|
||||||
|
* accepter les préfixes : « chlord » trouve « chlordécone ».
|
||||||
|
*/
|
||||||
|
function requeteFts(saisie: string): string {
|
||||||
|
const termes = saisie
|
||||||
|
.replace(/["^*:()]/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((terme) => terme.trim())
|
||||||
|
.filter((terme) => terme.length > 1);
|
||||||
|
|
||||||
|
if (!termes.length) return '';
|
||||||
|
return termes.map((terme) => `"${terme}"*`).join(' AND ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Recherche
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
export function rechercher(filtres: Filtres): Resultats {
|
||||||
|
const db = base();
|
||||||
|
const where = conditions(filtres);
|
||||||
|
const fts = filtres.q ? requeteFts(filtres.q) : '';
|
||||||
|
|
||||||
|
const source = fts
|
||||||
|
? `v_textes t JOIN textes_fts f ON f.rowid = t.rowid AND textes_fts MATCH ?`
|
||||||
|
: `v_textes t`;
|
||||||
|
const parametresSource = fts ? [fts] : [];
|
||||||
|
|
||||||
|
const ordre = fts
|
||||||
|
? filtres.tri === 'date'
|
||||||
|
? `COALESCE(t.date_promulgation, t.date_adoption, t.statut_date) DESC`
|
||||||
|
: `bm25(textes_fts, ${POIDS_BM25})`
|
||||||
|
: `COALESCE(t.date_promulgation, t.date_adoption, t.statut_date) DESC, t.id`;
|
||||||
|
|
||||||
|
const total = (
|
||||||
|
db
|
||||||
|
.prepare(`SELECT COUNT(*) AS n FROM ${source} WHERE ${where.sql}`)
|
||||||
|
.get(...parametresSource, ...where.parametres) as { n: number }
|
||||||
|
).n;
|
||||||
|
|
||||||
|
const extrait = fts
|
||||||
|
? `snippet(textes_fts, -1, '<mark>', '</mark>', '…', 24) AS extrait`
|
||||||
|
: `NULL AS extrait`;
|
||||||
|
|
||||||
|
const lignes = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.*, ${extrait}
|
||||||
|
FROM ${source}
|
||||||
|
WHERE ${where.sql}
|
||||||
|
ORDER BY ${ordre}
|
||||||
|
LIMIT ? OFFSET ?`
|
||||||
|
)
|
||||||
|
.all(
|
||||||
|
...parametresSource,
|
||||||
|
...where.parametres,
|
||||||
|
PAR_PAGE,
|
||||||
|
(filtres.page - 1) * PAR_PAGE
|
||||||
|
) as Record<string, unknown>[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
textes: lignes.map(hydrater),
|
||||||
|
total,
|
||||||
|
page: filtres.page,
|
||||||
|
pages: Math.max(1, Math.ceil(total / PAR_PAGE)),
|
||||||
|
facettes: {
|
||||||
|
statut: facetteStatut(filtres, fts),
|
||||||
|
theme: facetteTheme(filtres, fts),
|
||||||
|
type: facetteSimple(filtres, fts, 'type', LIBELLES_TYPE),
|
||||||
|
guadeloupe: facetteSimple(
|
||||||
|
filtres,
|
||||||
|
fts,
|
||||||
|
'guadeloupe_pertinence',
|
||||||
|
LIBELLES_PERTINENCE,
|
||||||
|
'guadeloupe'
|
||||||
|
),
|
||||||
|
impact: facetteImpact(filtres, fts),
|
||||||
|
confiance: facetteSimple(filtres, fts, 'confiance', LIBELLES_CONFIANCE)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceEtParametres(fts: string): [string, unknown[]] {
|
||||||
|
return fts
|
||||||
|
? [`v_textes t JOIN textes_fts f ON f.rowid = t.rowid AND textes_fts MATCH ?`, [fts]]
|
||||||
|
: [`v_textes t`, []];
|
||||||
|
}
|
||||||
|
|
||||||
|
function facetteSimple(
|
||||||
|
filtres: Filtres,
|
||||||
|
fts: string,
|
||||||
|
colonne: string,
|
||||||
|
libelles: Record<string, string>,
|
||||||
|
champFiltre?: keyof Filtres
|
||||||
|
): Facette[] {
|
||||||
|
const champ = (champFiltre ?? colonne) as keyof Filtres;
|
||||||
|
const where = conditions(filtres, champ as string);
|
||||||
|
const [source, parametresSource] = sourceEtParametres(fts);
|
||||||
|
|
||||||
|
const lignes = base()
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${colonne} AS valeur, COUNT(*) AS nombre
|
||||||
|
FROM ${source}
|
||||||
|
WHERE ${where.sql} AND ${colonne} IS NOT NULL
|
||||||
|
GROUP BY ${colonne}
|
||||||
|
ORDER BY nombre DESC`
|
||||||
|
)
|
||||||
|
.all(...parametresSource, ...where.parametres) as { valeur: string; nombre: number }[];
|
||||||
|
|
||||||
|
const selection = filtres[champ] as string[];
|
||||||
|
return lignes.map((ligne) => ({
|
||||||
|
valeur: ligne.valeur,
|
||||||
|
libelle: libelles[ligne.valeur] ?? ligne.valeur,
|
||||||
|
nombre: ligne.nombre,
|
||||||
|
actif: selection.includes(ligne.valeur)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function facetteStatut(filtres: Filtres, fts: string): Facette[] {
|
||||||
|
const facettes = facetteSimple(filtres, fts, 'statut', LIBELLES_STATUT);
|
||||||
|
|
||||||
|
// Facette dérivée : « devant le Conseil constitutionnel » recouvre à la
|
||||||
|
// fois le statut `saisie_cc` et les textes ayant une affaire en instance.
|
||||||
|
const where = conditions(filtres, 'statut');
|
||||||
|
const [source, parametresSource] = sourceEtParametres(fts);
|
||||||
|
const nombre = (
|
||||||
|
base()
|
||||||
|
.prepare(`SELECT COUNT(*) AS n FROM ${source} WHERE ${where.sql} AND devant_cc = 1`)
|
||||||
|
.get(...parametresSource, ...where.parametres) as { n: number }
|
||||||
|
).n;
|
||||||
|
|
||||||
|
if (nombre > 0 && !facettes.some((f) => f.valeur === 'saisie_cc')) {
|
||||||
|
facettes.unshift({
|
||||||
|
valeur: 'saisie_cc',
|
||||||
|
libelle: LIBELLES_STATUT.saisie_cc,
|
||||||
|
nombre,
|
||||||
|
actif: filtres.statut.includes('saisie_cc')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return facettes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function facetteTheme(filtres: Filtres, fts: string): Facette[] {
|
||||||
|
const where = conditions(filtres, 'theme');
|
||||||
|
const [source, parametresSource] = sourceEtParametres(fts);
|
||||||
|
|
||||||
|
const lignes = base()
|
||||||
|
.prepare(
|
||||||
|
`SELECT json_each.value AS valeur, COUNT(*) AS nombre
|
||||||
|
FROM ${source}, json_each(t.themes)
|
||||||
|
WHERE ${where.sql}
|
||||||
|
GROUP BY json_each.value
|
||||||
|
ORDER BY nombre DESC`
|
||||||
|
)
|
||||||
|
.all(...parametresSource, ...where.parametres) as { valeur: string; nombre: number }[];
|
||||||
|
|
||||||
|
return lignes.map((ligne) => ({
|
||||||
|
valeur: ligne.valeur,
|
||||||
|
libelle: LIBELLES_THEME[ligne.valeur] ?? ligne.valeur,
|
||||||
|
nombre: ligne.nombre,
|
||||||
|
actif: filtres.theme.includes(ligne.valeur)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function facetteImpact(filtres: Filtres, fts: string): Facette[] {
|
||||||
|
const where = conditions(filtres, 'impact');
|
||||||
|
const [source, parametresSource] = sourceEtParametres(fts);
|
||||||
|
|
||||||
|
const lignes = base()
|
||||||
|
.prepare(
|
||||||
|
`SELECT json_each.key || ':' || json_extract(json_each.value, '$.sens') AS valeur,
|
||||||
|
COUNT(*) AS nombre
|
||||||
|
FROM ${source}, json_each(t.impacts)
|
||||||
|
WHERE ${where.sql}
|
||||||
|
GROUP BY valeur
|
||||||
|
ORDER BY nombre DESC`
|
||||||
|
)
|
||||||
|
.all(...parametresSource, ...where.parametres) as { valeur: string; nombre: number }[];
|
||||||
|
|
||||||
|
return lignes
|
||||||
|
.filter((ligne) => !ligne.valeur.endsWith(':neutre'))
|
||||||
|
.map((ligne) => ({
|
||||||
|
valeur: ligne.valeur,
|
||||||
|
libelle: LIBELLES_IMPACT[ligne.valeur] ?? ligne.valeur,
|
||||||
|
nombre: ligne.nombre,
|
||||||
|
actif: filtres.impact.includes(ligne.valeur)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Fiche détaillée
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
export function texte(slug: string): TexteDetaille | null {
|
||||||
|
const db = base();
|
||||||
|
const ligne = db.prepare(`SELECT * FROM v_textes WHERE id = ?`).get(slug) as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
if (!ligne) return null;
|
||||||
|
|
||||||
|
const detaille = hydrater(ligne) as TexteDetaille;
|
||||||
|
|
||||||
|
detaille.sources = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT url, titre, editeur, date_publication, tier, extrait_verbatim,
|
||||||
|
contexte, confiance, marqueur, fichier_origine
|
||||||
|
FROM sources WHERE texte_id = ?
|
||||||
|
ORDER BY tier ASC, date_publication DESC NULLS LAST, id`
|
||||||
|
)
|
||||||
|
.all(slug) as TexteDetaille['sources'];
|
||||||
|
|
||||||
|
detaille.evenements = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT date_evenement, type_etape, description, source_url, previsionnel
|
||||||
|
FROM evenements WHERE texte_id = ?
|
||||||
|
ORDER BY date_evenement ASC, id`
|
||||||
|
)
|
||||||
|
.all(slug) as TexteDetaille['evenements'];
|
||||||
|
|
||||||
|
detaille.decisions_cc = db
|
||||||
|
.prepare(`SELECT * FROM decisions_cc WHERE texte_id = ? ORDER BY numero_affaire`)
|
||||||
|
.all(slug) as TexteDetaille['decisions_cc'];
|
||||||
|
|
||||||
|
return detaille;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tousLesSlugs(): string[] {
|
||||||
|
return (base().prepare(`SELECT id FROM textes ORDER BY id`).all() as { id: string }[]).map(
|
||||||
|
(l) => l.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Tableau de bord
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
export interface Compteurs {
|
||||||
|
total: number;
|
||||||
|
parStatut: { valeur: string; libelle: string; nombre: number }[];
|
||||||
|
devantCc: number;
|
||||||
|
guadeloupeForte: number;
|
||||||
|
aVerifier: number;
|
||||||
|
sources: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compteurs(): Compteurs {
|
||||||
|
const db = base();
|
||||||
|
const un = (sql: string) => (db.prepare(sql).get() as { n: number }).n;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: un(`SELECT COUNT(*) AS n FROM textes`),
|
||||||
|
parStatut: (
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT statut AS valeur, COUNT(*) AS nombre FROM textes
|
||||||
|
GROUP BY statut ORDER BY nombre DESC`
|
||||||
|
)
|
||||||
|
.all() as { valeur: string; nombre: number }[]
|
||||||
|
).map((l) => ({ ...l, libelle: LIBELLES_STATUT[l.valeur] ?? l.valeur })),
|
||||||
|
devantCc: un(`SELECT COUNT(*) AS n FROM v_textes WHERE devant_cc = 1`),
|
||||||
|
guadeloupeForte: un(
|
||||||
|
`SELECT COUNT(*) AS n FROM textes WHERE guadeloupe_pertinence = 'forte'`
|
||||||
|
),
|
||||||
|
aVerifier: un(`SELECT COUNT(*) AS n FROM textes WHERE a_verifier = 1`),
|
||||||
|
sources: un(`SELECT COUNT(*) AS n FROM sources`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Échéances des `jours` prochains jours, textes et repères nationaux mêlés. */
|
||||||
|
export function prochainesEcheances(jours = 60, aPartirDe?: string): Echeance[] {
|
||||||
|
const depart = aPartirDe ?? '2026-07-25';
|
||||||
|
const fin = new Date(depart);
|
||||||
|
fin.setDate(fin.getDate() + jours);
|
||||||
|
const limite = fin.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const db = base();
|
||||||
|
|
||||||
|
const nationales = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM echeances
|
||||||
|
WHERE date_echeance >= ? AND date_echeance <= ?
|
||||||
|
ORDER BY date_echeance`
|
||||||
|
)
|
||||||
|
.all(depart, limite) as Echeance[];
|
||||||
|
|
||||||
|
const desTextes = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT NULL AS id, 'texte-' || id AS code, prochaine_echeance AS date_echeance,
|
||||||
|
titre_court AS libelle, prochaine_echeance_label AS description,
|
||||||
|
'texte' AS portee, id AS texte_id,
|
||||||
|
CASE WHEN guadeloupe_pertinence = 'forte' THEN 1 ELSE NULL END
|
||||||
|
AS concerne_guadeloupe,
|
||||||
|
guadeloupe_note AS note_guadeloupe, 1 AS previsionnel,
|
||||||
|
NULL AS source_url, NULL AS source_extrait
|
||||||
|
FROM textes
|
||||||
|
WHERE prochaine_echeance IS NOT NULL
|
||||||
|
AND prochaine_echeance >= ? AND prochaine_echeance <= ?
|
||||||
|
ORDER BY prochaine_echeance`
|
||||||
|
)
|
||||||
|
.all(depart, limite) as Echeance[];
|
||||||
|
|
||||||
|
return [...nationales, ...desTextes].sort((a, b) =>
|
||||||
|
a.date_echeance.localeCompare(b.date_echeance)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toutes les échéances, pour la page calendrier. */
|
||||||
|
export function calendrier(): Echeance[] {
|
||||||
|
return prochainesEcheances(400, '2026-07-01');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decisionsAttendues(): (Echeance & { numero_affaire: string })[] {
|
||||||
|
return base()
|
||||||
|
.prepare(
|
||||||
|
`SELECT d.numero_affaire, d.date_decision_attendue AS date_echeance,
|
||||||
|
COALESCE(t.titre_court, d.resume) AS libelle, d.resume AS description,
|
||||||
|
d.texte_id, t.guadeloupe_pertinence
|
||||||
|
FROM decisions_cc d
|
||||||
|
LEFT JOIN textes t ON t.id = d.texte_id
|
||||||
|
WHERE d.date_decision IS NULL
|
||||||
|
ORDER BY COALESCE(d.date_decision_attendue, '9999'), d.numero_affaire`
|
||||||
|
)
|
||||||
|
.all() as (Echeance & { numero_affaire: string })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insights(): Insight[] {
|
||||||
|
return (
|
||||||
|
base().prepare(`SELECT * FROM insights ORDER BY numero`).all() as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>[]
|
||||||
|
).map((ligne) => ({
|
||||||
|
numero: ligne.numero as number,
|
||||||
|
titre: ligne.titre as string,
|
||||||
|
corps: ligne.corps as string,
|
||||||
|
implications: ligne.implications as string | null,
|
||||||
|
confiance: ligne.confiance as string,
|
||||||
|
derive_de: JSON.parse((ligne.derive_de as string) || '[]')
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Changement {
|
||||||
|
horodatage: string;
|
||||||
|
mode: string;
|
||||||
|
texte_id: string | null;
|
||||||
|
titre: string | null;
|
||||||
|
nature: string;
|
||||||
|
champ: string | null;
|
||||||
|
ancienne_valeur: string | null;
|
||||||
|
nouvelle_valeur: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derniers changements détectés par le pipeline, pour l'accueil. */
|
||||||
|
export function derniersChangements(limite = 12): Changement[] {
|
||||||
|
return base()
|
||||||
|
.prepare(
|
||||||
|
`SELECT v.horodatage, v.mode, c.texte_id, t.titre_court AS titre,
|
||||||
|
c.nature, c.champ, c.ancienne_valeur, c.nouvelle_valeur, c.description
|
||||||
|
FROM veille_changements c
|
||||||
|
JOIN veille_log v ON v.id = c.run_id
|
||||||
|
LEFT JOIN textes t ON t.id = c.texte_id
|
||||||
|
ORDER BY v.horodatage DESC, c.id DESC
|
||||||
|
LIMIT ?`
|
||||||
|
)
|
||||||
|
.all(limite) as Changement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Run {
|
||||||
|
horodatage: string;
|
||||||
|
mode: string;
|
||||||
|
duree_s: number | null;
|
||||||
|
ajouts: number;
|
||||||
|
modifications: number;
|
||||||
|
echecs: number;
|
||||||
|
rapport: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dernierRun(): Run | null {
|
||||||
|
return (
|
||||||
|
(base()
|
||||||
|
.prepare(
|
||||||
|
`SELECT horodatage, mode, duree_s, ajouts, modifications, echecs, rapport
|
||||||
|
FROM veille_log ORDER BY horodatage DESC, id DESC LIMIT 1`
|
||||||
|
)
|
||||||
|
.get() as Run | undefined) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Hydratation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
function hydrater(ligne: Record<string, unknown>): Texte {
|
||||||
|
return {
|
||||||
|
...(ligne as unknown as Texte),
|
||||||
|
themes: JSON.parse((ligne.themes as string) || '[]'),
|
||||||
|
impacts: JSON.parse((ligne.impacts as string) || '{}'),
|
||||||
|
points_cles: JSON.parse((ligne.points_cles as string) || '[]')
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* Socle : polices auto-hébergées, réinitialisation, éléments communs.
|
||||||
|
*
|
||||||
|
* Aucune requête tierce. Les `@font-face` pointent vers `static/polices/`,
|
||||||
|
* copiées depuis npm par `npm run polices`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Polices ─────────────────────────────────────────────────────────────── */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Archivo';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/polices/archivo-latin-400-normal.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+2000-206F, U+20AC;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Archivo';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/polices/archivo-latin-600-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Archivo';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/polices/archivo-latin-700-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/polices/inter-latin-400-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/polices/inter-latin-500-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/polices/inter-latin-600-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/polices/inter-latin-700-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Réinitialisation ────────────────────────────────────────────────────── */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--noir-oki);
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
font-weight: 400;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(1.55rem, 3.4vw, 2.3rem);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: clamp(1.2rem, 2.4vw, 1.55rem);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--or-oki);
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 0.18em;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: var(--or-clair);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 3px solid var(--or-oki);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
background: color-mix(in srgb, var(--or-oki) 26%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
padding: 0 0.15em;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
margin: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mise en page ────────────────────────────────────────────────────────── */
|
||||||
|
.contenant {
|
||||||
|
width: min(100% - 2rem, var(--container));
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding-block: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ligne {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grille {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Signature de marque ─────────────────────────────────────────────────── */
|
||||||
|
.flag-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
#0d0d0d 0 25%,
|
||||||
|
var(--or-oki) 25% 50%,
|
||||||
|
var(--vert-oki) 50% 75%,
|
||||||
|
var(--rouge-oki) 75% 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
:root.light-theme .flag-bar {
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
#000 0 25%,
|
||||||
|
var(--or-oki) 25% 50%,
|
||||||
|
var(--vert-oki) 50% 75%,
|
||||||
|
var(--rouge-oki) 75% 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Carte canonique ─────────────────────────────────────────────────────── */
|
||||||
|
.carte {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-left: 4px solid var(--or-oki);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2);
|
||||||
|
transition:
|
||||||
|
transform var(--dur-tanbou) var(--ease-ka),
|
||||||
|
border-color var(--dur-tanbou) var(--ease-ka),
|
||||||
|
background var(--dur-tanbou) var(--ease-ka);
|
||||||
|
}
|
||||||
|
.carte:hover {
|
||||||
|
background: var(--card-bg-survol);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tag canonique ───────────────────────────────────────────────────────── */
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35em;
|
||||||
|
background: color-mix(in srgb, var(--or-oki) 8%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--or-oki) 55%, transparent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.15em 0.55em;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bouton canonique ────────────────────────────────────────────────────── */
|
||||||
|
.bouton {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 0.55em 1.1em;
|
||||||
|
border: var(--border-btn);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--or-oki);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: color var(--dur-tanbou) var(--ease-syncope);
|
||||||
|
}
|
||||||
|
.bouton::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--or-oki);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition: transform var(--dur-tanbou) var(--ease-syncope);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
.bouton:hover {
|
||||||
|
color: var(--noir-oki);
|
||||||
|
}
|
||||||
|
.bouton:hover::after {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
.bouton > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bouton-discret {
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Accessibilité ───────────────────────────────────────────────────────── */
|
||||||
|
.lien-evitement {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: 0;
|
||||||
|
background: var(--or-oki);
|
||||||
|
color: var(--noir-oki);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
font-weight: 700;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.lien-evitement:focus {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visuellement-cache {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Une seule garde, au niveau global : sans mouvement, tout reste lisible. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
.carte:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Impression : la veille se transmet aussi sur papier ─────────────────── */
|
||||||
|
@media print {
|
||||||
|
:root {
|
||||||
|
--noir-oki: #fff;
|
||||||
|
--blanc-creme: #000;
|
||||||
|
--card-bg: transparent;
|
||||||
|
--line: #999;
|
||||||
|
--muted: #444;
|
||||||
|
}
|
||||||
|
.entete-site,
|
||||||
|
.pied-site,
|
||||||
|
.panneau-facettes,
|
||||||
|
.bouton {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
a[href^='http']::after {
|
||||||
|
content: ' (' attr(href) ')';
|
||||||
|
font-size: 0.75em;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Tokens OKI — Organisation KA Internationale.
|
||||||
|
*
|
||||||
|
* Valeurs mesurées sur o-k-i.net, reprises telles quelles de la charte. Le
|
||||||
|
* thème sombre est l'identité par défaut ; le clair est une variante opt-in qui
|
||||||
|
* assombrit les accents pour rester au contraste AA.
|
||||||
|
*
|
||||||
|
* Trois règles ne se négocient pas :
|
||||||
|
* · l'or porte toute l'interaction — c'est la seule couleur d'action ;
|
||||||
|
* · rouge = signaler, vert = valider, or = agir : jamais permuter ;
|
||||||
|
* · angles nets partout, 3 px sur les petits éléments, 6 px sur les cartes.
|
||||||
|
*
|
||||||
|
* Usage propre à cette application : c'est un outil de veille, pas une vitrine.
|
||||||
|
* La densité informationnelle prime, le motion est réduit au strict minimum, et
|
||||||
|
* la flag-bar n'apparaît qu'une fois par écran.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ── Noyau ── */
|
||||||
|
--noir-oki: #0d0d0d;
|
||||||
|
--noir-profond: #1a0f1a;
|
||||||
|
--blanc-creme: #fff8e7;
|
||||||
|
--line: rgba(255, 255, 255, 0.1);
|
||||||
|
--or-oki: #fdb813;
|
||||||
|
--rouge-oki: #ff1654;
|
||||||
|
|
||||||
|
/* ── Étendue, avec parcimonie ── */
|
||||||
|
--vert-oki: #00d66c;
|
||||||
|
--turquoise-caraibes: #00ced1;
|
||||||
|
--jaune-soleil: #ffd700;
|
||||||
|
--orange-flamme: #ff6b35;
|
||||||
|
--violet-nuit: #6b2d5c;
|
||||||
|
--bleu-ocean: #0077be;
|
||||||
|
--or-clair: #ffe066;
|
||||||
|
--gris-sombre: #2d1b2e;
|
||||||
|
|
||||||
|
/* ── Sémantique dérivée ── */
|
||||||
|
--muted: color-mix(in srgb, var(--blanc-creme) 70%, transparent);
|
||||||
|
--muted-fort: color-mix(in srgb, var(--blanc-creme) 55%, transparent);
|
||||||
|
--card-bg: rgba(255, 255, 255, 0.03);
|
||||||
|
--card-bg-survol: rgba(255, 255, 255, 0.06);
|
||||||
|
|
||||||
|
/* ── Typographie ── */
|
||||||
|
--font-display: 'Archivo', 'Arial Black', sans-serif;
|
||||||
|
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: ui-monospace, 'SFMono-Regular', 'Menlo', monospace;
|
||||||
|
|
||||||
|
/* ── Formes et espacements ── */
|
||||||
|
--radius-sm: 3px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--border-card: 1px solid var(--line);
|
||||||
|
--border-btn: 2px solid var(--or-oki);
|
||||||
|
--space-1: 0.75rem;
|
||||||
|
--space-2: 1rem;
|
||||||
|
--space-3: 1.5rem;
|
||||||
|
--space-4: 2rem;
|
||||||
|
--space-5: 4rem;
|
||||||
|
--container: 1200px;
|
||||||
|
|
||||||
|
/* ── Motion : les cadences gwoka ── */
|
||||||
|
--ease-ka: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
--ease-syncope: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
--dur-tanbou: 120ms;
|
||||||
|
--dur-mesure: 480ms;
|
||||||
|
--dur-phrase: 960ms;
|
||||||
|
|
||||||
|
/* ── Sémantique métier ──
|
||||||
|
* Les statuts sont d'abord distingués par leur libellé et leur forme, la
|
||||||
|
* couleur ne fait que renforcer : un daltonien doit pouvoir lire la page.
|
||||||
|
*/
|
||||||
|
--statut-promulguee: var(--vert-oki);
|
||||||
|
--statut-attente: var(--or-oki);
|
||||||
|
--statut-cc: var(--orange-flamme);
|
||||||
|
--statut-navette: var(--turquoise-caraibes);
|
||||||
|
--statut-projet: var(--muted-fort);
|
||||||
|
--statut-rejetee: var(--rouge-oki);
|
||||||
|
|
||||||
|
--impact-positif: var(--vert-oki);
|
||||||
|
--impact-negatif: var(--rouge-oki);
|
||||||
|
--impact-mixte: var(--jaune-soleil);
|
||||||
|
--impact-neutre: var(--muted-fort);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thème clair — opt-in, accents assombris pour le contraste AA. */
|
||||||
|
:root.light-theme {
|
||||||
|
--noir-oki: #fff8e7;
|
||||||
|
--blanc-creme: #0d0d0d;
|
||||||
|
--noir-profond: #f5f0e8;
|
||||||
|
--gris-sombre: #e8ddd0;
|
||||||
|
--or-oki: #8f5c00;
|
||||||
|
--vert-oki: #006b3d;
|
||||||
|
--rouge-oki: #a01030;
|
||||||
|
--turquoise-caraibes: #006b75;
|
||||||
|
--bleu-ocean: #004b7f;
|
||||||
|
--or-clair: #a86e00;
|
||||||
|
--jaune-soleil: #7a5300;
|
||||||
|
--orange-flamme: #b34700;
|
||||||
|
--violet-nuit: #4a1f40;
|
||||||
|
--line: rgba(0, 0, 0, 0.14);
|
||||||
|
--card-bg: rgba(0, 0, 0, 0.025);
|
||||||
|
--card-bg-survol: rgba(0, 0, 0, 0.05);
|
||||||
|
--muted: color-mix(in srgb, var(--blanc-creme) 72%, transparent);
|
||||||
|
--muted-fort: color-mix(in srgb, var(--blanc-creme) 58%, transparent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/** Vocabulaire du domaine, aligné sur le schéma SQLite et les modèles pydantic. */
|
||||||
|
|
||||||
|
export type Statut =
|
||||||
|
| 'promulguee'
|
||||||
|
| 'adoptee_non_promulguee'
|
||||||
|
| 'saisie_cc'
|
||||||
|
| 'validee_cc'
|
||||||
|
| 'censuree_partiellement'
|
||||||
|
| 'navette'
|
||||||
|
| 'deposee_non_examinee'
|
||||||
|
| 'annoncee'
|
||||||
|
| 'rejetee';
|
||||||
|
|
||||||
|
export type TypeTexte =
|
||||||
|
| 'loi'
|
||||||
|
| 'loi_organique'
|
||||||
|
| 'pjl'
|
||||||
|
| 'ppl'
|
||||||
|
| 'ordonnance'
|
||||||
|
| 'decision_cc'
|
||||||
|
| 'decret'
|
||||||
|
| 'accord_international';
|
||||||
|
|
||||||
|
export type Theme =
|
||||||
|
| 'justice'
|
||||||
|
| 'securite'
|
||||||
|
| 'numerique'
|
||||||
|
| 'social'
|
||||||
|
| 'fiscal'
|
||||||
|
| 'environnement'
|
||||||
|
| 'agriculture'
|
||||||
|
| 'sante'
|
||||||
|
| 'memoire_patrimoine'
|
||||||
|
| 'outre_mer'
|
||||||
|
| 'economie'
|
||||||
|
| 'migration'
|
||||||
|
| 'institutions';
|
||||||
|
|
||||||
|
export type Public = 'individus' | 'associations' | 'entreprises';
|
||||||
|
export type SensImpact = 'positif' | 'negatif' | 'mixte' | 'neutre';
|
||||||
|
export type Pertinence = 'forte' | 'moyenne' | 'faible';
|
||||||
|
export type Confiance = 'high' | 'medium' | 'low';
|
||||||
|
export type RegimeLibertes = 'droits_plus' | 'controle_plus' | 'mixte' | 'neutre';
|
||||||
|
|
||||||
|
export interface Impact {
|
||||||
|
sens: SensImpact;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Source {
|
||||||
|
url: string;
|
||||||
|
titre: string | null;
|
||||||
|
editeur: string | null;
|
||||||
|
date_publication: string | null;
|
||||||
|
tier: 'T1' | 'T2';
|
||||||
|
extrait_verbatim: string | null;
|
||||||
|
contexte: string | null;
|
||||||
|
confiance: Confiance | null;
|
||||||
|
marqueur: string | null;
|
||||||
|
fichier_origine: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Evenement {
|
||||||
|
date_evenement: string;
|
||||||
|
type_etape: string;
|
||||||
|
description: string;
|
||||||
|
source_url: string | null;
|
||||||
|
previsionnel: 0 | 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecisionCC {
|
||||||
|
numero_affaire: string;
|
||||||
|
date_saisine: string | null;
|
||||||
|
date_decision: string | null;
|
||||||
|
resultat: string | null;
|
||||||
|
saisissants: string | null;
|
||||||
|
resume: string | null;
|
||||||
|
url: string | null;
|
||||||
|
date_decision_attendue: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Texte {
|
||||||
|
id: string;
|
||||||
|
numero_officiel: string | null;
|
||||||
|
type: TypeTexte;
|
||||||
|
titre_court: string;
|
||||||
|
titre_officiel: string | null;
|
||||||
|
statut: Statut;
|
||||||
|
statut_date: string | null;
|
||||||
|
date_depot: string | null;
|
||||||
|
date_adoption: string | null;
|
||||||
|
date_promulgation: string | null;
|
||||||
|
date_entree_vigueur: string | null;
|
||||||
|
prochaine_echeance: string | null;
|
||||||
|
prochaine_echeance_label: string | null;
|
||||||
|
themes: Theme[];
|
||||||
|
impacts: Partial<Record<Public, Impact>>;
|
||||||
|
regime_libertes: RegimeLibertes | null;
|
||||||
|
regime_libertes_note: string | null;
|
||||||
|
guadeloupe_pertinence: Pertinence | null;
|
||||||
|
guadeloupe_note: string | null;
|
||||||
|
resume: string | null;
|
||||||
|
points_cles: string[];
|
||||||
|
confiance: Confiance;
|
||||||
|
source_seed: string | null;
|
||||||
|
a_verifier: 0 | 1;
|
||||||
|
motif_verification: string | null;
|
||||||
|
derniere_verif: string | null;
|
||||||
|
nb_sources: number;
|
||||||
|
nb_evenements: number;
|
||||||
|
devant_cc: 0 | 1;
|
||||||
|
/** Extrait surligné, présent uniquement sur les résultats de recherche. */
|
||||||
|
extrait?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TexteDetaille extends Texte {
|
||||||
|
sources: Source[];
|
||||||
|
evenements: Evenement[];
|
||||||
|
decisions_cc: DecisionCC[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Echeance {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
date_echeance: string;
|
||||||
|
libelle: string;
|
||||||
|
description: string | null;
|
||||||
|
portee: 'nationale' | 'texte';
|
||||||
|
texte_id: string | null;
|
||||||
|
concerne_guadeloupe: 0 | 1 | null;
|
||||||
|
note_guadeloupe: string | null;
|
||||||
|
previsionnel: 0 | 1;
|
||||||
|
source_url: string | null;
|
||||||
|
source_extrait: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Insight {
|
||||||
|
numero: number;
|
||||||
|
titre: string;
|
||||||
|
corps: string;
|
||||||
|
implications: string | null;
|
||||||
|
confiance: string;
|
||||||
|
derive_de: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Facette {
|
||||||
|
valeur: string;
|
||||||
|
libelle: string;
|
||||||
|
nombre: number;
|
||||||
|
actif: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Filtres {
|
||||||
|
q: string;
|
||||||
|
statut: string[];
|
||||||
|
theme: string[];
|
||||||
|
type: string[];
|
||||||
|
impact: string[];
|
||||||
|
guadeloupe: string[];
|
||||||
|
confiance: string[];
|
||||||
|
from: string | null;
|
||||||
|
to: string | null;
|
||||||
|
tri: 'date' | 'pertinence';
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Resultats {
|
||||||
|
textes: Texte[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
facettes: {
|
||||||
|
statut: Facette[];
|
||||||
|
theme: Facette[];
|
||||||
|
type: Facette[];
|
||||||
|
guadeloupe: Facette[];
|
||||||
|
impact: Facette[];
|
||||||
|
confiance: Facette[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{page.status} · Veille législative Gwadloup</title>
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="erreur">
|
||||||
|
<p class="code">{page.status}</p>
|
||||||
|
{#if page.status === 404}
|
||||||
|
<h1>Pa ni ayen la</h1>
|
||||||
|
<p class="explication">
|
||||||
|
Cette page n'existe pas — ou n'existe plus. Le texte que vous cherchez a peut-être un
|
||||||
|
autre identifiant.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<h1>Sa pa maché</h1>
|
||||||
|
<p class="explication">{page.error?.message ?? 'Une erreur est survenue.'}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="ligne actions">
|
||||||
|
<a class="bouton" href="/"><span>Tableau de bord</span></a>
|
||||||
|
<a class="bouton bouton-discret" href="/recherche"><span>Rechercher un texte</span></a>
|
||||||
|
</div>
|
||||||
|
<div class="flag-bar" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.erreur {
|
||||||
|
max-width: 48ch;
|
||||||
|
margin: var(--space-5) auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--or-oki);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
.explication {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
justify-content: center;
|
||||||
|
margin-block: var(--space-3);
|
||||||
|
}
|
||||||
|
.flag-bar {
|
||||||
|
max-width: 56px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Le rendu serveur est le défaut sur toute l'application.
|
||||||
|
*
|
||||||
|
* On ne pose JAMAIS `ssr = false` ici : le mode SPA est explicitement écarté
|
||||||
|
* par l'architecture de rendu (§3bis), pour le référencement comme pour la
|
||||||
|
* lisibilité sans JavaScript. Le prérendu est activé page par page, uniquement
|
||||||
|
* sur celles dont le contenu est figé (`/a-propos`, `/methode`).
|
||||||
|
*/
|
||||||
|
export const prerender = false;
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '$lib/styles/oki-tokens.css';
|
||||||
|
import '$lib/styles/base.css';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
const { children } = $props();
|
||||||
|
|
||||||
|
const NAVIGATION = [
|
||||||
|
{ href: '/', libelle: 'Tableau de bord' },
|
||||||
|
{ href: '/recherche', libelle: 'Recherche' },
|
||||||
|
{ href: '/calendrier', libelle: 'Calendrier' },
|
||||||
|
{ href: '/methode', libelle: 'Méthode' },
|
||||||
|
{ href: '/a-propos', libelle: 'À propos' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const actif = (href: string) =>
|
||||||
|
href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a class="lien-evitement" href="#contenu">Aller au contenu</a>
|
||||||
|
|
||||||
|
<header class="entete-site">
|
||||||
|
<div class="contenant barre">
|
||||||
|
<a class="marque" href="/">
|
||||||
|
<span class="monogramme" aria-hidden="true">VL</span>
|
||||||
|
<span class="titre">
|
||||||
|
<strong>Veille législative</strong>
|
||||||
|
<small>Gwadloup · libertés, associations, entreprises</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav aria-label="Navigation principale">
|
||||||
|
<ul>
|
||||||
|
{#each NAVIGATION as entree (entree.href)}
|
||||||
|
<li>
|
||||||
|
<a href={entree.href} aria-current={actif(entree.href) ? 'page' : undefined}>
|
||||||
|
{entree.libelle}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bascule-theme"
|
||||||
|
aria-label="Basculer entre thème sombre et thème clair"
|
||||||
|
onclick={() => {
|
||||||
|
const racine = document.documentElement;
|
||||||
|
const clair = racine.classList.toggle('light-theme');
|
||||||
|
try {
|
||||||
|
localStorage.setItem('theme', clair ? 'clair' : 'sombre');
|
||||||
|
} catch {
|
||||||
|
/* stockage indisponible : le choix ne survivra pas au rechargement */
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">◐</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Signature de marque : une seule occurrence pleine largeur par écran. -->
|
||||||
|
<div class="flag-bar"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="contenu" class="contenant">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="pied-site">
|
||||||
|
<div class="contenant">
|
||||||
|
<p class="doctrine">
|
||||||
|
Aucune donnée législative n'est inventée. Chaque fait porte sa source, sa date et son
|
||||||
|
niveau de confiance. Ce qui n'est pas établi est marqué « à vérifier ».
|
||||||
|
</p>
|
||||||
|
<p class="federe">
|
||||||
|
<span class="monogramme petit" aria-hidden="true">OKI</span>
|
||||||
|
Un service libre opéré par
|
||||||
|
<a href="https://o-k-i.net" rel="noopener">ORGANISATION KA INTERNATIONALE</a>
|
||||||
|
· o-k-i.net
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.entete-site {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--noir-oki);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barre {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-block: 0.7rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marque {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monogramme {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--noir-oki);
|
||||||
|
background: var(--or-oki);
|
||||||
|
padding: 0.28em 0.45em;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.monogramme.petit {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2em 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titre {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.titre strong {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.titre small {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.15rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
display: block;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
nav a:hover {
|
||||||
|
color: var(--or-oki);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
nav a[aria-current='page'] {
|
||||||
|
color: var(--or-oki);
|
||||||
|
border-color: color-mix(in srgb, var(--or-oki) 55%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--or-oki) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bascule-theme {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.35rem 0.55rem;
|
||||||
|
}
|
||||||
|
.bascule-theme:hover {
|
||||||
|
color: var(--or-oki);
|
||||||
|
border-color: var(--or-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: 60vh;
|
||||||
|
padding-block: var(--space-3) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pied-site {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-block: var(--space-3);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
.doctrine {
|
||||||
|
max-width: 62ch;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.federe {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.marque {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
nav ul {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
compteurs,
|
||||||
|
derniersChangements,
|
||||||
|
dernierRun,
|
||||||
|
decisionsAttendues,
|
||||||
|
insights,
|
||||||
|
prochainesEcheances
|
||||||
|
} from '$serveur/requetes';
|
||||||
|
import { DATE_ARRETE_CORPUS } from '$serveur/db';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tableau de bord — rendu serveur, sans prérendu.
|
||||||
|
*
|
||||||
|
* Les compteurs et les derniers changements doivent refléter le dernier
|
||||||
|
* passage du pipeline sans qu'on ait à reconstruire le site.
|
||||||
|
*/
|
||||||
|
export const load: PageServerLoad = async () => ({
|
||||||
|
compteurs: compteurs(),
|
||||||
|
echeances: prochainesEcheances(45),
|
||||||
|
decisionsAttendues: decisionsAttendues(),
|
||||||
|
changements: derniersChangements(8),
|
||||||
|
run: dernierRun(),
|
||||||
|
insights: insights(),
|
||||||
|
dateArrete: DATE_ARRETE_CORPUS
|
||||||
|
});
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BadgeStatut from '$composants/BadgeStatut.svelte';
|
||||||
|
import { dateLongue, joursRestants } from '$lib/format';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
const { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
// L'encart « point de veille » tourne sur les sept analyses transversales.
|
||||||
|
let analyseCourante = $state(0);
|
||||||
|
const analyse = $derived(data.insights[analyseCourante]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Veille législative Gwadloup — tableau de bord</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Suivi des lois françaises et de leurs effets sur les libertés des individus, des associations et des entreprises, avec un zoom Guadeloupe."
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<h1>Ki lwa ka chanjé lavi an nou ?</h1>
|
||||||
|
<p class="accroche">
|
||||||
|
{data.compteurs.total} textes suivis, {data.compteurs.sources} sources vérifiables.
|
||||||
|
Données arrêtées au {dateLongue(data.dateArrete)}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="recherche" action="/recherche" method="get" role="search">
|
||||||
|
<label class="visuellement-cache" for="q">Rechercher dans les textes suivis</label>
|
||||||
|
<input
|
||||||
|
id="q"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
placeholder="chlordécone, associations, facturation électronique…"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button class="bouton" type="submit"><span>Rechercher</span></button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>Où en sont les textes</h2>
|
||||||
|
<div class="compteurs">
|
||||||
|
{#each data.compteurs.parStatut as ligne (ligne.valeur)}
|
||||||
|
<a class="compteur" href="/recherche?statut={ligne.valeur}">
|
||||||
|
<strong>{ligne.nombre}</strong>
|
||||||
|
<span>{ligne.libelle}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ligne raccourcis">
|
||||||
|
<a class="bouton bouton-discret" href="/recherche?statut=saisie_cc">
|
||||||
|
<span>{data.compteurs.devantCc} devant le Conseil constitutionnel</span>
|
||||||
|
</a>
|
||||||
|
<a class="bouton bouton-discret" href="/recherche?guadeloupe=forte">
|
||||||
|
<span>{data.compteurs.guadeloupeForte} de pertinence forte pour la Guadeloupe</span>
|
||||||
|
</a>
|
||||||
|
<a class="bouton bouton-discret" href="/recherche?confiance=low">
|
||||||
|
<span>{data.compteurs.aVerifier} à vérifier</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="deux-colonnes">
|
||||||
|
<section class="section">
|
||||||
|
<h2>Prochaines échéances</h2>
|
||||||
|
{#if data.echeances.length}
|
||||||
|
<ol class="echeances">
|
||||||
|
{#each data.echeances as echeance (echeance.code)}
|
||||||
|
{@const jours = joursRestants(echeance.date_echeance, data.dateArrete)}
|
||||||
|
<li class="carte echeance" class:gwada={echeance.concerne_guadeloupe === 1}>
|
||||||
|
<div class="quand">
|
||||||
|
<time datetime={echeance.date_echeance}>{dateLongue(echeance.date_echeance)}</time>
|
||||||
|
{#if jours >= 0}<span class="dans">dans {jours} j</span>{/if}
|
||||||
|
</div>
|
||||||
|
<h3>
|
||||||
|
{#if echeance.texte_id}
|
||||||
|
<a href="/textes/{echeance.texte_id}">{echeance.libelle}</a>
|
||||||
|
{:else}
|
||||||
|
{echeance.libelle}
|
||||||
|
{/if}
|
||||||
|
</h3>
|
||||||
|
{#if echeance.description}<p>{echeance.description}</p>{/if}
|
||||||
|
{#if echeance.concerne_guadeloupe === 0}
|
||||||
|
<p class="hors-gwada">Ne concerne pas la Guadeloupe.</p>
|
||||||
|
{/if}
|
||||||
|
{#if echeance.note_guadeloupe}
|
||||||
|
<p class="note-gwada">{echeance.note_guadeloupe}</p>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
<p><a href="/calendrier">Voir tout le calendrier →</a></p>
|
||||||
|
{:else}
|
||||||
|
<p class="vide">Aucune échéance dans les 45 prochains jours.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="colonne-secondaire">
|
||||||
|
<section class="section">
|
||||||
|
<h2>Point de veille</h2>
|
||||||
|
{#if analyse}
|
||||||
|
<article class="carte analyse">
|
||||||
|
<h3>{analyse.titre}</h3>
|
||||||
|
<p>{analyse.corps.slice(0, 460)}{analyse.corps.length > 460 ? '…' : ''}</p>
|
||||||
|
{#if analyse.implications}
|
||||||
|
<p class="implications"><strong>Implications —</strong> {analyse.implications}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="ligne navigation-analyse">
|
||||||
|
<span class="position">{analyseCourante + 1} / {data.insights.length}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bouton bouton-discret"
|
||||||
|
onclick={() =>
|
||||||
|
(analyseCourante = (analyseCourante + 1) % data.insights.length)}
|
||||||
|
>
|
||||||
|
<span>Analyse suivante</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>Décisions attendues</h2>
|
||||||
|
<ul class="decisions">
|
||||||
|
{#each data.decisionsAttendues as decision (decision.numero_affaire)}
|
||||||
|
<li>
|
||||||
|
<span class="affaire">{decision.numero_affaire}</span>
|
||||||
|
{#if decision.texte_id}
|
||||||
|
<a href="/textes/{decision.texte_id}">{decision.libelle}</a>
|
||||||
|
{:else}
|
||||||
|
<span>{decision.libelle}</span>
|
||||||
|
{/if}
|
||||||
|
{#if decision.date_echeance}
|
||||||
|
<span class="quand-decision">→ {dateLongue(decision.date_echeance)}</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>Derniers changements détectés</h2>
|
||||||
|
{#if data.changements.length}
|
||||||
|
<ul class="changements">
|
||||||
|
{#each data.changements as changement, i (i)}
|
||||||
|
<li>
|
||||||
|
{#if changement.texte_id}
|
||||||
|
<a href="/textes/{changement.texte_id}">{changement.titre ?? changement.texte_id}</a>
|
||||||
|
{:else}
|
||||||
|
<span>{changement.description}</span>
|
||||||
|
{/if}
|
||||||
|
{#if changement.champ}
|
||||||
|
<span class="diff">
|
||||||
|
{changement.champ} : {changement.ancienne_valeur ?? '—'} →
|
||||||
|
<strong>{changement.nouvelle_valeur}</strong>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="vide">
|
||||||
|
Aucun changement enregistré. Le pipeline n'a pas encore tourné en mode écriture —
|
||||||
|
lancer <code>make update</code>.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.run}
|
||||||
|
<p class="run">
|
||||||
|
Dernier passage : {data.run.horodatage} ({data.run.mode}),
|
||||||
|
{data.run.ajouts} ajout(s), {data.run.modifications} modification(s).
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hero {
|
||||||
|
padding-block: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.accroche {
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 60ch;
|
||||||
|
}
|
||||||
|
.recherche {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 720px;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
.recherche input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
}
|
||||||
|
.recherche input:focus {
|
||||||
|
border-color: var(--or-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compteurs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.compteur {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-left: 4px solid var(--or-oki);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.compteur:hover {
|
||||||
|
background: var(--card-bg-survol);
|
||||||
|
}
|
||||||
|
.compteur strong {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--or-oki);
|
||||||
|
}
|
||||||
|
.compteur span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.raccourcis {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deux-colonnes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.deux-colonnes {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.echeances {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.echeance {
|
||||||
|
border-left-color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
.echeance.gwada {
|
||||||
|
border-left-color: var(--turquoise-caraibes);
|
||||||
|
}
|
||||||
|
.echeance h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-transform: none;
|
||||||
|
margin: 0.2rem 0 0.3rem;
|
||||||
|
}
|
||||||
|
.echeance p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
}
|
||||||
|
.quand {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.quand time {
|
||||||
|
color: var(--or-oki);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.dans {
|
||||||
|
color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
.hors-gwada {
|
||||||
|
color: var(--rouge-oki) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.note-gwada {
|
||||||
|
color: var(--turquoise-caraibes) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analyse h3 {
|
||||||
|
text-transform: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.analyse p {
|
||||||
|
font-size: 0.87rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.implications {
|
||||||
|
border-left: 2px solid var(--or-oki);
|
||||||
|
padding-left: 0.7rem;
|
||||||
|
}
|
||||||
|
.navigation-analyse {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.position {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decisions,
|
||||||
|
.changements {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.decisions li,
|
||||||
|
.changements li {
|
||||||
|
padding-bottom: 0.45rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.affaire {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--orange-flamme);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.quand-decision,
|
||||||
|
.diff {
|
||||||
|
color: var(--muted-fort);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.vide,
|
||||||
|
.run {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: var(--card-bg);
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<svelte:head>
|
||||||
|
<title>À propos · Veille législative Gwadloup</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Qui édite cette veille législative, avec quels moyens, sous quelle licence, et comment la reprendre."
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>À propos</h1>
|
||||||
|
|
||||||
|
<div class="prose">
|
||||||
|
<p class="chapeau">
|
||||||
|
Cette plateforme suit la production législative française et ce qu'elle change aux libertés
|
||||||
|
des individus, des associations et des entreprises — avec un zoom Guadeloupe et outre-mer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Pourquoi</h2>
|
||||||
|
<p>
|
||||||
|
Les textes qui restreignent ou étendent des libertés se lisent rarement dans un seul journal.
|
||||||
|
Ils arrivent par morceaux : un article dans une loi de simplification, un cavalier censuré
|
||||||
|
dans une loi de finances, un décret d'application six mois plus tard. Suivre cela demande du
|
||||||
|
temps que peu d'associations et de petites structures ont.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Cette veille rassemble ce travail au même endroit, avec les sources en clair, et signale
|
||||||
|
explicitement ce qui n'est pas établi.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Ce que la plateforme ne fait pas</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Elle ne donne pas de conseil juridique. Un texte cité ici ne remplace pas un avocat.</li>
|
||||||
|
<li>
|
||||||
|
Elle n'invente rien. Quand une information manque, elle manque : la fiche le dit, plutôt
|
||||||
|
que de combler le vide.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Elle ne suit pas votre navigation. Aucun traceur, aucun cookie de mesure, aucune requête
|
||||||
|
vers un service tiers — polices comprises.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Technique</h2>
|
||||||
|
<p>
|
||||||
|
Tout est libre et auto-hébergeable sur un serveur modeste : un pipeline Python qui lit les
|
||||||
|
sources officielles, une base SQLite unique, une application SvelteKit rendue côté serveur.
|
||||||
|
La recherche plein texte est assurée par SQLite lui-même. Aucun service externe n'est
|
||||||
|
nécessaire au fonctionnement.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Le code est sous licence AGPL. La sauvegarde consiste à copier un fichier.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Reprendre les données</h2>
|
||||||
|
<p>
|
||||||
|
Une interface de programmation en lecture est ouverte, sans clé ni inscription :
|
||||||
|
</p>
|
||||||
|
<ul class="api">
|
||||||
|
<li><code>GET /api/textes</code> — liste filtrable</li>
|
||||||
|
<li><code>GET /api/textes/[identifiant]</code> — fiche complète avec sources</li>
|
||||||
|
<li><code>GET /api/echeances</code> — calendrier</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Si vous réutilisez ces données, citez la source primaire plutôt que cette plateforme : elle
|
||||||
|
n'est qu'un intermédiaire.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>
|
||||||
|
Une erreur, un texte manquant, une source cassée ? Signalez-le — une veille se corrige à
|
||||||
|
plusieurs. <a href="mailto:cyber.mawonaj@gmail.com">cyber.mawonaj@gmail.com</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="lien-methode">
|
||||||
|
<a class="bouton" href="/methode"><span>Lire la méthode en détail</span></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.prose {
|
||||||
|
max-width: 68ch;
|
||||||
|
}
|
||||||
|
.chapeau {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
}
|
||||||
|
.prose h2 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--or-oki);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
.prose p,
|
||||||
|
.prose li {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.prose ul {
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.prose li::marker {
|
||||||
|
color: var(--or-oki);
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.88em;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.api {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.lien-methode {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Page figée : aucun contenu dynamique, donc exclue du manifeste du rendu
|
||||||
|
* serveur et servie en statique (§3bis de l'architecture de rendu).
|
||||||
|
*/
|
||||||
|
export const prerender = true;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { calendrier, decisionsAttendues, prochainesEcheances } from '$serveur/requetes';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/echeances` — calendrier.
|
||||||
|
*
|
||||||
|
* `?jours=N` restreint aux N prochains jours ; sans paramètre, tout le
|
||||||
|
* calendrier est rendu, décisions attendues comprises.
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const jours = Number.parseInt(url.searchParams.get('jours') ?? '', 10);
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
echeances: Number.isFinite(jours) && jours > 0 ? prochainesEcheances(jours) : calendrier(),
|
||||||
|
decisions_attendues: decisionsAttendues()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'cache-control': 'public, max-age=300',
|
||||||
|
'access-control-allow-origin': '*'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { lireFiltres, rechercher } from '$serveur/requetes';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/textes` — liste filtrable.
|
||||||
|
*
|
||||||
|
* Mêmes paramètres que la page de recherche : `q`, `statut`, `theme`, `type`,
|
||||||
|
* `impact`, `guadeloupe`, `confiance`, `from`, `to`, `tri`, `page`. Les
|
||||||
|
* paramètres multivalués acceptent la répétition (`?theme=sante&theme=social`)
|
||||||
|
* comme la virgule (`?theme=sante,social`).
|
||||||
|
*
|
||||||
|
* Ouverte sans clé : ces données sont publiques et proviennent de sources
|
||||||
|
* publiques. Le cache est court — le pipeline peut écrire à tout moment.
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const filtres = lireFiltres(url.searchParams);
|
||||||
|
const resultats = rechercher(filtres);
|
||||||
|
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
filtres,
|
||||||
|
total: resultats.total,
|
||||||
|
page: resultats.page,
|
||||||
|
pages: resultats.pages,
|
||||||
|
textes: resultats.textes,
|
||||||
|
facettes: resultats.facettes
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'cache-control': 'public, max-age=300',
|
||||||
|
'access-control-allow-origin': '*'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { texte } from '$serveur/requetes';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/** `GET /api/textes/[slug]` — fiche complète, sources et timeline comprises. */
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const fiche = texte(params.slug);
|
||||||
|
if (!fiche) error(404, { message: `Texte inconnu : ${params.slug}` });
|
||||||
|
|
||||||
|
return json(fiche, {
|
||||||
|
headers: {
|
||||||
|
'cache-control': 'public, max-age=300',
|
||||||
|
'access-control-allow-origin': '*'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { calendrier, decisionsAttendues } from '$serveur/requetes';
|
||||||
|
import { DATE_ARRETE_CORPUS } from '$serveur/db';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
/** Calendrier — rendu serveur : les échéances vivent avec la base. */
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const echeances = calendrier();
|
||||||
|
|
||||||
|
// Regroupement par mois, dans l'ordre chronologique.
|
||||||
|
const parMois = new Map<string, typeof echeances>();
|
||||||
|
for (const echeance of echeances) {
|
||||||
|
const cle = echeance.date_echeance.slice(0, 7);
|
||||||
|
if (!parMois.has(cle)) parMois.set(cle, []);
|
||||||
|
parMois.get(cle)!.push(echeance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mois: [...parMois.entries()].map(([cle, liste]) => ({ cle, echeances: liste })),
|
||||||
|
decisions: decisionsAttendues(),
|
||||||
|
dateArrete: DATE_ARRETE_CORPUS
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { dateLongue, joursRestants, mois as libelleMois } from '$lib/format';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
const { data }: { data: PageData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Calendrier des échéances · Veille législative Gwadloup</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Décisions attendues du Conseil constitutionnel, entrées en vigueur du 1er septembre 2026, sénatoriales du 27 septembre, projet de loi de finances 2027."
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>Calendrier</h1>
|
||||||
|
<p class="intro">
|
||||||
|
Échéances de la fenêtre de veille. Les dates postérieures au
|
||||||
|
{dateLongue(data.dateArrete)} sont <strong>prévisionnelles</strong> : elles indiquent ce qui est
|
||||||
|
attendu, jamais ce qui est acquis.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if data.decisions.length}
|
||||||
|
<section class="section">
|
||||||
|
<h2>Décisions attendues du Conseil constitutionnel</h2>
|
||||||
|
<ul class="decisions">
|
||||||
|
{#each data.decisions as decision (decision.numero_affaire)}
|
||||||
|
<li class="carte">
|
||||||
|
<span class="affaire">{decision.numero_affaire}</span>
|
||||||
|
<div>
|
||||||
|
{#if decision.texte_id}
|
||||||
|
<a href="/textes/{decision.texte_id}">{decision.libelle}</a>
|
||||||
|
{:else}
|
||||||
|
<span>{decision.libelle}</span>
|
||||||
|
{/if}
|
||||||
|
{#if decision.date_echeance}
|
||||||
|
<p class="quand">Attendue vers le {dateLongue(decision.date_echeance)}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each data.mois as bloc (bloc.cle)}
|
||||||
|
<section class="section mois">
|
||||||
|
<h2>{libelleMois(bloc.cle + '-01')}</h2>
|
||||||
|
<ol class="liste">
|
||||||
|
{#each bloc.echeances as echeance (echeance.code)}
|
||||||
|
{@const jours = joursRestants(echeance.date_echeance, data.dateArrete)}
|
||||||
|
<li
|
||||||
|
class="carte entree"
|
||||||
|
class:gwada={echeance.concerne_guadeloupe === 1}
|
||||||
|
class:hors-gwada={echeance.concerne_guadeloupe === 0}
|
||||||
|
class:passe={jours < 0}
|
||||||
|
>
|
||||||
|
<div class="date">
|
||||||
|
<time datetime={echeance.date_echeance}>{dateLongue(echeance.date_echeance)}</time>
|
||||||
|
{#if echeance.previsionnel === 1}
|
||||||
|
<span class="prevu">prévisionnel</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="contenu">
|
||||||
|
<h3>
|
||||||
|
{#if echeance.texte_id}
|
||||||
|
<a href="/textes/{echeance.texte_id}">{echeance.libelle}</a>
|
||||||
|
{:else}
|
||||||
|
{echeance.libelle}
|
||||||
|
{/if}
|
||||||
|
</h3>
|
||||||
|
{#if echeance.description}<p>{echeance.description}</p>{/if}
|
||||||
|
{#if echeance.concerne_guadeloupe === 0}
|
||||||
|
<p class="mention-gwada negative">Ne concerne pas la Guadeloupe</p>
|
||||||
|
{:else if echeance.concerne_guadeloupe === 1}
|
||||||
|
<p class="mention-gwada positive">Concerne la Guadeloupe</p>
|
||||||
|
{/if}
|
||||||
|
{#if echeance.note_guadeloupe}<p class="note">{echeance.note_guadeloupe}</p>{/if}
|
||||||
|
{#if echeance.source_extrait}
|
||||||
|
<blockquote>{echeance.source_extrait}</blockquote>
|
||||||
|
{/if}
|
||||||
|
{#if echeance.source_url}
|
||||||
|
<a class="source" href={echeance.source_url} rel="noopener nofollow" target="_blank">
|
||||||
|
Source
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.intro {
|
||||||
|
max-width: 70ch;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.mois h2,
|
||||||
|
.section h2 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--or-oki);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.liste,
|
||||||
|
.decisions {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.decisions li {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
align-items: baseline;
|
||||||
|
border-left-color: var(--orange-flamme);
|
||||||
|
}
|
||||||
|
.affaire {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--orange-flamme);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.quand {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entree {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(150px, auto) 1fr;
|
||||||
|
gap: var(--space-2);
|
||||||
|
border-left-color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
.entree.gwada {
|
||||||
|
border-left-color: var(--turquoise-caraibes);
|
||||||
|
}
|
||||||
|
.entree.hors-gwada {
|
||||||
|
border-left-color: var(--rouge-oki);
|
||||||
|
}
|
||||||
|
.entree.passe {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
.date {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
time {
|
||||||
|
color: var(--or-oki);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.prevu {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0 0.35em;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.contenu h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-transform: none;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.contenu p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
}
|
||||||
|
.mention-gwada {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.82rem !important;
|
||||||
|
}
|
||||||
|
.mention-gwada.negative {
|
||||||
|
color: var(--rouge-oki) !important;
|
||||||
|
}
|
||||||
|
.mention-gwada.positive {
|
||||||
|
color: var(--turquoise-caraibes) !important;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
color: var(--turquoise-caraibes) !important;
|
||||||
|
}
|
||||||
|
blockquote {
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
padding-left: 0.8rem;
|
||||||
|
border-left: 2px solid var(--line);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.source {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.entree {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<svelte:head>
|
||||||
|
<title>Méthode · Veille législative Gwadloup</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Comment cette veille distingue promulgué, adopté et en navette ; d'où viennent les données ; ce qu'elles ne disent pas."
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>Méthode</h1>
|
||||||
|
|
||||||
|
<div class="prose">
|
||||||
|
<p class="chapeau">
|
||||||
|
Une veille législative ne vaut que par ce qu'elle refuse d'affirmer. Cette page dit d'où
|
||||||
|
viennent les données, comment elles sont qualifiées, et où sont leurs limites.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>La règle d'or : le statut prime sur l'intitulé</h2>
|
||||||
|
<p>
|
||||||
|
Un texte « voté » n'est pas un texte applicable. Entre le vote et l'entrée en vigueur, il
|
||||||
|
peut être censuré, amputé, ou attendre des décrets pendant des mois. La confusion entre ces
|
||||||
|
états est la première source d'erreur dans le débat public — et elle change tout pour qui
|
||||||
|
veut savoir ce qui s'applique aujourd'hui.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl class="statuts">
|
||||||
|
<dt>Promulguée</dt>
|
||||||
|
<dd>
|
||||||
|
Le texte porte un numéro de loi et a été publié au Journal officiel. Il produit ses
|
||||||
|
effets, sous réserve de ses propres dates d'entrée en vigueur différées.
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Adoptée, non promulguée</dt>
|
||||||
|
<dd>
|
||||||
|
Le Parlement a voté définitivement, mais le texte n'est pas signé. Il n'a pas de numéro.
|
||||||
|
Il ne s'applique pas. Une saisine du Conseil constitutionnel suspend la promulgation
|
||||||
|
pendant un mois.
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Devant le Conseil constitutionnel</dt>
|
||||||
|
<dd>
|
||||||
|
Une affaire est enregistrée et la décision n'est pas rendue. Le texte peut en sortir
|
||||||
|
intact, amputé, ou annulé. Tant que la décision n'est pas rendue, son contenu applicable
|
||||||
|
est <em>inconnu</em>.
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Validée, en attente de promulgation</dt>
|
||||||
|
<dd>
|
||||||
|
Le Conseil a statué favorablement ; il ne manque que la signature. La promulgation est
|
||||||
|
très probable mais pas encore intervenue.
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>En navette</dt>
|
||||||
|
<dd>
|
||||||
|
Le texte fait l'aller-retour entre l'Assemblée et le Sénat. Son contenu bouge encore.
|
||||||
|
Rien de ce qu'il contient n'est acquis.
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Déposée, non examinée · Annoncée</dt>
|
||||||
|
<dd>
|
||||||
|
Le texte existe sur le papier — ou seulement dans une déclaration. Il documente une
|
||||||
|
intention politique, pas le droit positif.
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h2>D'où viennent les données</h2>
|
||||||
|
<p>
|
||||||
|
Le socle est un corpus de recherche arrêté au <strong>25 juillet 2026</strong>, couvrant la
|
||||||
|
production législative du 1<sup>er</sup> mai au 30 septembre 2026 : un rapport consolidé de
|
||||||
|
onze chapitres, douze rapports de dimension thématique, une bibliographie de 481 références,
|
||||||
|
et un fichier d'arbitrage des désaccords tranchés sur sources primaires.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Chaque fait importé conserve sa citation d'origine — adresse, date, extrait verbatim — et son
|
||||||
|
niveau de confiance. Les marqueurs de citation du rapport sont résolus vers leur source :
|
||||||
|
aucune affirmation n'entre en base sans que l'on puisse remonter à ce qui l'atteste.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Comment la base se met à jour</h2>
|
||||||
|
<p>
|
||||||
|
Un pipeline interroge régulièrement les sources officielles : Légifrance par son interface de
|
||||||
|
programmation publique, la liste chronologique des lois promulguées du Sénat, et le registre
|
||||||
|
des affaires du Conseil constitutionnel. Chaque source est interrogée séparément ; si l'une
|
||||||
|
est indisponible, les autres continuent.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Un texte collecté est rapproché de la base par son numéro officiel, à défaut par similarité
|
||||||
|
de titre. Entre deux seuils, le rapprochement est <strong>signalé mais pas appliqué</strong> :
|
||||||
|
mieux vaut une vérification humaine qu'une fusion erronée. En cas de contradiction entre
|
||||||
|
sources, la source primaire l'emporte — Journal officiel et Légifrance devant les assemblées,
|
||||||
|
les assemblées devant la presse.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Ce qui est déduit, et signalé comme tel</h2>
|
||||||
|
<p>
|
||||||
|
Certaines informations ne figurent nulle part explicitement et sont déduites. Elles portent
|
||||||
|
alors la mention <strong>« à vérifier »</strong> et un motif lisible sur la fiche :
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Pertinence Guadeloupe des textes non promulgués.</strong> Le rapport ne cote que
|
||||||
|
les lois promulguées ; pour les autres, la cotation vient d'une détection de vocabulaire.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Sens des impacts par public.</strong> Le rapport consacre un chapitre aux
|
||||||
|
individus, un aux associations, un aux entreprises : le placement d'un texte dans l'un de
|
||||||
|
ces chapitres est un choix éditorial explicite, et sert de base à la ventilation. Le sens
|
||||||
|
— favorable, défavorable, mixte — vient de la cotation manuelle quand elle existe, du
|
||||||
|
vocabulaire sinon.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Thèmes.</strong> Déduits par mots-clés sur l'intitulé et le résumé.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Textes ajoutés par le pipeline.</strong> Ils entrent avec une confiance basse et
|
||||||
|
le drapeau de revue : la machine propose, un humain valide.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Les échéances sont des prévisions</h2>
|
||||||
|
<p>
|
||||||
|
Toute date postérieure au 25 juillet 2026 est prévisionnelle, et affichée comme telle. Une
|
||||||
|
décision « attendue vers le 24 août » peut tomber avant, après, ou être reportée. Les
|
||||||
|
libellés du corpus — « fin août », « automne 2026 » — sont conservés tels quels ; la date
|
||||||
|
associée ne sert qu'au tri et au filtrage.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Limites connues</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Le corpus s'arrête au 25 juillet 2026. Tout ce qui suit dépend des passages du pipeline.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Certaines références du corpus n'ont pas d'adresse consultable — surtout dans la
|
||||||
|
dimension consacrée aux associations. Elles sont conservées comme références
|
||||||
|
bibliographiques, sans lien.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Le contenu du titre « adaptation outre-mer » de la loi « Riposte » n'était pas public à
|
||||||
|
la date d'arrêté. Les effets ultramarins de ce texte sont donc incomplets.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
La veille suit les lois, pas les décrets d'application. Or c'est souvent au niveau
|
||||||
|
réglementaire que se jouent les effets concrets — les décrets chlordécone en sont
|
||||||
|
l'exemple.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Corriger une erreur</h2>
|
||||||
|
<p>
|
||||||
|
Si une fiche est fausse, elle doit être corrigée, pas défendue.
|
||||||
|
<a href="mailto:cyber.mawonaj@gmail.com">Signalez-la</a> en indiquant l'identifiant du texte
|
||||||
|
et la source qui vous donne raison.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.prose {
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
.chapeau {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
}
|
||||||
|
.prose h2 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--or-oki);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
.prose p,
|
||||||
|
.prose li,
|
||||||
|
.prose dd {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.prose ul {
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.prose li::marker {
|
||||||
|
color: var(--or-oki);
|
||||||
|
}
|
||||||
|
.statuts {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem 0;
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
.statuts dt {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
.statuts dd {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 0.9rem;
|
||||||
|
border-left: 2px solid var(--line);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Page figée, prérendue au build — voir a-propos/+page.ts. */
|
||||||
|
export const prerender = true;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { basculerFacette, lireFiltres, rechercher } from '$serveur/requetes';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche à facettes — rendu serveur, jamais prérendue.
|
||||||
|
*
|
||||||
|
* Les facettes vivent dans `url.searchParams` : une URL de recherche est
|
||||||
|
* partageable et se recharge à l'identique. Le prérendu interdit d'y accéder,
|
||||||
|
* c'est pourquoi cette route reste en rendu serveur (§3bis).
|
||||||
|
*/
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const filtres = lireFiltres(url.searchParams);
|
||||||
|
const resultats = rechercher(filtres);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filtres,
|
||||||
|
resultats,
|
||||||
|
// Les liens de facette sont calculés côté serveur : la page fonctionne
|
||||||
|
// entièrement sans JavaScript.
|
||||||
|
liens: Object.fromEntries(
|
||||||
|
(['statut', 'theme', 'type', 'impact', 'guadeloupe', 'confiance'] as const).map(
|
||||||
|
(champ) => [
|
||||||
|
champ,
|
||||||
|
Object.fromEntries(
|
||||||
|
resultats.facettes[champ === 'guadeloupe' ? 'guadeloupe' : champ].map((f) => [
|
||||||
|
f.valeur,
|
||||||
|
basculerFacette(filtres, champ, f.valeur)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CarteTexte from '$composants/CarteTexte.svelte';
|
||||||
|
import PanneauFacettes from '$composants/PanneauFacettes.svelte';
|
||||||
|
import { pluriel } from '$lib/format';
|
||||||
|
import type { Filtres } from '$lib/types';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
const { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
const lien = (champ: keyof Filtres, valeur: string) =>
|
||||||
|
(data.liens as Record<string, Record<string, string>>)[champ as string]?.[valeur] ??
|
||||||
|
'/recherche';
|
||||||
|
|
||||||
|
const chipsActifs = $derived(
|
||||||
|
(['statut', 'guadeloupe', 'impact', 'theme', 'type', 'confiance'] as const).flatMap(
|
||||||
|
(champ) =>
|
||||||
|
data.resultats.facettes[champ]
|
||||||
|
.filter((f) => f.actif)
|
||||||
|
.map((f) => ({ champ, ...f }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function urlPage(numero: number): string {
|
||||||
|
const parametres = new URLSearchParams(
|
||||||
|
typeof window === 'undefined' ? '' : window.location.search
|
||||||
|
);
|
||||||
|
parametres.set('page', String(numero));
|
||||||
|
return `/recherche?${parametres.toString()}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>
|
||||||
|
{data.filtres.q ? `« ${data.filtres.q} » — recherche` : 'Recherche'} · Veille législative
|
||||||
|
</title>
|
||||||
|
<meta name="robots" content="noindex, follow" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>Recherche</h1>
|
||||||
|
|
||||||
|
<form class="recherche" action="/recherche" method="get" role="search">
|
||||||
|
<label class="visuellement-cache" for="q">Rechercher</label>
|
||||||
|
<input id="q" name="q" type="search" value={data.filtres.q} placeholder="Mot-clé, numéro de loi…" />
|
||||||
|
{#each ['statut', 'theme', 'type', 'impact', 'guadeloupe', 'confiance'] as champ (champ)}
|
||||||
|
{#each data.filtres[champ as keyof Filtres] as string[] as valeur (valeur)}
|
||||||
|
<input type="hidden" name={champ} value={valeur} />
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
<button class="bouton" type="submit"><span>Chercher</span></button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if chipsActifs.length}
|
||||||
|
<div class="ligne chips">
|
||||||
|
<span class="etiquette">Filtres actifs :</span>
|
||||||
|
{#each chipsActifs as chip (chip.champ + chip.valeur)}
|
||||||
|
<a class="chip" href={lien(chip.champ as keyof Filtres, chip.valeur)} rel="nofollow">
|
||||||
|
{chip.libelle}
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
<span class="visuellement-cache">Retirer ce filtre</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
<a class="chip effacer" href={data.filtres.q ? `/recherche?q=${encodeURIComponent(data.filtres.q)}` : '/recherche'}>
|
||||||
|
Tout effacer
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mise-en-page">
|
||||||
|
<PanneauFacettes facettes={data.resultats.facettes} filtres={data.filtres} {lien} />
|
||||||
|
|
||||||
|
<div class="resultats">
|
||||||
|
<p class="total" role="status">
|
||||||
|
<strong>{data.resultats.total}</strong>
|
||||||
|
{pluriel(data.resultats.total, 'texte')}
|
||||||
|
{#if data.filtres.q}pour « {data.filtres.q} »{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if data.resultats.textes.length}
|
||||||
|
<div class="liste">
|
||||||
|
{#each data.resultats.textes as texte (texte.id)}
|
||||||
|
<CarteTexte {texte} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.resultats.pages > 1}
|
||||||
|
<nav class="pagination" aria-label="Pagination">
|
||||||
|
{#if data.resultats.page > 1}
|
||||||
|
<a class="bouton bouton-discret" href={urlPage(data.resultats.page - 1)}>
|
||||||
|
<span>← Précédent</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<span class="position">Page {data.resultats.page} sur {data.resultats.pages}</span>
|
||||||
|
{#if data.resultats.page < data.resultats.pages}
|
||||||
|
<a class="bouton bouton-discret" href={urlPage(data.resultats.page + 1)}>
|
||||||
|
<span>Suivant →</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="carte vide">
|
||||||
|
<h2>Aucun résultat</h2>
|
||||||
|
<p>
|
||||||
|
Aucun texte ne correspond à cette combinaison de filtres. Essayez de retirer un
|
||||||
|
critère, ou de chercher sur un mot plus général.
|
||||||
|
</p>
|
||||||
|
<a class="bouton" href="/recherche"><span>Repartir de zéro</span></a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.recherche {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
.recherche input[type='search'] {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
}
|
||||||
|
.recherche input:focus {
|
||||||
|
border-color: var(--or-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.etiquette {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
background: color-mix(in srgb, var(--or-oki) 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--or-oki) 55%, transparent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.18em 0.6em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
background: color-mix(in srgb, var(--or-oki) 22%, transparent);
|
||||||
|
}
|
||||||
|
.chip.effacer {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mise-en-page {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(210px, 260px) minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.mise-en-page {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.total {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.total strong {
|
||||||
|
color: var(--or-oki);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
.position {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vide {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-4) var(--space-2);
|
||||||
|
border-left-color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
.vide h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.vide p {
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 46ch;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { texte } from '$serveur/requetes';
|
||||||
|
import { DATE_ARRETE_CORPUS } from '$serveur/db';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fiche détaillée — rendu serveur.
|
||||||
|
*
|
||||||
|
* Pas de prérendu : la fiche doit être fraîche dès que le pipeline écrit dans
|
||||||
|
* SQLite, sans reconstruction du site. C'est aussi la page la plus utile au
|
||||||
|
* référencement, donc celle qui gagne le plus au rendu serveur.
|
||||||
|
*/
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const fiche = texte(params.slug);
|
||||||
|
if (!fiche) {
|
||||||
|
error(404, `Aucun texte suivi ne porte l'identifiant « ${params.slug} ».`);
|
||||||
|
}
|
||||||
|
return { texte: fiche, dateArrete: DATE_ARRETE_CORPUS };
|
||||||
|
};
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BadgeGuadeloupe from '$composants/BadgeGuadeloupe.svelte';
|
||||||
|
import BadgeStatut from '$composants/BadgeStatut.svelte';
|
||||||
|
import { dateLongue } from '$lib/format';
|
||||||
|
import {
|
||||||
|
LIBELLES_CONFIANCE,
|
||||||
|
LIBELLES_ETAPE,
|
||||||
|
LIBELLES_IMPACT,
|
||||||
|
LIBELLES_RESULTAT_CC,
|
||||||
|
LIBELLES_THEME,
|
||||||
|
LIBELLES_TYPE
|
||||||
|
} from '$lib/libelles';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
const { data }: { data: PageData } = $props();
|
||||||
|
const t = $derived(data.texte);
|
||||||
|
|
||||||
|
const PUBLICS = ['individus', 'associations', 'entreprises'] as const;
|
||||||
|
|
||||||
|
let copie = $state(false);
|
||||||
|
|
||||||
|
function citation(): string {
|
||||||
|
const nom = t.numero_officiel ? `Loi n° ${t.numero_officiel}` : t.titre_court;
|
||||||
|
const date = t.date_promulgation
|
||||||
|
? `, ${dateLongue(t.date_promulgation)}`
|
||||||
|
: t.date_adoption
|
||||||
|
? `, adoptée le ${dateLongue(t.date_adoption)}`
|
||||||
|
: '';
|
||||||
|
const url =
|
||||||
|
typeof window === 'undefined' ? `/textes/${t.id}` : window.location.href;
|
||||||
|
return `${nom} — ${t.titre_court}${date}. Veille législative Gwadloup, consulté le ${dateLongue(
|
||||||
|
new Date().toISOString()
|
||||||
|
)}. ${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copierCitation() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(citation());
|
||||||
|
copie = true;
|
||||||
|
setTimeout(() => (copie = false), 2500);
|
||||||
|
} catch {
|
||||||
|
/* presse-papiers refusé : la citation reste sélectionnable à la main */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{t.titre_court} · Veille législative Gwadloup</title>
|
||||||
|
<meta name="description" content={(t.resume ?? t.titre_court).slice(0, 180)} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<nav class="fil" aria-label="Fil d'Ariane">
|
||||||
|
<a href="/">Tableau de bord</a> ›
|
||||||
|
<a href="/recherche">Recherche</a> ›
|
||||||
|
<span aria-current="page">{t.numero_officiel ? `Loi n° ${t.numero_officiel}` : t.titre_court}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<article class="fiche">
|
||||||
|
<header>
|
||||||
|
{#if t.numero_officiel}<p class="numero">Loi n° {t.numero_officiel}</p>{/if}
|
||||||
|
<h1>{t.titre_court}</h1>
|
||||||
|
{#if t.titre_officiel && t.titre_officiel !== t.titre_court}
|
||||||
|
<p class="titre-officiel">{t.titre_officiel}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="ligne badges">
|
||||||
|
<BadgeStatut statut={t.statut} devantCc={t.devant_cc === 1} />
|
||||||
|
<BadgeGuadeloupe
|
||||||
|
pertinence={t.guadeloupe_pertinence}
|
||||||
|
note={t.guadeloupe_note}
|
||||||
|
aVerifier={t.a_verifier === 1}
|
||||||
|
/>
|
||||||
|
<span class="tag">{LIBELLES_TYPE[t.type] ?? t.type}</span>
|
||||||
|
<span class="tag confiance" data-niveau={t.confiance}>
|
||||||
|
Confiance : {LIBELLES_CONFIANCE[t.confiance] ?? t.confiance}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if t.a_verifier === 1 && t.motif_verification}
|
||||||
|
<aside class="avertissement" role="note">
|
||||||
|
<strong>À vérifier —</strong>
|
||||||
|
{t.motif_verification}
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="corps">
|
||||||
|
<div class="principal">
|
||||||
|
{#if t.resume}
|
||||||
|
<section>
|
||||||
|
<h2>Résumé</h2>
|
||||||
|
<p class="resume">{t.resume}</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if t.points_cles.length}
|
||||||
|
<section>
|
||||||
|
<h2>Points clés</h2>
|
||||||
|
<ul class="points">
|
||||||
|
{#each t.points_cles as point (point)}
|
||||||
|
<li>{point}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if Object.keys(t.impacts).length}
|
||||||
|
<section>
|
||||||
|
<h2>Effets sur les libertés</h2>
|
||||||
|
<div class="impacts">
|
||||||
|
{#each PUBLICS as pub (pub)}
|
||||||
|
{@const impact = t.impacts[pub]}
|
||||||
|
{#if impact}
|
||||||
|
<div class="impact" data-sens={impact.sens}>
|
||||||
|
<h3>{pub}</h3>
|
||||||
|
<p class="sens">{LIBELLES_IMPACT[`${pub}:${impact.sens}`] ?? impact.sens}</p>
|
||||||
|
{#if impact.note}<p class="note">{impact.note}</p>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if t.evenements.length}
|
||||||
|
<section>
|
||||||
|
<h2>Parcours du texte</h2>
|
||||||
|
<ol class="timeline">
|
||||||
|
{#each t.evenements as evenement (evenement.date_evenement + evenement.type_etape)}
|
||||||
|
<li class:previsionnel={evenement.previsionnel === 1}>
|
||||||
|
<time datetime={evenement.date_evenement}>
|
||||||
|
{dateLongue(evenement.date_evenement)}
|
||||||
|
</time>
|
||||||
|
<div>
|
||||||
|
<strong>{LIBELLES_ETAPE[evenement.type_etape] ?? evenement.type_etape}</strong>
|
||||||
|
{#if evenement.previsionnel === 1}
|
||||||
|
<span class="prevu">prévisionnel</span>
|
||||||
|
{/if}
|
||||||
|
<p>{evenement.description}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if t.decisions_cc.length}
|
||||||
|
<section>
|
||||||
|
<h2>Conseil constitutionnel</h2>
|
||||||
|
{#each t.decisions_cc as decision (decision.numero_affaire)}
|
||||||
|
<div class="carte decision">
|
||||||
|
<h3>Affaire n° {decision.numero_affaire}</h3>
|
||||||
|
<dl>
|
||||||
|
{#if decision.date_saisine}
|
||||||
|
<dt>Saisine</dt>
|
||||||
|
<dd>{dateLongue(decision.date_saisine)}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if decision.date_decision}
|
||||||
|
<dt>Décision</dt>
|
||||||
|
<dd>{dateLongue(decision.date_decision)}</dd>
|
||||||
|
{:else if decision.date_decision_attendue}
|
||||||
|
<dt>Décision attendue</dt>
|
||||||
|
<dd>{dateLongue(decision.date_decision_attendue)}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if decision.resultat}
|
||||||
|
<dt>Résultat</dt>
|
||||||
|
<dd>{LIBELLES_RESULTAT_CC[decision.resultat] ?? decision.resultat}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if decision.saisissants}
|
||||||
|
<dt>Saisissants</dt>
|
||||||
|
<dd>{decision.saisissants}</dd>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
{#if decision.resume}<p>{decision.resume}</p>{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Sources ({t.sources.length})</h2>
|
||||||
|
<p class="avant-sources">
|
||||||
|
Chaque source est citée telle qu'elle a été consultée. Les sources primaires
|
||||||
|
(Journal officiel, assemblées, Conseil constitutionnel, Conseil d'État) sont
|
||||||
|
marquées T1, la presse et les analyses T2.
|
||||||
|
</p>
|
||||||
|
<ol class="sources">
|
||||||
|
{#each t.sources as source (source.url + (source.marqueur ?? ''))}
|
||||||
|
<li class="carte source" data-tier={source.tier}>
|
||||||
|
<div class="ligne entete-source">
|
||||||
|
<span class="tier">{source.tier}</span>
|
||||||
|
{#if source.editeur}<span class="editeur">{source.editeur}</span>{/if}
|
||||||
|
{#if source.date_publication}
|
||||||
|
<time datetime={source.date_publication}>
|
||||||
|
{dateLongue(source.date_publication)}
|
||||||
|
</time>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if source.titre}<p class="titre-source">{source.titre}</p>{/if}
|
||||||
|
{#if source.extrait_verbatim}
|
||||||
|
<blockquote>{source.extrait_verbatim}</blockquote>
|
||||||
|
{/if}
|
||||||
|
{#if source.contexte}<p class="contexte">{source.contexte}</p>{/if}
|
||||||
|
<a class="url" href={source.url} rel="noopener nofollow" target="_blank">
|
||||||
|
{source.url}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="lateral">
|
||||||
|
<div class="carte encart">
|
||||||
|
<h2>Repères</h2>
|
||||||
|
<dl>
|
||||||
|
{#if t.date_depot}
|
||||||
|
<dt>Dépôt</dt>
|
||||||
|
<dd>{dateLongue(t.date_depot)}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if t.date_adoption}
|
||||||
|
<dt>Adoption</dt>
|
||||||
|
<dd>{dateLongue(t.date_adoption)}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if t.date_promulgation}
|
||||||
|
<dt>Promulgation</dt>
|
||||||
|
<dd>{dateLongue(t.date_promulgation)}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if t.date_entree_vigueur}
|
||||||
|
<dt>Entrée en vigueur</dt>
|
||||||
|
<dd>{dateLongue(t.date_entree_vigueur)}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if t.prochaine_echeance_label}
|
||||||
|
<dt>Prochaine étape</dt>
|
||||||
|
<dd>{t.prochaine_echeance_label}</dd>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if t.guadeloupe_note}
|
||||||
|
<div class="carte encart gwada">
|
||||||
|
<h2>Guadeloupe</h2>
|
||||||
|
<p>{t.guadeloupe_note}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if t.themes.length}
|
||||||
|
<div class="carte encart">
|
||||||
|
<h2>Thèmes</h2>
|
||||||
|
<div class="ligne">
|
||||||
|
{#each t.themes as theme (theme)}
|
||||||
|
<a class="tag" href="/recherche?theme={theme}">
|
||||||
|
{LIBELLES_THEME[theme] ?? theme}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="carte encart">
|
||||||
|
<h2>Citer cette fiche</h2>
|
||||||
|
<button type="button" class="bouton" onclick={copierCitation}>
|
||||||
|
<span>{copie ? 'Copié' : 'Copier la citation'}</span>
|
||||||
|
</button>
|
||||||
|
{#if t.source_seed}
|
||||||
|
<p class="provenance">Origine : <code>{t.source_seed}</code></p>
|
||||||
|
{/if}
|
||||||
|
{#if t.derniere_verif}
|
||||||
|
<p class="provenance">Dernière vérification : {t.derniere_verif}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fil {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.fil a {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.numero {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--or-oki);
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.titre-officiel {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
max-width: 72ch;
|
||||||
|
}
|
||||||
|
.badges {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.confiance[data-niveau='low'] {
|
||||||
|
border-color: color-mix(in srgb, var(--rouge-oki) 55%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--rouge-oki) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avertissement {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--or-oki) 55%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--or-oki) 9%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.corps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(240px, 300px);
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.corps {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
section h2 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--or-oki);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume {
|
||||||
|
font-size: 1rem;
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
max-width: 74ch;
|
||||||
|
}
|
||||||
|
.points li::marker {
|
||||||
|
color: var(--or-oki);
|
||||||
|
}
|
||||||
|
|
||||||
|
.impacts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
.impact {
|
||||||
|
border: var(--border-card);
|
||||||
|
border-left: 4px solid var(--impact-neutre);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.impact[data-sens='positif'] {
|
||||||
|
border-left-color: var(--impact-positif);
|
||||||
|
}
|
||||||
|
.impact[data-sens='negatif'] {
|
||||||
|
border-left-color: var(--impact-negatif);
|
||||||
|
}
|
||||||
|
.impact[data-sens='mixte'] {
|
||||||
|
border-left-color: var(--impact-mixte);
|
||||||
|
}
|
||||||
|
.impact h3 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.impact .sens {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.impact .note {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 1rem;
|
||||||
|
border-left: 2px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.timeline li {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, auto) 1fr;
|
||||||
|
gap: 0.8rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.timeline li::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -1.35rem;
|
||||||
|
top: 0.4rem;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--or-oki);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
.timeline li.previsionnel::before {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid var(--or-oki);
|
||||||
|
}
|
||||||
|
.timeline time {
|
||||||
|
color: var(--or-oki);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.timeline p {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.prevu {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0 0.35em;
|
||||||
|
margin-left: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision {
|
||||||
|
border-left-color: var(--orange-flamme);
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.decision h3 {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.25rem 0.8rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avant-sources {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
max-width: 68ch;
|
||||||
|
}
|
||||||
|
.sources {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.source {
|
||||||
|
border-left-color: var(--muted-fort);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
.source[data-tier='T1'] {
|
||||||
|
border-left-color: var(--vert-oki);
|
||||||
|
}
|
||||||
|
.entete-source {
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
.tier {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--vert-oki);
|
||||||
|
}
|
||||||
|
.source[data-tier='T2'] .tier {
|
||||||
|
color: var(--muted-fort);
|
||||||
|
}
|
||||||
|
.editeur {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--blanc-creme);
|
||||||
|
}
|
||||||
|
.titre-source {
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
}
|
||||||
|
blockquote {
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
padding-left: 0.8rem;
|
||||||
|
border-left: 2px solid var(--or-oki);
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.contexte {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted-fort);
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
}
|
||||||
|
.url {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lateral {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 5.5rem;
|
||||||
|
}
|
||||||
|
.encart h2 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.encart.gwada {
|
||||||
|
border-left-color: var(--turquoise-caraibes);
|
||||||
|
}
|
||||||
|
.encart p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.provenance {
|
||||||
|
font-size: 0.74rem !important;
|
||||||
|
color: var(--muted-fort) !important;
|
||||||
|
margin-top: 0.5rem !important;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.lateral {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.timeline li {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service worker minimal — consultation hors ligne des dernières données.
|
||||||
|
*
|
||||||
|
* Deux stratégies, et pas une de plus :
|
||||||
|
*
|
||||||
|
* - **Le coffre du build** (JS, CSS, polices, icônes) est mis en cache à
|
||||||
|
* l'installation et servi depuis le cache. Ces fichiers portent une empreinte
|
||||||
|
* dans leur nom : ils ne changent jamais sous le même nom.
|
||||||
|
* - **Les pages et l'API** passent par le réseau d'abord, avec repli sur le
|
||||||
|
* cache. Une veille législative doit montrer l'état le plus récent qu'elle
|
||||||
|
* peut atteindre ; servir une page périmée alors que le réseau répond serait
|
||||||
|
* trompeur sur un sujet où une date de promulgation change tout.
|
||||||
|
*
|
||||||
|
* Le hors-ligne n'est donc pas un mode de consultation normal : c'est un filet
|
||||||
|
* pour les connexions instables, fréquentes en zone rurale guadeloupéenne. La
|
||||||
|
* page servie depuis le cache le signale à l'utilisateur via l'en-tête ajouté
|
||||||
|
* `X-Depuis-Cache`.
|
||||||
|
*/
|
||||||
|
import { build, files, version } from '$service-worker';
|
||||||
|
|
||||||
|
const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
const CACHE_COFFRE = `coffre-${version}`;
|
||||||
|
const CACHE_PAGES = `pages-${version}`;
|
||||||
|
|
||||||
|
// Les polices et le manifeste valent d'être précachés ; pas les gros binaires.
|
||||||
|
const A_PRECACHER = [...build, ...files.filter((f) => !f.endsWith('.map'))];
|
||||||
|
|
||||||
|
sw.addEventListener('install', (evenement) => {
|
||||||
|
evenement.waitUntil(
|
||||||
|
caches.open(CACHE_COFFRE).then((cache) => cache.addAll(A_PRECACHER))
|
||||||
|
);
|
||||||
|
// Le nouveau worker prend la main sans attendre la fermeture des onglets :
|
||||||
|
// une correction de données ne doit pas rester bloquée derrière un onglet
|
||||||
|
// ouvert depuis une semaine.
|
||||||
|
sw.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
sw.addEventListener('activate', (evenement) => {
|
||||||
|
evenement.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const noms = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
noms
|
||||||
|
.filter((nom) => nom !== CACHE_COFFRE && nom !== CACHE_PAGES)
|
||||||
|
.map((nom) => caches.delete(nom))
|
||||||
|
);
|
||||||
|
await sw.clients.claim();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sw.addEventListener('fetch', (evenement) => {
|
||||||
|
const requete = evenement.request;
|
||||||
|
if (requete.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(requete.url);
|
||||||
|
// Rien qui ne vienne de cette origine : la politique de sécurité de contenu
|
||||||
|
// interdit déjà les tiers, le worker ne les mettra pas en cache non plus.
|
||||||
|
if (url.origin !== location.origin) return;
|
||||||
|
|
||||||
|
// Coffre du build : cache d'abord, sans condition.
|
||||||
|
if (A_PRECACHER.includes(url.pathname)) {
|
||||||
|
evenement.respondWith(
|
||||||
|
caches
|
||||||
|
.open(CACHE_COFFRE)
|
||||||
|
.then((cache) => cache.match(url.pathname))
|
||||||
|
.then((reponse) => reponse ?? fetch(requete))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages et API : réseau d'abord, cache en repli.
|
||||||
|
evenement.respondWith(reseauPuisCache(requete));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function reseauPuisCache(requete: Request): Promise<Response> {
|
||||||
|
const cache = await caches.open(CACHE_PAGES);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reponse = await fetch(requete);
|
||||||
|
if (reponse.ok) {
|
||||||
|
cache.put(requete, reponse.clone());
|
||||||
|
}
|
||||||
|
return reponse;
|
||||||
|
} catch {
|
||||||
|
const enCache = await cache.match(requete);
|
||||||
|
if (enCache) {
|
||||||
|
// On signale explicitement que la réponse peut être périmée.
|
||||||
|
const entetes = new Headers(enCache.headers);
|
||||||
|
entetes.set('X-Depuis-Cache', 'true');
|
||||||
|
return new Response(enCache.body, {
|
||||||
|
status: enCache.status,
|
||||||
|
statusText: enCache.statusText,
|
||||||
|
headers: entetes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
`<!doctype html><html lang="fr"><head><meta charset="utf-8">
|
||||||
|
<title>Hors ligne</title>
|
||||||
|
<style>
|
||||||
|
body{background:#0D0D0D;color:#FFF8E7;font-family:system-ui,sans-serif;
|
||||||
|
display:grid;place-items:center;min-height:100vh;margin:0;text-align:center;padding:2rem}
|
||||||
|
h1{font-size:1.4rem;text-transform:uppercase;letter-spacing:-0.01em}
|
||||||
|
p{color:#B6B1A6;max-width:40ch}
|
||||||
|
.bar{height:6px;width:56px;margin:1.5rem auto 0;background:linear-gradient(to right,
|
||||||
|
#0D0D0D 0 25%,#FDB813 25% 50%,#00D66C 50% 75%,#FF1654 75% 100%)}
|
||||||
|
</style></head><body><div>
|
||||||
|
<h1>Pa ni rézo</h1>
|
||||||
|
<p>Cette page n'a pas encore été consultée et le réseau est indisponible.
|
||||||
|
Les pages déjà visitées restent accessibles.</p>
|
||||||
|
<div class="bar"></div>
|
||||||
|
</div></body></html>`,
|
||||||
|
{ status: 503, headers: { 'content-type': 'text/html; charset=utf-8' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Veille législative Gwadloup">
|
||||||
|
<rect width="64" height="64" fill="#0D0D0D"/>
|
||||||
|
<rect x="0" y="52" width="16" height="12" fill="#0D0D0D"/>
|
||||||
|
<rect x="16" y="52" width="16" height="12" fill="#FDB813"/>
|
||||||
|
<rect x="32" y="52" width="16" height="12" fill="#00D66C"/>
|
||||||
|
<rect x="48" y="52" width="16" height="12" fill="#FF1654"/>
|
||||||
|
<path d="M14 12 L24 44 L34 12" fill="none" stroke="#FDB813" stroke-width="7" stroke-linejoin="miter"/>
|
||||||
|
<rect x="40" y="12" width="7" height="32" fill="#FDB813"/>
|
||||||
|
<rect x="40" y="37" width="14" height="7" fill="#FDB813"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 643 B |
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "Veille législative Gwadloup",
|
||||||
|
"short_name": "Vèy Lwa",
|
||||||
|
"description": "Lois françaises et effets sur les libertés des individus, des associations et des entreprises — zoom Guadeloupe.",
|
||||||
|
"lang": "fr",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#0D0D0D",
|
||||||
|
"theme_color": "#0D0D0D",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
|
||||||
|
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable" }
|
||||||
|
],
|
||||||
|
"categories": ["news", "government", "reference"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Applique le thème choisi avant le premier rendu.
|
||||||
|
*
|
||||||
|
* Le thème sombre est l'identité par défaut : on n'ajoute la classe que si le
|
||||||
|
* visiteur a explicitement demandé le clair, ou si son système le préfère sans
|
||||||
|
* qu'il ait exprimé de choix.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var choix = localStorage.getItem('theme');
|
||||||
|
var systemeClair =
|
||||||
|
window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||||
|
if (choix === 'clair' || (choix === null && systemeClair)) {
|
||||||
|
document.documentElement.classList.add('light-theme');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* stockage indisponible : on garde le thème sombre par défaut */
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Architecture de rendu : « transitional app » en rendu serveur sur
|
||||||
|
* adapter-node, avec prérendu ciblé des seules pages figées.
|
||||||
|
*
|
||||||
|
* Le rendu serveur est le défaut partout : les fiches doivent être fraîches dès
|
||||||
|
* que le pipeline écrit dans SQLite, et les facettes de recherche vivent dans
|
||||||
|
* `url.searchParams`, ce que le prérendu interdit.
|
||||||
|
*
|
||||||
|
* `precompress` génère les variantes .gz et .br au build ; la négociation est
|
||||||
|
* faite par le reverse proxy.
|
||||||
|
*/
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({ precompress: true }),
|
||||||
|
alias: {
|
||||||
|
$composants: 'src/lib/composants',
|
||||||
|
$serveur: 'src/lib/server'
|
||||||
|
},
|
||||||
|
csp: {
|
||||||
|
// Aucune ressource tierce : ni police, ni script, ni image externe.
|
||||||
|
directives: {
|
||||||
|
'default-src': ['self'],
|
||||||
|
'img-src': ['self', 'data:'],
|
||||||
|
'style-src': ['self', 'unsafe-inline'],
|
||||||
|
'font-src': ['self'],
|
||||||
|
'connect-src': ['self'],
|
||||||
|
'frame-ancestors': ['none'],
|
||||||
|
'base-uri': ['self'],
|
||||||
|
'form-action': ['self']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
// better-sqlite3 est un module natif : il ne doit jamais être empaqueté.
|
||||||
|
ssr: { external: ['better-sqlite3'] },
|
||||||
|
build: { target: 'es2022' }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user