Aller au contenu

Frontend (Vue 3 + Pinia)

Décomposition C4-C3 du container SPA. L'architecture post-refactor est entièrement spécifiée dans ADR-010 — ce document en donne la vue orientée building blocks.

Vue C4 niveau 3 frontend (exportée depuis Structurizr) :

C4 Components Frontend — organisms, composables, lib/, stores

5.F.1 Couches

src/
├── components/
│   ├── atoms/         # a- (ABadge, AButton, AIcon, ANumericValue, ASourcingBadge)
│   ├── molecules/     # m- (MSectionHeader, MStatCard, MStationCard, MStatusBadge
│   │                  #     + énergie : MCategorySelect, MEnergySource, MPageHeader, MVerdictGroup
│   │                  #     + accueil : MDomainCard)
│   ├── organisms/     # o- (OHeroSection, OHydroChart, OKeyMetricsSection, OMapSection,
│   │                  #     OResearchZonesSection, OSiteFooter, OStationDrawer,
│   │                  #     OStationMap, OWhyLindasSection
│   │                  #     + couche IA : OAlertsPanel (B), OChatPanel (C)
│   │                  #     + énergie : OEnergyPositioning, OEnergyTrajectory,
│   │                  #       OEnergyBreakdown, OPositioningChart, OTrajectoryChart,
│   │                  #       OBreakdownChart, OGroupingMethod
│   │                  #     + plateforme : OSiteHeader, OHomeMethod, OHomeDomains)
│   └── templates/     # t- (TDefaultLayout : en-tête, main, pied de page)
├── pages/             # p- (PHomePage, PHydrologyPage, PEnergyPage, PGroupingMethodPage)
├── composables/
│   ├── shared/        # useEscapeClose, useScrollLock, usePolling
│   ├── stations/      # useStationsList, useStationSelection,
│   │                  # useStationMeasurements, useStationDrawer
│   ├── alerts/        # useAlertsPanel (couche IA B)
│   ├── chat/          # useDataChat (couche IA C)
│   ├── energy/        # useEnergyTariffs (extension énergie, ADR-013 ; loadPositioning pour l'accueil)
│   └── useI18nList.ts
├── lib/               # logique pure — aucune dépendance Vue/Pinia
│   ├── api-client.ts  # HTTP centralisé + ApiError discriminé
│   ├── charts/        # chart-model (D3 pure)
│   ├── map/           # station-map-mapping (Leaflet mapping pure)
│   ├── constants/     # chart.ts, map.ts, time.ts, energy-chart.ts
│   ├── energy/        # energy-presentation (formatage fr-CH, verdicts, encart H1),
│   │                  # grouping-snapshot (chiffres datés de la note de méthode)
│   ├── home/          # domain-figures (chiffres des cartes de l'accueil)
│   ├── hydrodaten.ts  # stationToHydrodatenUrl + RESEARCH_OFEV_PREFIX
│   ├── navigation.ts  # isSectionActive (lien de domaine actif sur ses sous-pages)
│   ├── page-title.ts  # titre d'onglet « Page — AlpiMonitor »
│   └── status.ts      # type BadgeStatus partagé
├── stores/            # Pinia singletons (stations, status, alerts, aiStatus, energy)
├── locales/           # fr.json (vue-i18n FR uniquement)
├── router/            # 4 routes : / (accueil), /hydrologie, /energie, /energie/regroupement
│                      # (domaines chargés à la demande) ; redirection des anciennes ancres
│                      # /#map|#alerts|#chat ; titre d'onglet par route (legacy-anchors.ts)
├── assets/            # main.css (Tailwind layers)
└── main.ts

Préfixes ABEM strictement appliqués (ADR-002) — un composant .vue porte toujours un préfixe a-/m-/o-/t-/p- à son fichier et à sa classe racine.

5.F.2 Pattern façades feature-grouped

useStationsStore portait 3 responsabilités (liste, sélection UI, cache mesures). R3 du refactor a introduit 3 façades read-only sous composables/stations/ :

  • useStationsList() — expose stations, isLoading, error, hasLoadedOnce, loadAll(). Consommé par OMapSection et OKeyMetricsSection.
  • useStationSelection() — expose selectedStation, selectedStationId, selectStation(id), clearSelection(). Consommé par OStationMap (click → sélection) et indirectement par OStationDrawer.
  • useStationMeasurements(stationId: Ref<string | null>) — reçoit un stationId réactif, dérive series, isLoading, error via computed (pas storeToRefs car la clé varie), expose load() et reload(). Consommé par useStationDrawer.

Un barrel composables/stations/index.ts ré-exporte les 3 + le composable orchestrateur useStationDrawer.

Règle enforced — aucun fichier .vue de production n'importe useStationsStore. Vérifiable :

grep -rn "useStationsStore" --include="*.vue" src/
# → 0 résultat

Exceptions documentées dans les JSDoc de tête : tests (.test.ts) et decorators Storybook (seedStations) peuvent $patch le store directement pour le seed de scénarios contrôlés.

5.F.3 Orchestrateur useStationDrawer

OStationDrawer.vue est un organisme visuel — son <script setup> ne contient plus que 22 lignes après le refactor R2 (contre 102 avant). Toute la logique vit dans composables/stations/useStationDrawer.ts :

  • Souscription aux façades useStationSelection + useStationMeasurements(selectedStationId).
  • Snapshot de now à la sélection (fenêtre stable tant que le drawer est ouvert).
  • watch(selectedStationId) → déclenche load() + reset du snapshot.
  • Dérivations présentationnelles : dischargeSeries, coordsLabel, hydrodatenUrl.
  • Montage des primitives transverses : useEscapeClose(isOpen, close) + useScrollLock(isOpen).
  • Méthodes publiques : close(), retry().

Testé en isolation (useStationDrawer.test.ts) via un composant probe — pas besoin de mount le .vue.

5.F.4 Couche lib/ — logique pure

lib/ ne contient aucune dépendance Vue reactivity ni Pinia (hors Ref en signature pour useStationMeasurements). Testable sans monter de composant :

  • api-client.ts — api.getStations(), api.getStationMeasurements(id, params), api.getStatus(), api.getHealth(). Retourne un ApiResponse<T> = { success: true; data: T } | { success: false; error: ApiError } où ApiError est une union discriminée network | http | parse. Chaque consumer (stores + composables) nomme sa branche d'échec via le compilateur — impossible d'oublier un cas.
  • charts/chart-model.ts — computeYDomain, findNearestPointByPx. Fonctions pures D3, testées avec des points synthétiques.
  • map/station-map-mapping.ts — stationToMarkerOptions, findLatestDischarge. Mapping Leaflet pur (couleurs, radius, fill).
  • hydrodaten.ts — stationToHydrodatenUrl(station) + constante RESEARCH_OFEV_PREFIX = 'TBD'. Lie la règle de génération d'URL Hydrodaten à un préfixe seed explicite.
  • constants/{chart,map,time}.ts — magic numbers dédupliqués (MARGIN, MAP_CENTER, MAP_ZOOM, ONE_DAY_MS, NARROW_BREAKPOINT, …).
  • status.ts — type BadgeStatus = 'live' | 'stale' | 'offline' | 'loading' partagé entre MStatusBadge.vue et OHeroSection.vue (auparavant inliné dans chaque fichier).

5.F.5 Stores Pinia

Deux singletons :

  • useStationsStore — état liste + sélection + cache per-station des mesures. Accessible uniquement via les 3 façades (règle §5.F.2).
  • useStatusStore — snapshot ingestion (lastSuccessAt, minutesSinceLastSuccess, isHealthy, today.runsCount, …). Pas de façade — 2 consommateurs prod (OHeroSection, OKeyMetricsSection), rule-of-three non atteinte. Décision explicitée dans le JSDoc de tête de stores/status.ts et tracée dans ADR-010 §2.2.

Chaque fetch utilise api.* de lib/api-client.ts — aucun fetch() direct dans les stores ni les composables.

5.F.6 Couche IA — chat sur données structurées (ADR-012, extension C)

Le chat est livré en C6 comme une section dédiée scrollable (organisme OChatPanel), pas un widget flottant (décision D7) — cohérent avec la page single-scroll.

  • OChatPanel.vue — organisme présentationnel : champ de question, fil question/réponse, trace discrète des tools appelés (used[], transparence du grounding), disclaimer « réponses assistées par IA » (ADR-008), questions d'exemple cliquables pour la découvrabilité (polish chat-ux).
  • useDataChat() — façade on-demand, façon useStationNarrative et non adossée à un store Pinia : un fil de chat est un état local au panneau, pas une donnée partagée cross-composant. Elle possède son état réactif (messages, isLoading, error, hasMessages) et délègue l'appel HTTP à api.ask(question, language?).
  • Backend stateless / single-turn — chaque POST /ask est indépendant (pas d'historique serveur). Le messages ref est un transcript purement client de la session courante pour le rendu du fil ; il n'est jamais rejoué au serveur.
  • Échecs honnêtes — useDataChat mappe les refus de garde en raisons UI explicites (429 → trop de requêtes, 503 → IA indisponible, 400 → requête invalide) plutôt que de laisser fuiter des formes HTTP brutes. api.ask est ajouté à lib/api-client.ts (même ApiResponse<T> discriminé que les autres appels).

Testé en isolation (useDataChat.test.ts, OChatPanel.test.ts) — la façade n'a pas de store à monter ; les appels api.ask sont mockés.

5.F.7 Extension énergie — écrans ElCom (ADR-013)

Page PEnergyPage sur la route /energie, chargée à la demande (chunk séparé) : ni l'accueil ni la page hydrologie n'en portent le poids. Elle est atteinte par la barre de navigation (§5.F.8). Le pied de page y passe en variante energy (sources ElCom et OFS, sans les sources hydrologiques).

  • useEnergyStore + façade useEnergyTariffs() — même frontière que useAlertsPanel (ADR-010) : les organismes n'importent jamais le store. Les trois écrans sont chargés ensemble une fois par visite (tarifs annuels, pas de polling) et partagent une catégorie sélectionnée (H4 par défaut). Un 404 ENERGY_DATA_NOT_IMPORTED est un état distinct d'une erreur.
  • Trois organismes de section — OEnergyPositioning (trois groupes de verdict, détail sur quatre bases avec tableau), OEnergyTrajectory, OEnergyBreakdown (avec l'encart H1 sur le comptage). Chacun affiche source et millésime (MEnergySource) ; une valeur null s'affiche « donnée manquante », jamais 0.
  • Trois graphiques — OPositioningChart, OTrajectoryChart, OBreakdownChart, sur le patron de OHydroChart : échelles D3 dans des computed, rendu SVG par Vue, largeur suivie par ResizeObserver. La médiane ElCom est un objet graphique distinct des boîtes ; une valeur manquante coupe une ligne au lieu d'être interpolée.
  • lib/energy/energy-presentation.ts — logique pure : formatage fr-CH, regroupement par verdict, lignes de l'encart H1 déduites des taux publiés.
  • Note de méthode PGroupingMethodPage (/energie/regroupement, liée depuis l'en-tête de /energie) — remplace la carte d'éligibilité du cadrage initial. Page statique : OGroupingMethod affiche un instantané daté et sourcé (lib/energy/grouping-snapshot.ts, mesuré le 2026-09-27 sur les trois communes de SEDRE SA) en trois blocs : ce que les données ouvertes établissent, ce qu'elles approximent (avec la méthode), ce qui est hors de portée (la donnée interne du gestionnaire de réseau, champ par champ). Les critères juridiques des regroupements sont affichés comme non vérifiés et ne sont associés à aucune donnée ; un test le garantit.
  • En-tête partagé MPageHeader — en-tête sombre des deux pages énergie (h1, sous-titre, introduction, emplacement pour les contrôles).

5.F.8 Plateforme — accueil et navigation (ADR-014)

La plateforme compte quatre pages, reliées par une barre de navigation commune (ADR-014).

  • TDefaultLayout porte l'en-tête, le <main id="main-content"> et le pied de page de toutes les pages ; deux props choisissent les variantes (headerVariant clair/sombre, footerVariant hydro / energy / platform).
  • OSiteHeader — marque vers /, liens « Hydrologie » et « Énergie », lien d'évitement en premier élément focusable ; sticky, 56 px, sans menu burger. Le domaine actif est marqué par soulignement et graisse ; il reste actif sur ses sous-pages grâce à lib/navigation.ts (isSectionActive), car des routes déclarées à plat ne le permettent pas à vue-router. aria-current vaut page sur la page elle-même, true sur une sous-page.
  • PHomePage (/) — introduction, OHomeMethod (les quatre principes de la méthode, statique) et OHomeDomains (une MDomainCard par domaine). Seule page chargée d'emblée : l'hydrologie et l'énergie sont des chunks séparés.
  • MDomainCard — molécule présentationnelle : question, un chiffre, sa date, la source et un lien ; quatre états (loading, error, missing, dated). Un chiffre n'est jamais affiché sans sa date.
  • OHomeDomains — branche les deux chiffres sur des données déjà servies : le débit à Porte du Scex (code OFEV 2009, exutoire du Rhône valaisan) depuis useStationsList, avec l'heure de mesure en heure suisse ; la répartition des verdicts de positionnement depuis useEnergyTariffs().loadPositioning, qui charge l'écran de positionnement seul, avec son propre état, sans marquer la page énergie comme chargée. Logique pure dans lib/home/domain-figures.ts.
  • PHydrologyPage (/hydrologie) — l'ancien tableau de bord servi sur /, inchangé hormis la hauteur du hero (écran moins l'en-tête). Les anciennes ancres /#map, /#alerts, /#chat redirigent vers la même ancre sur /hydrologie (router/legacy-anchors.ts).
  • Titres d'onglet — chaque route porte la clé i18n de son nom (meta.titleKey) ; router.afterEach fixe « Hydrologie — AlpiMonitor », et « AlpiMonitor » seul sur l'accueil.