Zum Inhalt springen
SuperScheduler

Pro-ModuleGilt fürSuperScheduler Pro

Daten nach Zeitraum laden

Erzeugen Sie einen Loader mit `createRangeLoader` aus `super-scheduler/ranges`, geben Sie ihm eine Funktion `load({ start, end, signal })`, die die Ereignisse dieses halboffenen Zeitraums zurückgibt, und binden Sie ihn mit `extensions={[loader]}` an. Der Loader fordert beim Mount und nach dem Abklingen von Scrollen oder Zoomen feste Tagesabschnitte rund um den sichtbaren Zeitraum an, bricht Anfragen ab, die das Fenster verlassen, hält die zuletzt geladenen Abschnitte im Cache und führt Ereignisse nach ID zusammen. Ihr Server muss nur Abfragen `[start, end)` mit stabilen Ereignis-IDs beantworten.

Geprüft mit v0.1.0 · überarbeitet am 7. Oktober 2026.md

Bereichsweises Laden hält eine lange Zeitleiste schnell, ohne den gesamten Datenbestand an den Browser zu schicken. Der Loader fragt Ihr Backend nach dem Zeitraum, den der Besucher sehen kann, plus einem Rand, und vergisst weit entfernte Zeiträume wieder. Er wird mit SuperScheduler Pro als super-scheduler/ranges ausgeliefert; Lite zeigt die Ereignisse an, die Sie übergeben.

Sie brauchen ihn nicht immer. Ein Plan mit einigen Tausend Ereignissen lässt sich mit einer einzigen Anfrage laden, und der Planer virtualisiert ihn (siehe Virtualisierung und Performance). Greifen Sie zum bereichsweisen Laden, wenn die Zeitleiste Jahre umfasst, wenn der vollständige Datenbestand zu groß ist, um ihn abzurufen oder im Speicher zu halten, oder wenn Ihre API ohnehin nach Datum paginiert.

Wie der Loader arbeitet

Der Loader teilt die Zeitachse in Abschnitte von chunkDays Tagen und hält ein Fenster aus Abschnitten rund um den sichtbaren Bereich:

  • Feste Abschnittsgrenzen. Die Grenzen sind Vielfache von chunkDays, gezählt ab dem 1970-01-01; sie hängen also weder von startDate noch von der Scrollposition oder von weekStarts ab. Siebentägige Abschnitte beginnen an einem Donnerstag; mit chunkDays: 14 und startDate="2026-01-01" ist der erste sichtbare Abschnitt [2026-01-01, 2026-01-15). Derselbe Abschnitt erzeugt immer dieselbe Anfrage, sodass sich Antworten cachen lassen.
  • Anfragen an Grenzen, nie pro Frame. Das gewünschte Fenster besteht aus jedem Abschnitt, der sich mit dem sichtbaren Zeitraum überschneidet, plus prefetch Abschnitten auf jeder Seite (ein vorab geladener Abschnitt darf vor startDate liegen). Fehlende Abschnitte werden direkt nach dem Mount angefordert und erneut, sobald eine Scroll- oder Zoomgeste abgeklungen ist. Programmatisches Scrollen mit control.scrollTo() zählt als Scrollen.
  • Abbruch. Eine Anfrage, deren Abschnitt das gewünschte Fenster verlässt, bevor sie beantwortet ist, wird über ihr AbortSignal abgebrochen. Ignoriert Ihre Funktion das Signal, wird ihr verspätetes Ergebnis trotzdem verworfen.
  • Cache und Verdrängung. Höchstens cacheChunks Abschnitte werden behalten. Darüber hinaus werden die Abschnitte verdrängt, die am weitesten von der Ansicht entfernt sind, und ihre Ereignisse verlassen den Planer, sofern kein anderer behaltener Abschnitt sie ebenfalls geliefert hat. Beim Zurückscrollen werden sie erneut angefordert.
  • Zusammenführung nach ID. Ein Ereignis, das zwei Abschnitte liefern, weil es eine Grenze überspannt, erscheint nur einmal.
  • Rückmeldung. Während ein Abschnitt lädt, liegt ein durchscheinendes Band über seinem Zeitraum. Es hat die Klasse super-scheduler__range-skeleton und verwendet das Token --super-scheduler-skeleton-base; skeleton: false entfernt es.
  • Kein Verlauf. Ladevorgänge erzeugen nie Rückgängig-Einträge und erreichen onEventsChange mit reason: 'load'.

Einen Loader anbinden

Erzeugen Sie den Loader einmal pro Planer und übergeben Sie ihn in extensions. Das Control bindet Erweiterungen anhand ihrer Objektidentität an und gibt sie wieder frei; ein Loader, der bei jedem Rendern neu erzeugt wird, würde seinen Cache also jedes Mal neu starten. Die Funktion load erhält start und end des Abschnitts als SuperScheduler.Date-Werte; start.value ist der ISO-String in bürgerlicher Zeit (2026-01-01T00:00:00), den Sie an Ihre API senden.

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}
    />
  )
}

Dieser Planer hat keine events-Prop, daher schreibt der Loader direkt in den eigenen Speicher des Controls. Bearbeitungen durch den Nutzer kommen weiterhin in onEventsChange an; der folgende Handler speichert Verschiebungen und Dauer-Änderungen, überspringt Ladevorgänge und fragt den Server erneut an, wenn ein Speichern abgelehnt wird:

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)))
    }
  }
}

Sie sollten kurz ein Platzhalterband über dem sichtbaren Zeitraum sehen, dann die Buchungen. Im Netzwerk-Panel erzeugt das Scrollen um einige Wochen nach rechts eine Anfrage pro neuem 14-Tage-Abschnitt, sobald das Scrollen stoppt, und schnelles Scrollen über viele Abschnitte hinweg erzeugt nur die Anfragen für die Stelle, an der Sie anhalten.

Optionen

OptionTypStandardBedeutung
load({ start, end, signal }) => Promise<EventData[]>erforderlichGibt die Ereignisse zurück, die sich mit [start, end) überschneiden.
chunkDaysnumber7Tage pro Abschnitt. Größere Abschnitte bedeuten weniger, aber größere Anfragen.
prefetchnumber1Abschnitte, die auf jeder Seite des sichtbaren Zeitraums geladen werden.
cacheChunksnumber26Behaltene Abschnitte, bevor die am weitesten entfernten verdrängt werden.
skeletonbooleantrueZeigt das Ladeband über ausstehenden Abschnitten.
onError(error, { start, end }) => voidkeinerWird einmal pro fehlgeschlagenem Abschnitt aufgerufen. Abgebrochene Anfragen sind keine Fehler.

Das Loader-Objekt selbst stellt reload(range?), clear() und einen Getter loading bereit, der true ist, solange irgendein Abschnitt aussteht. loading ist eine einfache Eigenschaft, kein Abonnement: Lesen Sie sie, wenn Sie sie brauchen, oder steuern Sie Ihren eigenen Spinner aus load heraus.

Kontrollierte Ereignisse oder der Speicher des Controls

Bereichsweises Laden funktioniert mit beiden Datenmodi, die unter Kontrollierter Zustand beschrieben sind:

  • Keine events-Prop, oder defaultEvents. Der Loader fügt neue Ereignisse in den Speicher des Controls ein, aktualisiert sie, wenn ein Abschnitt neu geladen wird, und entfernt sie, wenn ihr Abschnitt verdrängt wird. Ereignisse, die schon im Speicher liegen, werden von späteren Ladevorgängen nicht überschrieben; eine Buchung, die der Nutzer gerade verschoben hat, bleibt also, wo sie ist, bis Sie reload() aufrufen. Das ist die einfachste Wahl, wenn Nutzer bereichsweise geladene Daten bearbeiten.
  • Kontrollierte events plus onEventsChange. Der Loader schreibt nie in den Speicher. Jeder fertige Abschnitt ruft onEventsChange mit reason: 'load' auf, wobei events die zusammengeführte Liste enthält, und Ihr State muss sie wie jede andere Änderung übernehmen. React bündelt diese Aktualisierungen; rechnen Sie mit einem Aufruf pro Abschnitt.
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, Aktualisierung und Filter

Zwei Methoden decken die meisten Aktionen einer Werkzeugleiste ab:

  • reload() fordert das sichtbare Fenster erneut an, ob im Cache oder nicht, und ersetzt diese Abschnitte. Ereignisse, die in den neuen Antworten fehlen, werden entfernt. reload({ start, end }) macht dasselbe für einen beliebigen Zeitraum, was nach einem Speichern oder einer Benachrichtigung des Servers nützlich ist.
  • clear() bricht ausstehende Anfragen ab und leert den Cache. Die Ereignisse auf dem Bildschirm entfernt es nicht.

Eine Änderung von startDate oder days über Props oder control.update() scrollt nicht von selbst und löst daher keinen Ladevorgang aus. Scrollen Sie zum neuen Zeitraum und rufen Sie reload() auf, sobald React die Änderung angewendet hat. Wenn sich die Abfrage selbst ändert, zum Beispiel ein anderer Standort oder ein Statusfilter, leeren Sie den Cache, entfernen Sie die alten Ereignisse und laden Sie neu:

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}
      />
    </>
  )
}

Bei kontrollierten Ereignissen machen Sie dasselbe mit setEvents([]) statt mit control.update({ events: [] }) und rufen reload() aus einem Effect auf, der läuft, nachdem die leere Liste angewendet wurde. Wenn es akzeptabel ist, die Scrollposition zurückzusetzen, liefert ein Rendern des Planers mit key={site} ein frisches Control und einen frischen Loader.

Fehler und Wiederholungen

Wenn das Promise von load abgelehnt wird, ruft der Loader onError(error, { start, end }) für diesen Abschnitt auf, entfernt sein Platzhalterband und vergisst ihn. Der Abschnitt wird erneut angefordert, sobald ein abgeschlossenes Scrollen oder Zoomen ihn noch braucht, oder bei reload(). Zeigen Sie den Fehler dort, wo Nutzer hinschauen: control.message() blendet eine kurze Meldungsleiste im Planer ein, wie im ersten Beispiel. Anfragen, die der Loader abbricht, erreichen onError nie.

Der Serververtrag

Ihr Endpunkt beantwortet eine einzige Frage: Welche Ereignisse überschneiden sich mit diesem halboffenen Zeitraum in bürgerlicher Zeit?

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" }
]
  • Halboffene Überschneidung. Liefern Sie jedes Ereignis mit event.start < end und event.end > start. Ein Ereignis, das vor dem Abschnitt beginnt oder nach ihm endet, gehört in die Antwort, wie b-1042 und b-1043 oben. Ein Ereignis, das genau bei start endet, gehört nicht dazu.
  • Stabile, eindeutige IDs. Dieselbe Buchung muss in jeder Antwort dieselbe id haben, und keine zwei Ereignisse dürfen sich eine teilen, über alle Ressourcen hinweg. IDs werden strikt verglichen: 1 und '1' sind verschiedene Ereignisse.
  • Bürgerliche Zeitangaben mit Sekunden. start und end sind Wanduhrzeiten ohne Zeitzone, und Strings brauchen Sekunden (2026-01-14T14:00:00). Wenn Sie Zeitpunkte (Instants) speichern, rechnen Sie sie zuerst in die Zeitzone des Geschäfts um; siehe Sprachen, Kalenderdaten und Zeitzonen.
  • Abbruch ist willkommen. Wenn Sie das signal an fetch weitergeben, wird die Verbindung früher freigegeben; der Loader ist darauf nicht angewiesen.
  • Cachefähig. Feste Abschnittsgrenzen sorgen dafür, dass sich identische Anfragen wiederholen; HTTP-Caching oder ein CDN kann sie also bedienen. Halten Sie den Cache kurz, wenn andere Nutzer denselben Plan bearbeiten.

Eine Ebene tiefer: dynamicLoading und onScroll

Das Control bietet auch den klassischen, Callback-basierten Mechanismus. Mit dynamicLoading und einem onScroll-Handler ruft das Control onScroll auf, sobald das Scrollen scrollDelayDynamic Millisekunden lang geruht hat (standardmäßig 500). args.viewport enthält start, end und resources des sichtbaren Bereichs; Sie füllen args.events und rufen args.loaded() auf.

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}
    />
  )
}

Im Vergleich zum Range-Loader bietet dieser Weg volle Kontrolle und sonst nichts: keine Ausrichtung an Abschnitten, kein Cache, kein Vorabladen, kein Platzhalterband, kein Abbruch. onScroll wird beim Mount nicht aufgerufen; rendern Sie den ersten Bildschirm also selbst. args.async ist anfangs true, sodass das Ergebnis erst angewendet wird, wenn Sie args.loaded() aufrufen. Standardmäßig ist args.clearEvents true, und die zurückgegebenen Ereignisse ersetzen alle Ereignisse; setzen Sie es auf false, um nach ID zusammenzuführen, und führen Sie zu entfernende IDs in args.remove auf. Da viewport.resources die sichtbaren Zeilen auflistet, kann dieser Mechanismus auch zeilenweise laden.

Was in Ihrer Anwendung bleibt

  • Änderungen speichern. Der Loader liest; Ihr onEventsChange-Handler oder explizite Aktionen schreiben.
  • Live-Aktualisierungen von anderen Nutzern. Die Optionen für automatisches Aktualisieren (autoRefreshEnabled und verwandte) sind reserviert und bewirken nichts. Fragen Sie regelmäßig ab (Polling), wenden Sie Server-Benachrichtigungen mit control.events.add/update/remove an oder rufen Sie reload({ start, end }) für den betroffenen Zeitraum auf.
  • Verknüpfungen und Zeilen. Der Loader verarbeitet nur Ereignisse; übergeben Sie links und resources selbst.
  • Das in das Control eingebaute Laden per HTTP (events.load(url), rows.load(url), links.load(url)) ist typisiert, aber reserviert: Es warnt in der Entwicklung und lädt nichts.

Verwandte Anleitungen: Kontrollierter Zustand, Rückgängig und Wiederholen, Ressourcen, Ereignisse und Intervalle und die API-Referenz.