Módulos ProSe aplica aSuperScheduler Pro
Carga de datos por rangos de fechas
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). 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
chunkDayscontados desde el 1 de enero de 1970, así que no dependen destartDate, de la posición de scroll ni deweekStarts. Los bloques de siete días empiezan en jueves; conchunkDays: 14ystartDate="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
prefetchbloques a cada lado (un bloque precargado puede quedar antes destartDate). 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 concontrol.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
cacheChunksbloques. 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-skeletony usa el token--super-scheduler-skeleton-base;skeleton: falsela elimina. - Sin historial. Las cargas nunca crean entradas de deshacer y llegan a
onEventsChangeconreason: 'load'.
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.
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:
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:
- Sin prop
events, o condefaultEvents. 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 areload(). Es la opción más sencilla cuando los usuarios editan datos cargados por rangos. eventscontrolados másonEventsChange. El cargador nunca escribe en el almacén. Cada bloque terminado llama aonEventsChangeconreason: 'load'yeventscon la lista combinada, y tu estado tiene que adoptarla como cualquier otro cambio. React agrupa estas actualizaciones; cuenta con una llamada por bloque.
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}
/>
)
}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:
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?
GET /api/bookings?start=2026-01-01T00:00:00&end=2026-01-15T00:00:00[
{ "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 < endyevent.end > start. Un evento que empieza antes del bloque o termina después pertenece a la respuesta, comob-1042yb-1043arriba. Un evento que termina exactamente enstartno. - Ids estables y únicos. La misma reserva tiene que tener el mismo
iden 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:1y'1'son eventos distintos. - Fechas y horas civiles con segundos.
startyendson 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. - La cancelación es bienvenida. Pasar el
signalafetchlibera 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().
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
onEventsChangeo tus acciones explícitas escriben. - Las actualizaciones en directo de otros usuarios. Las opciones de refresco automático (
autoRefreshEnabledy relacionadas) están reservadas y no hacen nada. Consulta el servidor cada cierto tiempo, aplica las notificaciones del servidor concontrol.events.add/update/removeo llama areload({ start, end })para las fechas afectadas. - Los vínculos y las filas. El cargador solo gestiona eventos; pasa tú
linksyresources. - 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, Deshacer y rehacer, Recursos, eventos e intervalos y la referencia de la API.