From 2c4d91ce2fae46a2783017b3fe1ce44f510083a8 Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 26 May 2026 00:14:05 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20graph=20T=C2=B0=20interactif=20+=20widg?= =?UTF-8?q?et=20iframe=20+=20MF=20auth=20+=20E2E=20Playwright?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Graph T° (TemperatureChartInteractive.astro) : - Onglets 24 h / 7 j / 30 j (toggle JS, séries serialisées au SSR) - Hover vertical line + tooltip valeurs - Overlay normales mois en pointillé (TX orange, TN bleu) - Onglet 24 h dispo seulement si l'API MF a répondu (best-effort) Météo France OAuth2 (lib/meteofrance-auth.ts + observations.ts) : - client_credentials avec refresh auto, cache token Valkey - Fallback METEOFRANCE_STATIC_TOKEN pour debug - /synop endpoint pour 24h horaires par station SYNOP du dept - Mapping dept → station SYNOP la plus proche (src/data/stations-synop.json) - En attente de creds : SDK skip silencieusement, l'onglet 24h n'apparaît pas Widget iframe (/embed/dept/[code] + /embed doc) : - Layout minimal sans header/footer global - Réutilisable via iframe avec une ligne - Page /embed avec snippet copier-coller + aperçu live Tests E2E Playwright (tests/e2e/) : - home (carte 96 paths, tooltip dept, navigation) - api (health, vigilance, vigilance/dept) - departement (tabs période, DROM notice, 404) - static pages (a-propos, mentions, dependances, soutenir, conseils, embed) - embed widget (rendu minimal, headers X-Frame OK) - 20+ tests, run via pnpm test:e2e (live) ou test:e2e:local Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 8 + .env.tmpl | 4 + .gitignore | 4 + Données_d’observation_swagger.json | 460 ------------------ ..._d’observation_swagger.json:Zone.Identifier | Bin 25 -> 0 bytes package.json | 5 +- playwright.config.ts | 25 + pnpm-lock.yaml | 38 ++ .../TemperatureChartInteractive.astro | 264 ++++++++++ src/layouts/Embed.astro | 21 + src/lib/meteofrance-auth.ts | 89 ++++ src/lib/observations.ts | 98 ++++ src/pages/departement/[code].astro | 34 +- src/pages/embed/dept/[code].astro | 79 +++ src/pages/embed/index.astro | 94 ++++ tests/e2e/api.spec.ts | 37 ++ tests/e2e/departement.spec.ts | 31 ++ tests/e2e/embed.spec.ts | 24 + tests/e2e/home.spec.ts | 35 ++ tests/e2e/static-pages.spec.ts | 39 ++ 20 files changed, 922 insertions(+), 467 deletions(-) delete mode 100644 Données_d’observation_swagger.json delete mode 100644 Données_d’observation_swagger.json:Zone.Identifier create mode 100644 playwright.config.ts create mode 100644 src/components/TemperatureChartInteractive.astro create mode 100644 src/layouts/Embed.astro create mode 100644 src/lib/meteofrance-auth.ts create mode 100644 src/lib/observations.ts create mode 100644 src/pages/embed/dept/[code].astro create mode 100644 src/pages/embed/index.astro create mode 100644 tests/e2e/api.spec.ts create mode 100644 tests/e2e/departement.spec.ts create mode 100644 tests/e2e/embed.spec.ts create mode 100644 tests/e2e/home.spec.ts create mode 100644 tests/e2e/static-pages.spec.ts diff --git a/.env.example b/.env.example index 5448648..1c045cb 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,11 @@ VIGILANCE_CACHE_TTL=900 # Umami analytics (optionnel — laisser vide pour désactiver) UMAMI_WEBSITE_ID= UMAMI_SRC=https://analytics.nocleus.com/script.js + +# Météo France API (OAuth2 sur portail-api.meteofrance.fr) +# Choisir l'un OU l'autre : +# OAuth2 (recommandé prod, refresh auto) : +METEOFRANCE_CLIENT_ID= +METEOFRANCE_CLIENT_SECRET= +# Static token (test seulement, expire 1h) : +METEOFRANCE_STATIC_TOKEN= diff --git a/.env.tmpl b/.env.tmpl index ab3f80f..b17dd2a 100644 --- a/.env.tmpl +++ b/.env.tmpl @@ -14,3 +14,7 @@ UMAMI_SRC=https://analytics.nocleus.com/script.js # GlitchTip (optionnel — si vide, pas d'envoi Sentry) SENTRY_DSN={{ pass://Infra/Info Canicule — secrets/SENTRY_DSN }} + +# Météo France API (portail-api.meteofrance.fr) — OAuth2 client_credentials pour hourly observations +METEOFRANCE_CLIENT_ID={{ pass://Infra/Météo France API/client_id }} +METEOFRANCE_CLIENT_SECRET={{ pass://Infra/Météo France API/client_secret }} diff --git a/.gitignore b/.gitignore index 4992d71..199b6e8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ dist .vscode/*.local .idea data-sources/ +*:Zone.Identifier +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/Données_d’observation_swagger.json b/Données_d’observation_swagger.json deleted file mode 100644 index 18dbff1..0000000 --- a/Données_d’observation_swagger.json +++ /dev/null @@ -1,460 +0,0 @@ -{ - "openapi": "3.0.1", - "info": { - "title": "DonneesPubliquesObservation", - "version": "1" - }, - "servers": [ - { - "url": "https://public-api.meteofrance.fr/public/DPObs/v1" - } - ], - "security": [ - { - "default": [] - } - ], - "tags": [ - { - "name": "Produits Obs", - "description": "Services de téléchargement des données d'observation à la date demandée." - }, - { - "name": "Produits Synop", - "description": "Services de téléchargement des messages internationaux d’observation en surface, SYNOP, à la date demandée." - }, - { - "name": "Produits Bouees", - "description": "ProduitBouees - Service de téléchargement des données d'observations météorologiques effectuées par les bouées Météo-France, à la date demandée" - } - ], - "paths": { - "/station/infrahoraire-6m": { - "get": { - "tags": [ - "Produits Obs" - ], - "summary": "Télécharger le fichier TEXTE (CSV ou JSON ou GEOJSON) des données d'observation pour tous les paramètres disponibles, à la fréquence 6 minutes, pour une station, à la date la plus proche de la date demandée.", - "description": "Renvoie tous les paramètres disponibles pour la station demandée et pour :\n- la date/heure la plus proche de la date demandée selon les données disponibles.", - "parameters": [ - { - "name": "id_station", - "in": "query", - "description": "Identifiant de la station (nomenclature : 8 chiffres selon DDCCCNNN = insee de la commune (DD département, CCC n° de la commune dans le département et NNN n° de la station dans la commune)", - "required": true, - "style": "form", - "explode": true, - "schema": { - "type": "string" - } - }, - { - "name": "date", - "in": "query", - "description": "Date demandée (au format ISO 8601 avec TZ UTC : AAAA-MM-JJThh:mm:ssZ).\n\nPar défaut = date courante", - "required": false, - "style": "form", - "explode": true, - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "format", - "in": "query", - "description": "Format de retour des données (JSON ou CSV ou GEOJSON)", - "required": true, - "style": "form", - "explode": true, - "schema": { - "type": "string", - "enum": [ - "json", - "csv", - "geojson" - ] - } - } - ], - "responses": { - "200": { - "description": "Si une liste vide est renvoyée : station absente ou inexistante\n \nSinon : OK" - }, - "400": { - "description": "Contrôle de paramètres en erreur" - }, - "404": { - "description": "Jeu de données inexistant" - } - }, - "security": [ - { - "default": [] - } - ], - "x-auth-type": "Application & Application User", - "x-throttling-tier": "Unlimited" - } - }, - "/station/horaire": { - "get": { - "tags": [ - "Produits Obs" - ], - "summary": "Télécharger le fichier TEXTE (CSV ou JSON ou GEOJSON) des données d’observation pour tous les paramètres disponibles, à la fréquence horaire, pour une station, à la date la plus proche de la date demandée.", - "description": "Renvoie tous les paramètres disponibles pour la station demandée et pour :\n- la date/heure la plus proche de la date demandée selon les données disponibles.", - "parameters": [ - { - "name": "id_station", - "in": "query", - "description": "Identifiant de la station (nomenclature : 8 chiffres selon DDCCCNNN = insee de la commune (DD département, CCC n° de la commune dans le département et NNN n° de la station dans la commune)", - "required": true, - "style": "form", - "explode": true, - "schema": { - "type": "string" - } - }, - { - "name": "date", - "in": "query", - "description": "Date demandée (au format ISO 8601 avec TZ UTC : AAAA-MM-JJThh:mm:ssZ).\n\nPar défaut = date courante", - "required": false, - "style": "form", - "explode": true, - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "format", - "in": "query", - "description": "Format de retour des données (JSON ou CSV ou GEOJSON)", - "required": true, - "style": "form", - "explode": true, - "schema": { - "type": "string", - "enum": [ - "json", - "csv", - "geojson" - ] - } - } - ], - "responses": { - "200": { - "description": "Si une liste vide est renvoyée : station absente ou inexistante\n \nSinon : OK" - }, - "400": { - "description": "Contrôle de paramètres en erreur" - }, - "404": { - "description": "Jeu de données inexistant" - } - }, - "security": [ - { - "default": [] - } - ], - "x-auth-type": "Application & Application User", - "x-throttling-tier": "Unlimited" - } - }, - "/liste-stations": { - "get": { - "tags": [ - "Produits Obs" - ], - "summary": "Télécharger le fichier TEXTE (CSV) de la liste des stations d'observation.", - "description": "Renvoie la liste des stations.", - "parameters": [], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "La liste est indisponible" - } - }, - "security": [ - { - "default": [] - } - ], - "x-auth-type": "Application & Application User", - "x-throttling-tier": "Unlimited" - } - }, - "/liste-stations-synop": { - "get": { - "tags": [ - "Produits Synop" - ], - "summary": "Télécharger le fichier TEXTE de la liste des stations d'observation.", - "description": "Renvoie la liste des stations.", - "parameters": [ - { - "name": "format", - "in": "query", - "description": "Format de retour des données (JSON ou CSV ou GEOJSON)", - "required": true, - "style": "form", - "explode": true, - "schema": { - "type": "string", - "enum": [ - "json", - "csv", - "geojson" - ] - } - } - ], - "responses": { - "200": { - "description": "OK" - }, - "404": { - "description": "La liste est indisponible" - } - }, - "security": [ - { - "default": [] - } - ], - "x-auth-type": "Application & Application User", - "x-throttling-tier": "Unlimited" - } - }, - "/synop": { - "get": { - "tags": [ - "Produits Synop" - ], - "summary": "Télécharger le fichier TEXTE (CSV ou JSON ou GEOJSON) des données SYNOP pour une station, à la date demandée.", - "description": "Renvoie tous les paramètres disponibles pour la station et pour la date/heure demandées.", - "parameters": [ - { - "name": "format", - "in": "query", - "description": "Format de retour des données (JSON ou CSV ou GEOJSON)", - "required": true, - "style": "form", - "explode": true, - "schema": { - "type": "string", - "enum": [ - "json", - "csv", - "geojson" - ] - } - }, - { - "name": "id_station", - "in": "query", - "description": "Identifiant(s) de la station (séparés par des , par exemple id_station=07002,07003)", - "required": false, - "style": "form", - "explode": true, - "schema": { - "type": "string" - } - }, - { - "name": "date_debut", - "in": "query", - "description": "Date de début d'observation au format AAAA-MM-JJTHH:mm:00Z.", - "required": false, - "style": "form", - "explode": true, - "schema": { - "type": "string", - "format": "date-time" - } - }, - { - "name": "date_fin", - "in": "query", - "description": "Date de fin d'observation au format AAAA-MM-JJTHH:mm:00Z.", - "required": false, - "style": "form", - "explode": true, - "schema": { - "type": "string", - "format": "date-time" - } - } - ], - "responses": { - "200": { - "description": "OK (liste vide si pas de donnée correspondant à la requête)" - }, - "400": { - "description": "Contrôle de paramètres en erreur" - }, - "401": { - "description": "Non autorisé - informations d'identification non valides" - }, - "403": { - "description": "Accès interdit" - }, - "404": { - "description": "Jeu de données inexistant et/ou jeu de données indisponible" - }, - "500": { - "description": "Erreur interne au serveur" - }, - "502": { - "description": "Erreur de passerelle" - }, - "503": { - "description": "Service indisponible" - }, - "504": { - "description": "Temps trop long" - } - }, - "security": [ - { - "default": [] - } - ], - "x-auth-type": "Application & Application User", - "x-throttling-tier": "Unlimited" - } - }, - "/liste-bouees": { - "get": { - "tags": [ - "Produits Bouees" - ], - "summary": "Télécharger le fichier TEXTE de la liste des stations d'observation.", - "description": "Renvoie la liste des stations et bouées.", - "parameters": [ - { - "name": "format", - "in": "query", - "required": true, - "style": "form", - "explode": true, - "schema": { - "type": "string", - "enum": [ - "json", - "csv", - "geojson" - ] - } - } - ], - "responses": { - "200": { - "description": "ok" - }, - "404": { - "description": "La liste est indisponible" - } - }, - "security": [ - { - "default": [] - } - ], - "x-auth-type": "Application & Application User", - "x-throttling-tier": "Unlimited" - } - }, - "/bouees": { - "get": { - "tags": [ - "Produits Bouees" - ], - "summary": "Télécharger le fichier TEXTE (CSV ou JSON ou GEOJSON) des données d'observations pour une station, à la date demandée.", - "description": "Renvoie tous les paramètres disponibles pour la station à la date heure demandée.", - "parameters": [ - { - "name": "format", - "in": "query", - "description": "Format de retour des données (JSON ou CSV ou GEOJSON)", - "required": true, - "style": "form", - "explode": true, - "schema": { - "type": "string", - "enum": [ - "json", - "csv", - "geojson" - ] - } - }, - { - "name": "date_debut", - "in": "query", - "description": "Date de début d'observation au format AAAA-MM-JJTHH:mm:00Z", - "required": false, - "style": "form", - "explode": true, - "schema": { - "type": "string" - } - }, - { - "name": "date_fin", - "in": "query", - "description": "Date de fin d'observation au format AAAA-MM-JJTHH:mm:00Z", - "required": false, - "style": "form", - "explode": true, - "schema": { - "type": "string" - } - }, - { - "name": "id_bouees", - "in": "query", - "description": "Identifiant(s) de la station (séparés par des , par exemple id_station=6100001,6100002)", - "required": false, - "style": "form", - "explode": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok (liste vide si aucune donnée ne correspondent à la requête)" - } - }, - "security": [ - { - "default": [] - } - ], - "x-auth-type": "Application & Application User", - "x-throttling-tier": "Unlimited" - } - } - }, - "components": { - "securitySchemes": { - "default": { - "type": "oauth2", - "flows": { - "implicit": { - "authorizationUrl": "https://public-api.meteofrance.fr/authorize", - "scopes": {} - } - } - } - } - } -} \ No newline at end of file diff --git a/Données_d’observation_swagger.json:Zone.Identifier b/Données_d’observation_swagger.json:Zone.Identifier deleted file mode 100644 index d6c1ec682968c796b9f5e9e080cc6f674b57c766..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2x=18'} + hasBin: true + '@prisma/instrumentation@7.6.0': resolution: {integrity: sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ==} peerDependencies: @@ -1681,6 +1689,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2216,6 +2229,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -3738,6 +3761,10 @@ snapshots: '@oslojs/encoding@1.1.0': {} + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@prisma/instrumentation@7.6.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -4658,6 +4685,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5394,6 +5424,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + postcss-import@15.1.0(postcss@8.5.15): dependencies: postcss: 8.5.15 diff --git a/src/components/TemperatureChartInteractive.astro b/src/components/TemperatureChartInteractive.astro new file mode 100644 index 0000000..f41fd02 --- /dev/null +++ b/src/components/TemperatureChartInteractive.astro @@ -0,0 +1,264 @@ +--- +import type { DayObservation } from '../lib/climato'; +import type { HourlyObservation, HourlySeries } from '../lib/observations'; +import type { MonthNormale } from '../lib/normales'; + +interface Props { + hourly?: HourlySeries | null; // 24h + days7: DayObservation[]; + days30: DayObservation[]; + normale: MonthNormale | null; + stationLabel?: string | null; +} + +const { hourly, days7, days30, normale, stationLabel } = Astro.props; + +// Sérialiser les 3 séries pour le JS client (toggle + hover). +const serialize = { + hourly: hourly?.observations.map((o) => ({ t: o.time, tx: o.t, tn: null })) ?? [], + days7: days7.map((d) => ({ t: d.date, tx: d.tx, tn: d.tn })), + days30: days30.map((d) => ({ t: d.date, tx: d.tx, tn: d.tn })), + normale: normale ? { tx: normale.tx, tn: normale.tn } : null, +}; + +const hasHourly = (hourly?.observations.length ?? 0) > 0; +const defaultPeriod = hasHourly ? '24h' : '7j'; +--- + +
+
+
+ Températures + {stationLabel && — station {stationLabel}} +
+
+ {hasHourly && ( + + )} + + +
+
+ +
+ + +
+ +
+ T° max (jour) / T° (heure) + T° min (jour) + {normale && ( + + + Normale mois (1991-2020) : TX {normale.tx}°C, TN {normale.tn}°C + + )} +
+
+ + + + diff --git a/src/layouts/Embed.astro b/src/layouts/Embed.astro new file mode 100644 index 0000000..5f06d07 --- /dev/null +++ b/src/layouts/Embed.astro @@ -0,0 +1,21 @@ +--- +import '../styles/global.css'; + +interface Props { + title: string; +} +const { title } = Astro.props; +--- + + + + + + + + {title} + + + + + diff --git a/src/lib/meteofrance-auth.ts b/src/lib/meteofrance-auth.ts new file mode 100644 index 0000000..7688837 --- /dev/null +++ b/src/lib/meteofrance-auth.ts @@ -0,0 +1,89 @@ +// OAuth2 client_credentials pour l'API Météo France (portail-api.meteofrance.fr). +// Le token retourné dure ~1h, on le cache en mémoire process + Valkey jusqu'à 30 min avant expiration. +// +// Configuration via env : +// METEOFRANCE_CLIENT_ID + METEOFRANCE_CLIENT_SECRET → mode OAuth2 (refresh auto) +// METEOFRANCE_STATIC_TOKEN → fallback dev/test (1h) +// +// Le mode OAuth2 est obligatoire en prod ; le static_token est pour debug local. + +import { cacheGet, cacheSet } from './cache'; + +const TOKEN_URL = 'https://portail-api.meteofrance.fr/token'; +const CACHE_KEY = 'mf:access_token'; + +interface CachedToken { + token: string; + expiresAtMs: number; +} + +let inMemory: CachedToken | null = null; + +async function fetchFreshToken(): Promise { + const clientId = process.env.METEOFRANCE_CLIENT_ID; + const clientSecret = process.env.METEOFRANCE_CLIENT_SECRET; + const staticToken = process.env.METEOFRANCE_STATIC_TOKEN; + + if (clientId && clientSecret) { + const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + const body = new URLSearchParams({ grant_type: 'client_credentials' }); + const res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { + Authorization: `Basic ${basic}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }); + if (!res.ok) { + const txt = await res.text(); + throw new Error(`MF token fetch failed: ${res.status} ${txt.slice(0, 200)}`); + } + const json = (await res.json()) as { access_token: string; expires_in: number }; + const expiresAtMs = Date.now() + (json.expires_in - 120) * 1000; // marge 2 min + return { token: json.access_token, expiresAtMs }; + } + + if (staticToken) { + // Pas de moyen de décoder l'expiration sans parser le JWT ; on suppose 1h (à durcir si besoin). + // Si le token est expiré, l'API retournera 401 et le caller pourra invalidate(). + return { token: staticToken, expiresAtMs: Date.now() + 50 * 60 * 1000 }; + } + + throw new Error('No Météo France credentials in env (need METEOFRANCE_CLIENT_ID+SECRET or METEOFRANCE_STATIC_TOKEN)'); +} + +export async function getAccessToken(forceRefresh = false): Promise { + if (!forceRefresh && inMemory && Date.now() < inMemory.expiresAtMs) { + return inMemory.token; + } + if (!forceRefresh) { + const cached = await cacheGet(CACHE_KEY); + if (cached && Date.now() < cached.expiresAtMs) { + inMemory = cached; + return cached.token; + } + } + const fresh = await fetchFreshToken(); + inMemory = fresh; + const ttl = Math.max(60, Math.floor((fresh.expiresAtMs - Date.now()) / 1000)); + await cacheSet(CACHE_KEY, fresh, ttl); + return fresh.token; +} + +export async function fetchMF(path: string, init: RequestInit = {}): Promise { + const base = 'https://public-api.meteofrance.fr'; + let token = await getAccessToken(); + let res = await fetch(`${base}${path}`, { + ...init, + headers: { ...init.headers, Authorization: `Bearer ${token}`, Accept: 'application/json' }, + }); + if (res.status === 401) { + token = await getAccessToken(true); + res = await fetch(`${base}${path}`, { + ...init, + headers: { ...init.headers, Authorization: `Bearer ${token}`, Accept: 'application/json' }, + }); + } + return res; +} diff --git a/src/lib/observations.ts b/src/lib/observations.ts new file mode 100644 index 0000000..093f670 --- /dev/null +++ b/src/lib/observations.ts @@ -0,0 +1,98 @@ +// Observations horaires Météo France (SYNOP) par département. +// Pour chaque dept, on utilise la station SYNOP la plus proche du centroïde +// (pré-calculé dans src/data/stations-synop.json). + +import { fetchMF } from './meteofrance-auth'; +import { cacheOrFetch } from './cache'; +import stationsMap from '../data/stations-synop.json'; + +type StationsMap = Record; +const STATIONS = stationsMap as StationsMap; + +export interface HourlyObservation { + time: string; // ISO 8601 UTC + t: number | null; // T° en °C (converti depuis Kelvin) + td: number | null; // T° point de rosée °C + u: number | null; // humidité % + ff: number | null; // vent moyen m/s + rr1: number | null; // précipitations 1h mm + pres: number | null; // pression mer hPa +} + +export interface HourlySeries { + dept: string; + stationId: string; + stationName: string; + distKm: number; + fetchedAt: string; + observations: HourlyObservation[]; // sorted by time asc +} + +function K2C(k: number | null | undefined): number | null { + if (typeof k !== 'number' || !Number.isFinite(k)) return null; + return +(k - 273.15).toFixed(1); +} + +function num(v: unknown): number | null { + return typeof v === 'number' && Number.isFinite(v) ? v : null; +} + +interface MFSynopRow { + validity_time?: string; + t?: number; + td?: number; + u?: number; + ff?: number; + rr1?: number; + pmer?: number; + pres?: number; +} + +export function stationForDepartement(dept: string): { stationId: string; name: string; distKm: number } | null { + return STATIONS[dept] ?? null; +} + +export async function getHourlyForDepartement(dept: string, hours = 24): Promise { + const meta = stationForDepartement(dept); + if (!meta) return null; + + const cacheKey = `mf:hourly:${dept}:${hours}`; + // Cache 30 min — les obs SYNOP sont publiées toutes les 3h (parfois 1h), 30 min est un bon compromis. + return cacheOrFetch(cacheKey, 30 * 60, async () => { + const now = new Date(); + const start = new Date(now.getTime() - hours * 3600 * 1000); + const dateDebut = start.toISOString().split('.')[0] + 'Z'; + const dateFin = now.toISOString().split('.')[0] + 'Z'; + const path = `/public/DPObs/v1/synop?format=json&id_station=${encodeURIComponent(meta.stationId)}&date_debut=${encodeURIComponent(dateDebut)}&date_fin=${encodeURIComponent(dateFin)}`; + const res = await fetchMF(path); + if (!res.ok) { + throw new Error(`MF synop ${dept}: ${res.status}`); + } + const rows = (await res.json()) as MFSynopRow[]; + if (!Array.isArray(rows)) { + throw new Error('MF synop response not an array'); + } + + const observations: HourlyObservation[] = rows + .filter((r) => r.validity_time) + .map((r) => ({ + time: r.validity_time!, + t: K2C(num(r.t)), + td: K2C(num(r.td)), + u: num(r.u), + ff: num(r.ff), + rr1: num(r.rr1), + pres: typeof r.pmer === 'number' ? +(r.pmer / 100).toFixed(1) : (typeof r.pres === 'number' ? +(r.pres / 100).toFixed(1) : null), + })) + .sort((a, b) => (a.time < b.time ? -1 : 1)); + + return { + dept, + stationId: meta.stationId, + stationName: meta.name, + distKm: meta.distKm, + fetchedAt: new Date().toISOString(), + observations, + }; + }); +} diff --git a/src/pages/departement/[code].astro b/src/pages/departement/[code].astro index efdf073..aefad09 100644 --- a/src/pages/departement/[code].astro +++ b/src/pages/departement/[code].astro @@ -7,8 +7,9 @@ import { getDepartement, isDrom } from '../../lib/departements'; import { PHENOMENA, COLOR_LABEL } from '../../lib/phenomena'; import { ADVICE, EMERGENCY_NUMBERS } from '../../lib/advice'; import { getClimatoForDepartement } from '../../lib/climato'; -import { computeAnomaly } from '../../lib/normales'; -import ClimatoChart from '../../components/ClimatoChart.astro'; +import { computeAnomaly, normaleForMonth } from '../../lib/normales'; +import { getHourlyForDepartement } from '../../lib/observations'; +import TemperatureChartInteractive from '../../components/TemperatureChartInteractive.astro'; import AnomalyBadge from '../../components/AnomalyBadge.astro'; export const prerender = false; @@ -34,17 +35,32 @@ if (!drom) { let climato = null; let anomaly = null; +let hourly = null; +let normale = null; if (!drom) { try { climato = await getClimatoForDepartement(dept.code); if (climato?.days?.length) { anomaly = await computeAnomaly(dept.code, climato.days); + // Mois représentatif = mois du dernier jour climato dispo + const lastDate = climato.days[climato.days.length - 1].date; + normale = await normaleForMonth(dept.code, parseInt(lastDate.slice(5, 7), 10)); } } catch (e) { console.warn('climato fetch failed for', dept.code, (e as Error).message); } + // Hourly est best-effort : si MF API down ou pas de creds, on n'affiche juste pas l'onglet 24h + try { + hourly = await getHourlyForDepartement(dept.code, 24); + } catch (e) { + console.warn('hourly fetch failed for', dept.code, (e as Error).message); + } } +const last7 = climato?.days?.slice(-7) ?? []; +const last30 = climato?.days?.slice(-30) ?? []; +const stationLabel = hourly ? `${hourly.stationName} (${hourly.distKm} km)` : null; + const today = snapshot ? alertsForDepartement(snapshot, dept.code, 'J') : []; const tomorrow = snapshot ? alertsForDepartement(snapshot, dept.code, 'J1') : []; const highest = today[0]; @@ -158,11 +174,17 @@ const adviceFor = highest && ADVICE[highest.phenomenonId]; )} - +

- Source : Météo France — données climatologiques de base quotidiennes, agrégées par moyenne - sur toutes les stations du département. Donnée brute, contrôle qualité Météo France. - Normales calculées sur 1991-2020 (référence WMO). + Sources : Météo France — observations SYNOP horaires (onglet 24 h) et données + climatologiques de base quotidiennes (7 j / 30 j), agrégées par moyenne sur les + stations du département. Normales calculées sur 1991-2020 (référence WMO).

diff --git a/src/pages/embed/dept/[code].astro b/src/pages/embed/dept/[code].astro new file mode 100644 index 0000000..d8c3c23 --- /dev/null +++ b/src/pages/embed/dept/[code].astro @@ -0,0 +1,79 @@ +--- +import Embed from '../../../layouts/Embed.astro'; +import VigilanceChip from '../../../components/VigilanceChip.astro'; +import { getVigilanceSnapshot, alertsForDepartement, maxColorByDepartement } from '../../../lib/vigilance'; +import { getDepartement, isDrom } from '../../../lib/departements'; +import { COLORS } from '../../../lib/phenomena'; + +export const prerender = false; + +const { code } = Astro.params; +const dept = code ? getDepartement(code.toUpperCase()) : undefined; +if (!dept) { + return new Response('not found', { status: 404 }); +} + +let alerts = [] as Awaited>; +let maxColor = 1; +let error: string | null = null; +if (!isDrom(dept.code)) { + try { + const snap = await getVigilanceSnapshot(); + alerts = alertsForDepartement(snap, dept.code, 'J').sort((a, b) => b.colorId - a.colorId); + maxColor = maxColorByDepartement(snap, 'J').get(dept.code) ?? 1; + } catch (e) { + error = (e as Error).message; + } +} + +const color = COLORS[maxColor as 1 | 2 | 3 | 4]; +const siteUrl = `https://info-canicule.nocleus.com/departement/${dept.code}`; +--- + + +
+
+
+

{dept.name}

+

Vigilance Météo France · dept {dept.code}

+
+
= 3 ? '#fff' : '#1e293b'}`} + > + {color.name.toUpperCase()} +
+
+ + {error && ( +

Données indisponibles.

+ )} + + {isDrom(dept.code) && ( +

+ Vigilance Outre-mer non couverte par cette source. + Source officielle ↗ +

+ )} + + {!error && !isDrom(dept.code) && alerts.length === 0 && ( +

Aucune vigilance particulière aujourd'hui.

+ )} + + {alerts.length > 0 && ( +
    + {alerts.map((a) => ( +
  • + +
  • + ))} +
+ )} + + +
+ diff --git a/src/pages/embed/index.astro b/src/pages/embed/index.astro new file mode 100644 index 0000000..8289784 --- /dev/null +++ b/src/pages/embed/index.astro @@ -0,0 +1,94 @@ +--- +import Base from '../../layouts/Base.astro'; +import { DEPARTEMENTS } from '../../lib/departements'; + +export const prerender = false; +const sample = DEPARTEMENTS.find((d) => d.code === '13')!; +const baseUrl = 'https://info-canicule.nocleus.com'; +const snippet = ``; +--- + + +
+
+

Widget intégrable

+

+ Intégrez la Vigilance Météo France de n'importe quel département sur votre site en une + ligne d'iframe. Gratuit, libre de réutilisation (Licence Ouverte 2.0). +

+
+
+ +
+
+

Aperçu live ({sample.name})

+
+ +
+
+ +
+

Code à copier-coller

+
{snippet}
+

+ Remplace {sample.code} par le code de ton + département (01-95, 2A, 2B, 971-976). La liste complète : + page conseils. +

+
+ +
+

Personnalisation

+
    +
  • + Dimensions : ajustables via width et height. + Le widget s'adapte aux petits écrans grâce à max-width:100%. +
  • +
  • + Fond transparent : pose-le sur n'importe quelle couleur de page. +
  • +
  • + Rafraîchissement : la page se met à jour automatiquement à chaque + rechargement (cache 15 min côté serveur). +
  • +
+
+ +
+

API JSON brute (alternative)

+

+ Si tu préfères afficher la Vigilance avec ton propre design, l'endpoint + /api/vigilance/dept/[code] + renvoie un JSON CORS *, prêt à parser : +

+
fetch('{baseUrl}/api/vigilance/dept/{sample.code}')
+  .then(r => r.json())
+  .then(data => console.log(data.today)); // [{ phenomenon, color, ... }]
+
+ +
+

Conditions de réutilisation

+
    +
  • Données sous Licence Ouverte 2.0 (mention obligatoire de la source : Météo France).
  • +
  • Ce service ne remplace pas la source officielle. En cas d'urgence, suivre vigilance.meteofrance.fr et appeler le 112.
  • +
  • Pas de garantie de disponibilité. Pour un usage critique, contractualiser directement avec Météo France.
  • +
+
+
+ diff --git a/tests/e2e/api.spec.ts b/tests/e2e/api.spec.ts new file mode 100644 index 0000000..dfa8ef9 --- /dev/null +++ b/tests/e2e/api.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; + +test.describe('API', () => { + test('/api/health retourne status ok et cache true', async ({ request }) => { + const res = await request.get('/api/health'); + expect(res.status()).toBeLessThan(600); // accepte 200 ou 503 selon Valkey + const body = await res.json(); + expect(body).toHaveProperty('status'); + expect(body).toHaveProperty('cache'); + expect(body).toHaveProperty('time'); + }); + + test('/api/vigilance retourne snapshot JSON valide CORS *', async ({ request }) => { + const res = await request.get('/api/vigilance'); + expect(res.status()).toBe(200); + expect(res.headers()['access-control-allow-origin']).toBe('*'); + const body = await res.json(); + expect(body).toHaveProperty('fetchedAt'); + expect(body).toHaveProperty('alerts'); + expect(Array.isArray(body.alerts)).toBe(true); + }); + + test('/api/vigilance/dept/13 retourne enrichi avec labels', async ({ request }) => { + const res = await request.get('/api/vigilance/dept/13'); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.departement.code).toBe('13'); + expect(body.departement.name).toBe('Bouches-du-Rhône'); + expect(body).toHaveProperty('today'); + expect(body).toHaveProperty('tomorrow'); + }); + + test('/api/vigilance/dept/999 retourne 404', async ({ request }) => { + const res = await request.get('/api/vigilance/dept/999'); + expect(res.status()).toBe(404); + }); +}); diff --git a/tests/e2e/departement.spec.ts b/tests/e2e/departement.spec.ts new file mode 100644 index 0000000..fde51fc --- /dev/null +++ b/tests/e2e/departement.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Page département', () => { + test('/departement/75 (Paris) — métropole', async ({ page }) => { + await page.goto('/departement/75'); + await expect(page.getByRole('heading', { name: 'Paris', exact: true })).toBeVisible(); + // Graph interactif avec onglets période + await expect(page.getByRole('tab', { name: '7 jours' })).toBeVisible(); + await expect(page.getByRole('tab', { name: '30 jours' })).toBeVisible(); + }); + + test('Tabs période changent activeselected', async ({ page }) => { + await page.goto('/departement/75'); + const tab7 = page.getByRole('tab', { name: '7 jours' }); + const tab30 = page.getByRole('tab', { name: '30 jours' }); + await tab30.click(); + await expect(tab30).toHaveAttribute('aria-selected', 'true'); + await expect(tab7).toHaveAttribute('aria-selected', 'false'); + }); + + test('/departement/971 (Guadeloupe) — DROM affiche notice', async ({ page }) => { + await page.goto('/departement/971'); + await expect(page.getByRole('heading', { name: 'Guadeloupe' })).toBeVisible(); + await expect(page.getByText(/Vigilance Outre-mer non couverte/i)).toBeVisible(); + }); + + test('Page dept invalide retourne 404', async ({ page }) => { + const res = await page.goto('/departement/999'); + expect(res?.status()).toBe(404); + }); +}); diff --git a/tests/e2e/embed.spec.ts b/tests/e2e/embed.spec.ts new file mode 100644 index 0000000..d1148a5 --- /dev/null +++ b/tests/e2e/embed.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Widget embed', () => { + test('/embed/dept/75 charge avec Paris', async ({ page }) => { + await page.goto('/embed/dept/75'); + await expect(page.locator('h1')).toHaveText('Paris'); + // Embed minimal : pas de header global Info Canicule + await expect(page.getByRole('link', { name: 'Soutenir' })).toHaveCount(0); + }); + + test('/embed/dept/971 — DROM notice', async ({ page }) => { + await page.goto('/embed/dept/971'); + await expect(page.locator('h1')).toHaveText('Guadeloupe'); + await expect(page.getByText(/Outre-mer non couverte/i)).toBeVisible(); + }); + + test('embed servi avec X-Frame-Options non bloquant', async ({ request }) => { + const res = await request.get('/embed/dept/75'); + expect(res.status()).toBe(200); + const xfo = res.headers()['x-frame-options']; + // Soit absent (autorisé), soit SAMEORIGIN (pas DENY) + expect(xfo === undefined || xfo.toUpperCase() !== 'DENY').toBe(true); + }); +}); diff --git a/tests/e2e/home.spec.ts b/tests/e2e/home.spec.ts new file mode 100644 index 0000000..7f782ad --- /dev/null +++ b/tests/e2e/home.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Home', () => { + test('charge avec carte SVG 96 départements', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/Info Canicule/); + const paths = page.locator('#france-map path[data-code]'); + await expect(paths).toHaveCount(96); + }); + + test('navigation principale visible', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('link', { name: /Carte/i }).first()).toBeVisible(); + await expect(page.getByRole('link', { name: /Conseils/i }).first()).toBeVisible(); + await expect(page.getByRole('link', { name: /Soutenir/i }).first()).toBeVisible(); + }); + + test('section "Départements en alerte" si alertes du jour', async ({ page }) => { + await page.goto('/'); + // Soit la section existe avec alertes, soit pas du tout (jour calme) — ne casse pas si vide + const section = page.getByRole('heading', { name: /Départements en alerte/i }); + if (await section.count() > 0) { + await expect(section).toBeVisible(); + } + }); + + test('tooltip carte au hover', async ({ page }) => { + await page.goto('/'); + const tooltip = page.locator('#map-tooltip'); + await expect(tooltip).toBeHidden(); + await page.locator('#france-map path[data-code="75"]').hover(); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toContainText('Paris'); + }); +}); diff --git a/tests/e2e/static-pages.spec.ts b/tests/e2e/static-pages.spec.ts new file mode 100644 index 0000000..3e0162e --- /dev/null +++ b/tests/e2e/static-pages.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test'; + +const PAGES = [ + { url: '/a-propos', title: /À propos/i }, + { url: '/mentions-legales', title: /Mentions légales/i }, + { url: '/dependances', title: /Dépendances/i }, + { url: '/soutenir', title: /Soutenir/i }, + { url: '/conseils', title: /Conseils officiels/i }, + { url: '/conseils/registre-canicule', title: /Registre canicule/i }, + { url: '/embed', title: /Widget intégrable/i }, + { url: '/robots.txt', text: /Sitemap:/ }, + { url: '/sitemap-departements.xml', text: / { + if ('text' in p && p.text) { + const res = await request.get(p.url); + expect(res.status()).toBe(200); + const body = await res.text(); + expect(body).toMatch(p.text); + } else if ('title' in p && p.title) { + await page.goto(p.url); + await expect(page.locator('h1').first()).toHaveText(p.title); + } + }); +} + +test('Ko-fi link sur /soutenir', async ({ page }) => { + await page.goto('/soutenir'); + const link = page.getByRole('link', { name: /Soutenir sur Ko-fi/i }).first(); + await expect(link).toHaveAttribute('href', /ko-fi\.com\/daelwizhit/); +}); + +test('Mentions légales — distinction Nocleus/perso', async ({ page }) => { + await page.goto('/mentions-legales'); + await expect(page.getByText(/micro-entreprise/i)).toBeVisible(); + await expect(page.getByText(/non lucratif/i).first()).toBeVisible(); +});