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:
co-authored by
Claude Opus 4.8
parent
f994bfac30
commit
596b5e22fd
@@ -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
|
||||
|
||||
@@ -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 :
|
||||
|
||||
+75
-3
@@ -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,
|
||||
}));`,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user