Formats

27 — Calendar : le format calendar et calendar.recit.dev

Statut : proposé · Phase : P10 · Mis à jour : 02/11/2026.

Ce plan détaille la phase P10 de 06 · Feuille de route : le format calendar, la seconde moitié de la fondation @recit/time (heures, fuseaux, récurrences, ICS en lecture), le type Place (règle B3, porté par 12 · Glossaire et annuaire), le pont roadmap → calendar et le site calendar.recit.dev. Il part de 08 · Formats §5 et le précise sans le contredire ; les écarts que le code impose sont posés en questions ouvertes (§12). Il suit le plan 26 (roadmap, P9), dont il reprend la table de visionneuses, l'horloge de build, l'écrivain ICS, l'outil MCP validate_story et les variantes ics du manifeste exports.json.

Il dépend de 02 · Architecture, de 03 · Packaging §6 et §8, de 07 · Domaines, de 09 · Langues et JSON, de 12 · Glossaire et annuaire (Place, JSON-LD partagé), 13 · Cartes (qui importe Place sans le redéfinir), de 15 · Export PDF et de 21 · Tests. Le lot avancé de P10 du plan 10 · Formulaires (FO0, FO1) se déroule dans la même phase et n'est pas repris ici. Le pont calendar → stories est le plan 28 (P11).

Chaque choix porte une recommandation. Rien n'est implémenté avant l'accord de l'utilisateur.

1. Pourquoi

  • Un agenda est une liste d'événements qui se lit de quatre façons (mois, semaine, agenda, année) et s'exporte vers les agendas de tout le monde (ICS). Meetups, cours, sorties, planning éditorial : aujourd'hui, on les tient dans un tableur, un Google Agenda et une page web, à la main et en trois exemplaires. Récit en fait une source unique, en Markdown pour la prose, en JSON pour le multilingue et l'outillage.
  • C'est le cas d'usage type du JSON multilingue (08 §5) : un meetups.json où chaque titre est un Localized, sans Markdown du tout, est la meilleure démonstration du jumeau typé.
  • Les fuseaux et les changements d'heure sont le piège de tout calendrier. Les traiter une fois, dans une fondation testée (@recit/time), protège tous les formats qui auront un jour des heures.
  • Les formats se relient : les jalons de la roadmap remontent dans le calendrier (critère de sortie de P10), un événement passé renvoie vers son deck ou son film, et les prochains événements deviennent une story (P11).

2. Ce qui existe déjà et qu'on réutilise

BriqueÉtat réel au 02/11/2026, ou à la fin de P9 (plan 26)Ce que P10 en fait
CalendarSourceSchemagenericSource("calendar") dans packages/core/src/schema/source.ts : looseObject sans champ typéstrictObject typé (§3.3)
Pivot MarkdowncompileStorySource passe un JSON par sourceToMarkdown avant de le compilerle codec du calendrier dans SCENE_CODECS (plan 26 RM0) ; les objets (Place inline) passent par la forme JSON du séparateur
Séparateurparser/split-slides.ts : key=value avec guillemets, ou --- {…} en JSON ; une valeur ne tient que sur une ligneles attributs d'un événement restent sur la ligne du séparateur, ou passent en forme JSON
Compagnonfusion par id de scène (mergeSidecar), listes à clé ajoutées par le plan 26un événement est une scène : rien de plus à faire
@recit/timeaprès P9 : dates civiles, trimestres, today()/now() sur SOURCE_DATE_EPOCH, écrivain ICS de journées entièresheures et fuseaux, récurrences, VTIMEZONE, lecture ICS (§3.5, §4.4)
Temporalabsent de Node 22.22, la version du dépôt (vérifié : typeof Temporal vaut undefined)polyfill côté build seulement ; le client n'utilise que Intl (§3.5)
Semaine localenew Intl.Locale("fr-FR").weekInfo répond dans Node 22 (firstDay: 1) ; pas dans tous les navigateurscalculé au build, jamais dans le client
Place, JSON-LDspécifiés par le plan 12 (D6, D11) : un seul Place dans @recit/core/schema, un module JSON-LD partagé @recit/core/jsonldlivrés ici, premier consommateur (B3)
@recit/roadmapaprès P9 : storyToRoadmap, jalons datéslu par le pont (§3.7), dépendance format → format permise (03 §8)
Sitestable FORMAT_VIEWERS, modèle calculé au build, fichiers .ics écrits à côté des pages, ExportPanel (plan 26 §6.1)une ligne de plus, plus des routes par événement (§6.1)
PDFchaîne de 15 · Export PDF, liste fermée : calendar imprime « mois ou agenda »CalendarPdfOptionsSchema
Squelettepackages/formats/calendar : export {}, privé, scope:formatdevient publié

3. Le modèle

3.1 Une scène = un événement

Accepté dans 08 §0 : une scène est un événement. Un événement récurrent reste une scène, qui produit plusieurs occurrences au rendu. La mention « (ou un jour) » de 08 §0 n'a pas de syntaxe dans 08 §5 et n'est pas reprise : un jour est une case de la vue mois, pas une scène.

Conséquence pour le JSON : les événements sont les scenes du jumeau, comme pour tous les formats. 08 §5 et 09 §2.3 parlent d'un tableau events[] ; un tel tableau doublerait scenes, casserait la fusion du compagnon par id de scène et le test d'aller-retour. Recommandation : pas d'events[], les événements sont les scènes (question 1).

3.2 La syntaxe Markdown

MarkdownMarkdown
--- title: Lorem Meetups format: calendar lang: en timezone: Europe/Paris view: month # month | week | agenda | year firstDay: monday --- --- id=kickoff start=2026-11-05T18:30 end=2026-11-05T20:30 location="Lorem Hall" tags=meetup ## Kickoff meetup Lorem ipsum dolor sit amet, consectetur adipiscing elit. --- id=office-hours start=2026-11-03T10:00 duration=1h rrule="FREQ=WEEKLY;BYDAY=TU;COUNT=8" url=https://example.com/live ## Office hours --- id=course start=2026-10-06T18:30 duration=1h30m rrule="FREQ=WEEKLY;COUNT=6" exdate=2026-10-20T18:30 location=@lorem-hall ## Lorem course --- id=launch date=2027-03-31 tags=release ## 1.0 launch --- id=retreat date=2027-05-10 end=2027-05-12 film=./films/retreat.story.md ## Ipsum retreat
AttributRôle
startheure murale locale AAAA-MM-JJTHH:MM, dans timezone de l'événement, sinon celle du calendrier
end ou durationl'un ou l'autre, jamais les deux. duration : 1h, 90m, 1h30m ou ISO 8601 (PT1H30M)
dateun événement sur la journée entière ; avec end (date), sur plusieurs jours, end compris (§3.5)
allDayaccepté (08, 09) ; implicite avec date. allDay=true avec start est une erreur
timezonefuseau IANA (America/New_York) ; interdit sur un événement date
rrule, exdaterécurrence RFC 5545 sans le préfixe RRULE: ; exceptions, liste séparée par des virgules, dans la même forme que start (ou date)
locationchaîne libre (affichage seul), @id d'une entrée d'annuaire, ou Place inline en JSON (plan 12 D6)
url, tagslien (visio, billetterie), étiquettes filtrables
statusconfirmed (défaut), tentative, cancelled : un événement annulé reste affiché barré et sort dans l'ICS avec STATUS:CANCELLED
deck, filmstory liée : « voir le deck », « voir le film ». 08 §5 écrit video= ; le vocabulaire d'AGENTS.md dit film
colorune clarté de la teinte du site (soft, base, strong), jamais une couleur libre (08 §5 : teinte unique)
titre et corps de la scènele premier titre est le titre de l'événement, le reste sa description en Markdown complet

Les attributs tiennent sur la ligne du séparateur : l'exemple de 08 §5 coupe celle d'office-hours en deux lignes, ce que l'analyseur ne lit pas. Une ligne trop longue s'écrit en forme JSON (--- {"id": "office-hours", …}), déjà acceptée par parseInlineConfigWithDiagnostics. Les sources de stories ne passent jamais par oxfmt, qui ne les recoupera donc pas.

3.3 Le schéma (dans @recit/core/schema)

Primitives dans packages/core/src/schema/time.ts (créé par le plan 26), Place dans packages/core/src/schema/place.ts (plan 12, livré ici), le reste dans source.ts.

TypeScriptTypeScript
import * as z from "zod/mini"; // time.ts — added by P10 next to PlainDateSchema and QuarterSchema (P9) /** Wall-clock time without offset: "2026-11-05T18:30" (seconds optional) */ export const LocalDateTimeSchema = z .string() .check(z.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/), z.refine(isValidLocalDateTime)); /** IANA time zone known to Intl: "Europe/Paris" */ export const TimeZoneSchema = z.string().check(z.refine(isKnownTimeZone, { message: "unknown time zone" })); /** "1h", "90m", "1h30m" or ISO 8601 "PT1H30M" */ export const DurationSchema = z.string().check(z.refine(isDuration)); /** RFC 5545 RRULE value, without "RRULE:"; grammar checked here, expansion in @recit/time */ export const RRuleSchema = z.string().check(z.refine(isRRuleGrammar)); // place.ts — owned by plan 12, shipped by P10 export const PlaceSchema = z.strictObject({ name: z.optional(LocalizedSchema), address: z.optional(z.string()), // display only street: z.optional(z.string()), city: z.optional(z.string()), postcode: z.optional(z.string()), region: z.optional(z.string()), country: z.optional(z.string().check(z.regex(/^[A-Z]{2}$/))), geo: z.optional(z.tuple([z.number(), z.number()])), }); /** Free text, "@id" of a directory entry, or an inline Place */ export const LocationSchema = z.union([z.string(), PlaceSchema]); // source.ts export const CalendarSceneSourceSchema = z.strictObject({ ...sceneFields, // id, attrs, body (description), notes title: z.optional(LocalizedSchema), start: z.optional(LocalDateTimeSchema), end: z.optional(z.union([LocalDateTimeSchema, PlainDateSchema])), duration: z.optional(DurationSchema), date: z.optional(PlainDateSchema), allDay: z.optional(z.boolean()), timezone: z.optional(TimeZoneSchema), rrule: z.optional(RRuleSchema), exdate: z.optional(z.array(z.union([LocalDateTimeSchema, PlainDateSchema]))), location: z.optional(z.union([LocalizedSchema, PlaceSchema])), url: z.optional(z.url()), tags: z.optional(z.array(z.string())), status: z.optional(z.enum(["confirmed", "tentative", "cancelled"])), deck: z.optional(z.string()), film: z.optional(z.string()), color: z.optional(z.enum(["soft", "base", "strong"])), }); export const CalendarSettingsSourceSchema = z.strictObject({ timezone: z.optional(TimeZoneSchema), view: z.optional(z.enum(["month", "week", "agenda", "year"])), firstDay: z.optional(z.enum(["monday", "sunday", "saturday"])), /** Occurrences computed around the build date */ window: z.optional(z.strictObject({ past: z.optional(DurationSchema), future: z.optional(DurationSchema) })), maxOccurrences: z.optional(z.int().check(z.positive())), noOverlap: z.optional(z.boolean()), /** Roadmap stories whose dated milestones become events (§3.7) */ include: z.optional(z.array(z.string())), // pdf: CalendarPdfOptionsSchema, added by CA3 (15 §3.2) }); export const CalendarSourceSchema = z.strictObject({ ...storyFields, format: z.literal("calendar"), settings: z.optional(CalendarSettingsSourceSchema), scenes: z.array(CalendarSceneSourceSchema), });
  • location accepte un Localized (une chaîne, ou une chaîne par langue : « Lorem Hall » et « Salle Lorem ») ; une chaîne qui commence par @ est une référence d'annuaire (DI5.4, résolue plus tard par @recit/directory) ; un objet est un Place. Une seule résolution, dans le cœur (plan 12 D6). Les noms exacts de zod/mini sont à vérifier à l'implémentation.
  • Les défauts (view: month, window: { past: P3M, future: P12M }, maxOccurrences: 500, firstDay de la langue) sont posés par la section calendar: de recit.config (CalendarConfigSchema, .prefault({})), jamais par la source.
  • Contrôles du schéma (z.refine au niveau de la scène) : exactement un de start ou date ; end et duration exclusifs ; end de même nature que le début (date avec date, heure avec start) ; timezone interdit sur un date ; exdate de même nature que le début.
  • timezone du calendrier est obligatoire dès qu'un événement a une heure sans fuseau propre. Il n'y a pas d'heure « flottante » (sans fuseau) : elle change de sens d'un lecteur à l'autre.

3.4 Le jumeau et le compagnon

Le jumeau d'un calendrier Markdown est un JSON dont chaque scène porte ses champs typés ; un calendrier peut aussi être seulement du JSON (meetups.json, kind: "story", sans .md), ce qui est la forme naturelle d'un agenda multilingue :

JSONJSON
{ "$schema": "https://recit.dev/schema/v1/calendar.json", "kind": "story", "version": 1, "format": "calendar", "meta": { "title": { "en": "Lorem Meetups", "fr": "Rencontres Lorem" }, "lang": "en" }, "settings": { "timezone": "Europe/Paris", "view": "month" }, "scenes": [ { "id": "kickoff", "title": { "en": "Kickoff meetup", "fr": "Rencontre de lancement" }, "start": "2026-11-05T18:30", "end": "2026-11-05T20:30", "location": { "name": { "en": "Lorem Hall", "fr": "Salle Lorem" }, "city": "Ipsum", "country": "FR" }, "tags": ["meetup"], "body": { "en": "Lorem ipsum dolor sit amet.", "fr": "Lorem ipsum dolor sit amet, en français." } } ] }
  • Codec dans SCENE_CODECS (plan 26 RM0) : markdownToSource lit le premier titre en title, les attributs typés du séparateur en champs, le reste en attrs ; sourceToMarkdown émet ## titre, la description, et les attributs en key=value — ou en forme JSON du séparateur quand un champ est un objet (Place inline) ou contient un caractère qui ne se cite pas.
  • Le pivot résout les Localized dans une langue (compileStorySource), si bien que le calendrier compile une fois par langue, comme le deck.
  • Le compagnon complète un événement par son id (statut, url ajoutée par un outil, geo d'un lieu) ; recit convert meetups.ics --to json est la seule voie pour importer un agenda existant (§5), jamais une fusion silencieuse.

3.5 Dates, heures et fuseaux

Le cœur du plan. Principes, testés un par un (§9) :

  1. On stocke l'heure murale et son fuseau, jamais un instant UTC. start=2026-11-05T18:30 dans Europe/Paris est « 18 h 30 à Paris », quoi qu'il arrive aux règles de fuseau. L'instant est calculé au build par Temporal.ZonedDateTime. C'est la règle de 08 §9 (« les heures sont toujours stockées avec leur fuseau, et l'affichage est converti »).
  2. Une récurrence garde l'heure murale. Un cours hebdomadaire à 18 h 30 reste à 18 h 30 à Paris après le passage à l'heure d'hiver (25/10/2026) ; c'est l'instant UTC qui se décale. Exemple de course : occurrences du 06/10 au 10/11/2026, le 13/10 à 16 h 30 UTC, le 27/10 à 17 h 30 UTC.
  3. Heure inexistante ou ambiguë. 2026-03-29T02:30 n'existe pas à Paris ; 2026-10-25T02:30 existe deux fois. Résolution disambiguation: "compatible" de Temporal (comme RFC 5545 : décalée d'une heure vers l'avant pour l'inexistante, la première pour l'ambiguë), avec un avertissement (calendar-nonexistent-time, calendar-ambiguous-time) qui porte la ligne.
  4. duration est un temps écoulé, end une heure murale. duration=2h d'un événement à 1 h 30 la nuit du changement d'heure dure deux heures réelles ; end=…T03:30 finit à 3 h 30 au mur. Les deux sont légitimes et documentés.
  5. Journée entière = dates civiles, sans fuseau. date=2027-03-31 est le 31 mars partout. Dans l'ICS, DTSTART;VALUE=DATE et un DTEND exclusif (lendemain) : l'auteur écrit end compris (« du 10 au 12 mai »), le convertisseur ajoute le jour (question 5).
  6. Affichage dans le fuseau du calendrier, par défaut. La page prérendue affiche les heures dans timezone du calendrier, écrite à côté (« 18:30 CET »), pour que le HTML soit le même pour tout le monde et que l'hydratation ne diverge pas. TimezoneSwitcher propose ensuite le fuseau du lecteur (Intl.DateTimeFormat().resolvedOptions().timeZone) et le retient en localStorage (préférence de confort, protégée par try). Le changement se fait côté client par Intl, sur les instants déjà calculés : un lecteur de New York voit le cours du 27/10 à 13 h 30, et celui du 03/11 à 12 h 30, sans Temporal dans le navigateur (question 6).
  7. Fenêtre de calcul. Une récurrence sans fin (rrule sans COUNT ni UNTIL) est développée entre today() - window.past et today() + window.future, au plus maxOccurrences (avertissement calendar-occurrences-capped). L'ICS, lui, garde la règle RRULE entière : un client d'agenda la développe lui-même.
  8. Une horloge. today() et now() de @recit/time (plan 26 §3.5), sur SOURCE_DATE_EPOCH. Les vues ouvrent sur le mois de l'horloge de build ; UpcomingEvents filtre côté client avec l'heure réelle, après l'hydratation, pour qu'un site construit lundi ne montre pas mardi un événement de lundi comme « à venir ».

Outils, dans @recit/time :

Sous-exportContenuOù il tourne
.dates civiles, trimestres, semaines ISO, durées, today(), formatage Intl (P9)partout, client compris
./zonedheure murale → instant, désambiguïsation, développement des récurrences, fenêtres ; charge le polyfill Temporal si globalThis.Temporal manqueNode (build, CLI) seulement
./icsécriture (P9 : journées entières ; P10 : heures, TZID, VTIMEZONE, RRULE, EXDATE, STATUS) et lectureNode ; pur

Dépendances : un polyfill Temporal et rrule, déjà prévus par 03 §6 (« rrule, polyfill Temporal »). Le développement des récurrences de rrule est réputé fragile aux changements d'heure quand on lui passe un fuseau ; d'où le spike CA0 (§10), qui tranche entre rrule et un développement maison sur Temporal, limité au sous-ensemble utile (FREQ, INTERVAL, COUNT, UNTIL, BYDAY, BYMONTHDAY, BYMONTH, BYSETPOS, WKST), avec les exemples de RFC 5545 §3.8.5.3 en tests (question 2).

3.6 Les règles de validation

CodeGravitéCas
calendar-date-ordererreurend avant start (ou avant date)
calendar-starterreurni start ni date, ou les deux ; end et duration ensemble
calendar-unknown-timezoneerreurfuseau inconnu d'Intl
calendar-missing-timezoneerreurévénement à l'heure sans fuseau, dans un calendrier sans timezone
calendar-invalid-rruleerreurgrammaire fausse, UNTIL et COUNT ensemble, BYDAY invalide
calendar-exdate-mismatchavertissementexdate qui ne tombe sur aucune occurrence
calendar-nonexistent-timeavertissementheure murale qui n'existe pas (passage à l'heure d'été)
calendar-ambiguous-timeavertissementheure murale qui existe deux fois (passage à l'heure d'hiver)
calendar-overlaperreurdeux occurrences se chevauchent, seulement si noOverlap: true
calendar-occurrences-cappedavertissementdéveloppement arrêté à maxOccurrences
calendar-unknown-placeerreurlocation=@id sans entrée d'annuaire (quand @recit/directory est présent)

Toutes portent fichier et ligne, ou chemin JSON (meetups.json /scenes/3/rrule).

3.7 Le pont roadmap → calendar

include: [../roadmap/product.md] dans le frontmatter (08 §5). Le calendrier compile chaque roadmap listée avec storyToRoadmap (@recit/roadmap) et en tire un événement date par jalon daté (id préfixé : product.v1), avec les tags milestone et le slug de la roadmap. Les éléments datés n'entrent que si la roadmap le demande (ics.items: true, plan 26 §4.4), en événements sur plusieurs jours. Correspondance fermée (règle des ponts de 08 §0) : un jalon sans date ne passe pas, sans erreur ; un id en double est une erreur.

Le chemin d'include est relatif au fichier et ne sort pas du projet ; sur un site, la roadmap doit être publiée par le même site ou par un exemple déclaré en devDependencies (constat ST-20). Les jalons inclus suivent la langue de la page (titres Localized de la roadmap).

3.8 Les langues

Localisables : title, body, le name d'un Place, une location libre. Communs : id, heures, fuseaux, récurrences, url, tags, status (09 §2.3). recit i18n check compare en plus les attributs temporels entre meetups.md et meetups.fr.md : une heure différente est une erreur (attribut structurel). Les noms de jours et de mois, le premier jour de la semaine et le format des heures (24 h ou 12 h) viennent d'Intl, dans la langue de la page.

4. Le rendu

4.1 Le modèle compilé

TypeScriptTypeScript
// packages/formats/calendar/src/model.ts export const OccurrenceSchema = z.strictObject({ event: z.string(), // scene id /** Instants, ISO 8601 with offset: "2026-10-27T18:30:00+01:00" */ start: z.string(), end: z.string(), allDay: z.boolean(), /** For an all-day occurrence: civil dates, end inclusive */ dates: z.optional(z.strictObject({ from: PlainDateSchema, to: PlainDateSchema })), cancelled: z.boolean(), }); export const CalendarSchema = z.strictObject({ title: z.string(), lang: LocaleTagSchema, timezone: TimeZoneSchema, view: z.enum(["month", "week", "agenda", "year"]), firstDay: z.int().check(z.gte(1), z.lte(7)), events: z.array(CalendarEventSchema), // id, title, html, location (resolved), url, tags, links occurrences: z.array(OccurrenceSchema), // sorted, inside the window window: z.strictObject({ from: PlainDateSchema, to: PlainDateSchema }), today: PlainDateSchema, }); export type Calendar = z.infer<typeof CalendarSchema>; export function storyToCalendar(story: Story, options?: { file?: string }): CalendarResult;

Le modèle est calculé au build, une fois par langue ; le client reçoit des instants avec leur décalage, qu'Intl.DateTimeFormat affiche dans n'importe quel fuseau. Ni Temporal ni rrule n'entrent dans un bundle client (règle « Bundle Size » d'AGENTS.md, size-limit).

4.2 Les quatre vues

VueCe qu'elle montreStructure accessible
monthgrille du mois, une case par jour, les événements en liens ; « +3 » quand la case déborde ; navigation mois précédent et suivant<table> avec <caption> (mois, année), en-têtes de jours <th>
weeksept colonnes, les heures en lignes, les événements placés par leur instant dans le fuseau affiché ; journées entières en bandeauliste par jour sous la grille visuelle, lue par un lecteur d'écran
agendaliste des occurrences à venir, groupées par jour<ol> de jours, chaque événement un <article> avec <time datetime>
yeardouze mini-mois, les jours occupés marqués ; un clic ouvre le moisdouze <table> titrées ; marque doublée d'un nombre

La vue et la période se lisent dans l'URL (?view=week&date=2026-11-02), les filtres aussi (?tag=meetup). Navigation au clavier : flèches entre les jours dans la grille, Entrée sur un événement, Escape ferme le détail. Une semaine ou un mois hors de la fenêtre de build affiche « hors de la période publiée », jamais une grille vide trompeuse.

Composants (08 §5) : Calendar (racine), MonthView, WeekView, AgendaList, YearView, EventCard, EventDetail (<dialog> natif, et page propre §6.1, avec « ajouter à mon agenda »), MiniCalendar, UpcomingEvents, Countdown, TimezoneSwitcher (<select> natif), CalendarFilters (cases à cocher), SubscribeButton. Dans packages/formats/calendar/src/components/, un module CSS et une story chacun.

Style : la loi du plan 19, comme le plan 26 §4.3 — React et modules CSS compilés par Lightning CSS, une feuille @recit/calendar/styles.css, pas de Mantine dans un package publié, light-dark() pour le mode sombre. La teinte est celle du site calendar (rouge, 07 · Domaines §4) ; color ne choisit qu'une clarté. Un événement annulé se lit par le mot « Annulé » et le barré, pas par la couleur.

4.3 La page d'un événement et le JSON-LD

Chaque événement a sa page (/calendar/<slug>/<id>), prérendue : titre, occurrences à venir, lieu, description, liens vers son deck ou son film, SubscribeButton pour cet événement seul (/calendar/<slug>/<id>.ics). Elle porte un JSON-LD schema.org/Event produit par le module partagé @recit/core/jsonld (plan 12 D11) : startDate et endDate avec décalage, location en Place avec PostalAddress quand l'adresse est structurée (jamais depuis address libre), VirtualLocation quand seule url est donnée, eventStatus (EventCancelled), eventAttendanceMode. Un événement récurrent émet ses occurrences de la fenêtre, en eventSchedule si les moteurs le lisent, sinon une entrée par occurrence (à vérifier dans CA2).

4.4 Les exports

SortieCommandeContenu
Webrecit build meetups.jsonles quatre vues, une page par événement
ICSrecit export meetups.json --to icsVCALENDAR complet : un VEVENT par événement, RRULE et EXDATE gardées, VTIMEZONE par fuseau utilisé
Flux abonnableau build d'un site/calendar/<slug>.ics (un par langue : /fr/calendar/<slug>.ics), servi en text/calendar; charset=utf-8
PDFrecit export meetups.json --to pdfle mois (une page par mois de la plage) ou l'agenda, A4 portrait
  • ICS fiable : UID stable (<id>@<slug>.<hôte>, jamais régénéré) pour qu'un client abonné mette à jour au lieu de dupliquer ; SEQUENCE et LAST-MODIFIED dérivés d'un hachage du contenu de l'événement et de l'horloge de build ; DTSTAMP de l'horloge de build ; TZID IANA et VTIMEZONE calculé avec Temporal (transitions du fuseau sur la période des événements), parce qu'Outlook l'exige ; X-WR-CALNAME, X-WR-TIMEZONE et REFRESH-INTERVAL;VALUE=DURATION:PT12H pour les abonnés. Lignes pliées à 75 octets, CRLF.
  • Abonnement : SubscribeButton propose webcal://…, Google Agenda (ajout par URL), Outlook (addfromweb) et le téléchargement du fichier ; aucun script tiers, aucun suivi.
  • PDF : CalendarPdfOptionsSchema = z.extend(PdfOptionsSchema, { view: month | agenda, from, months }) en CA3, section calendar.pdf et pdf: du frontmatter (15 §3.2), vue ?print=1 et whenPrintable().
  • exports.json : variante ics du plan 26, avec subscribable: true pour les flux.

5. CLI et MCP

CommandeEffet
recit init --format calendar [file]calendar.md Lorem, ou calendar.json multilingue
recit dev, buildcomme le plan 26
recit validate meetups.jsonschéma, règles du §3.6, développement des récurrences dans la fenêtre
recit convert meetups.md --to json|mdjumeau
recit convert agenda.ics --to json|mdimport d'un .ics existant en story calendar (CA1 de 08) ; les propriétés inconnues sont listées, jamais perdues en silence
recit export … --to ics|pdf|html§4.4 ; --locale, --all-locales
recit schema calendarJSON Schema

L'import passe par recit convert (une commande par besoin, B9), comme Fountain au plan 14.

MCP : describe_format gagne calendar ; validate_story (plan 26) valide aussi un calendrier ; un outil expand_events rend les occurrences d'une story dans une fenêtre donnée et un fuseau donné, pour qu'un agent vérifie une rrule avant de l'écrire ; guide recit://guide/calendar, qui insiste sur l'heure murale et le fuseau.

6. Le site

6.1 L'intégration (@recit/site-stories)

  • Une ligne calendar dans FORMAT_VIEWERS (plan 26 §6.1), chargée en React.lazy.
  • Routes par scène : c'est le premier format qui publie une page par scène. Le plugin gagne une option par format, sceneRoutes, qui ajoute /<routePrefix>/<slug>/<sceneId> au prérendu, au sitemap, à Pagefind et aux routes de langue (hreflang). Le deck n'en a pas besoin et ne change pas.
  • Les fichiers .ics (calendrier entier, et un par événement) sont écrits au build à côté des pages, et listés dans exports.json.
  • <StoryEmbed slug="meetups" view="agenda" /> et <UpcomingEvents slug="meetups" count={3} /> dans une page MDX (recit.dev, docs) : ce dernier est le composant que www pourra montrer.

6.2 calendar.recit.dev

sites/calendar, sur createSiteConfig({ site: "calendar" }).

PageContenu
/FormatHero : le calendrier des versions de Récit (§6.3), vue mois, SubscribeButton, source dépliable
/gallerycalendar-meetups (JSON multilingue) et calendar-course (Markdown, récurrences) dans les quatre vues, en en et fr
/jsondémo « le même agenda en JSON » : le jumeau à côté du rendu, sélecteur de langue
/templatesmeetups, cours, planning éditorial
/calendar/<slug>[/<id>]stories en plein écran et pages d'événement (publish.routePrefix: "/calendar", d'où /calendar/<slug>.ics de 08 §5)

Port local 3008, port e2e 4308, projet Vercel recit-calendar, entrée calendar dans SITES (07 · Domaines). vercel.json pose le type text/calendar sur *.ics. Doc : sites/docs › Formats › Calendar, avec une page « Fuseaux et changements d'heure ».

6.3 Dogfooding

Le hero de calendar.recit.dev (07 §2 : « calendrier des sorties et des événements Récit ») est fait de données réelles et ne doit rien inventer : Récit n'a ni meetup ni cours. Proposition : une story sites/calendar/stories/releases.md qui contient les versions publiées (0.1.0-alpha…, journée entière, film= ou deck= vers leur annonce quand elle existe) et qui inclut les jalons datés de la roadmap de Récit (plan 26 §7) par le pont du §3.7. C'est le critère de sortie « les jalons de la roadmap remontent dans le calendrier », démontré sur Récit lui-même. Comme un site ne dépend pas d'un autre site, la roadmap incluse doit être publiée par le même site : soit calendar.recit.dev republie la story de roadmap (copie), soit elle vit à un endroit que les deux sites peuvent déclarer (question 8).

7. Les exemples

Contenu en Lorem Ipsum, lieux fictifs (Lorem Hall, ville Ipsum, example.com), règle B7.

DossierContenu
examples/calendar-meetupsmeetups.json seul (kind: "story"), titres et lieux Localized en en et fr, un Place inline, un événement annulé, une visio (url seule) ; recit.config.ts avec i18n
examples/calendar-coursecourse.md : un cours hebdomadaire qui traverse le 25/10/2026, une exdate, des permanences sur America/New_York qui traversent le 01/11/2026, une journée entière sur trois jours, un include de ../roadmap-product/product.md

calendar-course déclare recit-example-roadmap-product en dépendance pour le pont (ST-20). Gabarits create-recit : calendar-meetups, calendar-course.

8. Les risques

RisqueParade
Erreurs aux changements d'heureheure murale et fuseau stockés ; Temporal pour les calculs ; tests sur les quatre transitions de 2026 (Paris 29/03 et 25/10, New York 08/03 et 01/11)
rrule faux avec un fuseauspike CA0 avec go/no-go, repli sur un développement maison sur Temporal
Base de fuseaux différente entre Node et navigateursles instants sont calculés au build (ICU de Node, full-icu) ; le client ne fait que formater ; un test compare au fuseau UTC
HTML prérendu différent selon le lecteur (hydratation)prérendu dans le fuseau du calendrier, toujours avec timeZone explicite ; le fuseau du lecteur ne s'applique qu'après l'hydratation
Site statique périmé (« à venir » d'hier)UpcomingEvents filtre côté client ; reconstruction planifiée du site (question 7)
Clients ICS capricieux (Outlook, Apple, Google)VTIMEZONE toujours émis, UID stable, essais manuels des trois avant la sortie de P10 ; l'ICS est aussi relu en test
Trop d'occurrences (récurrence quotidienne sans fin)fenêtre et maxOccurrences, avertissement
Le polyfill Temporal alourdit le clientsous-export ./zoned réservé à Node ; size-limit sur le bundle du calendrier
Fuites de données personnelles (adresse d'un particulier)Place structuré seulement pour des lieux publics ; l'avertissement « personne avec adresse » du plan 12 s'applique aux location
Un lieu libre pris pour une adresse dans le JSON-LDaddress libre jamais émis en PostalAddress (plan 12 D6)

9. Les tests

NiveauQuoiOù
Unitaire, cœurschéma et refine (exclusivités, natures de dates, fuseau obligatoire), codec Markdown ⇄ JSON (forme JSON du séparateur pour un Place), Place et locationpackages/core/src/__tests__/calendar-*.spec.ts
Aller-retourexamples/calendar-* et sites/calendar/stories, y compris un calendrier seulement JSON (json → md → json)source-conversion.spec.ts
Unitaire, timeles quatre transitions de 2026 ; heures inexistantes et ambiguës ; récurrences : exemples de RFC 5545 §3.8.5.3, EXDATE, UNTIL en heure locale, BYSETPOS ; durées contre heures de fin ; VTIMEZONE ; ICS écrit puis relu, identiquepackages/time/src/__tests__/
Unitaire, formatstoryToCalendar : fenêtre, plafond, annulés, pont roadmap → calendar, id préfixés ; JSON-LD validé contre le schéma Event attendupackages/formats/calendar/src/__tests__/
Storiesun play par composant : naviguer au clavier dans MonthView, ouvrir et fermer EventDetail (Escape), changer de fuseau et lire l'heure affichée (« 13:30 » à New York pour le 27/10), filtrer par tag, SubscribeButton qui expose ses liens ; axe en erreurpackages/formats/calendar/src/components/*.stories.tsx, titre Calendar/<Composant>
e2e sitechaque page en en et fr, les quatre vues, une page d'événement, .ics servi en text/calendar et relu, hreflang ; captures sous SOURCE_DATE_EPOCH fixe et timezoneId Playwright fixée2e/site-calendar.spec.ts, projet calendar
e2e Storybookcaptures Calendar/*e2e/storybook-calendar.spec.ts
PDFpages par mois, texte extrait, signetstest de la CLI

Un test e2e change aussi le fuseau du navigateur (timezoneId: "America/New_York") et vérifie que la page prérendue reste celle du fuseau du calendrier jusqu'au choix du lecteur.

10. Les lots

Identifiants de 08 §5 conservés (CA1 à CA5), plus CA0, sous-tâches CAn.m (B10). Prérequis : P9 terminé (plan 26 : SCENE_CODECS, FORMAT_VIEWERS, @recit/time moitié roadmap, écrivain ICS, validate_story). Chaque lot laisse pnpm check, build, typecheck et pnpm test:related au vert, avec son version plan (preminor au premier lot publié de la phase, prerelease ensuite), et les lignes de NOTICE.md des dépendances ajoutées.

CA0 — Spike récurrences et fuseaux (S), go/no-go

  • CA0.1 — Polyfill Temporal : temporal-polyfill (plus léger) ou @js-temporal/polyfill (référence du TC39), mesurés (poids, conformité sur nos cas).
  • CA0.2 — rrule contre un développement maison sur Temporal, sur la batterie du §9.

Sortie : une note dans ce plan, deux dépendances choisies (ou une seule), validées par l'utilisateur.

CA1 — @recit/time, moitié calendrier (M)

  • CA1.1 — LocalDateTimeSchema, TimeZoneSchema, DurationSchema, RRuleSchema dans le cœur.
  • CA1.2 — ./zoned : heure murale → instant, désambiguïsation, récurrences, fenêtre.
  • CA1.3 — ./ics : heures, TZID, VTIMEZONE, RRULE, EXDATE, STATUS ; lecture.

Sortie : tous les tests « Unitaire, time » verts ; un ICS écrit puis relu est identique.

CA2 — Le modèle, les vues, Place (L)

  • CA2.1 — PlaceSchema et LocationSchema (plan 12 DI0.1), CalendarSourceSchema typé, CalendarConfigSchema, JSON Schema calendar.json.
  • CA2.2 — Codec dans SCENE_CODECS, règles du §3.6.
  • CA2.3 — storyToCalendar, CalendarSchema.
  • CA2.4 — Les quatre vues et les composants, stories avec play.
  • CA2.5 — @recit/core/jsonld (Event, Place), partagé avec le plan 12.
  • CA2.6 — Runtime et CLI (init, dev, build, validate, convert, import .ics).

Sortie : recit build examples/calendar-meetups/meetups.json montre les quatre vues en en et fr ; pnpm test:stories packages/formats/calendar passe, axe compris.

CA3 — Flux, PDF, pont (M)

  • CA3.1 — export --to ics, flux abonnable par langue, ICS par événement.
  • CA3.2 — CalendarPdfOptionsSchema, vue imprimable, export --to pdf.
  • CA3.3 — include : pont roadmap → calendar.
  • CA3.4 — MCP : describe_format, expand_events, guide.

Sortie : flux abonné à la main dans Google Agenda, Apple Calendrier et Outlook, heures justes de part et d'autre du 25/10 ; les jalons de roadmap-product apparaissent dans calendar-course.

CA4 — Les exemples (S)

  • CA4.1 — examples/calendar-meetups et examples/calendar-course (§7), gabarits.

Sortie : aller-retour vert, recit i18n check vert.

CA5 — calendar.recit.dev et la doc (M)

  • CA5.1 — sceneRoutes dans @recit/site-stories.
  • CA5.2 — sites/calendar (§6.2), SITES, ports, Vercel, type des .ics.
  • CA5.3 — sites/calendar/stories/releases.md et l'inclusion de la roadmap de Récit (§6.3).
  • CA5.4 — sites/docs › Formats › Calendar, page des fuseaux ; formats.ts de @recit/site-kit.
  • CA5.5 — e2e/site-calendar.spec.ts, e2e/storybook-calendar.spec.ts, captures -linux.

Sortie (celle de P10 dans 06) : examples/calendar-meetups dans les quatre vues ; flux ICS abonnable et récurrences justes aux changements d'heure ; jalons de la roadmap dans le calendrier ; pnpm verify et pnpm test:e2e:linux e2e/site-calendar.spec.ts verts deux fois de suite ; version 0.6.0-alpha publiée.

Ordre et agents. CA0 d'abord (il conditionne CA1). CA1 et CA2.1–CA2.2 (cœur) peuvent avancer en parallèle une fois CA0 tranché ; CA2.3 et suivants attendent CA1.2. CA3 et CA4 se partagent ; CA5 ferme. Les lots FO0 et FO1 du plan 10 se déroulent à côté, sans fichier commun.

11. Ce qui reste hors de P10

  • Synchronisation bidirectionnelle avec Google Agenda ou CalDAV : jamais dans le cœur ; l'import .ics ponctuel suffit.
  • Inscription à un événement : le bloc :::form du plan 10 peut vivre dans la description d'un événement, sans rien de propre au calendrier.
  • Pont calendar → stories (« à venir » en cartes) : plan 28, P11.
  • Disponibilités, prise de rendez-vous, fuseaux multiples affichés côte à côte : non prévus.

12. Questions ouvertes

  1. Forme du JSON : les événements sont les scenes du jumeau (recommandé, §3.1) ; ou un tableau events[] à part, comme l'écrivent 08 §5 et 09 §2.3 ?
  2. Récurrences : garder rrule (prévu par 03 §6) si le spike CA0 le valide, sinon un développement maison sur Temporal limité au sous-ensemble du §3.5 (recommandé en repli) ?
  3. Polyfill Temporal : temporal-polyfill (recommandé, plus léger, Node seulement) ou @js-temporal/polyfill ?
  4. Lecture ICS : ical.js (MPL-2.0), qui servirait aussi d'oracle aux tests du plan 26 (question 9) ; ou un analyseur maison limité à ce que Récit écrit et importe ?
  5. Journée entière sur plusieurs jours : end écrit compris par l'auteur, converti en DTEND exclusif dans l'ICS (recommandé) ; ou end exclusif comme RFC 5545 ?
  6. Fuseau d'affichage : celui du calendrier par défaut, celui du lecteur sur demande (recommandé) ; ou celui du lecteur d'office après l'hydratation ?
  7. Fraîcheur : accepter une reconstruction planifiée de calendar.recit.dev (une par jour, par une tâche Vercel), pour que la vue « à venir » et le flux suivent le temps ?
  8. Dogfooding : le calendrier des versions de Récit inclut la roadmap de Récit (§6.3). Où vit la story de roadmap pour que deux sites la lisent sans dépendre l'un de l'autre : copiée dans sites/calendar, ou dans un projet de contenu commun hors de examples/ (non Lorem) ?
  9. video= → film= : renommer l'attribut de 08 §5 selon le vocabulaire d'AGENTS.md (recommandé) ?
  10. color : limiter aux trois clartés soft, base, strong de la teinte du site (recommandé) ; ou supprimer l'attribut et ne colorer que par tag ?
  11. Quatre vues dès P10 (critère de sortie de 06) ; ou month et agenda d'abord, week et year en prerelease suivante ?

13. À reporter dans les autres plans (par l'intégrateur)

FichierCe qui change
06-roadmap.mdP10 renvoie à ce plan ; CA0 ajouté
08-formats.md §0, §5« (ou un jour) » retiré ; events[] selon la question 1 ; séparateur d'office-hours sur une ligne ; export --to ics ; film= ; status
09-langues-et-json.md §2.3ligne calendar : champs de scène (title, start, end, duration, date, timezone, rrule, exdate, location…) au lieu d'events[] ; ligne Place (B3)
12-glossaire-annuaire.mdPlace livré en CA2.1, @recit/core/jsonld en CA2.5
15-export-pdf.md §3.2CalendarPdfOptionsSchema ; subscribable sur la variante ics
21-tests.md §3.1titre Storybook Calendar/<Composant>, surface e2e/storybook-calendar.spec.ts
sites/plans/recit.config.tsentrée de navigation pour 27