diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index dfa83f3..a68cf6e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -102,6 +102,16 @@ conversion de fuseau. C'est pour la même raison que la liste formate les journées entières **sans** `timeZone` (`presentation.js`) : forcer `Europe/Paris` faisait glisser la date au jour précédent depuis Tokyo ou Auckland. +⚠️ **`fin` est un DTEND EXCLUSIF (RFC 5545) — ne pas l'afficher/soumettre tel quel.** +Le payload porte la valeur brute du parseur. Pour une **journée entière**, un +événement du 18 porte `fin` = le **19**, et un festival du 18 au 20 porte `fin` = +le **21** : le dernier jour réel est **`fin` moins un jour** (composantes locales, +jamais « -24h »). Concerne directement la tâche sœur, qui remplit `#input_6_32` +(Date fin) **depuis DTEND** : sans ce recul d'un jour, elle soumettra une date +**fausse** à la mairie. La liste applique déjà la règle (`presentation.js`, +`veilleDe`). Autre cas mesuré : **`DTEND` absent → le parseur pose `fin === debut`** +(il n'y a alors pas de plage à afficher, ni de fin à soumettre). + **`TZ=Europe/Paris` sur `bun test` est PORTEUR — ne pas le retirer.** Le runner de Bun force **`TZ=UTC`** quand `TZ` est absente (vérifié : `bun -e` lit le fuseau système, le runner non). Sans le pin, les tests ne tournent donc pas dans diff --git a/extension/presentation.js b/extension/presentation.js index 4c2e0c4..b357d53 100644 --- a/extension/presentation.js +++ b/extension/presentation.js @@ -10,9 +10,12 @@ export const LIEU_PAR_DEFAUT = "8 Rue du Pré Vicinal 31270 Cugnaux"; // Un événement horodaté porte un vrai instant : on l'affiche à l'heure de // Cugnaux, quel que soit le fuseau de la machine. -const FORMAT_AVEC_HEURE = new Intl.DateTimeFormat(LOCALE, { +const FORMAT_JOUR_PARIS = new Intl.DateTimeFormat(LOCALE, { timeZone: FUSEAU, dateStyle: "full", +}); +const FORMAT_HEURE_PARIS = new Intl.DateTimeFormat(LOCALE, { + timeZone: FUSEAU, timeStyle: "short", }); @@ -20,7 +23,7 @@ const FORMAT_AVEC_HEURE = new Intl.DateTimeFormat(LOCALE, { // entière est une date *flottante* (RFC 5545), sans fuseau. ical.js la // matérialise à minuit local ; la relire en local restitue le bon jour partout, // alors que la convertir vers Paris la ferait glisser au 9 depuis Tokyo. -const FORMAT_SANS_HEURE = new Intl.DateTimeFormat(LOCALE, { +const FORMAT_JOUR_FLOTTANT = new Intl.DateTimeFormat(LOCALE, { dateStyle: "full", }); @@ -31,8 +34,45 @@ export function trierParDebut(evenements) { // Une journée entière n'a pas d'heure : afficher « 00:00 » serait un mensonge. export function formaterDate(evenement) { - const format = evenement.journeeEntiere ? FORMAT_SANS_HEURE : FORMAT_AVEC_HEURE; - return format.format(evenement.debut); + return evenement.journeeEntiere + ? formaterJourneeEntiere(evenement) + : formaterHorodate(evenement); +} + +function formaterJourneeEntiere({ debut, fin }) { + const premierJour = FORMAT_JOUR_FLOTTANT.format(debut); + const dernierJour = veilleDe(fin); + + // Un seul jour, ou pas de DTEND exploitable (le parseur pose alors fin === debut). + if (dernierJour.getTime() <= debut.getTime()) return premierJour; + + return `du ${premierJour} au ${FORMAT_JOUR_FLOTTANT.format(dernierJour)}`; +} + +// DTEND est EXCLUSIF (RFC 5545) : un événement du 18 porte DTEND=19, un festival +// du 18 au 20 porte DTEND=21. Sans ce recul d'un jour, on annoncerait un jour de +// trop — une date FAUSSE, pas seulement laide. +// Composantes locales (jamais « -24h ») : la date est flottante, et un +// changement d'heure ferait basculer le jour. +function veilleDe(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1); +} + +function formaterHorodate({ debut, fin }) { + const jourDebut = FORMAT_JOUR_PARIS.format(debut); + const heureDebut = FORMAT_HEURE_PARIS.format(debut); + + // DTEND absent : le parseur pose fin === debut. « de 10:30 à 10:30 » serait absurde. + if (fin.getTime() <= debut.getTime()) return `${jourDebut} à ${heureDebut}`; + + const jourFin = FORMAT_JOUR_PARIS.format(fin); + const heureFin = FORMAT_HEURE_PARIS.format(fin); + + // Comparaison des jours tels qu'AFFICHÉS (donc à Paris) : un événement de + // 22:00 à 02:00 change de jour, l'annoncer sur un seul serait un mensonge. + if (jourFin === jourDebut) return `${jourDebut} de ${heureDebut} à ${heureFin}`; + + return `du ${jourDebut} à ${heureDebut} au ${jourFin} à ${heureFin}`; } // La plupart des événements se tiennent au café et n'ont pas de LOCATION : diff --git a/presentation.test.ts b/presentation.test.ts index 7d691b7..409ed95 100644 --- a/presentation.test.ts +++ b/presentation.test.ts @@ -61,11 +61,17 @@ describe("trierParDebut", () => { // ment. Composantes LOCALES, jamais un instant absolu ("...T00:00+02:00") : une // date flottante (VALUE=DATE) n'a pas de fuseau et ical.js la matérialise à // minuit local, donc un instant figé ne serait juste qu'à +02:00. -const JOURNEE_ENTIERE = [2026, 6, 10] as const; +const JOURNEE_ENTIERE_DEBUT = [2026, 6, 10] as const; +// DTEND est EXCLUSIF (RFC 5545) : le 11 signifie « dernier jour réel = le 10 ». +const JOURNEE_ENTIERE_FIN = [2026, 6, 11] as const; const JOUR_ATTENDU = "10 juillet 2026"; function evenementJourneeEntiere() { - return evenement({ debut: new Date(...JOURNEE_ENTIERE), journeeEntiere: true }); + return evenement({ + debut: new Date(...JOURNEE_ENTIERE_DEBUT), + fin: new Date(...JOURNEE_ENTIERE_FIN), + journeeEntiere: true, + }); } describe("formaterDate", () => { @@ -89,6 +95,71 @@ describe("formaterDate", () => { expect(rendu).toContain("13:00"); }); + // Les 6 formes de fin mesurées sur le vrai parseur (cf. rapport de test). + // Cas de Pierre : 10:30 → 12:30 le 18/07 (10:30 Paris = 08:30Z). + describe("plage horaire", () => { + test("début et fin le même jour → « de 10:30 à 12:30 »", () => { + const rendu = formaterDate( + evenement({ + debut: new Date("2026-07-18T08:30:00Z"), + fin: new Date("2026-07-18T10:30:00Z"), + }), + ); + + expect(rendu).toContain("18 juillet 2026"); + expect(rendu).toContain("10:30"); + expect(rendu).toContain("12:30"); + }); + + // DTEND absent : le parseur pose fin === debut. « de 10:30 à 10:30 » serait absurde. + test("sans fin (fin === debut) → une seule heure affichée", () => { + const debut = new Date("2026-07-18T08:30:00Z"); + const rendu = formaterDate(evenement({ debut, fin: new Date(debut) })); + + expect(rendu).toContain("10:30"); + expect(rendu.match(/\d{1,2}:\d{2}/g)).toHaveLength(1); + }); + + // 22:00 → 02:00 : « samedi 18 de 22:00 à 02:00 » serait un mensonge. + test("franchit minuit → la date de fin est affichée", () => { + const rendu = formaterDate( + evenement({ + debut: new Date("2026-07-18T20:00:00Z"), + fin: new Date("2026-07-19T00:00:00Z"), + }), + ); + + expect(rendu).toContain("18 juillet 2026"); + expect(rendu).toContain("22:00"); + expect(rendu).toContain("19 juillet 2026"); + expect(rendu).toContain("02:00"); + }); + + test("journée entière d'un jour (DTEND exclusif) → le seul jour réel", () => { + const rendu = formaterDate(evenementJourneeEntiere()); + + expect(rendu).toContain(JOUR_ATTENDU); + expect(rendu).not.toContain("11 juillet"); // le DTEND exclusif n'est pas un jour de l'événement + expect(rendu).not.toMatch(/\d{1,2}:\d{2}/); + }); + + // Festival 18→21 exclusif : il se termine réellement le 20. + test("journée entière sur plusieurs jours → dernier jour réel = DTEND - 1", () => { + const rendu = formaterDate( + evenement({ + debut: new Date(2026, 6, 18), + fin: new Date(2026, 6, 21), + journeeEntiere: true, + }), + ); + + expect(rendu).toContain("18 juillet 2026"); + expect(rendu).toContain("20 juillet 2026"); + expect(rendu).not.toContain("21 juillet"); + expect(rendu).not.toMatch(/\d{1,2}:\d{2}/); + }); + }); + // Le fuseau des tests est épinglé à Europe/Paris : c'est justement le seul où // une journée entière mal formatée reste invisible. On sous-traite donc à des // processus réglés ailleurs. @@ -112,7 +183,8 @@ async function formaterDansLeFuseau(fuseau: string): Promise { "-e", `import { formaterDate } from ${module}; console.log(formaterDate({ - debut: new Date(${JOURNEE_ENTIERE.join(", ")}), + debut: new Date(${JOURNEE_ENTIERE_DEBUT.join(", ")}), + fin: new Date(${JOURNEE_ENTIERE_FIN.join(", ")}), journeeEntiere: true, }));`, ],