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
+75 -3
View File
@@ -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<string> {
"-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,
}));`,
],