Scheduler

A time-slot scheduler for appointments: Day, Week and Work week lanes plus a Month grid, resource lanes and a resource filter, all-day event bars, active-hours shading, a built-in event editor, right-click context menus, drag-to-move and edge resizing, RFC 5545 recurrence, and per-event IANA time zones.

Day and Week

Bind Events, Date and View. A single click only highlights a slot or an appointment — double-click, press Enter, or use the right-click menu to open the editor. StartHour and EndHour decide which hours are rendered at all, while InitialScrollHour only chooses where the viewport starts — earlier hours stay reachable by scrolling.

Week Start and Work Week

Week view offers a Monday or Sunday start through the toolbar — bind FirstDayOfWeek to keep the choice. Work week always shows Monday to Friday whatever that value is, so the two weekend appointments below disappear when you switch to it. EnableTimeZones="false" hides the zone label and the editor's zone selector.

Drag, Resize and Slot Size

Drag an appointment to another time, day or resource lane — the duration is preserved — or drag its top and bottom edges to change the start or end. Everything snaps to SlotMinutes, which must divide 1440 and sit between 5 and 120. A committed gesture saves as an Update in Occurrence scope, so dragging one instance of a series never moves the rest.

Resources, Recurrence and Time Zones

Each resource gets its own lane per day, plus an "Unassigned" lane when something does not match. The Planning series is stored in America/New_York and stays at 9:00 there whatever the display zone is — change the selector and the same instants move. Every save runs through OnEventChange: tick the box to set Cancel and watch the editor keep your edits instead of losing them.

Custom Event Content and Opening the Editor from Code

EventTemplate replaces the default title and time inside each appointment button; the resize handles stay. Put the time text in an element with data-scheduler-event-time so the drag preview can keep it updated. CreateEvent and EditEvent open the same editor from code through @ref.

Month, All-Day Events and Active Hours

SchedulerView.Month draws six week rows, always six, so the grid height does not change between months. Single-day occurrences are chips and multi-day occurrences are bars; MaxEventsPerDay collapses the rest into a "+x more" count. Drag and resize are forced off, and there is no time axis, so StartHour, EndHour, InitialScrollHour and ActiveHours do nothing here. Switch to Day or Week to see ActiveHours: slots outside every range for their day are muted, the lunch gap included, and weekends are muted end to end because they have no entry. BlockOutsideActiveHours turns that muting into refusal. All-day events draw as bars above the time grid — End is exclusive, so the one-day offsite runs from the 19th to the 20th.

Resource Filter and a Custom Toolbar

VisibleResourceIds narrows which lanes are drawn. Null shows every resource, an empty list shows none, and the empty string selects the Unassigned lane. ShowResourceFilter adds the built-in picker to the toolbar. ToolbarContent replaces the toolbar entirely here. The context is deliberately small: Date, View, FirstDayOfWeek and VisibleResourceIds are bindable parameters already, so it carries only Navigate, GoToToday, SetView and SetVisibleResources, plus the Heading the built-in toolbar would show. Render context.DefaultContent instead to keep the built-in toolbar and add around it.

Context Menus, a Derived Event Type and Extra Editor Fields

Right-click an empty slot or an appointment. SlotContextMenuContent and EventContextMenuContent replace each menu; rendering context.DefaultItems keeps the built-in entries and adds your own around them. SchedulerEvent is not sealed, so this demo derives ClinicVisit with its own Reason field, overrides Clone (without that override every edit silently drops the field), and supplies NewEventFactory so a newly created event is the derived type. EditorContent then casts context.Draft to bind the extra field below the built-in ones.

Read-Only

ReadOnly disables creating, editing, deleting, moving and resizing — slot buttons are disabled and appointment buttons get aria-disabled. Navigation and view switching still work, so it stays a useful overview.

Month View

SchedulerView.Month draws six week rows — always six, so the grid height does not change between months. Days outside the month are still rendered and visually distinguished. Single-day occurrences are chips inside their cell, ordered by start then event id; multi-day occurrences are bars across the days they cover, packed per week row so they never overlap. A run crossing a week boundary becomes one bar per row, squared off at the join.

MaxEventsPerDay is a per-day budget. A bar spends the budget in every day it covers, so a day carrying a bar shows fewer chips; whatever does not fit collapses into that day's "+x more". A timed event ending exactly at midnight belongs to the previous day, and all-day End is exclusive, so both cases take the last covered day from the final tick.

There is no time axis in Month, so StartHour, EndHour, InitialScrollHour and ActiveHours have no effect, and drag and resize are forced off. Resources become a filter rather than lanes, because a month cell cannot carry one column per resource.

All-Day Events

An event with IsAllDay set draws as a bar in a band above the time grid rather than as a timed block. Start and End are then read as dates in the event's own TimeZoneId and their time components are ignored. End is exclusive, matching iCalendar's DTEND: a single all-day event on the 19th runs from the 19th to the 20th.

Recurrence for an all-day event is measured in whole local days, so a daily all-day series stays on midnight across a 23- or 25-hour daylight saving day. Multi-day bars stack when they overlap and share a lane when they do not touch. With two or more resources visible the band groups by resource, so bars stay contiguous within each group. MaxAllDayRows caps the rows drawn before the rest collapse into a "+x more" count. The editor has an "All day" toggle that switches the start and end fields between date-and-time entry and date-only entry.

Active Hours

ActiveHours lists the weekday ranges the schedule emphasises, as SchedulerDayHours records. Slots outside every range for their day are muted and carry the text "Outside the usual hours." Empty — the default — mutes nothing. A day with no range at all is muted end to end, so weekends need no entry, and two ranges for one day mute the gap between them, such as a lunch break.

Muting is visual only by default: those slots stay clickable and events can still be created in them. Set BlockOutsideActiveHours to turn muting into refusal. That guard covers the pointer and keyboard surfaces only — it does not validate the Events you supply, and the CreateEvent method stays open as the programmatic escape hatch.

Resource Filter

VisibleResourceIds narrows which resources are drawn. Null — the default — shows every resource. An empty list shows none, and the schedule renders "No resources are selected" instead of lanes. The empty string in the list selects the Unassigned lane.

ShowResourceFilter renders the built-in picker in the toolbar; it has no effect when Resources is empty. Use @bind-VisibleResourceIds to keep the choice in the parent, or drive it from a custom toolbar through SchedulerToolbarContext.SetVisibleResources.

Context Menus and Custom Regions

Right-clicking an empty time slot opens a slot menu; right-clicking an event opens an event menu. Both are built on BbContextMenu and clamp to the viewport rather than opening off-screen near an edge. The built-in slot menu has one item, "New event". The built-in event menu has "Edit event" and "Delete event"; Delete opens the delete confirmation directly, without opening the editor first.

ToolbarContent, EditorContent, SlotContextMenuContent and EventContextMenuContent each replace one region while offering its default content back. Render DefaultContent or DefaultItems from the context to keep the built-in region and add around it; leave it out to replace the region entirely.

SchedulerEvent is not sealed. To carry your own fields, derive from it, override Clone to return your type (calling the protected CopyTo so you only have to remember your own fields), and supply NewEventFactory so a newly created event is your type rather than the base one. Without the Clone override every edit silently drops your fields. Cast SchedulerEditorContext.Draft inside EditorContent to bind them.

Persistence

Every create, update, delete, move and resize goes through one callback. OnEventChange receives a SchedulerChangeContext holding the Kind, the Scope, the edited event and — most usefully — Events, the complete proposed collection after the change. Save that collection atomically and return.

To reject a change, set Cancel = true or throw. The original events are kept, EventsChanged does not fire, and the editor stays open with the proposed edit and an error, so the user's work is never silently lost. Without an OnEventChange handler, changes are accepted immediately.

Use @bind-Events, or assign the collection in EventsChanged, so a parent re-render does not restore the old list. The original SchedulerEvent objects are never mutated — the scheduler always hands you independent copies.

Recurrence

RecurrenceRule takes an RFC 5545 RRULE without the RRULE: prefix, for example FREQ=WEEKLY;BYDAY=MO,WE;COUNT=24. Ical.Net expands DAILY, WEEKLY, MONTHLY and YEARLY rules with a positive INTERVAL and an optional COUNT or UNTIL — not both. Only the visible range is expanded.

Occurrences keep the event's wall time in its own TimeZoneId, so a 9:00 New York meeting stays at 9:00 across a daylight saving change, and each occurrence keeps the event's elapsed duration. Monthly rules on a day some months lack skip those months; yearly rules on 29 February skip years without it.

Editing, moving or resizing a single occurrence adds its original start to the series' ExcludedStarts and creates a standalone override event with a new Id, a SeriesId pointing at the series and a RecurrenceId holding the original start. Choosing "Entire series" replaces the series event and keeps the existing overrides.