feat: OG png + sentry + dept api + drom notice + registre canicule
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run

Quick wins :
- public/og-image.png (1200x630, via sharp depuis le SVG, build via pnpm build)
  SVG ne fonctionne pas pour Open Graph (Slack/Discord/X/FB).
- @sentry/astro intégré conditionnellement (skip si SENTRY_DSN absent → no-op).
  GIT_COMMIT_SHA en var pour le release tag dans GlitchTip si voulu.
- /api/vigilance/dept/[code] : JSON enrichi (phenomenon label + color name)
  pour J et J1, CORS *, Cache-Control 5min. 404 si code unknown.
- JSON-LD enrichi : @graph WebSite + Service avec isBasedOn Dataset + license LOv2.
- Lien retour vigilance.meteofrance.fr visible sous la carte.

DROM (97x / 976) :
- 5 entrées ajoutées dans departements.ts (région "DROM").
- /departement/[code] DROM : bannière "Vigilance Outre-mer non couverte par
  cette source open data" + bouton vers vigilance.meteofrance.fr.
- Home : ligne sous la carte listant les 5 DROM + lien retour.
- L'API /api/vigilance/dept/<DROM> retourne quand même un JSON 200 (arrays vides).

Registre canicule :
- Page /conseils/registre-canicule : qui, quoi, comment s'inscrire au CCAS.
- Numéro vert 0 800 06 66 66.
- Bannière mise en avant en haut de /conseils.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian 2026-05-25 20:59:11 +02:00
parent 89e48c18e4
commit 87d173684c
13 changed files with 1595 additions and 35 deletions

View file

@ -2,6 +2,10 @@ import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
import sentry from '@sentry/astro';
const sentryDsn = process.env.SENTRY_DSN;
const release = process.env.GIT_COMMIT_SHA || 'dev';
export default defineConfig({
output: 'server',
@ -14,6 +18,19 @@ export default defineConfig({
!page.includes('/departement/'),
// /departement/* est dynamique pour les 96 dépts — généré dans /sitemap-departements.xml.ts à part.
}),
// Sentry / GlitchTip — opt-in via env. Si SENTRY_DSN absent, intégration omise (no-op).
...(sentryDsn
? [
sentry({
dsn: sentryDsn,
environment: process.env.NODE_ENV ?? 'production',
release,
tracesSampleRate: 0.1,
// GlitchTip est OK avec source maps mais on les omet pour éviter le upload.
sourceMapsUploadOptions: { enabled: false },
}),
]
: []),
],
server: { host: '0.0.0.0', port: 4321 },
site: 'https://info-canicule.nocleus.com',

View file

@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"build": "node scripts/build-og-image.mjs && astro build",
"build:og": "node scripts/build-og-image.mjs",
"preview": "astro preview",
"start": "node ./dist/server/entry.mjs",
"astro": "astro",
@ -16,6 +17,7 @@
"@astrojs/node": "^9.2.2",
"@astrojs/sitemap": "^3.6.0",
"@astrojs/tailwind": "^6.0.2",
"@sentry/astro": "^10.53.1",
"@tailwindcss/typography": "^0.5.16",
"astro": "^5.7.0",
"ioredis": "^5.6.0",
@ -24,6 +26,7 @@
"devDependencies": {
"@astrojs/check": "^0.9.4",
"@types/node": "^22.10.5",
"sharp": "^0.34.5",
"typescript": "^5.7.2"
},
"packageManager": "pnpm@10.0.0"

1272
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

BIN
public/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -0,0 +1,20 @@
#!/usr/bin/env node
// Convertit public/og-image.svg en public/og-image.png (1200×630).
// Lancé manuellement ou via `pnpm prebuild` quand le SVG bouge.
import sharp from 'sharp';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SVG = resolve(__dirname, '../public/og-image.svg');
const PNG = resolve(__dirname, '../public/og-image.png');
const buf = readFileSync(SVG);
await sharp(buf, { density: 144 })
.resize(1200, 630, { fit: 'fill' })
.png({ compressionLevel: 9 })
.toFile(PNG);
console.log(`Wrote ${PNG}`);

View file

@ -14,7 +14,7 @@ const {
title = 'Info Canicule — Vigilance météo France en temps réel',
description = 'Suivi gratuit et sans publicité des alertes Vigilance Météo France (canicule, orages, tempêtes), avec carte interactive par département et conseils officiels.',
canonical,
ogImage = '/og-image.svg',
ogImage = '/og-image.png',
noindex = false,
} = Astro.props;
@ -25,20 +25,43 @@ const umamiSrc = process.env.UMAMI_SRC ?? 'https://analytics.nocleus.com/script.
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Info Canicule',
url: SITE,
description,
inLanguage: 'fr-FR',
publisher: {
'@type': 'Person',
name: 'Florian Bouchet',
},
potentialAction: {
'@type': 'SearchAction',
target: `${SITE}/departement/{code}`,
'query-input': 'required name=code',
},
'@graph': [
{
'@type': 'WebSite',
'@id': `${SITE}/#website`,
name: 'Info Canicule',
url: SITE,
description,
inLanguage: 'fr-FR',
publisher: { '@type': 'Person', name: 'Florian Bouchet' },
potentialAction: {
'@type': 'SearchAction',
target: `${SITE}/departement/{code}`,
'query-input': 'required name=code',
},
},
{
'@type': 'Service',
'@id': `${SITE}/#service`,
name: 'Info Canicule',
serviceType: 'Service d\'information météorologique grand public',
areaServed: { '@type': 'Country', name: 'France' },
audience: { '@type': 'PeopleAudience', audienceType: 'Grand public, personnes fragiles' },
provider: {
'@type': 'Person',
name: 'Florian Bouchet',
url: `${SITE}/a-propos`,
},
isBasedOn: {
'@type': 'Dataset',
name: 'Vigilance Météo France',
url: 'https://vigilance.meteofrance.fr/',
creator: { '@type': 'Organization', name: 'Météo-France' },
license: 'https://www.etalab.gouv.fr/licence-ouverte-open-licence/',
},
isAccessibleForFree: true,
},
],
};
---

View file

@ -48,7 +48,7 @@ export const ADVICE: Record<PhenomenonId, PhenomenonAdvice> = {
title: 'Veiller sur les proches',
items: [
"Prendre des nouvelles des personnes isolées (voisins âgés, malades, sans-abri)",
"S'inscrire ou inscrire un proche fragile sur le registre canicule de sa mairie",
"Inscrire un proche fragile sur le registre canicule de sa mairie — voir page dédiée",
'En cas de symptôme inquiétant (maux de tête, vertiges, nausées, fièvre) : alerter rapidement',
],
},

View file

@ -106,8 +106,20 @@ export const DEPARTEMENTS: Departement[] = [
{ code: '94', name: 'Val-de-Marne', region: 'Île-de-France' },
{ code: '95', name: "Val-d'Oise", region: 'Île-de-France' },
{ code: '99', name: 'Andorre (zone Vigilance)', region: 'Hors France' },
// DROM — non couverts par le dataset Opendatasoft actuel.
// Vigilance DROM dispo via l'API Météo France officielle uniquement (TODO).
{ code: '971', name: 'Guadeloupe', region: 'DROM' },
{ code: '972', name: 'Martinique', region: 'DROM' },
{ code: '973', name: 'Guyane', region: 'DROM' },
{ code: '974', name: 'La Réunion', region: 'DROM' },
{ code: '976', name: 'Mayotte', region: 'DROM' },
];
export const DROM_CODES = new Set(['971', '972', '973', '974', '976']);
export function isDrom(code: string): boolean {
return DROM_CODES.has(code);
}
const BY_CODE = new Map(DEPARTEMENTS.map((d) => [d.code, d]));
export function getDepartement(code: string): Departement | undefined {

View file

@ -0,0 +1,62 @@
import type { APIRoute } from 'astro';
import { getVigilanceSnapshot, alertsForDepartement } from '../../../../lib/vigilance';
import { getDepartement } from '../../../../lib/departements';
import { PHENOMENA, COLORS } from '../../../../lib/phenomena';
export const prerender = false;
// JSON par département (J + J1) — CORS *, réutilisable sous Licence Ouverte 2.0.
// Ex : GET /api/vigilance/dept/75 → alertes Paris
export const GET: APIRoute = async ({ params }) => {
const codeRaw = params.code;
if (!codeRaw) {
return new Response(JSON.stringify({ error: 'missing_code' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const code = codeRaw.toUpperCase();
const dept = getDepartement(code);
if (!dept) {
return new Response(JSON.stringify({ error: 'unknown_departement', code }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const snap = await getVigilanceSnapshot();
const today = alertsForDepartement(snap, code, 'J');
const tomorrow = alertsForDepartement(snap, code, 'J1');
const enrich = (a: ReturnType<typeof alertsForDepartement>[0]) => ({
phenomenonId: a.phenomenonId,
phenomenon: PHENOMENA[a.phenomenonId].label,
colorId: a.colorId,
color: COLORS[a.colorId].name,
beginTime: a.beginTime,
endTime: a.endTime,
});
const body = {
departement: { code, name: dept.name, region: dept.region },
productDatetime: snap.productDatetime,
fetchedAt: snap.fetchedAt,
today: today.map(enrich),
tomorrow: tomorrow.map(enrich),
};
return new Response(JSON.stringify(body), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
},
});
} catch (e) {
return new Response(
JSON.stringify({ error: 'fetch_failed', detail: (e as Error).message }),
{ status: 502, headers: { 'Content-Type': 'application/json' } },
);
}
};

View file

@ -21,6 +21,17 @@ const phenomenaList = Object.values(PHENOMENA).sort((a, b) =>
Recommandations à appliquer en cas d'alerte Vigilance, par type de phénomène.
Sources : Météo France, santé.gouv.fr, gouvernement.fr.
</p>
<div class="mt-4 rounded-lg border border-canicule-200 bg-canicule-50 p-4">
<p class="text-sm font-semibold text-canicule-900">
🛡️ Aider une personne fragile : <a href="/conseils/registre-canicule" class="font-bold underline">
registre canicule de la mairie
</a>
</p>
<p class="mt-1 text-sm text-canicule-800">
Dispositif communal gratuit qui permet à un proche âgé, malade ou isolé d'être contacté en
cas d'alerte. Inscription en quelques minutes via le CCAS.
</p>
</div>
</div>
</section>

View file

@ -0,0 +1,108 @@
---
import Base from '../../layouts/Base.astro';
export const prerender = false;
---
<Base
title="Registre canicule — s'inscrire pour soi ou un proche fragile"
description="Le registre canicule est un dispositif communal gratuit qui permet d'être contacté par la mairie en cas d'épisode caniculaire. Comment s'y inscrire."
>
<section class="bg-gradient-to-b from-canicule-50 to-white">
<div class="container-tight py-10">
<a href="/conseils" class="text-sm text-canicule-700">← Tous les conseils</a>
<h1 class="mt-2 text-3xl font-bold sm:text-4xl">Registre canicule</h1>
<p class="mt-2 max-w-2xl text-slate-600">
Un dispositif communal, gratuit et confidentiel, qui permet d'être contacté par sa mairie en
cas d'alerte canicule. Conçu en priorité pour les personnes fragiles et isolées.
</p>
</div>
</section>
<section class="container-tight py-8 prose prose-slate max-w-none">
<h2>À qui ça s'adresse</h2>
<p>Le registre nominatif communal est ouvert à toute personne qui le souhaite, mais il est
particulièrement utile pour :</p>
<ul>
<li>Les <strong>personnes âgées de 65 ans et plus</strong> vivant à domicile</li>
<li>Les <strong>personnes en situation de handicap</strong></li>
<li>Les <strong>personnes adultes isolées</strong> (sans aidant proche)</li>
<li>Les personnes <strong>fragilisées par une maladie chronique</strong></li>
</ul>
<p>
Vous pouvez inscrire un proche (parent, voisin) avec son accord, ou demander une inscription
d'office pour une personne dont vous estimez qu'elle est en danger.
</p>
<h2>Comment ça se passe en cas d'alerte</h2>
<ul>
<li>Quand un département passe en vigilance orange ou rouge canicule, le plan « Plan National
Canicule » (PNC) s'enclenche en mairie.</li>
<li>Les personnes inscrites sur le registre sont <strong>appelées</strong> ou
<strong>visitées</strong> par des agents communaux ou bénévoles (Croix-Rouge, ADMR…).</li>
<li>L'objectif : vérifier qu'elles vont bien, qu'elles ont de quoi s'hydrater et rester au
frais, et alerter si besoin (médecin traitant, 15, famille).</li>
</ul>
<h2>Comment s'inscrire</h2>
<ol>
<li>
Contacter le <strong>CCAS</strong> (Centre Communal d'Action Sociale) de votre commune, ou
directement la mairie. Numéros disponibles via l'annuaire officiel des mairies :
<a href="https://lannuaire.service-public.fr/navigation/mairie" rel="noopener">
lannuaire.service-public.fr/navigation/mairie
</a>
</li>
<li>
Demander le <strong>formulaire d'inscription au registre canicule</strong> (parfois appelé
« registre des personnes vulnérables » ou « plan canicule »).
</li>
<li>
Remplir le formulaire avec : nom, âge, adresse, téléphone, personne à prévenir, médecin
traitant, éventuelles particularités (isolement, mobilité, etc.).
</li>
<li>
Retourner le formulaire à la mairie. <strong>Gratuit et confidentiel</strong> — les données
ne servent qu'au plan canicule, ne sont pas partagées commercialement.
</li>
</ol>
<h2>Bon à savoir</h2>
<ul>
<li>L'inscription est <strong>valable pour toute la saison estivale</strong> (juin à
septembre), à renouveler chaque année si besoin.</li>
<li>Vous pouvez vous <strong>désinscrire à tout moment</strong> par simple courrier ou appel.</li>
<li>
Cadre légal : article L121-6-1 du Code de l'action sociale et des familles + circulaire
annuelle relative au Plan National Canicule.
</li>
</ul>
<div class="not-prose my-6 rounded-lg border border-canicule-200 bg-canicule-50 p-4">
<p class="text-sm font-semibold text-canicule-900">📞 Plateforme téléphonique canicule</p>
<p class="mt-1 text-sm text-canicule-800">
Numéro vert <a href="tel:0800066666" class="font-mono font-bold">0 800 06 66 66</a> (gratuit
depuis un poste fixe). Activé pendant les épisodes de canicule pour informer et conseiller.
</p>
</div>
<h2>Pour aller plus loin</h2>
<ul>
<li>
<a href="https://www.sante.gouv.fr/sante-et-environnement/risques-climatiques/article/plan-national-canicule-pnc" rel="noopener">
Plan National Canicule (santé.gouv.fr)
</a>
</li>
<li>
<a href="https://solidarites.gouv.fr/sites/solidarite/files/migration/Brochure_canicule_grand_public_2024.pdf" rel="noopener">
Brochure officielle Canicule grand public (PDF)
</a>
</li>
<li>
<a href="https://meteofrance.com/comprendre-meteo/temperature-et-chaleur/canicule" rel="noopener">
Comprendre une canicule (Météo France)
</a>
</li>
</ul>
</section>
</Base>

View file

@ -3,7 +3,7 @@ import Base from '../../layouts/Base.astro';
import VigilanceChip from '../../components/VigilanceChip.astro';
import VigilanceLegend from '../../components/VigilanceLegend.astro';
import { getVigilanceSnapshot, alertsForDepartement } from '../../lib/vigilance';
import { getDepartement } from '../../lib/departements';
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';
@ -18,19 +18,25 @@ if (!dept) {
return new Response('Département introuvable', { status: 404 });
}
const drom = isDrom(dept.code);
let snapshot;
let error: string | null = null;
try {
snapshot = await getVigilanceSnapshot();
} catch (e) {
error = (e as Error).message;
if (!drom) {
try {
snapshot = await getVigilanceSnapshot();
} catch (e) {
error = (e as Error).message;
}
}
let climato = null;
try {
climato = await getClimatoForDepartement(dept.code);
} catch (e) {
console.warn('climato fetch failed for', dept.code, (e as Error).message);
if (!drom) {
try {
climato = await getClimatoForDepartement(dept.code);
} catch (e) {
console.warn('climato fetch failed for', dept.code, (e as Error).message);
}
}
const today = snapshot ? alertsForDepartement(snapshot, dept.code, 'J') : [];
@ -52,10 +58,30 @@ const adviceFor = highest && ADVICE[highest.phenomenonId];
</section>
<section class="container-tight py-8">
{error && <p class="text-red-700">Données indisponibles : {error}</p>}
{drom && (
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="font-semibold text-amber-900">Vigilance Outre-mer non couverte par cette source</p>
<p class="mt-1 text-sm text-amber-800">
Les départements et régions d'Outre-mer disposent de leur propre dispositif Vigilance, géré
par les centres météorologiques locaux de Météo France. Ces données ne sont pas (encore)
rediffusées en open data au même format que la métropole.
</p>
<p class="mt-3">
<a
href="https://vigilance.meteofrance.fr/"
rel="noopener"
class="inline-flex items-center gap-1 rounded bg-amber-700 px-4 py-2 text-sm font-semibold text-white no-underline hover:bg-amber-800"
>
Voir la Vigilance officielle Outre-mer →
</a>
</p>
</div>
)}
{!drom && error && <p class="text-red-700">Données indisponibles : {error}</p>}
{
!error && today.length === 0 && (
!drom && !error && today.length === 0 && (
<div class="rounded border border-green-200 bg-green-50 p-4">
<p class="font-semibold text-green-800">Aucune vigilance particulière aujourd'hui.</p>
<p class="text-sm text-green-700">Le département est en niveau vert pour tous les phénomènes.</p>
@ -64,7 +90,7 @@ const adviceFor = highest && ADVICE[highest.phenomenonId];
}
{
!error && today.length > 0 && (
!drom && !error && today.length > 0 && (
<>
<h2 class="mb-3 text-xl font-semibold">Alertes en cours</h2>
<ul class="space-y-3">
@ -100,7 +126,7 @@ const adviceFor = highest && ADVICE[highest.phenomenonId];
}
{
!error && tomorrow.length > 0 && (
!drom && !error && tomorrow.length > 0 && (
<div class="mt-8">
<h2 class="mb-3 text-xl font-semibold">Prévision pour demain</h2>
<ul class="space-y-2">

View file

@ -119,6 +119,22 @@ const productDate = snapshot?.productDatetime
<DepartementGrid colorsByDept={colorsByDept} />
</div>
</details>
<p class="mt-4 text-center text-xs text-slate-500">
Source officielle :
<a href="https://vigilance.meteofrance.fr/" rel="noopener" class="text-canicule-700 font-medium">
vigilance.meteofrance.fr
</a>
— toujours s'y référer en cas d'urgence.
</p>
<p class="mt-2 text-center text-xs text-slate-500">
<strong>Outre-mer non couvert</strong> par cette source open data :
<a href="/departement/971" class="text-canicule-700">Guadeloupe</a> ·
<a href="/departement/972" class="text-canicule-700">Martinique</a> ·
<a href="/departement/973" class="text-canicule-700">Guyane</a> ·
<a href="/departement/974" class="text-canicule-700">La Réunion</a> ·
<a href="/departement/976" class="text-canicule-700">Mayotte</a> →
<a href="https://vigilance.meteofrance.fr/" rel="noopener" class="text-canicule-700">vigilance.meteofrance.fr</a>
</p>
</section>
)
}