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>
363 lines
8.9 KiB
Svelte
363 lines
8.9 KiB
Svelte
<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>
|