# Calendar Links: [Home](../index.md) A booking calendar on **capacity**: week, day and month views, a mobile agenda, occupancy with buffers, blocked intervals and opening hours, a create panel that checks itself before it asks, and a detail panel that asks the host to write. This is an **SGApps.IO product**: written in-house from scratch, with **no third-party code inside** and no network request of its own. Proprietary / source-available -- the terms are in `modules/gadgets/calendar/LICENSE`. The calendar knows nothing about what is being booked. It is given a resource with a capacity, bookings, blocked intervals, opening hours and statuses, and it reports what the user did. No prices, no workflow, no server: a host adapter translates its own domain into this data, and turns the calendar's requests into writes. ## Loading the Module ```js Application.require("gadgets/calendar").then(function (SGCalendar) { // SGCalendar is a constructor function }); ``` **Dependencies: none.** The two calculating halves are modules of their own, with no DOM and no strings, so a host, a worker or a test can do the same arithmetic without a calendar on screen: | Module | What it is | |---|---| | `gadgets/calendar/occupancy` | occupancy, peaks, clusters, lanes, free gaps, end fitting, the draft check, slot finding | | `gadgets/calendar/tz` | time-zone and calendar arithmetic: wall clock to instant and back, day keys, daylight-saving transitions | ```js Application.require(["oc :: gadgets/calendar/occupancy"]).then(function (libs) { libs.oc.check({ start: a, end: b, quantity: 1 }, ctx); // -> {severity, code, ...} }); ``` ## Time: instants in, instants out Every moment crosses the API as **ISO 8601 in UTC**, ending in `Z`. Inside, instants are epoch milliseconds. Wall clocks -- `'09:00'`, a weekday, `'2026-10-06'` -- appear only where they belong: opening hours, recurring blocked intervals, and the anchor date. - **`displayTimeZone`** is the clock the grid is drawn in; `null` means the browser's own. - **`businessTimeZone`** is the clock the *rules* were written against. A recurring rule's `09:00` stays 09:00 in winter and in summer, which means its instant moves by an hour across a daylight-saving change. `null` means the display zone. - The offset is **computed for each day**, never assumed: `Europe/Chisinau` is UTC+2 in winter and UTC+3 in summer, so a fixed "UTC+3" label is wrong half the year. A period that crosses a change shows `UTC+3 → UTC+2` and the date it happens on. The grid's vertical axis is a **wall clock**, so the 09:00 booking is on the 09:00 row in every column. The price is the two days a year when a day has 23 or 25 hours: the repeated hour shares its row, and a booking that spans the change is drawn between its wall-clock ends rather than at its elapsed length. Occupancy is never affected -- it is computed on instants. Zone support comes from `Intl.DateTimeFormat`. A named zone the browser does not know raises `event:error` with `ERR_TIMEZONE` and the calendar falls back to the browser's own zone rather than drawing the wrong times. ## API ### `SGCalendar([config])` Create a calendar. Returns an `ApplicationPrototype` instance. ```js var calendar = SGCalendar({ container: document.getElementById('cal'), view: 'week', date: '2026-10-06', locale: 'ro', displayTimeZone: 'Europe/Chisinau', resource: { id: 'svc-104531', label: 'Service bay', capacity: 3, unitLabel: ['post', 'posturi'] }, openingHours: { 0: null, 1: [['09:00', '18:00']], 2: [['09:00', '18:00']], 3: [['09:00', '18:00']], 4: [['09:00', '18:00']], 5: [['09:00', '18:00']], 6: [['10:00', '14:00']] }, statuses: { open: { label: 'deschisă', fg: '#184F95', bg: '#E2EEFA', accent: '#256ABF' }, 'pending-bill': { label: 'așteaptă încasare', fg: '#623900', bg: '#FAEBCE', accent: '#C98500' }, paid: { label: 'plătită', fg: '#005400', bg: '#E0F2DE', accent: '#0CA30C' }, cancelled: { label: 'anulată', fg: '#8E2626', bg: '#FEF3F3', accent: '#A32C2C', counts: false, editable: false } } }); calendar.whenLoaded().then(function () { calendar.on('event:create-requested', function (draft) { /* write, then upsertEvent */ }); }); ``` ### Configuration | Option | Default | Description | |---|---|---| | `container` | `null` | Element the calendar is appended to | | `view` | `'week'` | `week`, `day`, `month` | | `date` | `null` | The anchor day, `'YYYY-MM-DD'` in the display zone; `null` = today | | `locale` | `'en'` | `en`, `ro`, `ru`, `es`, `fr`, `it`, `de`, `zh`, or anything added with `addLocale()`. See *Languages* | | `displayTimeZone` | `null` | IANA name; `null` = the browser's zone | | `businessTimeZone` | `null` | The zone wall-clock rules are written in; `null` = the display zone | | `weekStart` | `1` | `0` = Sunday ... `6` = Saturday | | `slotMinutes` | `15` | The granularity of a slot, of a check and of the time inputs | | `defaultDurationMinutes` | `90` | The duration a new booking starts with | | `draftBuffer` | `null` | Minutes a new booking is assumed to hold on each side; `null` = whatever the existing bookings share | | `minFreeGapMinutes` | `30` | Shorter free stretches are not offered in the agenda | | `resource` | `null` | `{id, label, capacity, unitLabel}`; `unitLabel` is `[one, many]`, `[one, many, few]` where a language needs a third form, or a map of those per locale -- see *Languages* | | `openingHours` | `null` | Per weekday (`0` = Sunday): a list of `['HH:MM', 'HH:MM']`, or `null` for a closed day. `null` for the whole option = always open | | `statuses` | `null` | `{key: {label, fg, bg, accent, counts, editable}}`; see below | | `scopes` | `null` | `[{id, label}]` -- one partition dimension (a till, a branch, a channel) | | `cancelReasons` | `null` | `[{id, label}]` offered by the cancel form | | `events` | `null` | Bookings to show at startup | | `blocks` | `null` | Blocked intervals to show at startup | | `readOnly` | `false` | No slots, no `+`, no create or cancel | | `embedded` | `false` | Tighter spacing for a pinned window | | `showSidebar`, `showStrip`, `showMiniCalendar`, `showScopeFilter`, `showStatusFilter` | `true` | Parts of the chrome | | `showRulesButton` | `false` | A button that only emits `event:rules-requested` | | `axisStart`, `axisEnd` | `null` | Force the vertical axis (`'HH:MM'`); by default it is derived from the opening hours of the displayed days, padded to whole hours | | `hourHeight` | `56` | Pixels per hour of the grid | | `minColumnWidth` | `132` | A day column narrower than this scrolls horizontally instead of being squeezed | | `mobileBreakpoint` | `600` | Below this width the calendar becomes the agenda | | `now` | `null` | Freeze the clock at an ISO instant -- for tests and for reproducible screenshots | | `nowInterval` | `30000` | How often the "now" line is repositioned when the clock is live | | `draw` | `true` | Which drawn nodes are announced: `true` (all), `false` (none) or a list of kinds. See *Decorating what the calendar draws* | | `onDraw` | `null` | `function(info)` -- one call per drawn node, **including the first paint** | | `onRender` | `null` | `function(summary)` -- once per pass, after every node has been announced | | `loadRange` | `null` | `function({start, end, view, days}) -> Promise<{events, blocks}>` | | `checkDraft` | `null` | `function(draft) -> Promise<{severity, title, text}>` -- the host's own check | | `canOverride` | `null` | `function(draft) -> boolean` -- may a blocking verdict be overridden? | | `scopeLabel`, `scopeAllLabel` | `null` | Override the words for the scope filter (`'POS'`, `'Toate POS-urile'`) | ### The data **A booking** (`events`, `upsertEvent`): ```js { id: 'K-5', start: '2026-10-06T07:30:00Z', // ISO 8601, UTC end: '2026-10-06T09:00:00Z', quantity: 1, // capacity units taken; decimals allowed title: 'Cafenea Aroma SRL', subtitle: null, // one short line under the title description: 'Revizie completă espressor + detartrare', code: 'K-5', // a short reference, shown on the card status: 'open', // a key of `statuses` scope: 'LUT MD-2091-002', bufferBefore: 15, // minutes held before the start bufferAfter: 15, // and after the end editable: true, data: { anything: 'the host wants back' } } ``` **`title`, `subtitle` and `description`** are three different lengths of the same answer to "what is this?". The title is the card's headline; the subtitle is one short line beside the code and the status; the description is the real text -- it appears in full in the detail panel, in the card's tooltip, in the `.ics` export, and as one clipped line on a card that is at least 64 px tall. The create panel has a field for it, and it travels out in `event:create-requested`. `data` is opaque: it is handed back in `event:event-selected` and in the request events, and the calendar never looks inside it. A booking with no valid `start` / `end` is ignored, and `event:error` reports how many were. **A status** decides the colours and two behaviours: | Field | Meaning | |---|---| | `label` | what the user reads | | `fg`, `bg`, `accent` | text, background and the card's left stripe | | `counts` | `false` keeps the booking out of **every** occupancy figure -- a cancelled booking is drawn, struck through, and occupies nothing | | `editable` | `false` hides the cancel action for bookings in that status | **A blocked interval** (`blocks`, `setBlocks`) comes in two shapes. Absolute: ```js { id: 'maint-1', start: '2026-10-06T09:00:00Z', end: '2026-10-06T10:00:00Z', scopes: [], label: 'Maintenance', kind: 'deny' } ``` or recurring, in **wall-clock time of the business zone**: ```js { id: 'lunch', weekday: -1, startTime: '12:00', endTime: '13:00', validFrom: '2026-09-01', validTo: null, scopes: ['LUT MD-2091-002', 'terrace-pos'], label: 'Lunch break', kind: 'deny', active: true } ``` - `weekday`: `-1` = every day, `0` = Sunday ... `6` = Saturday. - `startTime` / `endTime`: `null` means the whole day. - `validFrom` / `validTo`: a day key or an instant; `null` = no bound. - `active: false` is not drawn at all. - `scopes`: empty = every scope. A partial one is drawn as *"only N scopes"* in the week view and *"only <names>"* in the day view. - `kind: 'allow'` inverts it into an **availability window**: outside it nothing can be booked, while the grid still shows the opening hours. This is the shape for an "available only Mon-Fri 10:00-18:00" rule. **Loading on demand.** With `loadRange`, every change of period or view asks the host for that period's data, showing a loading state and reporting `ERR_LOAD` on a rejection. Only the newest request's answer is drawn, so clicking quickly through four weeks cannot leave the third week's bookings on screen. The resolved object may carry `events`, `blocks`, `openingHours` and `resource`. Without `loadRange`, use `setEvents` / `setBlocks` and `refresh()`. ### Capacity, buffers and percentages - **Occupancy at an instant** = Σ `quantity` of the bookings whose status counts and whose span -- the booking **plus its buffers** -- covers the slot starting there. - **The buffer applies on both sides** (`bufferBefore` and `bufferAfter`), as the capacity rule says. A host that wants the trailing buffer only sets `bufferBefore: 0`; the policy is in the data, not in the module. - **The peak** of an interval is the highest occupancy of any slot in it. - **The percentage** = Σ (duration × quantity) / (open minutes × capacity), where the open minutes exclude the blocked intervals that apply to every scope. - **Over capacity** is never hidden: the `alert-triangle` icon, a 1.5 px red border and a red chip. - **The scope filter changes the list and the counters, not the occupancy.** Booked / capacity and the percentages stay global -- a unit taken by another till is still taken. ### Instance Methods #### Period and views | Method | Description | |---|---| | `node()` | The root element | | `whenLoaded()` | Promise resolved once the calendar is on screen | | `setView(view)` / `getView()` | `week`, `day`, `month` | | `setDate(value)` / `getDate()` | The anchor day; accepts a day key or an instant | | `today()`, `next()`, `prev()` | Move the period | | `getRange()` | `{view, start, end, days}` -- what is drawn, as ISO instants | | `focusDay(date, view)` | Focus a day, optionally switching view | | `getFocusedDay()` | The day the strip and the day view show | | `redraw()` | Re-measure and draw again (after the container changed size while hidden) | #### Data | Method | Description | |---|---| | `setEvents(list)` / `getEvents()` / `getEvent(id)` | Replace or read the bookings | | `upsertEvent(booking)` | Add or replace one, and clear its pending state | | `removeEvent(id)` | Remove one | | `setBlocks(list)` / `getBlocks([from, to])` | Blocked intervals; the getter returns them **expanded to instants** for the period | | `setOpeningHours(map)` / `getOpeningHours()` | Opening hours | | `setResource(res)` / `getResource()` / `setCapacity(n)` | The resource and its capacity | | `setStatuses(map)` / `getStatuses()` | The status vocabulary | | `setScopes(list)` / `getScopes()` | The partition dimension | | `refresh()` | Ask `loadRange` again for the current period | #### Selection, panels and requests | Method | Description | |---|---| | `selectEvent(id)` | Open the detail panel on a booking | | `getSelection()` | The selected booking, or `null` | | `openCreate(start, [end])` | Open the create panel on an interval | | `getDraft()` | The draft as the host will receive it | | `closePanel()` | Close whichever panel is open | | `createSlot()` / `detailSlot()` | An element the host fills with its **own fields and content**; it survives every redraw | | `failRequest(idOrToken, message)` | The write failed: the card and the panel say so and nothing changes | #### Filters | Method | Description | |---|---| | `setScopeFilter(id)` / `getScopeFilter()` | `null` or `'*'` = all scopes | | `setStatusFilter(hiddenKeys)` / `getStatusFilter()` | The statuses to hide | #### Configuration at runtime | Method | Description | |---|---| | `setReadOnly(flag)` / `isReadOnly()` | Read-only mode | | `setLocale(code)` / `getLocale()` | Switch the language; a regional code falls back to its language (`de-AT` to `de`) | | `locales()` | The codes the calendar can speak, the ones a host added included | | `localeInfo([code])` | How a locale writes: `{code, decimal, plural, dateStyle, weekdayComma, hour, minute, daysShort, daysLong, months, monthsOf, keys}` | | `addLocale(code, dict, [conventions])` | Register or extend a language -- see *Languages* | | `i18n()` | The translation namespace itself | | `setTimeZone(display, [business])` | Change either zone | | `timeZoneInfo()` | `{display, business, offset, offsetLabel, changesInPeriod, change, nextChange, intl}` | | `setNow(iso)` / `getNow()` | Freeze or release the clock | #### Capacity arithmetic | Method | Description | |---|---| | `occupancyAt(iso, [withBuffer])` | Occupancy at an instant | | `peak(from, to, [withBuffer])` | The highest occupancy in an interval | | `dayStats([date])` | `{date, percent, peak, bookings, openMinutes, bookedMinutes, capacity, closed}` | | `rangeStats()` | The same for the displayed period, with a count per status | | `findSlots({durationMinutes, quantity, from, to, count, stepMinutes})` | **When would this fit?** -- the first starts that pass the check. Pass `stepMinutes` equal to the duration for distinct, non-overlapping options | | `check({start, end, quantity, ignoreId})` | The module's own verdict on a hypothetical booking, without opening a panel | | `toICS([ids])` | The bookings as an RFC 5545 `.ics` text | | `occupancy()` / `tz()` | The two calculating namespaces | | `destroy()` | Remove the calendar and its listeners | ### Events Requests are **requests**: the module never changes its own data. | Event | Payload | |---|---| | `event:range-changed` | `{view, start, end, days}` | | `event:view-changed` | `{view}` | | `event:day-focused` | `{date}` | | `event:scope-filter` | `{scope}` | | `event:status-filter` | `{hidden: []}` | | `event:event-selected` | the booking, with its `data` | | `event:event-deselected` | `{id}` | | `event:draft-changed` | the draft, on every change in the create panel | | `event:create-requested` | `{start, end, date, durationMinutes, quantity, scope, title, token, override}` | | `event:reschedule-requested` | `{id, start, end, base: {start, end}, quantity, data}` | | `event:cancel-requested` | `{id, reason, note, data}` | | `event:rules-requested` | `{date}` -- only with `showRulesButton` | | `event:draw` | `{kind, node, view, date, ...}` -- one per drawn node, after the pass | | `event:rendered` | `{view, layout, date, days, root, counts, nodes}` -- once per pass | | `event:error` | `{code, message, error}` | | `event:destroyed` | `[]` | | `ready` | the instance | Every method also raises the framework's `before` / `on` / `after` lifecycle events, so a host can hook any call without patching the module. **The life of a request:** 1. the user confirms; the module emits `event:create-requested` (or `reschedule` / `cancel`) and puts the card and the panel into a *saving* state; 2. the host writes to its server; 3. the host calls `upsertEvent` / `removeEvent` / `refresh()` -- the saving state clears and, for a creation, the panel closes and the new booking is selected -- or `failRequest(id, message)`, which leaves the data untouched and says why. A creation has no id yet, so its payload carries a **`token`**, and `failRequest(token, message)` reports back to the panel that asked. ### Error codes | Code | When | |---|---| | `ERR_BAD_INPUT` | A booking, a blocked interval or a date could not be read | | `ERR_NO_EVENT` | `selectEvent` was given an unknown id | | `ERR_READ_ONLY` | A create was attempted on a read-only calendar | | `ERR_LOAD` | `loadRange` rejected or threw | | `ERR_TIMEZONE` | The browser does not know the requested zone; the local one is used | | `ERR_CHECK` | `checkDraft` rejected or threw | | `ERR_REQUEST` | The default message of `failRequest` | | `ERR_INIT` | The calendar could not be built at all | ## Languages The calendar ships with **English, Romanian, Russian, Spanish, French, Italian, German and Chinese (Simplified)**. A language here is more than a list of strings -- it also carries how the language *counts*, *writes a number* and *writes a date*: | | plural rule | decimal | a date | a period | |---|---|---|---|---| | `en` | 1 / rest | `.` | Tuesday, 6 October 2026 | 5 – 11 October 2026 | | `ro` | 1 / 2-19 / 20+ with *de* | `,` | Marți, 6 octombrie 2026 | 5 – 11 octombrie 2026 | | `ru` | 1, 21 / 2-4 / 5-20 | `,` | вторник, 6 октября 2026 | 5 – 11 октября 2026 | | `es` | 1 / rest | `,` | martes, 6 de octubre de 2026 | 5 – 11 de octubre de 2026 | | `fr` | 0 and 1 / rest | `,` | mardi 6 octobre 2026 | 5 – 11 octobre 2026 | | `it` | 1 / rest | `,` | martedì 6 ottobre 2026 | 5 – 11 ottobre 2026 | | `de` | 1 / rest | `,` | Dienstag, 6. Oktober 2026 | 5. – 11. Oktober 2026 | | `zh` | none | `.` | 2026年10月6日 星期二 | 2026年10月5日 – 11日 | Russian needs three forms of a noun and Romanian puts *de* back in at twenty (`21 de rezervări`), so `plural()` takes a third one; durations are abbreviated per language (`1 h 30 min`, `1 ч 30 мин`, `1 Std. 30 Min.`, `1 小时 30 分钟`); and Russian writes the month differently inside a date (*6 октября*) from a heading (*октябрь 2026*), which is what `monthsOf` is for. ```js calendar.locales(); // ['en','ro','ru','es','fr','it','de','zh'] calendar.setLocale('ru'); calendar.localeInfo(); // {code:'ru', decimal:',', plural:'ru', dateStyle:'dmy', ...} ``` ### The host's own words `resource.unitLabel` is the word for one unit of capacity -- a post, a bay, a table. It is the host's vocabulary, so the calendar does not translate it; instead it accepts one set per language: ```js calendar.setResource({ capacity: 3, unitLabel: { en: ['bay', 'bays'], ro: ['post', 'posturi'], ru: ['пост', 'постов', 'поста'], // one / many / few -- Russian needs three de: ['Platz', 'Plätze'] } }); ``` A plain `['post', 'posturi']` still works and is used in every language. Without one, the calendar uses its own translated word. **A limit worth knowing:** in the languages that inflect an adjective for gender -- Romanian, Russian, Italian -- the shipped *"N free"* string agrees with the module's own unit noun. Give it a noun of another gender and the agreement is wrong (`1 postazione libero`). One line fixes it, because `addLocale` merges: ```js calendar.addLocale('it', { 'cap.freeUnits': '{units} libere', 'cap.freeUnitsOne': '{units} libera' }); ``` ### Adding a language ```js calendar.addLocale('nl', { 'nav.today': 'Vandaag', 'agenda.book': 'Reserveren', 'events.one': 'boeking', 'events.many': 'boekingen' }, // the rest falls back to English { daysShort: ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'], daysLong: ['zondag', 'maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag'], months: ['januari', 'februari', 'maart', 'april', 'mei', 'juni', 'juli', 'augustus', 'september', 'oktober', 'november', 'december'], decimal: ',' }); calendar.setLocale('nl'); ``` The second argument carries the conventions: `daysShort`, `daysLong`, `months`, `monthsOf` (the form used inside a date, when a language needs a second set), `decimal`, `hour` / `minute` (the abbreviations in a duration), `plural` (`en`, `fr`, `ro`, `ru` or `zh` -- the rule, not the language), `dateStyle` (`dmy`, `dmy-de`, `dmy-dot` or `ymd-cjk`) and `weekdayComma`. A **regional code inherits its language**: `addLocale('de-AT', {}, { months: [...] })` keeps German's date style, plural rule and separator, and only replaces the month names. Passing an existing code **merges** into it, which is how a host renames three labels without re-translating the module. ## Decorating what the calendar draws The calendar draws; the host decorates. Every element worth reaching is announced **after the pass, with the node already in the document** -- so a hook can append to it, restyle it, or measure it -- together with the data it was drawn from: ```js var calendar = SGCalendar({ container: holder, events: bookings, onDraw: function (info) { if (info.kind === 'card' && info.event) { var price = document.createElement('span'); price.className = 'my-price'; // the host's own class price.textContent = money(info.event.data.total); info.node.appendChild(price); // the card's body, not its } // header line, which is tight if (info.kind === 'month-cell' && info.percent > 90) { info.node.classList.add('my-busy-day'); } if (info.kind === 'detail-panel') { info.node.appendChild(renderOrderLines(info.event.data)); } } }); ``` `onDraw` is a config callback rather than only an event because **the first paint happens inside the constructor**, before a host can attach a listener. The same information is also emitted as `event:draw`, which every later pass raises, and `redraw()` replays the whole set. Every info object carries `kind`, `node`, `view` (`week` / `day` / `month` / `agenda`) and, where the node belongs to one, `date`. The rest depends on the kind: | `kind` | The node | Also carries | |---|---|---| | `card` | a booking, or a group of overlapping ones | `event` (null for a group), `events`, `start`, `end`, `used`, `capacity`, `over`, `group`; in the day view `lane` and `lanes` | | `buffer` | the hatched band after a booking | `minutes`, `start`, `end`, `event`, `events` | | `block` | a blocked interval or an availability window | `block`, `start`, `end` | | `slot` | one free slot | `start`, `end`, `minutes`, `free` | | `ghost` | the preview of the draft | `draft`, `start`, `end` | | `hour-chip` | the `u/C` chip of the day view | `start`, `end`, `used`, `capacity`, `free` | | `strip-hour` | one cell of the capacity strip | `start`, `end`, `used`, `capacity`, `free`, `block` | | `now-line` | the "now" line | `at` | | `day-column` | a whole day column | `percent`, `peak`, `closed` | | `day-head` | the day's header button | `percent`, `peak`, `bookings`, `closed` | | `month-cell` | one day of the month grid | `percent`, `peak`, `bookings`, `closed`, `outside` | | `mini-day` | one day of the mini calendar | `peak`, `percent`, `outside` | | `week-day` | one button of the mobile week strip | `percent`, `peak`, `closed` | | `agenda-item` | one tile of the mobile agenda | `item` (`free` / `full` / `past` / `block` / `bookings`), `start`, `end`, `used`, `capacity`, `free`, `events`, `block` | | `agenda-row` | one booking inside an agenda tile | `event`, `events`, `start`, `end` | | `create-panel` | the create panel | `draft`, `verdict`, `start`, `end` | | `detail-panel` | the detail panel | `event`, `events` (the ones in the same interval) | | `reschedule-form` | the interval form | `event`, `verdict`, `start`, `end` | | `cancel-form` | the cancellation form | `event`, `reason`, `note` | | `sheet` | the bottom sheet on a phone | `mode` (`create` / `detail`), `draft`, `event`, `scrim` | On a phone the panel's content is moved into the sheet, so `create-panel` and `detail-panel` hand over **the sheet element** -- the node that is really on screen -- and a `sheet` hook follows it. `event:rendered` closes each pass with `{view, layout, date, days, root, counts, nodes}`, where `counts` is a tally per kind. It is the place for work that has to happen once rather than per node. Two practical notes: - **Volume.** A week of 15-minute slots is a few hundred nodes, and `slot` is most of them. A host that only cares about bookings should say so -- `draw: ['card', 'detail-panel']` -- and the rest is never announced. - **A hook that throws does not take the calendar with it**: the error is logged and the pass continues. Decorations are not remembered, by design: every pass redraws from the data, so apply them in the hook and nowhere else. ## Use Cases ### A service with a capacity of three, a closing rule and a buffer ```js var calendar = SGCalendar({ container: holder, locale: 'ro', displayTimeZone: 'Europe/Chisinau', resource: { capacity: 3, unitLabel: ['post', 'posturi'] }, openingHours: { 0: null, 1: [['09:00', '18:00']], 2: [['09:00', '18:00']], 3: [['09:00', '18:00']], 4: [['09:00', '18:00']], 5: [['09:00', '18:00']], 6: null }, statuses: { open: { label: 'deschisă', fg: '#184F95', bg: '#E2EEFA', accent: '#256ABF' }, paid: { label: 'plătită', fg: '#005400', bg: '#E0F2DE', accent: '#0CA30C' } }, blocks: [{ weekday: -1, startTime: '12:00', endTime: '13:00', label: 'Pauză de masă' }], events: bookings, // each with bufferBefore: 15, bufferAfter: 15 canOverride: function () { return currentUser.mayOverbook; }, checkDraft: function (draft) { return api.preview(draft).then(function (r) { return r.ok ? { severity: 'ok' } : { severity: 'block', title: r.message }; }); } }); calendar.on('event:create-requested', function (draft) { api.create(draft).then(function (booking) { calendar.upsertEvent(booking); // clears "saving", closes the panel, selects it }, function (err) { calendar.failRequest(draft.token, err.message); }); }); ``` ### A description on every booking ```js calendar.setEvents([{ id: 'K-5', code: 'K-5', start: '2026-10-06T07:30:00Z', end: '2026-10-06T09:00:00Z', title: 'Hotel Nord', description: 'Schimb garnituri grup, client așteaptă', status: 'open', quantity: 1 }]); calendar.on('event:create-requested', function (draft) { // draft.description is whatever was typed in the panel's Description field api.create(draft).then(function (b) { calendar.upsertEvent(b); }); }); ``` ### The host's own fields in the panels ```js var fields = calendar.createSlot(); // an element, once fields.appendChild(priceRow); // whatever the host needs calendar.on('event:draft-changed', function (draft) { priceRow.textContent = price(draft.quantity); }); var details = calendar.detailSlot(); calendar.on('event:event-selected', function (booking) { details.innerHTML = ''; details.appendChild(renderOrderLines(booking.data)); }); ``` ### "When can you take us?" ```js calendar.findSlots({ durationMinutes: 90, quantity: 2, count: 3, stepMinutes: 90 }) .forEach(function (slot) { console.log(slot.start, '->', slot.end, slot.free + ' free'); }); ``` ### Two different zones ```js var calendar = SGCalendar({ container: holder, displayTimeZone: 'Europe/Berlin', // the operator sits here businessTimeZone: 'Europe/Chisinau', // the venue, and its rules, are here openingHours: { 1: [['09:00', '18:00']] } }); ``` The rule stays 09:00-18:00 on the venue's clock all year, and the grid shows it at the operator's clock -- 08:00-17:00 in summer, 08:00-17:00 or 09:00-18:00 depending on which of the two zones has changed over yet. `timeZoneInfo()` reports both. ### A read-only calendar for a dashboard ```js SGCalendar({ container: holder, view: 'month', readOnly: true, embedded: true, showSidebar: false, events: bookings }); ``` ## Notes - **No network requests.** Data arrives only through `loadRange` or the `set*` methods. The icons are inline SVG paths, so there is nothing to fetch. - **Containers that change size.** The calendar watches its own element with a `ResizeObserver` and switches between the grid and the agenda at `mobileBreakpoint` -- which is its **own** width, not the window's, so a calendar in a 420 px column is the agenda on a wide screen too. A container that is hidden while it resizes has no render frames: call `redraw()` when it becomes visible. - **Horizontal scrolling.** The week and the day views scroll sideways below `minColumnWidth` per column, and the hour gutter stays put (`position: sticky`). - **Keyboard.** Every slot, card, day header, month cell and mini-calendar day is a real button with a label. The arrow keys move between them -- left and right between days, up and down through the times of a day, by a week in the month -- Enter opens, Escape closes a panel or a sheet, `PageUp` / `PageDown` change period and `Ctrl+Home` goes to today. - **Styling.** Everything is a custom property on `.sg-cal` (`--sgc-accent`, `--sgc-hour-height`, ...), so a host restyles the calendar by redefining a handful of values rather than overriding rules. - **Printing.** The controls, the slots and the panels are hidden; the grid, the month and the agenda are not. - **Not in v1**, deliberately: drag & drop and resizing of a card (`event:reschedule-requested` already carries everything such a gesture would need), multi-resource side-by-side views, a year view, a desktop agenda, and recurring *bookings* -- the calendar takes recurring blocked intervals, but a repeating reservation is the host's to expand.