--- ## ResourceTimeline Resource timeline: every row is a resource (a room, a chair, a vehicle, a person), every column a day of a navigable window, and each item a bar spanning the days it occupies, stacked when two overlap in one lane. The canonical occupancy view that neither `Calendar` (timed events on a date grid) nor `Planner` (one item, one day bucket) can express. Three contracts decide whether your data lands where you expect: - **`getRange` is inclusive.** `{ start, end }` are both days the bar covers. A booking stored as check-in → check-out converts by subtracting a day: the last *night* is `checkOut − 1`. Same convention as `CalendarEvent.end`. - **Local date strings are taken verbatim** — `'2026-06-16'` is never UTC-parsed, so a plain date never shifts a day west of Greenwich (the same parser `Planner.getDate` uses). Pass a `Date` when you need an instant converted to its local day. - **The window is `value` + `view`.** `view="week"` snaps to the week containing `value`; `view="days"` runs `days` columns **starting at** `value`, so "the next 14 nights" needs no second anchor. Two deliberate departures from `Calendar`/`Planner`, both forced by the sticky resource column: **navigation does not slide-animate** (a `transform` ancestor breaks `position: sticky`, so there is no `animated` prop) and **there is no swipe-to-navigate** (the day track already owns the horizontal gesture — it is what scrolls). Arrow keys and the header arrows are the navigation surface; `overflow-x` sits on the track, never on the root, so a wide window never scrolls the page sideways. Scale is honest rather than engineered: roughly 20 lanes × 30 days is comfortable, and there is no virtualization in this version. Sort a longer list into `groups` and page the window instead. **Import:** `import { ResourceTimeline } from '@urbicon-ui/blocks';` ### Examples ```svelte b.roomId} getRange={(b) => ({ start: b.checkIn, end: addDays(isoToDate(b.checkOut), -1) })} getLabel={(b) => b.guest} onItemClick={(booking, room) => openBooking(booking, room)} /> ``` ```svelte s.chairId} getCategoryId={(s) => s.serviceId} getRange={(s) => ({ start: s.day, end: s.day })} > {#snippet span({ item, totalDays })} {item.client} · {totalDays}d {/snippet} ``` ### Variants - variant: bordered, default, ghost (default: default) - size: lg, md, sm (default: md) ### Api | Prop | Type | Required | Default | Description | | --- | --- | :---: | --- | --- | | getRange | `(item: T) => TimelineRange` | yes | | The item's **inclusive** day range — both `start` and `end` are days the bar covers. A stay ending at check-out passes `checkOut − 1`. Return `Date`s, or local date strings (`'2026-06-16'`) which are read verbatim and never UTC-parsed. A range whose end precedes its start is rendered with the two swapped and warns in DEV. Required. | | getResourceId | `(item: T) => string` | yes | | Which lane an item belongs to. An id that is in no `resources` entry drops the item (DEV warns). Required. | | ...HTMLAttributes | `HTMLAttributes` | no | | HTML attributes (excluding: 'children') | | ...ResourceTimelineVariants | `VariantProps` | no | | Styling variants from ResourceTimelineVariants | | categories | `TimelineCategory[]` | no | | Colour buckets for the bars, and the legend below the grid. | | cell | `Snippet<[TimelineCellContext]>` | no | | Render extra content inside every (resource, day) cell, e.g. an "add" affordance on free days. | | class | `string` | no | | Extra classes merged onto the root element. | | dayHeader | `Snippet<[TimelineDayContext]>` | no | | Customise a day column's header. | | days | `number` | no | 14 | Column count for `view="days"`. Ignored in `week`. | | disabled | `boolean` | no | false | Disable navigation and cell activation. | | empty | `Snippet` | no | | Replace the "no resources" message shown when `resources` is empty. | | getCategoryId | `(item: T) => string | undefined` | no | | The item's category id, looked up in `categories`. Falls back to `resource.categoryId`. | | getId | `(item: T) => string` | no | | Stable key for an item, used as the `{#each}` key. Defaults to resource id + start day + index. | | getLabel | `(item: T) => string` | no | | The bar's text and accessible name. Without it a bar renders as a plain occupancy block. | | groupLabel | `Snippet<[TimelineGroupContext]>` | no | | Customise a group heading row. | | groups | `TimelineGroup[]` | no | | Heading rows above the lanes carrying the matching `groupId`. Supplying them re-orders the lanes to follow this list; a lane whose `groupId` names no group is appended without a heading rather than dropped. | | header | `Snippet<[TimelineHeaderContext]>` | no | | Replace the default toolbar (prev/title/today/next). | | highlightToday | `boolean` | no | true | Tint today's column. Visual only — `aria-current="date"` is set on today's header and cells either way, so switching the highlight off never costs the semantic pointer. | | highlightWeekend | `boolean` | no | false | Tint Saturday/Sunday columns. | | isDateDisabled | `(date: Date) => boolean` | no | | Predicate for days that cannot be activated, on top of `minDate`/`maxDate`. | | items | `T[]` | no | | The items to lay out as bars. | | legend | `Snippet<[TimelineLegendContext]>` | no | | Replace the default category legend. | | locale | `string` | no | 'auto' | BCP 47 locale tag for the weekday names and the header title. Defaults to `'auto'`, which follows the active `` locale, so an app that already declares its language does not repeat it here. SSR-safe: the locale comes from context, so server and client resolve the same tag. Pass an explicit tag (e.g. `'de-DE'`) to override. | | maxDate | `Date` | no | | Latest navigable date. The window clamps span-preserving, so it never collapses at the bound. | | maxRowsPerLane | `number` | no | | Bar rows to render per lane; anything past it becomes a `+n` chip at the lane's right edge. Unset renders every row. | | minDate | `Date` | no | | Earliest navigable date. The window clamps span-preserving, so it never collapses at the bound. | | onCellClick | `(resource: TimelineResource, date: Date) => void` | no | | Fires when a cell **no bar covers** is activated — the hook for an "add booking" affordance. A day inside an existing stay reports `onItemClick` instead, from either input. | | onItemClick | `(item: T, resource: TimelineResource) => void` | no | | Fires when a bar is activated — a click on it, or Enter/Space on **any** cell it covers (the bar overhangs those cells, so the keyboard reaches what the pointer hits). Where several bars stack on one day, repeated activation walks them top row first and wraps. | | onNavigate | `(date: Date, range: { start: Date; end: Date }) => void` | no | | Fires after navigation with the new reference date and the visible window — load data here. | | preset | `string` | no | | Apply a named preset registered via ``. | | resourceLabel | `Snippet<[TimelineResourceContext]>` | no | | Customise a lane's label in the resource column. | | resources | `TimelineResource[]` | no | | The lanes, top to bottom. An empty list renders the `empty` state. | | showLegend | `boolean` | no | true | Render the category legend below the grid. Ignored without `categories`. | | size | `'sm' | 'md' | 'lg'` | no | 'md' | Density: lane width, day-column width and bar height. | | slotClasses | `Partial>` | no | | Per-slot class overrides merged with tv() styles. Slots: base | header | headerTitle | nav | navButton | track | dayHeaderRow | corner | dayHeader | dayHeaderWeekday | dayHeaderDate | body | groupRow | groupLabel | lane | laneHeader | laneLabel | laneDescription | dayCell | span | spanLabel | overflow | legend | legendItem | legendDot | legendLabel | empty | | span | `Snippet<[TimelineSpanContext]>` | no | | Render a bar's content — the core of the API. Receives the clipped geometry and the typed item. | | stickyResourceColumn | `boolean` | no | true | Keep the resource column pinned while the day track scrolls sideways. Turn it off inside a shell that already provides its own horizontal scrolling. | | unstyled | `boolean` | no | false | Remove all default tv() classes — only user-provided classes apply. Note that this also strips the layout's custom properties (`--rt-lane-w`, `--rt-day-w`, `--rt-bar-h` …), so an unstyled timeline has to re-declare them along with the look. | | value | `Date` | no | today | Reference date the window is anchored on. Supports `bind:value`. | | variant | `'default' | 'bordered' | 'ghost'` | no | 'default' | Visual style variant for the ResourceTimeline component | | view | `ResourceTimelineView` | no | 'week' | `week` snaps to the week containing `value`; `days` starts at `value`. | | weekStartsOn | `0 | 1 | 2 | 3 | 4 | 5 | 6` | no | 1 | First day of the week for `view="week"` (0=Sun … 6=Sat). | Inherited from: - ResourceTimelineVariants (external) - Omit, 'children'> (omit-pattern) ### Types ```ts type ResourceTimelineView = 'week' | 'days' ``` ```ts interface TimelineCategory { id: string; label: string; color: string; } ``` ```ts interface TimelineCellContext { /** The lane's resource. */ resource: TimelineResource; /** The cell's date (local midnight). */ date: Date; /** `YYYY-MM-DD` local key for this date. */ isoDate: string; /** Whether this cell's day is today. */ isToday: boolean; /** Whether this cell's day is a Saturday or Sunday. */ isWeekend: boolean; /** Whether this cell cannot be activated (date bounds, predicate, `resource.disabled`, or the whole grid). */ isDisabled: boolean; /** Whether any span covers this cell. */ isOccupied: boolean; } ``` ```ts interface TimelineDayContext { /** The column's date (local midnight). */ date: Date; /** `YYYY-MM-DD` local key for this date. */ isoDate: string; /** 0-based column index inside the window. */ index: number; /** Whether this column is today. */ isToday: boolean; /** Whether this column is a Saturday or Sunday. */ isWeekend: boolean; /** Whether the whole column is blocked by `minDate`/`maxDate`/`isDateDisabled`/`disabled`. */ isDisabled: boolean; /** Localized short weekday name (e.g. "Mo"). */ weekday: string; /** ISO 8601 week number of this date. */ weekNumber: number; } ``` ```ts interface TimelineGroup { id: string; label: string; } ``` ```ts interface TimelineGroupContext { group: TimelineGroup; resources: TimelineResource[]; } ``` ```ts interface TimelineHeaderContext { /** Localized title for the current window. */ title: string; /** First day of the window. */ rangeStart: Date; /** Last day of the window (inclusive). */ rangeEnd: Date; /** ISO week number of the reference date. */ weekNumber: number; /** The active window mode. */ view: ResourceTimelineView; /** Step the window by `delta` (− back, + forward). */ navigate: (delta: number) => void; /** Jump to the window containing today. */ goToToday: () => void; /** Jump to the window containing `date`. */ goTo: (date: Date) => void; /** Whether back-navigation is within bounds. */ canGoBack: boolean; /** Whether forward-navigation is within bounds. */ canGoForward: boolean; /** Whether today is within `[minDate, maxDate]`; gate a custom Today control on it. */ canGoToToday: boolean; } ``` ```ts interface TimelineLegendContext { categories: TimelineCategory[]; } ``` ```ts interface TimelineRange { start: Date | string; end: Date | string; } ``` ```ts interface TimelineResource { /** Stable identity; what `getResourceId` has to return. */ id: string; /** Lane label, rendered in the sticky resource column. */ label: string; /** Secondary line under the label (room type, floor, capacity). */ description?: string; /** Id of the {@link TimelineGroup} this lane belongs to (a house, a department). */ groupId?: string; /** Fallback category for every span in this lane; a span-level id wins. */ categoryId?: string; /** Mark the whole lane unavailable — its cells report `aria-disabled` and do not activate. */ disabled?: boolean; /** Free-form payload the consumer's snippets can read back. */ meta?: Record; } ``` ```ts interface TimelineResourceContext { resource: TimelineResource; } ``` ```ts interface TimelineSpanContext { /** The consumer's item, with its real `T` type (no cast through an event shape). */ item: T; /** Stable key for this span (`getId`, else the resource id + start day). */ id: string; /** The lane the span sits on. */ resource: TimelineResource; /** The resolved category, if `categories` and a category id matched. */ category?: TimelineCategory; /** First day of the span (local midnight), **unclipped** — may precede the window. */ start: Date; /** Last day of the span (local midnight), inclusive and **unclipped**. */ end: Date; /** 0-based column of the span's first **visible** day. */ startCol: number; /** Number of visible columns — the span clipped to the window. */ spanCols: number; /** `false` when the bar is cut off at the left window edge. */ isStart: boolean; /** `false` when the bar is cut off at the right window edge. */ isEnd: boolean; /** 0-based stack row inside the lane. */ row: number; /** Total days of the unclipped span (≥ 1). */ totalDays: number; } ``` ```ts type SlotNames = keyof ReturnType & string ``` ```ts type VariantProps = Omit< Exclude[0], undefined>, 'class' > ``` ### Slots (slotClasses keys) `base`, `header`, `headerTitle`, `nav`, `navButton`, `track`, `dayHeaderRow`, `corner`, `dayHeader`, `dayHeaderWeekday`, `dayHeaderDate`, `body`, `groupRow`, `groupLabel`, `lane`, `laneHeader`, `laneLabel`, `laneDescription`, `dayCell`, `span`, `spanLabel`, `overflow`, `legend`, `legendItem`, `legendDot`, `legendLabel`, `empty`