Real World Example
A team calendar with everything the other pages introduce one at a time: data from a server, saves that can fail, a form, a backlog, a sidebar and a filter. The controls above the calendar make the server slow, strict or generous so every path can be tried.
The App
Move an event, then reject the next one and watch it slide back. Drag a backlog item onto the grid. Press New event in the sidebar, fill the form, and let the server assign the id. Switch weeks and see one request per uncovered range in the log; press Reload to fetch the week again.
Oct 19 – 25, 2026
Requests and saves, newest first
- Nothing yet. Interact with the calendar.
<script lang="ts">
import { now } from '@internationalized/date';
import { Button, ToggleGroup, toast } from 'sv5ui';
import {
Scheduler,
type ConflictResolution, type CreatePanelSnippetProps, type EventPanelSnippetProps,
type EventSourceFn, type Mutation, type SchedulerEvent, type SidebarSnippetProps, type SlotSelection
} from '@sv5ui/scheduler';
import EventForm from '$lib/calendar/EventForm.svelte';
import { loadEvents, saveMutation, type Meeting } from '$lib/calendar/api';
import { businessHours, calendars, holidays, backlog } from '$lib/calendar/data';
const zone = 'Asia/Ho_Chi_Minh';
let status = $state<'all' | 'confirmed' | 'tentative'>('all');
let draft = $state<SlotSelection | null>(null);
let lastSaved = $state<Date | null>(null);
let version = $state(0);
// A new function is how the scheduler is told to drop its cache and fetch again.
// version is read here, outside the async body, so the derived depends on it.
const source = $derived.by<EventSourceFn<Meeting>>(() => {
void version;
return ({ range, timeZone, signal }) => loadEvents(range, timeZone, signal);
});
async function onMutate(mutation: Mutation<Meeting>) {
const result = await saveMutation(mutation);
lastSaved = new Date();
return result;
}
const onConflict = (mutation: Mutation<Meeting>, server: SchedulerEvent<Meeting>): ConflictResolution => 'keep-server';
const onError = (mutation: Mutation<Meeting>, error: unknown) =>
toast.error(`Could not save ${mutation.after?.title ?? mutation.before?.title}`);
function newEvent(date = now(zone)) {
const start = date.set({ minute: 0, second: 0, millisecond: 0 }).add({ hours: 1 });
draft = { start, end: start.add({ hours: 1 }), allDay: false };
}
</script>
<Scheduler
{source}
{onMutate}
{onConflict}
{onError}
onLoadError={() => toast.error('Could not load events')}
bind:draft
{calendars}
{businessHours}
{holidays}
dragSources={backlog}
weekNumbers
sidebar
detail="popover"
createPanel={form}
eventPanel={panel}
toolbarActions={actions}
filter={(event) => status === 'all' || event.data?.status === status}
timeZone={zone}
height="min(80dvh, 820px)"
>
{#snippet sidebarHeader({ date }: SidebarSnippetProps<Meeting>)}
<Button label="New event" icon="lucide:plus" block onclick={() => newEvent(date)} />
{/snippet}
{#snippet sidebarFooter()}
<p class="text-xs">{lastSaved ? `Saved ${lastSaved.toLocaleTimeString()}` : 'Nothing saved yet'}</p>
{/snippet}
</Scheduler>
{#snippet actions()}
<ToggleGroup
items={[{ value: 'all', label: 'All' }, { value: 'confirmed', label: 'Confirmed' }, { value: 'tentative', label: 'Tentative' }]}
value={status}
onValueChange={(v: string | undefined) => v && (status = v as typeof status)}
size="sm"
/>
<Button label="Reload" icon="lucide:refresh-cw" size="sm" variant="ghost" onclick={() => (version += 1)} />
{/snippet}
{#snippet form({ start, end, allDay, close, create }: CreatePanelSnippetProps<Meeting>)}
<EventForm {start} {end} {allDay} {calendars}
onSubmit={(input) => create({ ...input, data: { ...input.data, status: 'tentative' } })}
onCancel={close} />
{/snippet}
{#snippet panel({ event, close, remove, update, deletable }: EventPanelSnippetProps<Meeting>)}
{#if deletable}
<EventForm start={event.start} end={event.end} allDay={event.allDay ?? false} {calendars}
initial={{ title: event.title, calendarId: event.calendarId, data: event.data }}
submitLabel="Update"
onSubmit={(changes) => { update({ ...changes, data: { ...event.data, ...changes.data } }); close(); }}
onCancel={close} />
<Button label="Delete" color="error" variant="soft" onclick={remove} />
{/if}
{/snippet}What This Uses
Feature by feature
| Feature | Props |
|---|---|
Load only what is on screen, with a progress bar and a Reload | source, onLoadError |
Save every change, roll back on failure, settle conflicts | onMutate, onConflict, onError |
Colours per calendar, hide some, search, filter by status | calendars, sidebar, filter |
A form to create, a panel to edit and delete | createPanel, draft, eventPanel, detail |
A backlog to drag onto the grid | dragSources |
Working hours, holidays, week numbers | businessHours, holidays, weekNumbers |
A New event button and a sync line in the sidebar | sidebarHeader, sidebarFooter |
The status filter and Reload in the toolbar | toolbarActions |
One zone for everyone on the team | timeZone |
Each row is a page: Loading on Demand, Saving Changes, Calendars & Filtering, Creating & Editing, Drag & Drop, Business Hours & Holidays, Sidebar, Toolbar, Time Zones.
Data Flow
What happens when
| You | The calendar |
|---|---|
Open, navigate | The scheduler asks source for the range on screen, minus what it already has. The endpoint answers with UTC instants; the app adds a status to each |
Drag, resize, drop from the backlog | The grid changes at once. onMutate sends the change; on reject the event slides back and a toast says why; on a different answer onConflict decides |
New event, or a drag over empty slots | draft opens the create panel with the picked range. create() goes through the same pipeline; with the server assigning ids, the temporary id is replaced |
Click an event, Open details, Update | update() merges the form's changes and saves them the same way. The source's cache is updated, so nothing is refetched |
Reload | version changes, the derived returns a new source function, the scheduler drops its cache and fetches the range on screen again |
Source
Four files. The page holds the scheduler and its snippets; a module talks to the API; the form is the one built on Creating & Editing; the endpoint is the one these docs run, stateless so every reader shares it. The demo above swaps the real save for the fake server of Saving Changes and logs each call; the code below is the version an app would ship.
<script lang="ts">
import { now } from '@internationalized/date';
import { Button, ToggleGroup, toast } from 'sv5ui';
import {
Scheduler,
type ConflictResolution, type CreatePanelSnippetProps, type EventPanelSnippetProps,
type EventSourceFn, type Mutation, type SchedulerEvent, type SidebarSnippetProps, type SlotSelection
} from '@sv5ui/scheduler';
import EventForm from '$lib/calendar/EventForm.svelte';
import { loadEvents, saveMutation, type Meeting } from '$lib/calendar/api';
import { businessHours, calendars, holidays, backlog } from '$lib/calendar/data';
const zone = 'Asia/Ho_Chi_Minh';
let status = $state<'all' | 'confirmed' | 'tentative'>('all');
let draft = $state<SlotSelection | null>(null);
let lastSaved = $state<Date | null>(null);
let version = $state(0);
// A new function is how the scheduler is told to drop its cache and fetch again.
// version is read here, outside the async body, so the derived depends on it.
const source = $derived.by<EventSourceFn<Meeting>>(() => {
void version;
return ({ range, timeZone, signal }) => loadEvents(range, timeZone, signal);
});
async function onMutate(mutation: Mutation<Meeting>) {
const result = await saveMutation(mutation);
lastSaved = new Date();
return result;
}
const onConflict = (mutation: Mutation<Meeting>, server: SchedulerEvent<Meeting>): ConflictResolution => 'keep-server';
const onError = (mutation: Mutation<Meeting>, error: unknown) =>
toast.error(`Could not save ${mutation.after?.title ?? mutation.before?.title}`);
function newEvent(date = now(zone)) {
const start = date.set({ minute: 0, second: 0, millisecond: 0 }).add({ hours: 1 });
draft = { start, end: start.add({ hours: 1 }), allDay: false };
}
</script>
<Scheduler
{source}
{onMutate}
{onConflict}
{onError}
onLoadError={() => toast.error('Could not load events')}
bind:draft
{calendars}
{businessHours}
{holidays}
dragSources={backlog}
weekNumbers
sidebar
detail="popover"
createPanel={form}
eventPanel={panel}
toolbarActions={actions}
filter={(event) => status === 'all' || event.data?.status === status}
timeZone={zone}
height="min(80dvh, 820px)"
>
{#snippet sidebarHeader({ date }: SidebarSnippetProps<Meeting>)}
<Button label="New event" icon="lucide:plus" block onclick={() => newEvent(date)} />
{/snippet}
{#snippet sidebarFooter()}
<p class="text-xs">{lastSaved ? `Saved ${lastSaved.toLocaleTimeString()}` : 'Nothing saved yet'}</p>
{/snippet}
</Scheduler>
{#snippet actions()}
<ToggleGroup
items={[{ value: 'all', label: 'All' }, { value: 'confirmed', label: 'Confirmed' }, { value: 'tentative', label: 'Tentative' }]}
value={status}
onValueChange={(v: string | undefined) => v && (status = v as typeof status)}
size="sm"
/>
<Button label="Reload" icon="lucide:refresh-cw" size="sm" variant="ghost" onclick={() => (version += 1)} />
{/snippet}
{#snippet form({ start, end, allDay, close, create }: CreatePanelSnippetProps<Meeting>)}
<EventForm {start} {end} {allDay} {calendars}
onSubmit={(input) => create({ ...input, data: { ...input.data, status: 'tentative' } })}
onCancel={close} />
{/snippet}
{#snippet panel({ event, close, remove, update, deletable }: EventPanelSnippetProps<Meeting>)}
{#if deletable}
<EventForm start={event.start} end={event.end} allDay={event.allDay ?? false} {calendars}
initial={{ title: event.title, calendarId: event.calendarId, data: event.data }}
submitLabel="Update"
onSubmit={(changes) => { update({ ...changes, data: { ...event.data, ...changes.data } }); close(); }}
onCancel={close} />
<Button label="Delete" color="error" variant="soft" onclick={remove} />
{/if}
{/snippet}import type { DateRange, EventInput, Mutation, MutationResult, TimeZoneId } from '@sv5ui/scheduler';
export interface Meeting {
status: 'confirmed' | 'tentative';
notes?: string;
}
// GET /api/events?from=<ISO>&to=<ISO>&zone=<IANA>. The API stores UTC instants;
// the scheduler reads them in its own zone. Only the range on screen is asked for.
export async function loadEvents(range: DateRange, timeZone: TimeZoneId, signal: AbortSignal): Promise<EventInput<Meeting>[]> {
const params = new URLSearchParams({
from: range.start.toAbsoluteString(),
to: range.end.toAbsoluteString(),
zone: timeZone
});
const response = await fetch(`/api/events?${params}`, { signal });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return response.json();
}
// One request per change. The server answers with its own version of the event,
// or nothing; a non-2xx response throws and the scheduler rolls the change back.
export async function saveMutation(mutation: Mutation<Meeting>): Promise<MutationResult<Meeting>> {
const body = mutation.after && {
...mutation.after,
start: mutation.after.start.toAbsoluteString(),
end: mutation.after.end.toAbsoluteString()
};
const response = await fetch(`/api/events/${encodeURIComponent(mutation.eventId)}`, {
method: mutation.kind === 'delete' ? 'DELETE' : mutation.kind === 'create' ? 'POST' : 'PUT',
headers: { 'content-type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
if (mutation.kind === 'delete') return;
return response.json(); // the server's version: a new id on create, or the same event
}<script lang="ts">
import type { DateValue, ZonedDateTime } from '@internationalized/date';
import { toCalendarDate, toCalendarDateTime, toZoned } from '@internationalized/date';
import { Button, DatePicker, FormField, Input, Select, Switch, Textarea } from 'sv5ui';
import { untrack } from 'svelte';
import type { EventChanges, NewEventInput, SchedulerCalendar } from '@sv5ui/scheduler';
/**
* The form the Creating & Editing and Real World demos put inside the
* scheduler's panels. It is built from sv5ui inputs and lives in the docs,
* not in the package: the scheduler provides the panel and the save, and
* every app draws its own fields.
*/
export interface Notes {
notes: string;
}
interface Props {
start: ZonedDateTime;
end: ZonedDateTime;
allDay: boolean;
calendars: SchedulerCalendar[];
/** Present when editing: the fields start from this event. */
initial?: { title: string; calendarId?: string; data?: Notes };
submitLabel?: string;
onSubmit: (input: NewEventInput<Notes> & EventChanges<Notes>) => void;
onCancel: () => void;
}
let {
start,
end,
allDay,
calendars,
initial,
submitLabel = 'Save',
onSubmit,
onCancel
}: Props = $props();
let title = $state(untrack(() => initial?.title ?? ''));
let calendarId = $state(untrack(() => initial?.calendarId ?? calendars[0]?.id ?? ''));
let wholeDay = $state(untrack(() => allDay));
let from = $state(untrack(() => start));
let to = $state(untrack(() => end));
let notes = $state(untrack(() => initial?.data?.notes ?? ''));
const calendarItems = $derived(
calendars.map((calendar) => ({ label: calendar.title, value: calendar.id }))
);
function pick(value: DateValue | undefined, fallback: ZonedDateTime) {
return value ? toZoned(toCalendarDateTime(value), fallback.timeZone) : fallback;
}
function submit(event: SubmitEvent) {
event.preventDefault();
if (!title.trim()) return;
onSubmit({
title: title.trim(),
calendarId,
start: wholeDay ? toCalendarDate(from).toString() : from,
end: wholeDay ? toCalendarDate(to).toString() : to,
allDay: wholeDay,
data: { notes }
});
}
</script>
<form class="flex flex-col gap-4" onsubmit={submit}>
<FormField label="Title" required>
<Input bind:value={title} placeholder="What is it about?" autofocus />
</FormField>
<FormField label="Calendar">
<Select items={calendarItems} bind:value={calendarId} />
</FormField>
<FormField label="All day">
<Switch bind:checked={wholeDay} />
</FormField>
<FormField label="Starts">
<DatePicker
value={from}
granularity={wholeDay ? 'day' : 'minute'}
onValueChange={(value) => (from = pick(value, from))}
/>
</FormField>
<FormField label="Ends">
<DatePicker
value={to}
granularity={wholeDay ? 'day' : 'minute'}
minValue={from}
onValueChange={(value) => (to = pick(value, to))}
/>
</FormField>
<FormField label="Notes">
<Textarea bind:value={notes} rows={3} placeholder="Agenda, links, anything" />
</FormField>
<div class="flex justify-end gap-2 pt-2">
<Button label="Cancel" variant="ghost" color="surface" type="button" onclick={onCancel} />
<Button label={submitLabel} color="primary" type="submit" />
</div>
</form>import { parseAbsolute, parseZonedDateTime, toCalendarDate } from '@internationalized/date';
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
/**
* The backend of the Loading on Demand and Real World demos.
*
* It is stateless on purpose: Vercel runs several instances and every reader
* shares them, so anything saved here would belong to nobody. Events are
* derived from the calendar day, so a range asked twice answers twice the
* same, and the scheduler's caching can be watched rather than believed.
*
* GET /docs/scheduler/api/events?from=<ISO>&to=<ISO>&zone=<IANA>&latency=<ms>&fail=1
*
* Dates go out as UTC instants (`toAbsoluteString`), the form an API that
* stores UTC would use, and the scheduler converts them to its own zone.
*/
const TITLES = [
'Client call',
'Code review',
'Interview',
'Sales demo',
'Workshop',
'One on one',
'Budget review',
'Site visit'
];
const CALENDARS = ['work', 'team', 'personal', 'learning'];
const MAX_LATENCY = 5000;
const MAX_DAYS = 120;
const sleep = (ms: number, signal: AbortSignal) =>
new Promise<void>((resolve) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener('abort', () => clearTimeout(timer), { once: true });
});
export const GET: RequestHandler = async ({ url, request }) => {
const from = url.searchParams.get('from');
const to = url.searchParams.get('to');
const zone = url.searchParams.get('zone') ?? 'Asia/Ho_Chi_Minh';
const latency = Math.min(Number(url.searchParams.get('latency') ?? 0), MAX_LATENCY);
if (!from || !to) error(400, 'from and to are required');
let start, end;
try {
start = toCalendarDate(parseAbsolute(from, zone));
end = toCalendarDate(parseAbsolute(to, zone));
} catch {
error(400, 'from and to must be ISO instants');
}
if (end.compare(start) > MAX_DAYS) error(400, `at most ${MAX_DAYS} days per request`);
if (latency > 0) await sleep(latency, request.signal);
if (url.searchParams.get('fail') === '1') error(503, 'The network is unreachable');
const events = [];
for (let day = start; day.compare(end) < 0; day = day.add({ days: 1 })) {
// Weekends stay empty; weekdays get 0 to 3 events from a hash of the date.
const weekday = day.toDate(zone).getDay();
if (weekday === 0 || weekday === 6) continue;
const seed = day.day * 7 + day.month * 3 + day.year;
const count = seed % 4;
for (let index = 0; index < count; index += 1) {
const hour = 8 + ((seed + index * 5) % 9);
const minute = index % 2 === 0 ? '00' : '30';
const startAt = parseZonedDateTime(
`${day.toString()}T${String(hour).padStart(2, '0')}:${minute}[${zone}]`
);
const endAt = startAt.add({ minutes: 30 + ((seed + index) % 3) * 30 });
events.push({
id: `${day.toString()}-${index}`,
title: TITLES[(seed + index) % TITLES.length],
calendarId: CALENDARS[(seed + index) % CALENDARS.length],
start: startAt.toAbsoluteString(),
end: endAt.toAbsoluteString()
});
}
}
return json(events, { headers: { 'cache-control': 'no-store' } });
};<script lang="ts">
import { ModeWatcher } from 'mode-watcher';
import '../scheduler.config';
import './layout.css';
let { children } = $props();
</script>
<ModeWatcher />
<div class="flex h-dvh flex-col">
<header class="h-14 shrink-0 border-b">Team calendar</header>
{@render children()}
</div>src/lib/calendar/data.ts holds calendars, businessHours, holidays and the backlog array: plain data in the shapes Events, Business Hours & Holidays and Drag & Drop describe.
Props
Everything the app passes to the scheduler.
| Prop | Type | Default |
|---|---|---|
source | EventSourceFn<T> | - |
onLoadError | (error: unknown) => void | - |
onMutate | (m: Mutation<T>) => MutationResult<T> | Promise<...> | - |
onConflict | (m: Mutation<T>, server: SchedulerEvent<T>) => ConflictResolution | 'keep-server' |
onError | (m: Mutation<T>, error: unknown) => void | - |
draft | SlotSelection | null | null |
calendars | SchedulerCalendar[] | [] |
businessHours | BusinessHours | - |
holidays | Holiday[] | [] |
dragSources | DragSourceData<T>[] | [] |
weekNumbers | boolean | false |
sidebar | boolean | Snippet<[SidebarSnippetProps<T>]> | false |
sidebarHeader | Snippet<[SidebarSnippetProps<T>]> | - |
sidebarFooter | Snippet<[SidebarSnippetProps<T>]> | - |
detail | 'popover' | 'slideover' | false | 'popover' |
createPanel | Snippet<[CreatePanelSnippetProps<T>]> | - |
eventPanel | Snippet<[EventPanelSnippetProps<T>]> | - |
toolbarActions | Snippet | - |
filter | (event: SchedulerEvent<T>) => boolean | - |
timeZone | TimeZoneId | local zone |
height | number | string | - |
Snippets
| Type | Fields |
|---|---|
CreatePanelSnippetProps | start, end, allDay, close, create |
EventPanelSnippetProps | event, close, remove, update, deletable |
SidebarSnippetProps | date, view, range, events, scheduler, navigate, close, docked |