feat: show the event time range, not just its start

Pierre's field test: the agenda announces "10:30 → 12:30", the list only
said "à 10:30". The end date was already parsed and already in the
payload; it was purely missing from the display.

Naively rendering start → end would have been wrong four ways, all
measured against the real parser:
- no DTEND: the parser sets fin === debut, so "de 10:30 à 10:30"
- crossing midnight: "samedi de 22:00 à 02:00" hides the end date
- all-day: DTEND is EXCLUSIVE (RFC 5545), so a one-day event carries
  the next day, and a 18→20 festival carries the 21st

The last one is the dangerous one: it yields a wrong date, not just an
ugly one. The end day is therefore fin minus one day, computed from
local components since a floating date has no timezone.

DECISIONS.md records the exclusive DTEND for the sibling task, which
fills the town hall's end date field straight from DTEND.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Pierre Martin
2026-07-17 11:55:23 +02:00
co-authored by Claude Opus 4.8
parent f994bfac30
commit 596b5e22fd
3 changed files with 129 additions and 7 deletions
+10
View File
@@ -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` journées entières **sans** `timeZone` (`presentation.js`) : forcer `Europe/Paris`
faisait glisser la date au jour précédent depuis Tokyo ou Auckland. 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 **`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 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 fuseau système, le runner non). Sans le pin, les tests ne tournent donc pas dans
+44 -4
View File
@@ -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 // Un événement horodaté porte un vrai instant : on l'affiche à l'heure de
// Cugnaux, quel que soit le fuseau de la machine. // 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, timeZone: FUSEAU,
dateStyle: "full", dateStyle: "full",
});
const FORMAT_HEURE_PARIS = new Intl.DateTimeFormat(LOCALE, {
timeZone: FUSEAU,
timeStyle: "short", 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 // 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, // 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. // 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", 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. // Une journée entière n'a pas d'heure : afficher « 00:00 » serait un mensonge.
export function formaterDate(evenement) { export function formaterDate(evenement) {
const format = evenement.journeeEntiere ? FORMAT_SANS_HEURE : FORMAT_AVEC_HEURE; return evenement.journeeEntiere
return format.format(evenement.debut); ? 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 : // La plupart des événements se tiennent au café et n'ont pas de LOCATION :
+75 -3
View File
@@ -61,11 +61,17 @@ describe("trierParDebut", () => {
// ment. Composantes LOCALES, jamais un instant absolu ("...T00:00+02:00") : une // 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 à // 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. // 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"; const JOUR_ATTENDU = "10 juillet 2026";
function evenementJourneeEntiere() { 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", () => { describe("formaterDate", () => {
@@ -89,6 +95,71 @@ describe("formaterDate", () => {
expect(rendu).toContain("13:00"); 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ù // 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 // une journée entière mal formatée reste invisible. On sous-traite donc à des
// processus réglés ailleurs. // processus réglés ailleurs.
@@ -112,7 +183,8 @@ async function formaterDansLeFuseau(fuseau: string): Promise<string> {
"-e", "-e",
`import { formaterDate } from ${module}; `import { formaterDate } from ${module};
console.log(formaterDate({ 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, journeeEntiere: true,
}));`, }));`,
], ],