# Carga de datos por rangos de fechas

> Carga eventos al desplazarte con super-scheduler/ranges: peticiones por bloques cancelables, caché, esqueletos de carga, reintentos y el contrato del servidor.

Source: https://superscheduler.org/es/docs/range-loading/
Reviewed: 2026-10-07

Crea un cargador con `createRangeLoader` de `super-scheduler/ranges`, dale una función `load({ start, end, signal })` que devuelva los eventos de ese rango semiabierto y conéctalo con `extensions={[loader]}`. El cargador pide bloques fijos de días alrededor del rango visible al montarse y cada vez que un scroll o un zoom se detiene, cancela las peticiones que salen de la ventana, guarda en caché los bloques recientes y combina los eventos por id. Tu servidor solo tiene que responder a consultas `[start, end)` con ids de evento estables.

La carga por rangos mantiene rápida una línea de tiempo larga sin enviar todo el conjunto de datos al navegador. El cargador pide a tu backend las fechas que el usuario puede ver, más un margen, y vuelve a olvidar las fechas lejanas. Se incluye en SuperScheduler Pro como `super-scheduler/ranges`; Lite muestra los eventos que le pasas.

No siempre lo necesitas. Un plan con unos pocos miles de eventos se carga en una sola petición y el scheduler lo virtualiza (consulta [Virtualización y rendimiento](https://superscheduler.org/es/docs/performance-virtualization/)). Recurre a la carga por rangos cuando la línea de tiempo abarca años, cuando el conjunto de datos completo es demasiado grande para pedirlo o mantenerlo en memoria, o cuando tu API ya pagina por fecha.

## Cómo funciona el cargador
El cargador divide el eje de tiempo en bloques de `chunkDays` días y mantiene una ventana de bloques alrededor del área visible:

- **Límites de bloque fijos.** Los límites son múltiplos de `chunkDays` contados desde el 1 de enero de 1970, así que no dependen de `startDate`, de la posición de scroll ni de `weekStarts`. Los bloques de siete días empiezan en jueves; con `chunkDays: 14` y `startDate="2026-01-01"`, el primer bloque visible es `[2026-01-01, 2026-01-15)`. El mismo bloque siempre genera la misma petición, así que las respuestas se pueden cachear.
- **Peticiones en los límites, nunca por fotograma.** La ventana deseada son todos los bloques que se solapan con las fechas visibles, más `prefetch` bloques a cada lado (un bloque precargado puede quedar antes de `startDate`). Los bloques que faltan se piden justo después del montaje y de nuevo cuando un gesto de scroll o de zoom se detiene. El scroll programático con `control.scrollTo()` cuenta como scroll.
- **Cancelación.** Una petición cuyo bloque sale de la ventana deseada antes de responder se aborta mediante su `AbortSignal`. Si tu función ignora la señal, su resultado tardío se descarta de todos modos.
- **Caché y expulsión.** Se conservan como máximo `cacheChunks` bloques. Por encima de esa cifra, se expulsan los bloques más alejados de la vista y sus eventos salen del scheduler, salvo que otro bloque conservado también los haya devuelto. Si vuelves a desplazarte hasta ellos, se piden de nuevo.
- **Combinación por id.** Un evento que devuelven dos bloques, porque cruza un límite, aparece una sola vez.
- **Indicación visual.** Mientras un bloque se carga, una banda translúcida cubre sus fechas. Tiene la clase `super-scheduler__range-skeleton` y usa el token `--super-scheduler-skeleton-base`; `skeleton: false` la elimina.
- **Sin historial.** Las cargas nunca crean entradas de deshacer y llegan a `onEventsChange` con `reason: 'load'`.

> **Behavior:**
> El cargador divide el tiempo, no las filas. Cada petición cubre todos los recursos de sus fechas, así que tu endpoint devuelve los eventos de todas las filas en ese rango. Las cabeceras de fila salen de los `resources` que pasas, como siempre.

## Conectar un cargador
Crea el cargador una vez por scheduler y pásalo en `extensions`. El control conecta y libera las extensiones por identidad de objeto, así que un cargador creado en cada render reiniciaría su caché cada vez. La función `load` recibe el `start` y el `end` del bloque como valores `SuperScheduler.Date`; `start.value` es la cadena ISO civil (`2026-01-01T00:00:00`) que hay que enviar a tu API.

```tsx
// src/RangePlanning.tsx
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}
    />
  )
}
```
Este scheduler no tiene prop `events`, así que el cargador escribe directamente en el almacén del propio control. Las ediciones del usuario siguen llegando a `onEventsChange`; el handler de abajo persiste los movimientos y los cambios de tamaño, ignora las cargas y vuelve a consultar al servidor cuando se rechaza un guardado:

```ts
// src/save-changes.ts
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)))
    }
  }
}
```
Deberías ver durante un momento una banda de carga sobre las fechas visibles y después las reservas. En el panel de red, desplazarte unas semanas hacia la derecha genera una petición por cada nuevo bloque de 14 días cuando el scroll se detiene, y desplazarte rápido por muchos bloques genera solo las peticiones del punto donde te paras.

## Opciones
| Opción | Tipo | Por defecto | Significado |
|---|---|---|---|
| `load` | `({ start, end, signal }) => Promise<EventData[]>` | obligatoria | Devuelve los eventos que se solapan con `[start, end)`. |
| `chunkDays` | `number` | `7` | Días por bloque. Bloques más grandes implican menos peticiones, pero más pesadas. |
| `prefetch` | `number` | `1` | Bloques que se cargan a cada lado del rango visible. |
| `cacheChunks` | `number` | `26` | Bloques que se conservan antes de expulsar los más alejados. |
| `skeleton` | `boolean` | `true` | Muestra la banda de carga sobre los bloques pendientes. |
| `onError` | `(error, { start, end }) => void` | ninguno | Se llama una vez por cada bloque fallido. Las peticiones abortadas no son errores. |

El propio objeto cargador expone `reload(range?)`, `clear()` y un getter `loading` que vale `true` mientras haya algún bloque pendiente. `loading` es una propiedad normal, no una suscripción: léela cuando la necesites, o controla tu propio indicador de carga desde `load`.

## Eventos controlados o el almacén del control
La carga por rangos funciona con los dos modos de datos descritos en [Estado controlado](https://superscheduler.org/es/docs/controlled-state/):

- **Sin prop `events`, o con `defaultEvents`.** El cargador añade los eventos nuevos al almacén del control, los actualiza cuando se recarga un bloque y los elimina cuando su bloque se expulsa. Las cargas posteriores no sobrescriben los eventos que ya están en el almacén, así que una reserva que el usuario acaba de mover se queda donde está hasta que llames a `reload()`. Es la opción más sencilla cuando los usuarios editan datos cargados por rangos.
- **`events` controlados más `onEventsChange`.** El cargador nunca escribe en el almacén. Cada bloque terminado llama a `onEventsChange` con `reason: 'load'` y `events` con la lista combinada, y tu estado tiene que adoptarla como cualquier otro cambio. React agrupa estas actualizaciones; cuenta con una llamada por bloque.

```tsx
// src/ControlledRange.tsx
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}
    />
  )
}
```
> **Limitation:**
> Con eventos controlados, cada bloque que termina vuelve a publicar la lista combinada, incluida la versión de cada evento que su bloque devolvió antes. Por eso, un evento sustituido por un arrastre o un cambio de tamaño puede volver de golpe a su posición cargada cuando llega otro bloque, hasta que se refresque la caché. Llama a `loader.clear()` y `loader.reload()` después de un guardado correcto, como arriba, o usa el almacén del propio control, donde las cargas nunca sobrescriben eventos existentes.

## Navegación, recarga y filtros
Dos métodos cubren la mayoría de las acciones de una barra de herramientas:

- `reload()` vuelve a pedir la ventana visible, esté en caché o no, y sustituye esos bloques. Los eventos que faltan en las nuevas respuestas se eliminan. `reload({ start, end })` hace lo mismo para cualquier rango, algo útil después de un guardado o de una notificación del servidor.
- `clear()` cancela las peticiones pendientes y vacía la caché. No elimina los eventos que hay en pantalla.

Cambiar `startDate` o `days` mediante props o `control.update()` no desplaza la vista por sí solo, así que no lanza ninguna carga. Desplázate al nuevo periodo y llama a `reload()` cuando React haya aplicado el cambio. Cuando cambia la propia consulta, por ejemplo otra sede o un filtro de estado, vacía la caché, elimina los eventos antiguos y vuelve a cargar:

```tsx
// src/SitePlanning.tsx
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}
      />
    </>
  )
}
```
Con eventos controlados, haz lo mismo con `setEvents([])` en lugar de `control.update({ events: [] })`, y llama a `reload()` desde un efecto que se ejecute después de aplicar la lista vacía. Si puedes permitirte reiniciar la posición de scroll, renderizar el scheduler con `key={site}` te da un control nuevo y un cargador nuevo.

## Errores y reintentos
Cuando la promesa de `load` se rechaza, el cargador llama a `onError(error, { start, end })` para ese bloque, quita su banda de carga y lo olvida. El bloque se vuelve a pedir en el siguiente scroll o zoom detenido que todavía lo necesite, o con `reload()`. Muestra el fallo donde miran los usuarios: `control.message()` muestra una barra de mensaje breve dentro del scheduler, como en el primer ejemplo. Las peticiones que aborta el cargador nunca llegan a `onError`.

## El contrato del servidor
Tu endpoint responde a una sola pregunta: ¿qué eventos se solapan con este rango civil semiabierto?

```txt
GET /api/bookings?start=2026-01-01T00:00:00&end=2026-01-15T00:00:00
```

```json
[
  { "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" }
]
```

- **Solapamiento semiabierto.** Devuelve todos los eventos con `event.start < end` y `event.end > start`. Un evento que empieza antes del bloque o termina después pertenece a la respuesta, como `b-1042` y `b-1043` arriba. Un evento que termina exactamente en `start` no.
- **Ids estables y únicos.** La misma reserva tiene que tener el mismo `id` en todas las respuestas, y no puede haber dos eventos con el mismo id, aunque estén en recursos distintos. Los ids se comparan de forma estricta: `1` y `'1'` son eventos distintos.
- **Fechas y horas civiles con segundos.** `start` y `end` son valores de reloj sin zona horaria, y las cadenas necesitan segundos (`2026-01-14T14:00:00`). Si guardas instantes, conviértelos antes a la zona horaria del negocio; consulta [Idiomas, fechas civiles y zonas horarias](https://superscheduler.org/es/docs/locales-dates-timezones/#time-zones).
- **La cancelación es bienvenida.** Pasar el `signal` a `fetch` libera la conexión antes; el cargador no depende de ello.
- **Cacheable.** Los límites de bloque fijos hacen que se repitan peticiones idénticas, así que una caché HTTP o una CDN puede servirlas. Mantén la caché corta si otros usuarios editan el mismo plan.

## Nivel inferior: dynamicLoading y onScroll
El control también tiene el mecanismo clásico basado en callbacks. Con `dynamicLoading` y un handler `onScroll`, el control llama a `onScroll` cuando el scroll lleva `scrollDelayDynamic` milisegundos en reposo (500 por defecto). `args.viewport` contiene los `start`, `end` y `resources` visibles; tú rellenas `args.events` y llamas a `args.loaded()`.

```tsx
// src/DynamicPlanning.tsx
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}
    />
  )
}
```
Comparado con el cargador por rangos, esto te da control total y nada más: ni alineación de bloques, ni caché, ni precarga, ni banda de carga, ni cancelación. `onScroll` no se llama al montar, así que renderiza tú la primera pantalla. `args.async` empieza valiendo `true`, así que el resultado solo se aplica cuando llamas a `args.loaded()`. Por defecto, `args.clearEvents` es `true` y los eventos devueltos sustituyen a todos los eventos; ponlo a `false` para combinar por id, e indica en `args.remove` los ids que hay que quitar. Como `viewport.resources` enumera las filas visibles, este mecanismo también permite cargar por fila.

## Qué se queda en tu aplicación
- La persistencia de los cambios. El cargador lee; tu handler `onEventsChange` o tus acciones explícitas escriben.
- Las actualizaciones en directo de otros usuarios. Las opciones de refresco automático (`autoRefreshEnabled` y relacionadas) están reservadas y no hacen nada. Consulta el servidor cada cierto tiempo, aplica las notificaciones del servidor con `control.events.add/update/remove` o llama a `reload({ start, end })` para las fechas afectadas.
- Los vínculos y las filas. El cargador solo gestiona eventos; pasa tú `links` y `resources`.
- La carga HTTP integrada en el control (`events.load(url)`, `rows.load(url)`, `links.load(url)`) tiene tipos, pero está reservada: avisa en desarrollo y no carga nada.

Guías relacionadas: [Estado controlado](https://superscheduler.org/es/docs/controlled-state/), [Deshacer y rehacer](https://superscheduler.org/es/docs/undo-redo/), [Recursos, eventos e intervalos](https://superscheduler.org/es/docs/resources-events-intervals/) y la [referencia de la API](https://superscheduler.org/es/docs/api-reference/#ranges).
