Aller au contenu
SuperScheduler

Modules ProS’applique àSuperScheduler Pro

Chargement des données par plage de dates

Créez un chargeur avec `createRangeLoader` depuis `super-scheduler/ranges`, donnez-lui une fonction `load({ start, end, signal })` qui renvoie les événements de cette plage semi-ouverte, et attachez-le avec `extensions={[loader]}`. Le chargeur demande des tranches fixes de jours autour de la plage visible au montage, puis une fois le défilement ou le zoom stabilisé ; il annule les requêtes qui sortent de la fenêtre, met en cache les tranches récentes et fusionne les événements par id. Votre serveur n’a qu’à répondre à des requêtes `[start, end)` avec des id d’événements stables.

Vérifié avec la v0.1.0 · relu le 7 octobre 2026.md

Le chargement par plages préserve la rapidité d’une longue frise sans envoyer tout le jeu de données au navigateur. Le chargeur demande à votre backend les dates que le visiteur peut voir, plus une marge, puis oublie les dates qui s’éloignent. Il est livré avec SuperScheduler Pro sous le nom super-scheduler/ranges ; Lite affiche les événements que vous lui passez.

Vous n’en avez pas toujours besoin. Un planning de quelques milliers d’événements se charge en une requête et le planificateur le virtualise (voir Virtualisation et performances). Tournez-vous vers le chargement par plages quand la frise couvre plusieurs années, quand le jeu de données complet est trop volumineux pour être récupéré ou gardé en mémoire, ou quand votre API est déjà paginée par date.

Fonctionnement du chargeur

Le chargeur découpe l’axe du temps en tranches de chunkDays jours et garde une fenêtre de tranches autour de la zone visible :

  • Limites de tranches fixes. Les limites sont des multiples de chunkDays comptés depuis le 1970-01-01 ; elles ne dépendent donc ni de startDate, ni de la position de défilement, ni de weekStarts. Les tranches de sept jours commencent le jeudi ; avec chunkDays: 14 et startDate="2026-01-01", la première tranche visible est [2026-01-01, 2026-01-15). Une même tranche produit toujours la même requête, si bien que les réponses peuvent être mises en cache.
  • Des requêtes aux limites, jamais à chaque image. La fenêtre voulue comprend chaque tranche qui chevauche les dates visibles, plus prefetch tranches de chaque côté (une tranche préchargée peut se trouver avant startDate). Les tranches manquantes sont demandées juste après le montage, puis de nouveau quand un geste de défilement ou de zoom se stabilise. Un défilement programmatique avec control.scrollTo() compte comme un défilement.
  • Annulation. Une requête dont la tranche quitte la fenêtre voulue avant de répondre est interrompue via son AbortSignal. Si votre fonction ignore le signal, son résultat tardif est de toute façon écarté.
  • Cache et éviction. Au plus cacheChunks tranches sont conservées. Au-delà, les tranches les plus éloignées de la vue sont évincées et leurs événements quittent le planificateur, sauf si une autre tranche conservée les a aussi renvoyés. Revenir à ces dates relance leur chargement.
  • Fusion par id. Un événement renvoyé par deux tranches, parce qu’il chevauche une limite, n’apparaît qu’une fois.
  • Retour visuel. Pendant le chargement d’une tranche, une bande translucide couvre ses dates. Elle porte la classe super-scheduler__range-skeleton et utilise le token --super-scheduler-skeleton-base ; skeleton: false la supprime.
  • Pas d’historique. Les chargements ne créent jamais d’entrées d’annulation, et ils parviennent à onEventsChange avec reason: 'load'.

Attacher un chargeur

Créez le chargeur une seule fois par planificateur et passez-le dans extensions. Le contrôle attache et libère les extensions selon l’identité des objets : un chargeur recréé à chaque rendu repartirait chaque fois d’un cache vide. La fonction load reçoit le start et le end de la tranche sous forme de valeurs SuperScheduler.Date ; start.value est la chaîne ISO civile (2026-01-01T00:00:00) à envoyer à votre API.

src/RangePlanning.tsxtsx
import { useMemo, useState, type RefObject } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { createRangeLoader } from 'super-scheduler/ranges'
import 'super-scheduler/styles.css'
import { createSaveHandler } from './save-changes'

const ROOMS: SuperScheduler.ResourceData[] = [
  { id: 'r101', name: 'Room 101' },
  { id: 'r102', name: 'Room 102' },
  { id: 'r103', name: 'Room 103' },
]
const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'Month' },
  { groupBy: 'Day', format: 'd' },
]

/** One server row as a scheduler event. The server sends civil ISO strings with seconds. */
export function toEvent(booking: Booking): SuperScheduler.EventData {
  return {
    id: booking.id,
    resource: booking.roomId,
    start: booking.start,
    end: booking.end,
    text: booking.guest,
  }
}

/**
 * The loader is created outside render and receives the control's ref object. Its callbacks read
 * `controlRef.current` later, when a chunk fails, never while React renders.
 */
function createBookingLoader(controlRef: RefObject<SuperScheduler.Scheduler | null>) {
  return createRangeLoader({
    // One chunk, [start, end). The signal aborts requests the visitor scrolled away from.
    load: async ({ start, end, signal }) => {
      const bookings = await fetchBookings(start.value, end.value, signal)
      return bookings.map(toEvent)
    },
    chunkDays: 14,
    prefetch: 1,
    onError: (error, range) => {
      console.error(error)
      const from = range.start.toString('d MMM')
      const to = range.end.addDays(-1).toString('d MMM')
      controlRef.current?.message(
        `Could not load ${from} to ${to}. Scroll back or refresh to retry.`,
      )
    },
  })
}

export function RangePlanning() {
  const { controlRef } = useSchedulerControl()

  // Created once per scheduler: extensions are attached and disposed by object identity.
  const [loader] = useState(() => createBookingLoader(controlRef))
  const extensions = useMemo(() => [loader], [loader])
  const onEventsChange = useMemo(() => createSaveHandler(loader), [loader])

  return (
    <SuperSchedulerComponent
      controlRef={controlRef}
      startDate="2026-01-01"
      days={365}
      scale="Day"
      cellWidth={40}
      heightSpec="Fixed"
      height={480}
      timeHeaders={TIME_HEADERS}
      resources={ROOMS}
      // No `events` prop: the loader writes into the control's own store (uncontrolled).
      extensions={extensions}
      onEventsChange={onEventsChange}
    />
  )
}

Ce planificateur n’a pas de prop events : le chargeur écrit donc directement dans le store propre au contrôle. Les modifications faites par l’utilisateur arrivent toujours dans onEventsChange ; le handler ci-dessous enregistre les déplacements et redimensionnements, ignore les chargements et interroge de nouveau le serveur quand un enregistrement est rejeté :

src/save-changes.tsts
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs } from 'super-scheduler'
import type { RangeLoader } from 'super-scheduler/ranges'

const ticks = (value: SuperScheduler.DateInput) => new SuperScheduler.Date(value).ticks

/** The range that covers an event before and after a change. */
function span(before: SuperScheduler.EventData, after: SuperScheduler.EventData) {
  return {
    start: ticks(before.start) < ticks(after.start) ? before.start : after.start,
    end: ticks(before.end) > ticks(after.end) ? before.end : after.end,
  }
}

/**
 * Persists moves and resizes. Range loads arrive with reason 'load' and are skipped. When the server
 * rejects a change, reloading the old and new dates puts the event back where the server has it.
 */
export function createSaveHandler(loader: RangeLoader) {
  return ({ reason, changed, removed }: SchedulerEventsChangeArgs) => {
    if (reason !== 'move' && reason !== 'resize') return
    for (const after of changed) {
      const before = removed.find((item) => item.id === after.id)
      if (before === undefined || after.resource === undefined) continue
      saveBooking({
        id: String(after.id),
        resource: String(after.resource),
        // After a drag, start and end are SuperScheduler.Date objects; String() gives civil ISO.
        start: String(after.start),
        end: String(after.end),
      }).catch(() => loader.reload(span(before, after)))
    }
  }
}

Vous devriez voir un instant une bande de chargement sur les dates visibles, puis les réservations. Dans l’onglet Réseau, faire défiler de quelques semaines vers la droite produit une requête par nouvelle tranche de 14 jours une fois le défilement arrêté, et faire défiler rapidement sur de nombreuses tranches ne produit que les requêtes de l’endroit où vous vous arrêtez.

Options

OptionTypeDéfautSignification
load({ start, end, signal }) => Promise<EventData[]>obligatoireRenvoie les événements qui chevauchent [start, end).
chunkDaysnumber7Jours par tranche. Des tranches plus grandes donnent des requêtes moins nombreuses mais plus lourdes.
prefetchnumber1Tranches chargées de chaque côté de la plage visible.
cacheChunksnumber26Tranches conservées avant l’éviction des plus éloignées.
skeletonbooleantrueAffiche la bande de chargement sur les tranches en attente.
onError(error, { start, end }) => voidaucunAppelé une fois par tranche en échec. Les requêtes interrompues ne sont pas des erreurs.

L’objet chargeur expose lui-même reload(range?), clear() et un getter loading qui vaut true tant qu’une tranche est en attente. loading est une simple propriété, pas un abonnement : lisez-la quand vous en avez besoin, ou pilotez votre propre indicateur depuis load.

Événements contrôlés ou store du contrôle

Le chargement par plages fonctionne avec les deux modes de données décrits dans État contrôlé :

  • Sans prop events, ou avec defaultEvents. Le chargeur ajoute les nouveaux événements au store du contrôle, les met à jour quand une tranche est rechargée et les retire quand leur tranche est évincée. Les événements déjà présents dans le store ne sont pas écrasés par les chargements suivants : une réservation que l’utilisateur vient de déplacer reste donc où elle est jusqu’à ce que vous appeliez reload(). C’est le choix le plus simple quand les utilisateurs modifient des données chargées par plages.
  • events contrôlé plus onEventsChange. Le chargeur n’écrit jamais dans le store. Chaque tranche terminée appelle onEventsChange avec reason: 'load' et events contenant la liste fusionnée, et votre état doit l’adopter comme n’importe quelle autre modification. React regroupe ces mises à jour ; comptez un appel par tranche.
src/ControlledRange.tsxtsx
import { useCallback, useMemo, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SuperScheduler } from 'super-scheduler'
import { createRangeLoader } from 'super-scheduler/ranges'
import { toEvent } from './RangePlanning'

interface Props {
  readonly rooms: SuperScheduler.ResourceData[]
}

export function ControlledRange({ rooms }: Props) {
  // React state owns the events; the loader proposes the merged list through onEventsChange.
  const [events, setEvents] = useState<SuperScheduler.EventData[]>([])
  const owned = useMemo(() => events.slice(), [events])

  const [loader] = useState(() =>
    createRangeLoader({
      load: async ({ start, end, signal }) =>
        (await fetchBookings(start.value, end.value, signal)).map(toEvent),
    }),
  )
  const extensions = useMemo(() => [loader], [loader])

  const onEventsChange = useCallback(
    (args: SchedulerEventsChangeArgs) => {
      // Adopt every change, loads included: `events` is the full list after this change.
      setEvents([...args.events])
      if (args.reason !== 'move' && args.reason !== 'resize') return
      for (const after of args.changed) {
        const before = args.removed.find((item) => item.id === after.id)
        if (before === undefined || after.resource === undefined) continue
        const change = {
          id: String(after.id),
          resource: String(after.resource),
          start: String(after.start),
          end: String(after.end),
        }
        saveBooking(change).then(
          () => {
            // The cached chunks still hold the version loaded before the change: refetch them.
            loader.clear()
            void loader.reload()
          },
          // Rejected: put the previous version back in state.
          () =>
            setEvents((current) => current.map((item) => (item.id === after.id ? before : item))),
        )
      }
    },
    [loader],
  )

  return (
    <SuperSchedulerComponent
      startDate="2026-01-01"
      days={365}
      scale="Day"
      cellWidth={40}
      resources={rooms}
      events={owned}
      onEventsChange={onEventsChange}
      extensions={extensions}
    />
  )
}

Navigation, rafraîchissement et filtres

Deux méthodes couvrent la plupart des actions d’une barre d’outils :

  • reload() redemande la fenêtre visible, en cache ou non, et remplace ces tranches. Les événements absents des nouvelles réponses sont retirés. reload({ start, end }) fait de même pour n’importe quelle plage, ce qui est utile après un enregistrement ou une notification du serveur.
  • clear() annule les requêtes en attente et vide le cache. Il ne retire pas les événements affichés.

Modifier startDate ou days via les props ou control.update() ne provoque aucun défilement, et ne déclenche donc aucun chargement. Faites défiler jusqu’à la nouvelle période et appelez reload() une fois que React a appliqué la modification. Quand la requête elle-même change, par exemple un autre site ou un filtre de statut, videz le cache, retirez les anciens événements et rechargez :

src/SitePlanning.tsxtsx
import { useEffect, useMemo, useRef, useState } from 'react'
import { SuperScheduler, SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import { createRangeLoader } from 'super-scheduler/ranges'
import { toEvent } from './RangePlanning'

type SiteId = 'north' | 'south'

const ROOMS: Record<SiteId, SuperScheduler.ResourceData[]> = {
  north: [
    { id: 'n1', name: 'North 1' },
    { id: 'n2', name: 'North 2' },
  ],
  south: [
    { id: 's1', name: 'South 1' },
    { id: 's2', name: 'South 2' },
  ],
}

/** The loader is created once; each chunk request reads the site chosen at that moment. */
function createSiteLoader(initial: SiteId) {
  let site = initial
  return {
    loader: createRangeLoader({
      load: async ({ start, end, signal }) =>
        (await fetchSiteBookings(site, start.value, end.value, signal)).map(toEvent),
    }),
    /** Later requests query this site. */
    setSite: (next: SiteId) => {
      site = next
    },
  }
}

export function SitePlanning() {
  const { controlRef } = useSchedulerControl()
  const [month, setMonth] = useState(() => SuperScheduler.Date.today().firstDayOfMonth())
  const [site, setSite] = useState<SiteId>('north')

  const [{ loader, setSite: setLoaderSite }] = useState(() => createSiteLoader('north'))
  const extensions = useMemo(() => [loader], [loader])

  // A new period is not a scroll: after React applied it, show its start and load the view.
  const shown = useRef(month)
  useEffect(() => {
    if (shown.current.equals(month)) return
    shown.current = month
    controlRef.current?.scrollTo(month)
    void loader.reload()
  }, [controlRef, loader, month])

  // Another site: forget its cache, drop its events and load the visible range again.
  const changeSite = (next: SiteId) => {
    setLoaderSite(next)
    setSite(next)
    loader.clear()
    controlRef.current?.update({ events: [] })
    void loader.reload()
  }

  return (
    <>
      <div role="toolbar" aria-label="Planning">
        <button type="button" onClick={() => setMonth((m) => m.addMonths(-1))}>
          Previous month
        </button>
        <button type="button" onClick={() => setMonth((m) => m.addMonths(1))}>
          Next month
        </button>
        <button type="button" onClick={() => void loader.reload()}>
          Refresh
        </button>
        <select value={site} onChange={(event) => changeSite(event.target.value as SiteId)}>
          <option value="north">North</option>
          <option value="south">South</option>
        </select>
      </div>
      <SuperSchedulerComponent
        controlRef={controlRef}
        startDate={month}
        days={month.daysInMonth()}
        scale="Day"
        cellWidth={48}
        resources={ROOMS[site]}
        extensions={extensions}
      />
    </>
  )
}

Avec des événements contrôlés, faites de même avec setEvents([]) au lieu de control.update({ events: [] }), et appelez reload() depuis un effet qui s’exécute après l’application de la liste vide. Si réinitialiser la position de défilement est acceptable, rendre le planificateur avec key={site} vous donne un contrôle neuf et un chargeur neuf.

Erreurs et nouvelles tentatives

Quand la promesse de load est rejetée, le chargeur appelle onError(error, { start, end }) pour cette tranche, retire sa bande de chargement et l’oublie. La tranche est redemandée au prochain défilement ou zoom stabilisé qui en a encore besoin, ou lors d’un reload(). Affichez l’échec là où les utilisateurs regardent : control.message() affiche une courte barre de message dans le planificateur, comme dans le premier exemple. Les requêtes interrompues par le chargeur n’atteignent jamais onError.

Le contrat serveur

Votre endpoint répond à une seule question : quels événements chevauchent cette plage civile semi-ouverte ?

txttxt
GET /api/bookings?start=2026-01-01T00:00:00&end=2026-01-15T00:00:00
jsonjson
[
  { "id": "b-1042", "roomId": "r101", "guest": "Ana Ruiz", "start": "2025-12-30T14:00:00", "end": "2026-01-03T11:00:00" },
  { "id": "b-1043", "roomId": "r102", "guest": "Tom Berg", "start": "2026-01-14T14:00:00", "end": "2026-01-16T11:00:00" }
]
  • Chevauchement semi-ouvert. Renvoyez chaque événement tel que event.start < end et event.end > start. Un événement qui commence avant la tranche ou se termine après fait partie de la réponse, comme b-1042 et b-1043 ci-dessus. Un événement qui se termine exactement à start n’en fait pas partie.
  • Des id stables et uniques. Une même réservation doit avoir le même id dans chaque réponse, et deux événements ne peuvent pas partager un id, toutes ressources confondues. Les id sont comparés strictement : 1 et '1' sont deux événements différents.
  • Des dates et heures civiles avec secondes. start et end sont des valeurs d’horloge murale sans fuseau, et les chaînes doivent comporter les secondes (2026-01-14T14:00:00). Si vous stockez des instants, convertissez-les d’abord dans le fuseau horaire de l’activité ; voir Langues, dates civiles et fuseaux horaires.
  • L’annulation est bienvenue. Passer le signal à fetch libère la connexion plus tôt ; le chargeur n’en dépend pas.
  • Mise en cache possible. Les limites de tranches fixes font se répéter des requêtes identiques, que le cache HTTP ou un CDN peut servir. Gardez un cache court si d’autres utilisateurs modifient le même planning.

Plus bas niveau : dynamicLoading et onScroll

Le contrôle dispose aussi du mécanisme classique fondé sur des callbacks. Avec dynamicLoading et un handler onScroll, le contrôle appelle onScroll une fois que le défilement est resté calme pendant scrollDelayDynamic millisecondes (500 par défaut). args.viewport contient les start, end et resources visibles ; vous remplissez args.events et appelez args.loaded().

src/DynamicPlanning.tsxtsx
import { useCallback, useRef, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerScrollArgs, SuperScheduler } from 'super-scheduler'
import { toEvent } from './RangePlanning'

interface Props {
  readonly rooms: SuperScheduler.ResourceData[]
  /** Events of the first screen: onScroll is not called at mount. */
  readonly initial: SuperScheduler.EventData[]
}

export function DynamicPlanning({ rooms, initial }: Props) {
  const [firstScreen] = useState(() => initial.slice())
  const pending = useRef<AbortController | null>(null)

  // Called once scrolling has been quiet for `scrollDelayDynamic` ms.
  const onScroll = useCallback((args: SchedulerScrollArgs) => {
    pending.current?.abort()
    const request = new AbortController()
    pending.current = request
    // Load a margin around the viewport: by default the result replaces every event.
    const from = args.viewport.start.addDays(-14)
    const to = args.viewport.end.addDays(14)
    args.async = true
    fetchBookings(from.value, to.value, request.signal).then(
      (bookings) => {
        args.events = bookings.map(toEvent)
        args.loaded()
      },
      () => {
        // Failed or superseded: keep what is on screen.
        args.clearEvents = false
        args.loaded()
      },
    )
  }, [])

  return (
    <SuperSchedulerComponent
      startDate="2026-01-01"
      days={365}
      scale="Day"
      cellWidth={40}
      resources={rooms}
      defaultEvents={firstScreen}
      dynamicLoading
      scrollDelayDynamic={300}
      onScroll={onScroll}
    />
  )
}

Par rapport au chargeur de plages, ce mécanisme vous donne un contrôle total et rien d’autre : ni alignement des tranches, ni cache, ni préchargement, ni bande de chargement, ni annulation. onScroll n’est pas appelé au montage : affichez donc le premier écran vous-même. args.async vaut true au départ, si bien que le résultat n’est appliqué que lorsque vous appelez args.loaded(). Par défaut, args.clearEvents vaut true et les événements renvoyés remplacent tous les événements ; passez-le à false pour fusionner par id, et listez dans args.remove les id à retirer. Comme viewport.resources liste les lignes visibles, ce mécanisme permet aussi de charger ligne par ligne.

Ce qui reste dans votre application

  • L’enregistrement des modifications. Le chargeur lit ; votre handler onEventsChange ou vos actions explicites écrivent.
  • Les mises à jour en direct venant d’autres utilisateurs. Les options de rafraîchissement automatique (autoRefreshEnabled et apparentées) sont réservées et ne font rien. Interrogez périodiquement le serveur, appliquez ses notifications avec control.events.add/update/remove, ou appelez reload({ start, end }) pour les dates concernées.
  • Les liens et les lignes. Le chargeur ne gère que les événements ; passez links et resources vous-même.
  • Le chargement HTTP intégré au contrôle (events.load(url), rows.load(url), links.load(url)) est typé mais réservé : il émet un avertissement en développement et ne charge rien.

Guides associés : État contrôlé, Annuler et rétablir, Ressources, événements et intervalles et la référence de l’API.