Passer au contenu principal

Partie 1 : Le temps n'est pas un horodatage

Enregistrez l'instant, enregistrez le fuseau horaire, et refusez le 2 h 30 qui n'a jamais eu lieu.

Chaque application finit tôt ou tard par en arriver à la même conversation. Quelqu'un qui l'utilise, et qui est tout à fait raisonnable, dit : "Est-ce qu'on pourrait juste ajouter un calendrier?"

Le mot juste fait un boulot énorme et non rémunéré dans cette phrase. J'ai interdit aux clients d'utiliser l'expression "Est-ce qu'on pourrait juste" et "Est-ce qu'on ne pourrait pas juste." C'est la preuve de la qualité du travail que nous avons fait, comme développeurs, pour donner l'impression que tout est simple. Tout le monde pense que ce qu'on fait demande seulement quelques clics.

Voici comment ça se passe dans ma tête. Un calendrier, c'est un tableau. Un événement a un titre, une heure de début, une heure de fin et peut-être un lieu. Dessiner les événements dans une grille, ajouter un bouton "mois suivant", aller boire un Red Bull. Ça, c'est un après-midi. J'y ai passé l'après-midi. J'ai aussi vu ce qu'il en restait deux ans plus tard.

Le problème commence avec la deuxième demande, et elle ne concerne jamais la grille. "Est-ce qu'il peut se répéter tous les mardis?" Parfait, ça, c'est une règle. "Est-ce que je peux seulement changer ce mardi-ci?" Là, c'est une règle avec des exceptions. "Est-ce qu'il peut envoyer des rappels?" Maintenant, il lui faut son propre horloge. "Est-ce qu'il peut apparaître sur mon téléphone?" Là, il doit parler un format conçu dans les années 1990. "Pourquoi ça indique 8:00 quand je suis à Toronto?" Là, je suis dans une toute autre conversation.

Chacune de ces demandes est petite, et vient d'une personne sensée. Chacune repose sur la même hypothèse cachée, à savoir que "quand" est une seule valeur qu'on peut mettre dans une colonne. Ce n'est pas le cas. La majeure partie de cette série, c'est moi qui découvre ça, une demande à la fois.

Pourquoi en construire un du tout

Bonne question. Je sais à quoi ça ressemble. Les calendriers sont un problème résolu, il en existe déjà plusieurs de très bons, et j'écris cinq articles sur la construction d'un autre en ColdFusion.

Ma situation est la suivante. Je développe des logiciels web pour des groupes de personnes qui doivent se présenter quelque part ensemble : des clubs, des équipes, des associations, le genre d'organisations qui fonctionnent grâce à des bénévoles et à des groupes de clavardage. Pour elles, le calendrier n'est pas de la décoration. C'est la raison pour laquelle la moitié d'entre elles ouvrent l'application en premier lieu. Où et quand est le prochain match, et est-ce qu'il a été déplacé?

J'ai bien envisagé la solution de facilité : intégrer le calendrier de quelqu'un d'autre et rentrer plus tôt à la maison. Ça fonctionne bien pour une seule organisation avec un seul administrateur qui vit déjà dans cet outil. Ça fonctionne moins bien quand les événements doivent appartenir à mon application. Ils doivent respecter qui a le droit de voir quoi. Ils doivent apparaître sur les pages publiques. Ils doivent envoyer des rappels par mon propre système de notifications, et informer les bonnes personnes quand quelque chose change. Et ils doivent apparaître dans l'application de calendrier que les gens ont déjà sur leur téléphone, sans que je demande à qui que ce soit d'en adopter une nouvelle.

Je ne pouvais pas répondre à la question "qui a été informé de ce changement?" si les données vivaient quelque part où je ne pouvais pas les voir. Alors je possède les événements, et je les publie vers le calendrier de tout le monde d'autre. Cette décision est à l'origine de tout le reste de cette série.

Cette série suit le calendrier que j'ai construit avec Adobe ColdFusion 2025 et PostgreSQL, et les façons précises dont il a essayé de me mentir. Il y a cinq parties :

  1. Comment je stocke l'heure et génère un événement récurrent (celle-ci)
  2. Ce qui se passe quand quelqu'un modifie une occurrence
  3. Comment les événements entrent et sortent des calendriers des autres
  4. Comment j'envoie des rappels et des notifications
  5. Comment j'exécute le calendrier, et ce que j'ai choisi de ne pas construire

Je vais vous montrer l'implémentation et les bogues qui m'ont appris pourquoi ça fonctionne ainsi. J'ai trouvé un de ces bogues en écrivant cet article. Apparemment, expliquer mon code est maintenant une forme de test destructif.

Je devrais aussi expliquer pourquoi je prends ça si personnellement. Je peux généralement m'en tirer avec un écran malcommode. Les gens tolèrent un formulaire d'inscription maladroit. Ils ont beaucoup moins de patience pour un système qui n'est pas d'accord avec eux sur ce que veut dire 9:00. Toute personne qui utilise un calendrier devient une experte du domaine dès qu'il l'envoie à l'aréna une heure trop tôt, et le groupe de clavardage devient alors mon système de signalement des incidents. C'est remarquablement complet.

Commencer par l'exemple le plus simple

Laissez-moi rendre ça concret avec l'événement le plus simple que je puisse imaginer. Une équipe s'entraîne chaque semaine, à 9:00 du matin, à Halifax, en Nouvelle-Écosse. Je vais utiliser Halifax pour les exemples tout au long du texte. La ville observe l'heure d'été, donc elle peut me faire mal, et c'est le fuseau vers lequel je me tourne dans mes propres tests.

Quelqu'un tape "Entraînement, 9:00" dans un formulaire et appuie sur Enregistrer. Qu'est-ce que je mets dans la base de données?

Un horodatage n'est qu'une demi-réponse

Voici la phrase qui peut ruiner mon après-midi :

"Practice is at 9:00."

La réponse évidente, c'est un horodatage, et c'est à moitié vrai. Une colonne timestamptz me dit quand quelque chose se produit. Elle n'enregistre pas ce que la personne voulait dire par 9:00. Pour un entraînement hebdomadaire à Halifax, l'instant UTC change selon la saison :

Summer  09:00 America/Halifax  =  12:00 UTC   (ADT, UTC-3)
Winter  09:00 America/Halifax  =  13:00 UTC   (AST, UTC-4)

Regardez ces deux lignes un instant. C'est le même entraînement et le même 9:00 à l'horloge, mais deux instants différents. Si je stocke la série comme "toutes les 7 journées à partir de cet instant UTC", l'entraînement dérive d'une heure deux fois par année. Personne à Halifax n'a jamais accepté l'argument de "l'heure avancée" comme raison pour laquelle la patinoire d'entraînement est fermée. J'ai besoin du fuseau pour maintenir l'entraînement à 09:00 pendant que l'instant bouge en dessous.

Alors je stocke les deux. Voici les colonnes d'heure de calendar_event, copiées du schéma :

starts_at_utc timestamp with time zone NOT NULL,
ends_at_utc timestamp with time zone NOT NULL,
timezone_iana text NOT NULL,
is_all_day boolean DEFAULT false NOT NULL,

Deux autres colonnes sont arrivées plus tard, quand la table a compris ce que veut vraiment dire "toute la journée". J'y arrive dans un instant :

ALTER TABLE public.calendar_event
    ADD COLUMN IF NOT EXISTS starts_on date,
    ADD COLUMN IF NOT EXISTS ends_on_exclusive date;

Je conserve l'instant parce que la base de données peut le trier, le comparer et l'indexer. Je conserve le fuseau parce qu'il me donne les règles qui rendent "9:00" significatif. J'ai hérité de systèmes qui stockaient l'un et reconstituaient l'autre en devinant, habituellement selon le fuseau horaire du serveur. Le serveur était toujours quelque part où les utilisateurs n'étaient pas. Personne n'avait pensé à l'inviter à pratiquer.

Je n'utilise jamais le fuseau du serveur comme solution de rechange. Un nouvel événement prend le fuseau de la personne qui possède le calendrier, que ce soit une équipe, un club ou toute une organisation, et chaque propriétaire porte son propre timezone_iana. Je fixe aussi les moteurs ColdFusion à UTC, pour qu'un now() isolé ne puisse pas vouloir dire discrètement "Halifax" sur une machine et "Las Vegas" sur une autre.

Je n'ai pas mis de clé étrangère sur timezone_iana. Je la valide dans le code à partir d'une liste de fuseaux sélectionnée, avec une porte de sortie qui vérifie la propre base de données des fuseaux de Java. J'en expliquerai bientôt la raison. Mon importateur avait des opinions sur la liste.

Une journée complète n'est pas une heure. C'est une date.

Maintenant, l'événement sans heure du tout : le week-end du tournoi, le congé, le jour de "bureau fermé". Quelqu'un en demandera un dans la semaine suivant la création du calendrier.

De minuit à 23:59:59 ressemble à une façon évidente de modéliser un événement d'une journée complète. Je peux faire en sorte que cela paraisse tout à fait raisonnable jusqu'au moment où les horloges changent.

Le jour où les horloges avancent, une journée du calendrier dure 23 heures. Le jour où elles reculent, elle dure 25 heures. À Halifax, le 2027-03-14, un événement d'une journée complète dure de 04:00 UTC à 03:00 UTC le jour suivant. Cela fait 23 heures réelles et exactement une journée du calendrier. Si j'insiste pour que "toute la journée" signifie 24 heures, je donne à cet événement une heure de plus qu'il n'en a jamais demandée.

Je stocke les événements d'une journée complète comme une plage de dates :

ALTER TABLE public.calendar_event
    ADD CONSTRAINT ck_calevent_allday_date_model CHECK (
        ( is_all_day
          AND starts_on IS NOT NULL
          AND ends_on_exclusive IS NOT NULL
          AND ends_on_exclusive > starts_on )
        OR
        ( NOT is_all_day
          AND starts_on IS NULL
          AND ends_on_exclusive IS NULL )
    );

Une ligne d'événement d'une journée complète doit avoir les deux dates, avec une fin exclusive. Une ligne d'événement à heure précise ne doit en avoir aucune. La base de données refuse tout ce qui se situe entre les deux. Mon moi futur est donc empêché d'inventer un événement à moitié de chaque, aussi convaincante que soit sa propre explication à 2 h du matin.

Je conserve tout de même les instants comme un encodage : minuit local de chaque date dans le fuseau de l'événement. Mes requêtes, tris et balayages de plages continuent de fonctionner sur starts_at_utc. Pour un événement d'une journée complète, toutefois, les dates font autorité et les instants sont dérivés. Le code de validation l'exprime ainsi :

// The submitted dates ARE the model: starts_on inclusive,
// ends_on_exclusive = UI's inclusive last day + 1. The
// instants are the canonical encoding (local midnight of
// each date in the event zone). Midnight swallowed by a
// spring-forward gap resolves to the first valid instant of
// the day (atStartOfDay) - the DATE, which is what an
// all-day event means, is unaffected.
var exclusive_end_date = addDaysIso( ends_date, 1 );
normalized.starts_at_utc = localDateStartToUtc( starts_date, timezone_iana );
normalized.ends_at_utc = localDateStartToUtc( exclusive_end_date, timezone_iana );

La migration devait reconnaître trois façons différentes dont une version antérieure de moi avait encodé "toute la journée" : minuit du jour suivant, 23:59:59 le même jour, et n'importe quel minuit qu'un importateur de flux jugeait bon d'utiliser. Je ne nommerai pas la version antérieure. Il sait très bien ce qu'il a fait.

Le 2:30 qui n'existe pas

Jusqu'ici, je stockais des heures qui existent. Voyons ce qui se passe quand quelqu'un en saisit une qui n'existe pas.

Voici une heure que je peux saisir dans un formulaire : le dimanche 14 mars 2027, à 2 h 30 du matin à Halifax.

Cette heure n'existe pas. À 2 h, les horloges passent à 3 h. Un administrateur qui entre 2 h 30 me demande de planifier quelque chose à un moment que l'horloge a sauté. Accepter le formulaire ne rend pas l'heure manquante plus disposée à revenir.

Je peux l'accepter et deviner. Je peux la déplacer silencieusement à 3 h 30 et laisser quelqu'un le découvrir plus tard. Ou je peux la refuser et expliquer pourquoi. Je fais en sorte que le formulaire interactif la refuse :

var zone = createObject( "java", "java.time.ZoneId" ).of( trim( arguments.timezone_iana ) );
var rules = zone.getRules();
var valid_offsets = rules.getValidOffsets( ldt );
if ( valid_offsets.size() == 0 ) {
    // Spring-forward gap: this wall-clock time never happens.
    return { ok: false, utc: "", token: "calendar_error_time_nonexistent", offset_id: "", offset_choices: [] };
}
if ( valid_offsets.size() == 1 ) {
    var only_offset = valid_offsets.get( 0 );
    return {
        ok: true,
        utc: instantToCfUtcDate( ldt.toInstant( only_offset ) ),
        token: "",
        offset_id: only_offset.getId(),
        offset_choices: []
    };
}
// Ambiguous fall-back overlap: an explicit, currently-valid offset
// choice is REQUIRED (never silently earlier or later).
var requested = parseZoneOffset( arguments.explicit_offset );
var choices = [];
for ( var candidate_index = 0; candidate_index < valid_offsets.size(); candidate_index++ ) {
    var candidate = valid_offsets.get( candidate_index );
    var candidate_instant = ldt.toInstant( candidate );
    arrayAppend( choices, {
        offset: candidate.getId(),
        is_dst: rules.isDaylightSavings( candidate_instant )
    } );
    if ( !isSimpleValue( requested ) && requested.equals( candidate ) ) {
        return {
            ok: true,
            utc: instantToCfUtcDate( candidate_instant ),
            token: "",
            offset_id: candidate.getId(),
            offset_choices: []
        };
    }
}
return { ok: false, utc: "", token: "calendar_error_time_ambiguous", offset_id: "", offset_choices: choices };

C'est le corps du bloc try dans resolveLocalInstant, exactement tel qu'il s'exécute.

ZoneRules.getValidOffsets() me donne les décalages valides pour une date et une heure locales. Une liste vide signifie que l'heure n'existe pas. Une liste de deux signifie qu'elle se produit deux fois.

Le cas du deux fois arrive en novembre. Le 2027-11-07 à Halifax, 1 h 30 se produit une fois à UTC-3 et de nouveau une heure plus tard à UTC-4. Deux instants différents, à une heure d'intervalle, partagent la même étiquette. Je renvoie les deux choix, avec le décalage d'heure avancée en premier, et je laisse l'administrateur en choisir un. Quand il modifie l'événement plus tard, je dérive le décalage à partir de l'instant enregistré, donc l'enregistrer de nouveau conserve la même 1 h 30. Un bouton Enregistrer ne devrait pas être une machine à remonter le temps.

Si un décalage obsolète arrive avec une heure qui n'est plus ambiguë, je l'ignore. Un ancien choix ne doit jamais pouvoir déplacer un instant.

Je teste tout cela sur du vrai HTTP. Une assertion lit aussi le texte source du service et échoue si getValidOffsets disparaît. D'habitude, j'appellerais ça paresseux. Pour une règle aussi facile à "simplifier" pendant une refonte, je prends la paresse. J'ai vu à quoi ressemble l'enthousiasme.

Les machines n'ont pas leur mot à dire

Le formulaire refuse une heure dans l'écart. Mon planificateur de rappels a besoin d'une autre politique.

Supposons qu'un événement d'une journée entière ait besoin d'un rappel à 2 h 30 du matin, heure locale, le matin où les horloges avancent. Aucun administrateur n'attend pour choisir une autre heure. Si un rappel tombe dans la répétition de 1 h 30 en novembre, il n'y a personne non plus à qui demander laquelle ils voulaient dire. Il faut que le planificateur choisisse de la même façon chaque fois, et il faut que ce choix soit documenté :

/**
 * Local wall time in an IANA zone -> UTC instant, with the documented DST
 * policy: a gap shifts FORWARD, an overlap takes the EARLIER offset.
 *
 * java.time.ZonedDateTime.of( LocalDateTime, ZoneId ) implements exactly
 * that, which is why it is called directly rather than re-derived from
 * ZoneRules.getValidOffsets(). calendar_event_validation_service.
 * resolveLocalInstant deliberately does the opposite - it REFUSES both
 * cases and asks the administrator - because a person entering an event
 * time can be asked which 01:30 they meant, and a scheduler cannot.
 *
 * Returns { ok, utc, code }.
 */

À l'intérieur de cette fonction, une ligne fait le travail :

var zdt = createObject( "java", "java.time.ZonedDateTime" ).of( ldt, zone );

ZonedDateTime.of(LocalDateTime, ZoneId) implémente déjà cette politique. Dans un écart, il avance l'heure de la durée de l'écart, donc 2 h 30 devient 3 h 30. Dans un chevauchement, il prend le décalage le plus tôt. Je l'appelle directement plutôt que de redériver ces règles.

Une personne obtient une erreur de champ et une chance de corriger l'heure. Une machine obtient un haussement d'épaules documenté et déterministe. Ça me convient, à condition que j'écrive les deux politiques là où quelqu'un sera éventuellement tenté de les "harmoniser".

Mon planificateur de liste d'envoi utilise le AT TIME ZONE de PostgreSQL, qui résout le chevauchement vers la plus tardive 1 h 30. J'ai donc trois endroits qui transforment une heure murale en instant et deux politiques entre eux. C'est dans ma liste. C'est dans ma liste depuis assez longtemps pour établir sa résidence.

UTC n'est pas un fuseau horaire (dans cette liste déroulante)

La liste de fuseaux sélectionnée semblait sensée jusqu'à ce que je la laisse devenir la seule autorité sur ce qu'un fuseau pouvait être.

J'ai commencé avec 51 entrées, toutes au Canada ou aux États-Unis, pour correspondre aux personnes qui utilisaient le calendrier. La liste ne contenait pas UTC. Personne ne demandait à planifier une pratique de hockey en UTC, alors j'étais plutôt satisfait de mon côté pratique.

Puis j'ai ajouté des flux de calendrier entrants, ce que je couvrirai dans la troisième partie. Les événements importés avec un Z final étaient stockés avec le fuseau UTC, parce que cela les décrivait avec précision. J'avais maintenant un formulaire de modification qui ne pouvait pas représenter un événement que mon propre importateur avait enregistré. Trois choses ont cassé :

  • Le formulaire ne pouvait pas sélectionner le fuseau enregistré, donc son script de préremplissage a substitué le fuseau du navigateur. Enregistrer a déplacé l'événement en silence.
  • La validation refusait UTC comme inconnu.
  • Sur un événement synchronisé, l'échec se manifestait comme un 500.

J'avais construit une liste qui ne pouvait pas décrire mes propres données. Je l'ai corrigé avec une échappatoire étroite : une mise à jour peut conserver le fuseau stocké de l'événement, à condition que Java convienne que c'est un vrai fuseau.

/**
 * True when an UPDATE keeps the event's own stored zone and that zone is a
 * real one, even though the reference list does not carry it. Feed events
 * imported with Z times are stored as "UTC", which the list lacks: refusing
 * it made every save of such an event fail validation or - once the form's
 * prefill had swapped in the browser's zone - move the event. A create, a
 * CHANGE of zone, and an unknown zone id are still held to the list.
 */
private boolean function isUnchangedStoredZone( required string timezone_iana, required struct ctx ) {
    var stored = trim( toString( arguments.ctx.existing_timezone_iana ?: "" ) );
    if ( !len( stored ) || compare( stored, arguments.timezone_iana ) != 0 ) {
        return false;
    }
    try {
        createObject( "java", "java.time.ZoneId" ).of( stored );
        return true;
    } catch ( any zone_err ) {
        return false;
    }
}

Je continue d'exiger qu'une création ou un changement de fuseau utilise la liste sélectionnée. Un fuseau inventé comme Mars/Olympus_Mons est toujours refusé. Il y a un test pour ça, parce que je suis le genre de personne qui écrit un test pour Mars tout en laissant un mardi ordinaire vulnérable. J'en arriverai à mardi.

Un événement récurrent n'est pas un tas de lignes

Une pratique hebdomadaire pendant deux ans produit environ 104 occurrences. Un événement quotidien produit environ 730. Tôt ou tard, quelqu'un demandera "tous les jours, pour toujours", et j'aimerais que ma base de données survive à son optimisme.

Je stocke un événement récurrent sur une seule ligne, la ligne maître, avec sa règle :

ALTER TABLE public.calendar_event
    ADD COLUMN IF NOT EXISTS schedule_type             varchar(20) NOT NULL DEFAULT 'one_time',
    ADD COLUMN IF NOT EXISTS recurrence_frequency      varchar(20),
    ADD COLUMN IF NOT EXISTS recurrence_interval       integer,
    ADD COLUMN IF NOT EXISTS recurrence_days_of_week   jsonb,
    ADD COLUMN IF NOT EXISTS recurrence_monthly_mode   varchar(20),
    ADD COLUMN IF NOT EXISTS recurrence_day_of_month   integer,
    ADD COLUMN IF NOT EXISTS recurrence_nth_week       integer,
    ADD COLUMN IF NOT EXISTS recurrence_nth_weekday    varchar(10),
    ADD COLUMN IF NOT EXISTS recurrence_end_mode       varchar(20),
    ADD COLUMN IF NOT EXISTS recurrence_end_date       timestamptz,
    ADD COLUMN IF NOT EXISTS recurrence_count          integer;

Je génère les occurrences à la demande pour la période que quelqu'un consulte. Les seules lignes persistées pour les occurrences individuelles sont des exceptions, que je couvrirai dans la deuxième partie.

Deux décisions dans ce schéma en valent la peine.

D'abord, je remplis toujours recurrence_end_date pour une ligne récurrente, même lorsque la règle dit "se termine après 10 occurrences". Je calcule le début de la dixième occurrence et je le stocke. Cela ajoute une étape de dérivation quand j'enregistre, mais donne à chaque requête de plage une colonne qu'elle peut utiliser :

WHERE schedule_type = 'recurring'
  AND starts_at_utc <= :to_utc
  AND recurrence_end_date >= :from_utc

Ensuite, je laisse la base de données faire respecter ce qu'elle peut. Un événement ponctuel ne peut pas avoir de colonnes de récurrence. Un événement récurrent doit avoir sa fréquence, son intervalle, son mode de fin et sa date de fin. Une règle basée sur un nombre doit avoir un nombre. Ces contraintes sont plates. Je les veux plates. J'ai assez de personnalité dans le code de l'application.

La limite de deux ans, trois fois

Je limite chaque série à deux ans à partir du début du maître et j'applique cette limite à trois endroits. J'ai appris à ne faire confiance à aucune couche unique, y compris celle que j'ai écrite hier :

  1. La validation refuse une règle qui dépasse la limite.
  2. La boucle d'expansion s'arrête à la limite, peu importe ce que dit la règle.
  3. Une contrainte CHECK refuse une ligne qui la dépasse :
ALTER TABLE public.calendar_event
    ADD CONSTRAINT ck_calevent_recurrence_two_year_cap
        CHECK (
            recurrence_end_date IS NULL
            OR recurrence_end_date <= starts_at_utc + INTERVAL '2 years'
        );

Je limite aussi l'expansion à 800 occurrences par événement, 5 000 par réponse et 2 000 itérations de boucle. Une règle mal formée devrait produire une réponse bornée plutôt qu'une requête bloquée. Je ne m'attends pas à ce qu'une personne atteigne ces limites. Je me prépare au jour où l'importateur de quelqu'un le fera. Les importateurs partagent rarement mon sens de la retenue.

Parcourir l'heure locale, mesurer la durée en secondes

Pour garder un événement récurrent de 9 h à 9 h quand l'heure change, je parcours l'heure locale. L'heure de l'horloge murale reste fixe et l'instant UTC se déplace.

Je conserve la durée d'un événement horodaté sous forme d'un nombre fixe de secondes. Pour un événement toute la journée, je conserve des jours locaux complets :

if ( rule.is_all_day ) {
    // All-day recurrence operates on LOCAL DATES: the end is the
    // same wall-clock (midnight) `span` local days later, via
    // ZonedDateTime.plusDays - NOT start + span*24h. Fixed-hour
    // addition overhung a 23-hour spring-forward day by an hour
    // and rendered every such occurrence one day too long.
    occ_end_zdt = occ_zdt.plusDays( javacast( "long", all_day_span_days ) );
    occ_end_utc = zonedToUtcCfDate( occ_end_zdt );
} else {
    // Timed recurrence preserves the FIXED duration in seconds
    // (a 60-minute practice stays 60 minutes across DST).
    occ_end_utc = dateAdd( "s", int( duration_millis / 1000 ), occ_start_utc );
}

Une pratique de 60 minutes reste de 60 minutes. Un événement d'une journée reste d'une journée, même lorsque cette journée a 23 heures. J'utilise des intervalles semi-ouverts partout : un événement qui se termine exactement à la limite d'une fenêtre appartient à la fenêtre précédente et jamais aux deux.

Chaque fréquence a aussi besoin de règles explicites :

  • Hebdomadaire : je reconstruis chaque occurrence à partir de la date et de l'heure locales du maître, ancrées à la semaine du maître, plutôt qu'à partir de l'occurrence précédente.
  • Mensuel le 31 : j'omets les mois qui n'ont pas de 31. Je ne limite pas au 30. "Mensuel le 31" qui se déclenche le 28, c'est une promesse différente, et la personne qui l'a planifié n'a pas fait cette promesse.
  • Annuel le 29 février : je génère des occurrences uniquement les années bissextiles. J'ai envisagé l'autre option pendant environ quatre secondes.

Je consigne aussi les jetons de jour de semaine rejetés lorsqu'une règle hebdomadaire contient un jeton que le moteur ne reconnaît pas. Un calendrier vide était autrefois la seule explication. Cette histoire appartient à la troisième partie.

Le mardi qui n'était pas

J'ai trouvé l'un de mes bugs préférés pendant une revue de lancement en utilisant l'interface de production.

Une série hebdomadaire le mardi commence le 20 octobre 2026 à 18 h à Halifax. L'administrateur sélectionne "Se termine le 24 novembre", également un mardi. Il s'attend à la dernière pratique le 24 novembre.

Je leur ai donné le 17 novembre.

Le formulaire envoie une simple date : 2026-11-24. Je l'ai analysée comme minuit et j'ai stocké ce minuit comme un instant UTC dans une colonne timestamptz. Chaque occurrence à la date de fin choisie tombait après cet instant et disparaissait. L'enregistrement a réussi. Mon calendrier avait discrètement annulé la pratique pour moi.

La vue mensuelle l'a perdue. Le flux publié indiquait UNTIL=20261124T000000Z, et Google Calendar a suivi cette instruction à la lettre. Je ne peux pas blâmer un autre calendrier d'avoir cru le mensonge que je lui ai envoyé. Les séries toute la journée ont perdu leur dernier jour de la même façon.

Je l'ai corrigé en liant la fin du jour local choisi dans le fuseau de l'événement :

public any function endOfLocalDayUtc( required any local_date, required string timezone_iana ) {
    var d = isSimpleValue( arguments.local_date ) ? parseDateTime( arguments.local_date ) : arguments.local_date;
    try {
        var LocalDate = createObject( "java", "java.time.LocalDate" );
        var LocalTime = createObject( "java", "java.time.LocalTime" );
        var ZoneId = createObject( "java", "java.time.ZoneId" );
        var zone = ZoneId.of( javacast( "string", trim( arguments.timezone_iana ) ) );
        var day = LocalDate.of(
            javacast( "int", year( d ) ),
            javacast( "int", month( d ) ),
            javacast( "int", day( d ) )
        );
        // LocalTime.MAX est 23:59:59.999999999; atZone résout l'offset
        // qui est réellement en vigueur à cette date locale.
        return zonedToUtcCfDate( day.atTime( LocalTime.MAX ).atZone( zone ) );
    } catch ( any zone_err ) {
        return d;
    }
}

J'ai trouvé un bogue lié dans le résumé en langage clair. Il formatte la date "jusqu'au" dans le fuseau du serveur et annonçait joyeusement "Nov 25" pour une date de fin au 24 nov. Une seule mauvaise hypothèse sur les dates m'avait donné deux bogues. Efficace, à sa façon horrible.

Le plafond qui n'en était pas un

Mon plafond de deux ans avait aussi une faille.

Le validateur vérifiait qu'une règle basée sur un nombre n'aboutissait pas à une date de fin au-delà du plafond. Mais la dérivation elle-même parcourait la série sous ce même plafond. Si elle ne pouvait pas atteindre la N-ième occurrence, elle retournait null. Mon contrôle lisait alors : "pas de date de fin? rien à comparer."

Le nombre trop grand pour être calculé était exactement le nombre que j'avais omis de valider. Une série quotidienne avec un nombre de 1 000, soit environ 2,7 ans, passait. J'avais donné à la garde une exemption pour la condition qu'elle existait pour attraper. Elle était très polie à laisser passer le problème.

La correction ressemble à des aveux dans le code, et je l'ai laissée ainsi :

// No derived end date. This is NOT "unknown, so allow it".
// normalizeRecurrence derives that date with
// computeNthOccurrenceUtc, which walks the series under a
// HARD two-year cap. If the Nth occurrence cannot be
// reached, it lies BEYOND the cap - exactly the condition
// this error exists to report.

Depuis, j'ai cessé d'écrire "unknown, so allow it" partout où je valide quoi que ce soit. Ce n'est pas vraiment une règle. C'est une cicatrice.

Changement de cap: j'en ai trouvé un en écrivant ceci

En écrivant cet article, j'ai jeté un autre coup d'oeil à la branche quotidienne du parcours de récurrence :

if ( freq == "daily" ) {
    var cur = arguments.master_zoned;
    var prev_millis = -1;
    while ( iter < variables.MAX_ITERATIONS ) {
        ...
        arrayAppend( locals, cur );
        cur = cur.plusDays( javacast( "long", interval ) );
        iter++;
        ...
    }
    return locals;
}

Je fais avancer cur à partir du résultat précédent. La branche hebdomadaire reconstruit chaque occurrence à partir de l'heure locale du maître. La branche quotidienne continue d'ajouter au temps produit par l'étape précédente.

ZonedDateTime.plusDays() fonctionne sur la ligne de temps locale. Quand le résultat tombe dans un trou, Java le fait avancer de la durée de ce trou. Une série quotidienne à 2 h 30 à Halifax produit donc 2 h 30 le samedi 13 mars 2027. Le dimanche 14, elle tente 2 h 30, obtient 3 h 30, puis ajoute un jour à 3 h 30. Chaque occurrence suivante reste à 3 h 30. La branche annuelle a la même forme.

J'entends déjà l'objection: qui planifie quoi que ce soit à 2 h 30 du matin? J'ai apparemment décidé que la réponse était "personne qui vaille la peine d'être testé." Mes specs utilisent 9 h 00, 18 h 00 et 1 h 30. Le test de parcours du passage à l'heure d'été décrit comme une série hebdomadaire à 9 h 00 est en fait une série quotidienne avec un intervalle de sept. Il traverse le trou sans s'en apercevoir.

Ma propre spec l'appelle hebdomadaire. Elle est quotidienne. Je vais m'arrêter là un moment.

[Note de brouillon: J'ai vérifié la dérive par rapport au calendar_recurrence_service.cfc livré sur Adobe ColdFusion 2025.0.13 en appelant expandEventOccurrences pour une série quotidienne à 02:30 America/Halifax commençant le 2027-03-13. Les heures locales sont revenues comme suit: 03-13 02:30, 03-14 03:30, 03-15 03:30, 03-16 03:30, et ainsi de suite. Une série hebdomadaire du dimanche à 02:30 à travers le même trou a retourné 03-07 02:30, 03-14 03:30, 03-21 02:30. Je n'ai pas encore ajouté de spec en échec ni corrigé la boucle. Avant la publication, je dois encore ajouter la spec quotidienne 02:30 America/Halifax et son équivalent annuel, observer les échecs, corriger les deux branches pour qu'elles se reconstruisent à partir de la date et de l'heure locales du maître, et mettre à jour cette section avec les résultats.]

Le navigateur a son mot à dire, mais pas le dernier

Je définis la signification de l'événement sur le serveur. L'afficher pour quelqu'un dans un autre fuseau horaire est une tâche distincte, avec une règle directrice :

A date from one zone is never paired with a time from another.

Je rends chaque libellé avec heure dans un seul fuseau: le fuseau du propriétaire du calendrier pour un calendrier intégré à une page, et le propre fuseau de l'événement sur la page de l'événement. Je ne convertis jamais les événements sans heure.

Puis j'améliore progressivement la page avec un petit script. Il lit l'instant et le fuseau de l'événement à partir d'attributs de données et ne convertit que lorsque le fuseau du navigateur diffère. Dans le calendrier intégré, je déplace aussi l'événement dans la journée locale du visiteur. Sur une page d'événement, je garde la ligne propre à l'événement comme autorité et j'ajoute une ligne distincte "votre heure" :

function readInstants(el) {
    if (el.getAttribute('data-calendar-all-day') === 'true') return null;
    var eventTz = el.getAttribute('data-calendar-tz') || '';
    if (!eventTz || eventTz === browserTz) return null;
    ...
}

Si le script échoue, je laisse le HTML du serveur intact. La page est déjà correcte avant l'arrivée de JavaScript. Je veux que l'amélioration soit facultative; j'ai déjà assez débogué d'améliorations obligatoires.

Encore un désaccord

Il me reste un autre élément ouvert dans mes notes, parce que même les composants qui fournissent les règles de fuseau horaire peuvent être en désaccord.

Les données de fuseau horaire Java de mon application et les données de fuseau horaire de ma base de données ne sont pas garanties de correspondre. La validation, l'expansion des récurrences et les définitions de fuseau horaire exportées utilisent les règles de Java. La migration qui a rétroalimenté les dates de toute la journée a utilisé celles de PostgreSQL. Quand ces copies de la base de données des fuseaux proviennent d'années différentes, une date dans un fuseau dont les règles ont récemment changé peut produire une différence d'une heure selon le composant que je consulte.

Mes tests d'intégration évitent délibérément une province touchée. Le commentaire explique qu'un trajet ne doit pas dépendre de la copie de la base de données des fuseaux qu'un composant détient. C'est une exigence sensée et une raison inconfortable d'exclure un cas de test.

Je ne vérifie pas actuellement tzdata au démarrage. C'est le genre de bogue qui attend qu'une règle change quelque part où j'ai des utilisateurs, puis qui se manifeste par une demande de soutien.

[Draft note: This is based on my implementation notes dated 2026-09-14 and reaffirmed 2026-10-02. I haven't re-measured it for this article. Before publishing, I still need to confirm that it holds and decide whether to name the affected province.]

Ce que je vous dirais de conserver

Voici les habitudes que j'emporterais dans une autre implémentation d'agenda :

  • Stockez l'instant et le fuseau. L'un seul n'est qu'une demi-réponse.
  • Modélisez un événement qui dure toute la journée comme des dates, pas comme des heures.
  • Demandez à une personne quelle 1 h 30 elle voulait dire. Donnez à un planificateur une politique documentée.
  • Stockez un événement récurrent comme une règle. Encadrez sa durée, sa sortie et le travail nécessaire pour générer cette sortie.
  • "Inconnu, donc on l'autorise" est la phrase la plus coûteuse en validation.
  • Vérifiez qu'un test nommé "weekly" teste vraiment une série hebdomadaire. Je peux recommander celui-ci à partir d'un récent embarras personnel.

Dans la prochaine partie, je vais laisser quelqu'un modifier une occurrence d'une série. C'est là que j'ai découvert combien de choses je supposais discrètement être la même chose.