Server-Side Example
Loading Scheduler example…
Loading Scheduler example…
Source code
import { applyControlPresentation } from '@revolist/ui';import { CalendarEditorDialogPlugin } from '@revolist/revogrid-pro';import { defineCustomElements } from '@revolist/revogrid/loader';import { EventSchedulerPlugin } from '@revolist/scheduler';import { currentTheme } from '../../composables/useRandomData';import { createRemoteSchedulerDemo } from './demo';import './styles.scss';
defineCustomElements();export function load(parentSelector: string, _rows: any[] = []) { const parent = document.querySelector(parentSelector); if (!parent) return () => {}; const model = createRemoteSchedulerDemo(); const root = document.createElement('section'); root.className = 'scheduler-remote-demo'; root.innerHTML = `<div class="scheduler-remote-demo__controls"> <button class="rv-btn" data-action="rename">Rename booking</button> <button class="rv-btn" data-action="refresh">Refresh from server</button> <button class="rv-btn" data-action="calendars">Working calendars</button> <label><input type="checkbox" data-action="reject" /> Reject next save</label> <label title="Changes appear immediately and revert if the server rejects the save."><input type="checkbox" data-action="optimistic" checked /> Update immediately</label> </div>`; const rename = root.querySelector<HTMLButtonElement>('[data-action="rename"]')!; const refresh = root.querySelector<HTMLButtonElement>('[data-action="refresh"]')!; const calendars = root.querySelector<HTMLButtonElement>('[data-action="calendars"]')!; calendars.onclick = () => model.openCalendars(); const reject = root.querySelector<HTMLInputElement>('[data-action="reject"]')!; const optimistic = root.querySelector<HTMLInputElement>('[data-action="optimistic"]')!; rename.onclick = () => model.rename(); refresh.onclick = () => { void model.refresh(); }; reject.onchange = () => model.rejectNext(reject.checked); optimistic.onchange = () => model.optimistic(optimistic.checked); applyControlPresentation(root); const grid = document.createElement('revo-grid'); grid.className = 'scheduler-remote-demo__grid'; grid.hideAttribution = true; grid.theme = currentTheme().isDark() ? 'darkMaterial' : 'material'; grid.plugins = [EventSchedulerPlugin, CalendarEditorDialogPlugin]; grid.columns = []; grid.eventScheduler = model.state.config; const render = (state: typeof model.state) => { rename.disabled = refresh.disabled = calendars.disabled = !state.ready || state.busy; reject.disabled = optimistic.disabled = state.busy; reject.checked = state.rejectNext; optimistic.checked = state.config.remote?.optimistic !== false; }; const feedback = document.createElement('div'); feedback.className = 'scheduler-remote-demo__activity'; const connect = () => { void model.connect(grid, render, feedback); }; grid.addEventListener('aftergridinit', connect); root.append(grid, feedback); parent.append(root); grid.source = []; render(model.state); return () => { grid.removeEventListener('aftergridinit', connect); model.disconnect(); grid.remove(); root.remove(); };}<template> <section class="scheduler-remote-demo"> <div class="scheduler-remote-demo__controls"> <button v-rv-control class="rv-btn" :disabled="!state.ready || state.busy" @click="model.rename()">Rename booking</button> <button v-rv-control class="rv-btn" :disabled="!state.ready || state.busy" @click="model.refresh()">Refresh from server</button> <button v-rv-control class="rv-btn" :disabled="!state.ready || state.busy" @click="model.openCalendars()">Working calendars</button> <label><input v-rv-control type="checkbox" :checked="state.rejectNext" :disabled="state.busy" @change="model.rejectNext(($event.target as HTMLInputElement).checked)" /> Reject next save</label> <label title="Changes appear immediately and revert if the server rejects the save."><input v-rv-control type="checkbox" :checked="state.config.remote?.optimistic" :disabled="state.busy" @change="model.optimistic(($event.target as HTMLInputElement).checked)" /> Update immediately</label> </div> <RevoGrid ref="gridRef" class="scheduler-remote-demo__grid" hide-attribution :theme="isDark ? 'darkMaterial' : 'material'" :plugins="plugins" :columns="columns" :source="source" :event-scheduler.prop="state.config" @aftergridinit="connect" /> <div ref="feedbackRef" class="scheduler-remote-demo__activity"></div> </section></template><script setup lang="ts">import { vRvControl } from '@revolist/ui/vue-control';import { CalendarEditorDialogPlugin } from '@revolist/revogrid-pro';import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue';import RevoGrid from '@revolist/vue3-datagrid';import { EventSchedulerPlugin } from '@revolist/scheduler';import { currentThemeVue } from '../../composables/useRandomData';import { createRemoteSchedulerDemo } from './demo';import './styles.scss';const { isDark } = currentThemeVue();const model = createRemoteSchedulerDemo();const state = shallowRef(model.state);const source = ref([]);const columns = ref([]);const plugins = [EventSchedulerPlugin, CalendarEditorDialogPlugin];const feedbackRef = ref<HTMLElement | null>(null);const gridRef = ref<{ $el: HTMLRevoGridElement } | null>(null);function connect() { if (gridRef.value?.$el && feedbackRef.value) void model.connect(gridRef.value.$el, next => { state.value = next; }, feedbackRef.value); }onMounted(connect);onBeforeUnmount(() => model.disconnect());</script>import { ButtonControl as UiButton, Input as UiInput } from '@revolist/ui/react-control';import { CalendarEditorDialogPlugin } from '@revolist/revogrid-pro';import React, { useEffect, useMemo, useRef, useState } from 'react';import { RevoGrid } from '@revolist/react-datagrid';import { EventSchedulerPlugin } from '@revolist/scheduler';import { currentTheme } from '../../composables/useRandomData';import { createRemoteSchedulerDemo } from './demo';import './styles.scss';
export default function RemoteData(_props: { rows?: any[] }) { const model = useMemo(() => createRemoteSchedulerDemo(), []); const [state, setState] = useState(model.state); const plugins = useMemo(() => [EventSchedulerPlugin, CalendarEditorDialogPlugin], []); const columns = useMemo(() => [], []); const source = useMemo(() => [], []); const feedback = useRef<HTMLDivElement>(null); const grid = useRef<HTMLRevoGridElement>(null); const connect = () => { if (grid.current && feedback.current) void model.connect(grid.current, setState, feedback.current); }; useEffect(() => { connect(); return () => model.disconnect(); }, [model]); return <section className="scheduler-remote-demo"> <div className="scheduler-remote-demo__controls"> <UiButton className="rv-btn" disabled={!state.ready || state.busy} onClick={() => model.rename()}>Rename booking</UiButton> <UiButton className="rv-btn" disabled={!state.ready || state.busy} onClick={() => { void model.refresh(); }}>Refresh from server</UiButton> <UiButton className="rv-btn" disabled={!state.ready || state.busy} onClick={() => model.openCalendars()}>Working calendars</UiButton> <label><UiInput type="checkbox" checked={state.rejectNext} disabled={state.busy} onChange={event => model.rejectNext(event.currentTarget.checked)} /> Reject next save</label> <label title="Changes appear immediately and revert if the server rejects the save."><UiInput type="checkbox" checked={state.config.remote?.optimistic} disabled={state.busy} onChange={event => model.optimistic(event.currentTarget.checked)} /> Update immediately</label> </div> <RevoGrid ref={grid} className="scheduler-remote-demo__grid" hideAttribution theme={currentTheme().isDark() ? 'darkMaterial' : 'material'} plugins={plugins} columns={columns} source={source} eventScheduler={state.config} onAftergridinit={connect} /> <div ref={feedback} className="scheduler-remote-demo__activity" /> </section>;}import { UiControlDirective } from '@revolist/ui/angular-control';import { CalendarEditorDialogPlugin } from '@revolist/revogrid-pro';import { AfterViewInit, Component, ElementRef, NO_ERRORS_SCHEMA, OnDestroy, ViewChild, ViewEncapsulation, signal } from '@angular/core';import { RevoGrid } from '@revolist/angular-datagrid';import { EventSchedulerPlugin } from '@revolist/scheduler';import { currentTheme } from '../../composables/useRandomData';import { createRemoteSchedulerDemo } from './demo';
@Component({ selector: 'scheduler-remote-data', standalone: true, imports: [UiControlDirective, RevoGrid], schemas: [NO_ERRORS_SCHEMA], styleUrls: ['./styles.scss'], encapsulation: ViewEncapsulation.None, template: `<section class="scheduler-remote-demo"> <div class="scheduler-remote-demo__controls"> <button rvControl class="rv-btn" [disabled]="!state().ready || state().busy" (click)="model.rename()">Rename booking</button> <button rvControl class="rv-btn" [disabled]="!state().ready || state().busy" (click)="model.refresh()">Refresh from server</button> <button rvControl class="rv-btn" [disabled]="!state().ready || state().busy" (click)="model.openCalendars()">Working calendars</button> <label><input rvControl type="checkbox" [checked]="state().rejectNext" [disabled]="state().busy" (change)="model.rejectNext($any($event.target).checked)" /> Reject next save</label> <label title="Changes appear immediately and revert if the server rejects the save."><input rvControl type="checkbox" [checked]="state().config.remote?.optimistic" [disabled]="state().busy" (change)="model.optimistic($any($event.target).checked)" /> Update immediately</label> </div> <revo-grid #grid class="scheduler-remote-demo__grid" [hideAttribution]="true" [theme]="dark ? 'darkMaterial' : 'material'" [plugins]="plugins" [columns]="columns" [source]="source" [eventScheduler]="state().config" (aftergridinit)="connect()"></revo-grid> <div #feedback class="scheduler-remote-demo__activity"></div> </section>`,})export class RemoteDataComponent implements AfterViewInit, OnDestroy { @ViewChild('feedback', { read: ElementRef }) feedback!: ElementRef<HTMLElement>; @ViewChild('grid', { read: ElementRef }) grid!: ElementRef<HTMLRevoGridElement>; readonly model = createRemoteSchedulerDemo(); readonly state = signal(this.model.state); readonly plugins = [EventSchedulerPlugin, CalendarEditorDialogPlugin]; readonly columns = []; readonly source = []; readonly dark = currentTheme().isDark(); ngAfterViewInit() { this.connect(); } connect() { if (this.grid && this.feedback) void this.model.connect(this.grid.nativeElement, next => this.state.set(next), this.feedback.nativeElement); } ngOnDestroy() { this.model.disconnect(); }}import { h as preactH } from 'preact';import { renderPreactInto } from '@revolist/ui/preact-mount';import { renderStatusFeedbackPanel, type StatusFeedbackTone } from '@revolist/ui/status-feedback/index';import { CalendarEditorDialogPlugin } from '@revolist/revogrid-pro';import { EventSchedulerPlugin, type EventSchedulerConfig, type EventSchedulerRemoteState } from '@revolist/scheduler';import { createRemoteSchedulerClient } from './client';import type { SchedulerCalendars } from './contracts';
export function createRemoteSchedulerDemo() { const server = createRemoteSchedulerClient(); let calendars: SchedulerCalendars = []; let config: EventSchedulerConfig = { view: 'resourceTimeline', weekStartDate: '2026-06-08', dateRange: { start: '2026-06-08', end: '2026-06-08' }, timeRange: { start: '08:00', end: '17:00' }, timeZone: 'UTC', calendars: { primaryCalendarId: 'weekday', calendars }, slotMinutes: 60, timelineColumnSize: 100, allowCreate: false, allowDelete: false, // Activity owns remote progress/errors; keep the built-in empty-data state. customization: { emptyState: { properties: ({ kind }) => ({ style: { display: kind === 'empty' ? '' : 'none' } }) }, }, remote: { enabled: true, mode: 'remote', optimistic: true, rollbackOnError: true, loadEvents: server.loadEvents, loadResources: server.loadResources, // The application owns this save. There is no second final-event save listener. commitMutation: async request => { const reject = state.rejectNext; update({ rejectNext: false }); const result = await server.commitMutation(request, reject); return result; }, query: { retry: 2, refetchOnWindowFocus: false }, }, }; let state = { config, ready: false, busy: true, rejectNext: false, message: 'Loading server data…', tone: 'neutral' as StatusFeedbackTone }; let grid: HTMLRevoGridElement | undefined; let scheduler: EventSchedulerPlugin | undefined; let publish: ((next: typeof state) => void) | undefined; let calendarEditor: CalendarEditorDialogPlugin | undefined; let generation = 0; let connecting = false; let titleVersion = 0; let detach = () => {}; let feedbackHost: HTMLElement | undefined; let feedbackDismissed = false; const renderFeedback = () => { if (feedbackHost) renderPreactInto(feedbackHost, feedbackDismissed ? null : renderStatusFeedbackPanel(preactH, { title: 'Activity', compact: true, onClose: () => { feedbackDismissed = true; renderFeedback(); }, items: [{ id: 'remote', title: state.message, tone: state.busy ? 'neutral' : state.tone, busy: state.busy }], })); }; const update = (patch: Partial<typeof state>) => { if ((patch.busy === true && !state.busy) || (patch.tone === 'neutral' && patch.message !== undefined && patch.message !== state.message)) feedbackDismissed = false; state = { ...state, ...patch }; publish?.(state); renderFeedback(); }; const remoteState = (remote: EventSchedulerRemoteState) => { const busy = Object.values(remote.loading).some(Boolean) || remote.pendingEventIds.length > 0; update({ busy, ...(remote.error ? { message: `Server request failed: ${remote.error}`, tone: 'danger' as const } : {}), ...(state.message === 'Loading server data…' && !busy && remote.loaded.events ? { message: 'Bookings loaded.', tone: 'success' as const } : {}) }); }; return { get state() { return state; }, async connect(nextGrid: HTMLRevoGridElement, onChange: typeof publish, feedback: HTMLElement) { if (grid === nextGrid && (scheduler || connecting)) return; feedbackHost = feedback; renderFeedback(); connecting = true; detach(); grid = nextGrid; publish = onChange; const current = ++generation; const onState = (event: Event) => remoteState((event as CustomEvent<{ state: EventSchedulerRemoteState }>).detail.state); const pending = () => update({ message: 'Saving booking…', tone: 'neutral' }); const committed = () => update({ message: 'Saved. The server returned the canonical booking.', tone: 'success', rejectNext: false }); const rejected = () => update({ message: 'Save rejected. Scheduler restored the accepted booking.', tone: 'danger', rejectNext: false }); const listeners = [ ['event-scheduler-remote-state-change', onState], ['event-scheduler-remote-mutation-pending', pending], ['event-scheduler-remote-mutation-committed', committed], ['event-scheduler-remote-mutation-rejected', rejected], ] as const; listeners.forEach(([name, handler]) => nextGrid.addEventListener(name, handler)); detach = () => listeners.forEach(([name, handler]) => nextGrid.removeEventListener(name, handler)); let plugins: Awaited<ReturnType<HTMLRevoGridElement['getPlugins']>>; try { const [snapshot, activePlugins] = await Promise.all([server.read(), nextGrid.getPlugins()]); if (generation !== current) return; calendars = snapshot.calendars; config = { ...config, calendars: { ...config.calendars, calendars } }; nextGrid.eventScheduler = config; update({ config }); plugins = activePlugins; } catch (error) { if (generation === current) { connecting = false; update({ busy: false, ready: false, tone: 'danger', message: `Could not start the demo backend: ${error instanceof Error ? error.message : String(error)}` }); } return; } if (generation !== current) return; connecting = false; scheduler = plugins.find((plugin): plugin is EventSchedulerPlugin => plugin instanceof EventSchedulerPlugin); calendarEditor = plugins.find((plugin): plugin is CalendarEditorDialogPlugin => plugin instanceof CalendarEditorDialogPlugin); update({ ready: !!scheduler }); if (scheduler) remoteState(scheduler.getRemoteState()); }, openCalendars() { if (!calendarEditor || state.busy) return; calendarEditor.open({ calendars, selectedCalendarId: config.calendars?.primaryCalendarId, referenceDate: '2026-06-08', onSave: async nextCalendars => { if (!grid || state.busy) return false; const current = generation; update({ busy: true, message: 'Saving working calendars…', tone: 'neutral' }); try { calendars = await server.saveCalendars(nextCalendars); } catch { if (generation === current) update({ busy: false, message: 'Working calendars could not be saved. Try again.', tone: 'danger' }); return false; } if (generation !== current || !grid) return false; const primary = calendars.find(item => item.id === config.calendars?.primaryCalendarId)?.id ?? calendars[0]?.id; config = { ...config, calendars: { ...config.calendars, primaryCalendarId: primary, calendars } }; grid.eventScheduler = config; update({ config, busy: false, message: 'Saved working calendars and updated the Scheduler.', tone: 'success' }); }, }); }, rename() { if (!scheduler || state.busy) return; scheduler.editEvent('booking-1', { title: `Planning workshop ${++titleVersion}` }); }, rejectNext(value: boolean) { if (!state.busy) { update({ rejectNext: value }); } }, optimistic(value: boolean) { if (state.busy) return; config = { ...config, remote: { ...config.remote, optimistic: value } }; if (grid) grid.eventScheduler = config; update({ config }); }, async refresh() { if (!scheduler || state.busy) return; const current = generation; const activeScheduler = scheduler; update({ busy: true, message: 'Fetching server updates…', tone: 'neutral' }); try { const result = await server.simulateUpdate(); if (generation !== current) return; await activeScheduler.refreshVisibleRange(); if (generation === current) update({ message: result.message, tone: 'success' }); } catch { if (generation === current) update({ message: 'Refresh failed. Try again.', tone: 'danger' }); } finally { if (generation === current) update({ busy: false }); } }, disconnect() { if (feedbackHost) renderPreactInto(feedbackHost, null); feedbackHost = undefined; ++generation; connecting = false; server.disconnect(); calendarEditor?.close(); calendarEditor = undefined; detach(); grid = undefined; scheduler = undefined; publish = undefined; state = { ...state, ready: false, busy: true }; }, };}import type { EventSchedulerRemoteConfig } from '@revolist/scheduler';import { createSchedulerTransport, jsonRequest } from '../backend-client';import type { RemoteSnapshot, SchedulerCalendars } from './contracts';
export function createRemoteSchedulerClient() { const transport = createSchedulerTransport( async () => (await import('./module')).remoteSchedulerBackend, { delayMs: 600 }, ); const loadEvents: NonNullable<EventSchedulerRemoteConfig['loadEvents']> = ({ dateRange, signal, }) => transport.request( `/events?${new URLSearchParams({ start: dateRange.start, end: dateRange.end })}`, { signal }, ); const loadResources: NonNullable< EventSchedulerRemoteConfig['loadResources'] > = ({ signal }) => transport.request('/resources', { signal }); const commitMutation = ( request: Parameters< NonNullable<EventSchedulerRemoteConfig['commitMutation']> >[0], reject: boolean, ) => { const { signal, ...mutation } = request; return transport.request< Awaited< ReturnType<NonNullable<EventSchedulerRemoteConfig['commitMutation']>> > >('/mutations', jsonRequest({ mutation, reject }, signal), true); }; return { loadEvents, loadResources, commitMutation, simulateUpdate: () => transport.request<{ message: string }>('/simulate-update', { method: 'POST' }), read: () => transport.request<RemoteSnapshot>('/snapshot'), saveCalendars: (calendars: SchedulerCalendars) => transport.request<SchedulerCalendars>('/calendars', { ...jsonRequest({ calendars }), method: 'PUT', }), disconnect: transport.disconnect, };}import { startDemoBackend, type DemoBackendModule, type DemoBackendSession, type DemoBackendOptions,} from '@revolist/revogrid-examples/demo-api-mocks';
/** One lazy HTTP session per mounted model. Reconnect starts a fresh lifetime. */export function createSchedulerTransport( loadBackend: () => Promise<DemoBackendModule>, options: DemoBackendOptions = {},) { let lifetime = new AbortController(); let pending: Promise<DemoBackendSession> | undefined; const session = () => { if (!pending) { const owner = lifetime; pending = (async () => { const backend = await loadBackend(); owner.signal.throwIfAborted(); const active = await startDemoBackend(backend, options); if (owner.signal.aborted) { active.dispose(); owner.signal.throwIfAborted(); } owner.signal.addEventListener('abort', () => active.dispose(), { once: true, }); return active; })(); } return pending; }; return { async request<T>( path: string, init: RequestInit = {}, domainRejection = false, ): Promise<T> { const signal = init.signal ? AbortSignal.any([lifetime.signal, init.signal]) : lifetime.signal; signal.throwIfAborted(); const active = await session(); signal.throwIfAborted(); const response = await fetch(`${active.apiUrl}${path}`, { ...init, signal, }); const value = await response.json(); signal.throwIfAborted(); if ( !response.ok && !( domainRejection && response.status === 409 && value?.accepted === false ) ) { throw Object.assign( new Error(value?.message ?? `Request failed (${response.status}).`), { status: response.status }, ); } return value as T; }, disconnect() { lifetime.abort(); lifetime = new AbortController(); pending = undefined; }, };}
export const jsonRequest = ( body: unknown, signal?: AbortSignal,): RequestInit => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal,});import { http, HttpResponse } from 'msw';import type { DemoBackendModule } from '@revolist/revogrid-examples/demo-api-mocks';import { createRemoteDemoServer } from './server';import type { MutationBody, SchedulerCalendars } from './contracts';
export const remoteSchedulerBackend: DemoBackendModule = { id: 'scheduler-remote', create({ apiUrl }) { const server = createRemoteDemoServer(0); const badRequest = () => HttpResponse.json( { message: 'Invalid Scheduler request.' }, { status: 400 }, ); return { handlers: [ // Demo-only external activity; ordinary event reads stay side-effect free. http.post(`${apiUrl}/simulate-update`, async ({ request }) => HttpResponse.json(await server.simulateUpdate(request.signal)), ), http.get(`${apiUrl}/snapshot`, () => HttpResponse.json({ events: server.readSaved(), calendars: server.readCalendars(), }), ), http.get(`${apiUrl}/events`, async ({ request }) => { const query = new URL(request.url).searchParams; const start = query.get('start'); const end = query.get('end'); // This fixed-date demo accepts canonical civil-date range keys only. if ( !start || !end || start.length !== 10 || end.length !== 10 || start > end ) return badRequest(); return HttpResponse.json( await server.loadEvents({ dateRange: { start, end }, signal: request.signal, } as Parameters<typeof server.loadEvents>[0]), ); }), http.get(`${apiUrl}/resources`, async ({ request }) => HttpResponse.json( await server.loadResources({ signal: request.signal } as Parameters< typeof server.loadResources >[0]), ), ), http.post(`${apiUrl}/mutations`, async ({ request }) => { let body: MutationBody; try { body = (await request.json()) as MutationBody; } catch { return badRequest(); } const mutation = body?.mutation; if ( !mutation || !['create', 'edit', 'move', 'resize', 'delete'].includes( mutation.action, ) || typeof body.reject !== 'boolean' || (mutation.action !== 'delete' && (!mutation.event || typeof mutation.event.title !== 'string' || mutation.event.id !== mutation.eventId)) ) return badRequest(); const result = await server.commitMutation( { ...mutation, signal: request.signal }, body.reject, ); return HttpResponse.json(result, { status: result.accepted ? 200 : 409, }); }), http.put(`${apiUrl}/calendars`, async ({ request }) => { let calendars: SchedulerCalendars; try { ({ calendars } = (await request.json()) as { calendars: SchedulerCalendars; }); } catch { return badRequest(); } if ( !Array.isArray(calendars) || calendars.some( (item) => !item || typeof item.id !== 'string' || typeof item.name !== 'string', ) ) return badRequest(); request.signal.throwIfAborted(); server.saveCalendars(calendars); return HttpResponse.json(server.readCalendars()); }), ], }; },};import { commonColors } from '@revolist/ui/color-palette/common-palettes';import type { EventSchedulerConfig, EventSchedulerEventEntity, EventSchedulerRemoteConfig, EventSchedulerRemoteMutationRequest, EventSchedulerResourceEntity } from '@revolist/scheduler';
/** Per-demo simulation. Replace these callbacks with authenticated application APIs. */export function createRemoteDemoServer(delayMs = 600) { const resources: EventSchedulerResourceEntity[] = [ { id: 'room-a', name: 'Room A' }, { id: 'room-b', name: 'Room B' }, { id: 'room-c', name: 'Room C' }, { id: 'studio', name: 'Design studio' }, { id: 'boardroom', name: 'Boardroom' }, ]; const bookings = [ ['room-a', 'Planning workshop', '09:00', '11:00', 'confirmed'], ['room-a', 'Client review', '13:00', '14:00', 'planned'], ['room-b', 'Team standup', '08:00', '09:00', 'completed'], ['room-b', 'Product demo', '10:00', '11:30', 'confirmed'], ['room-b', 'Sprint retrospective', '14:00', '15:30', 'planned'], ['room-c', 'Candidate interview', '09:00', '10:00', 'confirmed'], ['room-c', 'Onboarding', '11:00', '13:00', 'planned'], ['studio', 'Design critique', '08:30', '10:00', 'confirmed'], ['studio', 'Prototype session', '11:30', '13:00', 'draft'], ['studio', 'Recording session', '15:00', '17:00', 'planned'], ['boardroom', 'Strategy session', '09:00', '12:00', 'confirmed'], ['boardroom', 'Partner meeting', '14:00', '16:00', 'planned'], ]; let events: EventSchedulerEventEntity[] = bookings.map(([resourceId, title, start, end, status], index) => ({ id: `booking-${index + 1}`, resourceId, title, status, startDateTime: `2026-06-08T${start}:00.000Z`, endDateTime: `2026-06-08T${end}:00.000Z`, })); let serverRevision = 0; let calendars: NonNullable<NonNullable<EventSchedulerConfig['calendars']>['calendars']> = [{ id: 'weekday', holidays: [], name: 'Weekday booking hours', timeZone: 'UTC', workingDays: [1, 2, 3, 4, 5], workingHours: { start: '08:00', end: '17:00' }, hoursPerDay: 9, }]; let rejectNext = false; const wait = (signal: AbortSignal) => new Promise<void>((resolve, reject) => { signal.throwIfAborted(); const abort = () => { clearTimeout(timer); reject(signal.reason); }; const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve(); }, delayMs); signal.addEventListener('abort', abort, { once: true }); }); const loadEvents: NonNullable<EventSchedulerRemoteConfig['loadEvents']> = async ({ dateRange, signal }) => { await wait(signal); signal.throwIfAborted(); return { events: structuredClone(events.filter(event => event.startDateTime.slice(0, 10) <= dateRange.end && event.endDateTime.slice(0, 10) >= dateRange.start)), rangeComplete: true }; }; const loadResources: NonNullable<EventSchedulerRemoteConfig['loadResources']> = async ({ signal }) => { await wait(signal); signal.throwIfAborted(); return { resources: structuredClone(resources), hasMore: false }; }; const commitMutation = async ({ event, eventId, action, signal }: EventSchedulerRemoteMutationRequest, reject?: boolean) => { await wait(signal); signal.throwIfAborted(); if (reject ?? rejectNext) { rejectNext = false; return { accepted: false, message: 'The server rejected this save. The saved booking is unchanged.' }; } if (action === 'delete') { events = events.filter(item => item.id !== eventId); return { accepted: true }; } if (!event) return { accepted: false, message: 'An event is required.' }; // Canonical response from the application endpoint, detached from Scheduler state. const canonical = structuredClone({ ...event, title: (event.title ?? '').trim() }); events = events.some(item => item.id === canonical.id) ? events.map(item => item.id === canonical.id ? canonical : item) : [...events, canonical]; return { accepted: true, event: structuredClone(canonical) }; }; return { loadEvents, loadResources, commitMutation, async simulateUpdate(signal: AbortSignal) { await wait(signal); signal.throwIfAborted(); const revision = ++serverRevision; const afternoon = revision % 2 === 1; events = events.filter(event => event.id !== 'server-booking').map(event => event.id === 'booking-2' ? { ...event, title: `Client review · updated ${revision}`, status: 'confirmed', color: commonColors.rgb_190_91_0, startDateTime: afternoon ? '2026-06-08T14:00:00.000Z' : '2026-06-08T13:00:00.000Z', endDateTime: afternoon ? '2026-06-08T15:00:00.000Z' : '2026-06-08T14:00:00.000Z', } : event); events.push({ id: 'server-booking', resourceId: 'room-c', color: commonColors.hex166534, title: `New server booking · ${revision}`, status: 'confirmed', startDateTime: afternoon ? '2026-06-08T14:00:00.000Z' : '2026-06-08T15:00:00.000Z', endDateTime: afternoon ? '2026-06-08T15:00:00.000Z' : '2026-06-08T16:00:00.000Z', }); return { message: `Server update ${revision}: Client review moved; ${revision === 1 ? 'new booking added in' : 'server booking moved in'} Room C.` }; }, readCalendars() { return structuredClone(calendars); }, saveCalendars(next: typeof calendars) { calendars = structuredClone(next); }, rejectNextSave(value: boolean) { rejectNext = value; }, readSaved() { return structuredClone(events); }, };}import type { EventSchedulerConfig, EventSchedulerEventEntity, EventSchedulerRemoteMutationRequest,} from '@revolist/scheduler';export type SchedulerCalendars = NonNullable< NonNullable<EventSchedulerConfig['calendars']>['calendars']>;export interface RemoteSnapshot { events: EventSchedulerEventEntity[]; calendars: SchedulerCalendars;}export interface MutationBody { mutation: Omit<EventSchedulerRemoteMutationRequest, 'signal'>; reject: boolean;}This demo uses native fetch() with an isolated browser backend. Saved events stay outside the grid, and a short delay makes pending saves visible. No Node server is needed; reloading resets the data.
- Rename the booking to save a change and receive the server’s accepted event.
- Enable Reject next save, then rename again to see the edit roll back.
- Turn off Optimistic updates, then rename again. The saved title stays visible until the server accepts the change.
- Open Working calendars to edit working days, hours and exceptions. Choose Save to apply or Cancel to discard.
- Choose Refresh from server to reload saved data without resubmitting a save.
Expand the source panel above to compare all four frameworks and inspect the HTTP client and backend. To connect your own API, replace the demo’s loadEvents, loadResources and commitMutation callbacks.
See Remote Data for query policy and Mutations and App-Owned Saving for rollback and receipts. This demo covers accepted and rejected saves; durable receipt recovery and backend deployment are outside its scope.