kalendly 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -333,19 +333,24 @@ document.querySelector('kal-calendar').theme = {
333
333
 
334
334
  Primitives are set as HTML attributes:
335
335
 
336
- | Attribute | Type | Default | Description |
337
- | ----------------------- | ---------------- | ---------------- | ------------------------------------------------ |
338
- | `heading` | `string` | — | Calendar heading |
339
- | `title` | `string` | — | **Deprecated** — use `heading` |
340
- | `initial-date` | `string` | today | ISO date string for initial view |
341
- | `months` | `"1"\|"2"` | `"1"` | Render two months side by side |
342
- | `min-year` | `string` | currentYear - 30 | Minimum year in picker |
343
- | `max-year` | `string` | currentYear + 10 | Maximum year in picker |
344
- | `week-starts-on` | `"0"\|"1"` | `"0"` | Week start: 0 = Sunday, 1 = Monday |
345
- | `use-short-month-names` | `string` | — | Present = use abbreviated month names |
346
- | `availability-mode` | `"day"\|"time"` | — | Hides event details; shows booked/free cells |
347
- | `selectable` | `"range"` | — | Enables day/slot selection (requires avail mode) |
348
- | `loading` | `boolean` (flag) | | Present = render skeleton shimmer cells |
336
+ | Attribute | Type | Default | Description |
337
+ | ----------------------- | ----------------- | ---------------- | -------------------------------------------------- |
338
+ | `heading` | `string` | — | Calendar heading |
339
+ | `title` | `string` | — | **Deprecated** — use `heading` |
340
+ | `initial-date` | `string` | today | ISO date string for initial view |
341
+ | `months` | `"1"\|"2"` | `"1"` | Render two months side by side |
342
+ | `min-year` | `string` | currentYear - 30 | Minimum year in picker |
343
+ | `max-year` | `string` | currentYear + 10 | Maximum year in picker |
344
+ | `min-date` | `string` | | Earliest bookable day, inclusive |
345
+ | `max-date` | `string` | — | Latest bookable day, inclusive |
346
+ | `available-days` | `string` | — | Bookable weekdays, e.g. `"1,2,3,4,5"` |
347
+ | `available-hours` | `string` | — | Bookable hours, e.g. `"09:00-17:00"` |
348
+ | `week-starts-on` | `"0"\|"1"` | `"0"` | Week start: 0 = Sunday, 1 = Monday |
349
+ | `use-short-month-names` | `string` | — | Present = use abbreviated month names |
350
+ | `availability-mode` | `"day"\|"time"` | — | Hides event details; shows booked/free cells |
351
+ | `slot-duration` | `string` (number) | `"60"` | Time-grid granularity in minutes; must divide 1440 |
352
+ | `selectable` | `"range"` | — | Enables day/slot selection (requires avail mode) |
353
+ | `loading` | `boolean` (flag) | — | Present = render skeleton shimmer cells |
349
354
 
350
355
  ## Properties
351
356
 
@@ -370,11 +375,12 @@ Rich objects are set as JS properties (not attributes):
370
375
 
371
376
  ## Custom Events
372
377
 
373
- | Event | `detail` shape | Description |
374
- | ------------------------- | ---------------------------------------------------------------------- | -------------------------------------- |
375
- | `cal-date-select` | `{ date: Date, events: CalendarEvent[] }` | User clicked a date (normal mode) |
376
- | `cal-month-change` | `{ year: number, month: number }` | Fires **before** the new month renders |
377
- | `cal-availability-select` | `{ startDate: Date, endDate: Date }` or `{ date, startTime, endTime }` | Day/slot selected in availability mode |
378
+ | Event | `detail` shape | Description |
379
+ | ------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------- |
380
+ | `cal-date-select` | `{ date: Date, events: CalendarEvent[] }` | User clicked a date (normal mode) |
381
+ | `cal-month-change` | `{ year: number, month: number }` | Fires **before** the new month renders |
382
+ | `cal-availability-select` | `{ startDate: Date, endDate: Date }` or `{ date, startTime, endTime }` | Day/slot selected in availability mode |
383
+ | `cal-slot-select` | `{ date: Date, startTime: string, endTime: string, booked: boolean }` | Any time slot clicked, whether or not `selectable` is set |
378
384
 
379
385
  All events bubble and are composed (cross Shadow DOM boundaries).
380
386
 
@@ -479,13 +485,115 @@ cal.events = [
479
485
  <kal-calendar availability-mode="time"></kal-calendar>
480
486
  ```
481
487
 
482
- Clicking a day opens a popup with a 24-slot hourly grid (00:00 – 23:00). Each slot shows only "Booked" or "Available" — no event name or organiser is ever rendered. A slot is booked if any event's time window overlaps that hour; the rest are free.
488
+ Clicking a day opens a popup with a grid of slots. Each shows only "Booked" or "Available" — no event name or organiser is ever rendered. A slot is booked when any booking overlaps it.
489
+
490
+ #### Slot length
491
+
492
+ `slot-duration` sets the grid granularity in minutes. It must divide 1440 evenly; anything else falls back to 60 with a console warning.
493
+
494
+ ```html
495
+ <kal-calendar availability-mode="time" slot-duration="30"></kal-calendar>
496
+ ```
497
+
498
+ The grid renders `1440 / slot-duration` slots, and `cal-availability-select` emits times on that granularity — half-hour slots produce half-hour selections.
499
+
500
+ Precision below `slot-duration` is not representable: a booking from 17:30 on a 60-minute grid marks 17:00–18:00 booked, because that hour cannot be sold. A vendor working in half-hours sets `slot-duration="30"` rather than expecting the grid to subdivide itself.
501
+
502
+ ### Booking constraints
503
+
504
+ Four optional attributes describe when a vendor is open. Omit them all and nothing is constrained.
505
+
506
+ ```html
507
+ <kal-calendar
508
+ availability-mode="time"
509
+ min-date="2026-09-01"
510
+ max-date="2026-12-31"
511
+ available-days="1,2,3,4,5"
512
+ available-hours="09:00-12:00,13:00-17:00"
513
+ ></kal-calendar>
514
+ ```
515
+
516
+ They express two different kinds of rule, which is why there are four and not two:
517
+
518
+ | | Kind | Says |
519
+ | ----------------------- | ---------------- | --------------------------------------------- |
520
+ | `min-date` / `max-date` | one-off horizon | "we take bookings from September to December" |
521
+ | `available-days` | recurring weekly | "we work weekdays" |
522
+ | `available-hours` | recurring daily | "we work 9 to 5, closed for lunch" |
523
+
524
+ None substitutes for another. A horizon cannot say "weekdays only", a weekday list cannot say "not past December", and neither says anything about the working day.
525
+
526
+ `min-date` and `max-date` are **inclusive** and parse like `initial-date`. `available-days` uses `Date.prototype.getDay()` numbering — `0` is Sunday — which is independent of `week-starts-on`, a display setting.
527
+
528
+ `available-hours` takes a comma-separated list because a working day is not always contiguous: `"09:00-12:00,13:00-17:00"` closes for lunch, and `"09:00-17:00,17:30-22:00"` runs meetings then an evening class. Each range is half-open, `[start, end)` — `"09:00-17:00"` on an hourly grid makes 16:00–17:00 the last bookable slot, the same convention events use.
529
+
530
+ Excluded **days** are not click targets at all: no hover response, and neither `cal-date-select` nor `cal-availability-select` fires. Excluded **hours** render greyed and marked `Closed`, and emit no `cal-slot-select`. They are shown rather than hidden so a booking that falls outside the window is still visible to the vendor.
531
+
532
+ Style either with `--calendar-out-of-range-bg` and `--calendar-out-of-range-fg`, or the `outOfRangeBg` / `outOfRangeFg` theme keys.
533
+
534
+ Bad input throws and names the attribute — an unreadable date, `min-date` after `max-date`, a weekday outside `0`–`6`, a malformed or inverted range, ranges that overlap, or a boundary that misses the `slot-duration` grid (`"09:30-17:00"` with hourly slots has no slot to land on).
535
+
536
+ #### Bookings that cross midnight
537
+
538
+ An `endTime` at or before its `startTime` is treated as the next day, so a booking runs as one interval rather than two half-days:
539
+
540
+ ```js
541
+ { id: 1, date: '2026-03-15', startTime: '22:00', endTime: '06:00' }
542
+ ```
543
+
544
+ That marks 22:00–24:00 on 15 March and 00:00–06:00 on the 16th. Each day's grid shows the portion of any booking falling on that day.
545
+
546
+ #### A booking with no end time
547
+
548
+ An event with a `startTime` and no `endTime` occupies **one slot**, and the library warns once naming the event. The supported fix is an end time in your data — the duration is a fallback, not a feature.
549
+
550
+ Overlapping bookings merge rather than double-count, so a 09:00–17:00 meeting and a 17:30–22:00 class on the same day mark 09:00–22:00 booked between them.
483
551
 
484
552
  <div align="center">
485
553
  <img src="./docs/images/time.png" alt="Availability time view — day popup showing 24 hourly slots coloured red (booked) or green (available)"/>
486
554
  <p><em>Time view: clicking a day opens an hourly grid — booked slots in red, available slots in green; no event details exposed</em></p>
487
555
  </div>
488
556
 
557
+ #### Reacting to a slot click
558
+
559
+ `cal-slot-select` fires on every slot click, the way `cal-date-select` fires for
560
+ every day — including booked slots, and whether or not `selectable` is set. Use it
561
+ to drive your own booking flow without turning on range selection:
562
+
563
+ ```js
564
+ cal.addEventListener('cal-slot-select', e => {
565
+ const { date, startTime, endTime, booked } = e.detail;
566
+ if (booked) return showTakenMessage(startTime);
567
+ openBookingForm(date, startTime, endTime);
568
+ });
569
+ ```
570
+
571
+ `selectable="range"` still governs `cal-availability-select`, the three-click
572
+ range machine and the range highlighting. It also controls the cursor: slots only
573
+ show a pointer when the grid can actually be booked from.
574
+
575
+ #### Round-tripping a selection
576
+
577
+ `cal-availability-select` emits an inclusive `endDate`, so a saved selection goes
578
+ straight back as a single event — no expanding into one event per day:
579
+
580
+ ```js
581
+ cal.addEventListener('cal-availability-select', async e => {
582
+ const { startDate, endDate } = e.detail;
583
+ const booking = await save({ startDate, endDate });
584
+
585
+ cal.events = [
586
+ ...cal.events,
587
+ {
588
+ id: booking.id,
589
+ date: startDate,
590
+ endDate,
591
+ availabilityStatus: 'blocked',
592
+ },
593
+ ];
594
+ });
595
+ ```
596
+
489
597
  ### Selectable range
490
598
 
491
599
  Add `selectable="range"` to let the user pick a free day or time slot:
@@ -567,6 +675,27 @@ cal.getEngine();
567
675
  > `mailto:` and relative URLs; anything else becomes `#`. `color` accepts hex
568
676
  > values and CSS colour keywords.
569
677
 
678
+ > **`name` is optional.** It is rendered only as the event card's title, and
679
+ > availability mode never renders the card — so callers there need not supply one.
680
+ > An event without a name renders a card with no title rather than an empty one.
681
+ > Note this is a type-level break for anyone _reading_ `name` off
682
+ > `cal-date-select`: the field can now be absent, so `strictNullChecks` requires a
683
+ > guard. Nothing changes at runtime — events are handed back by reference, so
684
+ > whatever you supply comes back intact.
685
+
686
+ > **Multi-day events.** `endDate` is the last day of a span and is **inclusive** —
687
+ > `date: '2026-03-03', endDate: '2026-03-05'` covers three days. That matches the
688
+ > `endDate` `cal-availability-select` emits, so a selection can be handed straight
689
+ > back as one event. It deliberately differs from RFC 5545, whose all-day `DTEND`
690
+ > is exclusive; adjust if you map to iCalendar. An `endDate` before `date`, or one
691
+ > that cannot be read, throws and names the event.
692
+ >
693
+ > Times on a span repeat daily: `09:00`–`17:00` across three days means that
694
+ > window on each of the three, not one continuous block. A hall can hold a
695
+ > 09:00–17:00 meeting and a 17:30–22:00 class on overlapping days.
696
+ >
697
+ > `recurring` is declared on the type but not implemented — nothing reads it.
698
+
570
699
  > **Custom values.** `status`, `category` and `priority` accept any string. An
571
700
  > unrecognised value renders as an uppercased badge with a neutral fill, which
572
701
  > you can style via `.badge.status-<your-value>` or recolour through
@@ -575,8 +704,10 @@ cal.getEngine();
575
704
  ```typescript
576
705
  interface CalendarEvent {
577
706
  id: string | number;
578
- name: string;
579
707
  date: string | Date;
708
+ name?: string; // event card title; omit it and no title renders
709
+
710
+ endDate?: string | Date; // last day of a span, inclusive
580
711
 
581
712
  startTime?: string; // e.g. "09:00"
582
713
  endTime?: string; // e.g. "10:00"
@@ -637,6 +768,8 @@ for. Override the `--calendar-*` layer — the primitives are internal.
637
768
  --calendar-cell-hover: #f3f4f6;
638
769
  --calendar-header-bg: #f8f9fa;
639
770
  --calendar-selected-bg: #eff6ff;
771
+ --calendar-out-of-range-bg: #f3f4f6;
772
+ --calendar-out-of-range-fg: #6b7280;
640
773
  --calendar-popup-bg: #fff;
641
774
  --calendar-picker-bg: #fff;
642
775
  --calendar-today-outline: #f7db04;
@@ -699,6 +832,8 @@ cal.theme = {
699
832
  borderColor: '#e5e7eb',
700
833
  todayOutline: '#fbbf24',
701
834
  selectedBg: '#eff6ff',
835
+ outOfRangeBg: '#f3f4f6',
836
+ outOfRangeFg: '#6b7280',
702
837
  headerBg: '#f8f9fa',
703
838
  popupBg: '#ffffff',
704
839
  pickerBg: '#ffffff',
@@ -1,8 +1,9 @@
1
1
  type Open<T extends string> = T | (string & {});
2
2
  interface CalendarEvent {
3
3
  id: string | number;
4
- name: string;
5
4
  date: string | Date;
5
+ name?: string;
6
+ endDate?: string | Date;
6
7
  startTime?: string;
7
8
  endTime?: string;
8
9
  allDay?: boolean;
@@ -103,6 +104,8 @@ interface CalendarTheme {
103
104
  borderColor?: string;
104
105
  todayOutline?: string;
105
106
  selectedBg?: string;
107
+ outOfRangeBg?: string;
108
+ outOfRangeFg?: string;
106
109
  headerBg?: string;
107
110
  popupBg?: string;
108
111
  pickerBg?: string;
@@ -145,6 +148,10 @@ declare function normalizeDate(date: Date): Date;
145
148
  declare function isSameDay(date1: Date, date2: Date): boolean;
146
149
  declare function isToday(date: Date): boolean;
147
150
  declare function generateYears(minYear?: number, maxYear?: number): number[];
151
+ declare function parseHourRanges(value: string, slotDuration: number): Array<[number, number]>;
152
+ declare function isDateWithinWindow(date: Date, min: Date | null, max: Date | null): boolean;
153
+ declare function isDayAllowed(date: Date, days: number[] | null): boolean;
154
+ declare function eventCoversDate(event: CalendarEvent, date: Date): boolean;
148
155
  declare function getEventsForDate(events: CalendarEvent[], date: Date): CalendarEvent[];
149
156
  declare function hasEvents(events: CalendarEvent[], date: Date): boolean;
150
157
  declare function generateCalendarDates(year: number, month: number, events?: CalendarEvent[], weekStartsOn?: 0 | 1): CalendarDate[][];
@@ -163,6 +170,13 @@ declare function escapeHtml(value: unknown): string;
163
170
  declare function slugifyToken(value: string): string;
164
171
  declare function safeUrl(value: string): string;
165
172
  declare function safeColor(value: string): string;
173
+ declare const MINUTES_PER_DAY = 1440;
174
+ declare const DEFAULT_SLOT_DURATION = 60;
175
+ declare function parseTimeToMinutes(time: unknown): number | null;
176
+ declare function formatMinutes(total: number): string;
177
+ declare function eventInterval(event: CalendarEvent, slotDuration: number, onDay: Date): [number, number] | null;
178
+ declare function mergeIntervals(intervals: Array<[number, number]>): Array<[number, number]>;
179
+ declare function bookedSlots(events: CalendarEvent[], date: Date, slotDuration?: number): boolean[];
166
180
 
167
181
  declare class CalendarEngine {
168
182
  private state;
@@ -260,4 +274,4 @@ declare class CalendarEngine {
260
274
  destroy(): void;
261
275
  }
262
276
 
263
- export { type CalendarActions, type CalendarConfig, type CalendarDate, CalendarEngine, type CalendarEvent, type CalendarEventHandler, type CalendarPane, type CalendarProps, type CalendarState, type CalendarTheme, type CalendarViewModel, type CategoryColorMap, DAYS, DEFAULT_CATEGORY_COLORS, MONTHS, MONTHS_FULL, escapeHtml, formatAttendees, formatDateForDisplay, formatTimeRange, generateCalendarDates, generateYears, getCategoryColor, getCellClasses, getDefaultEventColor, getEventsForDate, getMonthYearText, hasEvents, isSameDay, isToday, isValidHexColor, mergeCategoryColors, normalizeDate, safeColor, safeUrl, slugifyToken, sortEventsByTime };
277
+ export { type CalendarActions, type CalendarConfig, type CalendarDate, CalendarEngine, type CalendarEvent, type CalendarEventHandler, type CalendarPane, type CalendarProps, type CalendarState, type CalendarTheme, type CalendarViewModel, type CategoryColorMap, DAYS, DEFAULT_CATEGORY_COLORS, DEFAULT_SLOT_DURATION, MINUTES_PER_DAY, MONTHS, MONTHS_FULL, bookedSlots, escapeHtml, eventCoversDate, eventInterval, formatAttendees, formatDateForDisplay, formatMinutes, formatTimeRange, generateCalendarDates, generateYears, getCategoryColor, getCellClasses, getDefaultEventColor, getEventsForDate, getMonthYearText, hasEvents, isDateWithinWindow, isDayAllowed, isSameDay, isToday, isValidHexColor, mergeCategoryColors, mergeIntervals, normalizeDate, parseHourRanges, parseTimeToMinutes, safeColor, safeUrl, slugifyToken, sortEventsByTime };
@@ -1,8 +1,9 @@
1
1
  type Open<T extends string> = T | (string & {});
2
2
  interface CalendarEvent {
3
3
  id: string | number;
4
- name: string;
5
4
  date: string | Date;
5
+ name?: string;
6
+ endDate?: string | Date;
6
7
  startTime?: string;
7
8
  endTime?: string;
8
9
  allDay?: boolean;
@@ -103,6 +104,8 @@ interface CalendarTheme {
103
104
  borderColor?: string;
104
105
  todayOutline?: string;
105
106
  selectedBg?: string;
107
+ outOfRangeBg?: string;
108
+ outOfRangeFg?: string;
106
109
  headerBg?: string;
107
110
  popupBg?: string;
108
111
  pickerBg?: string;
@@ -145,6 +148,10 @@ declare function normalizeDate(date: Date): Date;
145
148
  declare function isSameDay(date1: Date, date2: Date): boolean;
146
149
  declare function isToday(date: Date): boolean;
147
150
  declare function generateYears(minYear?: number, maxYear?: number): number[];
151
+ declare function parseHourRanges(value: string, slotDuration: number): Array<[number, number]>;
152
+ declare function isDateWithinWindow(date: Date, min: Date | null, max: Date | null): boolean;
153
+ declare function isDayAllowed(date: Date, days: number[] | null): boolean;
154
+ declare function eventCoversDate(event: CalendarEvent, date: Date): boolean;
148
155
  declare function getEventsForDate(events: CalendarEvent[], date: Date): CalendarEvent[];
149
156
  declare function hasEvents(events: CalendarEvent[], date: Date): boolean;
150
157
  declare function generateCalendarDates(year: number, month: number, events?: CalendarEvent[], weekStartsOn?: 0 | 1): CalendarDate[][];
@@ -163,6 +170,13 @@ declare function escapeHtml(value: unknown): string;
163
170
  declare function slugifyToken(value: string): string;
164
171
  declare function safeUrl(value: string): string;
165
172
  declare function safeColor(value: string): string;
173
+ declare const MINUTES_PER_DAY = 1440;
174
+ declare const DEFAULT_SLOT_DURATION = 60;
175
+ declare function parseTimeToMinutes(time: unknown): number | null;
176
+ declare function formatMinutes(total: number): string;
177
+ declare function eventInterval(event: CalendarEvent, slotDuration: number, onDay: Date): [number, number] | null;
178
+ declare function mergeIntervals(intervals: Array<[number, number]>): Array<[number, number]>;
179
+ declare function bookedSlots(events: CalendarEvent[], date: Date, slotDuration?: number): boolean[];
166
180
 
167
181
  declare class CalendarEngine {
168
182
  private state;
@@ -260,4 +274,4 @@ declare class CalendarEngine {
260
274
  destroy(): void;
261
275
  }
262
276
 
263
- export { type CalendarActions, type CalendarConfig, type CalendarDate, CalendarEngine, type CalendarEvent, type CalendarEventHandler, type CalendarPane, type CalendarProps, type CalendarState, type CalendarTheme, type CalendarViewModel, type CategoryColorMap, DAYS, DEFAULT_CATEGORY_COLORS, MONTHS, MONTHS_FULL, escapeHtml, formatAttendees, formatDateForDisplay, formatTimeRange, generateCalendarDates, generateYears, getCategoryColor, getCellClasses, getDefaultEventColor, getEventsForDate, getMonthYearText, hasEvents, isSameDay, isToday, isValidHexColor, mergeCategoryColors, normalizeDate, safeColor, safeUrl, slugifyToken, sortEventsByTime };
277
+ export { type CalendarActions, type CalendarConfig, type CalendarDate, CalendarEngine, type CalendarEvent, type CalendarEventHandler, type CalendarPane, type CalendarProps, type CalendarState, type CalendarTheme, type CalendarViewModel, type CategoryColorMap, DAYS, DEFAULT_CATEGORY_COLORS, DEFAULT_SLOT_DURATION, MINUTES_PER_DAY, MONTHS, MONTHS_FULL, bookedSlots, escapeHtml, eventCoversDate, eventInterval, formatAttendees, formatDateForDisplay, formatMinutes, formatTimeRange, generateCalendarDates, generateYears, getCategoryColor, getCellClasses, getDefaultEventColor, getEventsForDate, getMonthYearText, hasEvents, isDateWithinWindow, isDayAllowed, isSameDay, isToday, isValidHexColor, mergeCategoryColors, mergeIntervals, normalizeDate, parseHourRanges, parseTimeToMinutes, safeColor, safeUrl, slugifyToken, sortEventsByTime };
@@ -23,11 +23,17 @@ __export(core_exports, {
23
23
  CalendarEngine: () => CalendarEngine,
24
24
  DAYS: () => DAYS,
25
25
  DEFAULT_CATEGORY_COLORS: () => DEFAULT_CATEGORY_COLORS,
26
+ DEFAULT_SLOT_DURATION: () => DEFAULT_SLOT_DURATION,
27
+ MINUTES_PER_DAY: () => MINUTES_PER_DAY,
26
28
  MONTHS: () => MONTHS,
27
29
  MONTHS_FULL: () => MONTHS_FULL,
30
+ bookedSlots: () => bookedSlots,
28
31
  escapeHtml: () => escapeHtml,
32
+ eventCoversDate: () => eventCoversDate,
33
+ eventInterval: () => eventInterval,
29
34
  formatAttendees: () => formatAttendees,
30
35
  formatDateForDisplay: () => formatDateForDisplay,
36
+ formatMinutes: () => formatMinutes,
31
37
  formatTimeRange: () => formatTimeRange,
32
38
  generateCalendarDates: () => generateCalendarDates,
33
39
  generateYears: () => generateYears,
@@ -37,11 +43,16 @@ __export(core_exports, {
37
43
  getEventsForDate: () => getEventsForDate,
38
44
  getMonthYearText: () => getMonthYearText,
39
45
  hasEvents: () => hasEvents,
46
+ isDateWithinWindow: () => isDateWithinWindow,
47
+ isDayAllowed: () => isDayAllowed,
40
48
  isSameDay: () => isSameDay,
41
49
  isToday: () => isToday,
42
50
  isValidHexColor: () => isValidHexColor,
43
51
  mergeCategoryColors: () => mergeCategoryColors,
52
+ mergeIntervals: () => mergeIntervals,
44
53
  normalizeDate: () => normalizeDate,
54
+ parseHourRanges: () => parseHourRanges,
55
+ parseTimeToMinutes: () => parseTimeToMinutes,
45
56
  safeColor: () => safeColor,
46
57
  safeUrl: () => safeUrl,
47
58
  slugifyToken: () => slugifyToken,
@@ -102,12 +113,76 @@ function generateYears(minYear, maxYear) {
102
113
  const max = maxYear ?? currentYear + 10;
103
114
  return Array.from({ length: max - min + 1 }, (_, i) => min + i);
104
115
  }
105
- function getEventsForDate(events, date) {
106
- const normalizedTargetDate = normalizeDate(date);
107
- return events.filter((event) => {
108
- const eventDate = normalizeDate(new Date(event.date));
109
- return eventDate.getTime() === normalizedTargetDate.getTime();
116
+ function parseHourRanges(value, slotDuration) {
117
+ const parts = value.split(",").map((part) => part.trim()).filter(Boolean);
118
+ if (parts.length === 0) {
119
+ throw new Error(
120
+ `<kal-calendar> available-hours is empty. Omit the attribute to allow every hour, or name at least one HH:MM-HH:MM range.`
121
+ );
122
+ }
123
+ const ranges = parts.map((part) => {
124
+ const halves = part.split("-");
125
+ if (halves.length !== 2) {
126
+ throw new Error(
127
+ `<kal-calendar> available-hours range "${part}" is not HH:MM-HH:MM.`
128
+ );
129
+ }
130
+ const [start, end] = halves.map((half) => parseTimeToMinutes(half.trim()));
131
+ if (start === null || end === null) {
132
+ throw new Error(
133
+ `<kal-calendar> available-hours range "${part}" has an unreadable time. Use 24-hour HH:MM.`
134
+ );
135
+ }
136
+ if (start >= end) {
137
+ throw new Error(
138
+ `<kal-calendar> available-hours range "${part}" starts at or after it ends.`
139
+ );
140
+ }
141
+ if (start % slotDuration !== 0 || end % slotDuration !== 0) {
142
+ throw new Error(
143
+ `<kal-calendar> available-hours range "${part}" does not land on a ${slotDuration}-minute slot boundary.`
144
+ );
145
+ }
146
+ return [start, end];
110
147
  });
148
+ if (mergeIntervals(ranges).length !== ranges.length) {
149
+ throw new Error(
150
+ `<kal-calendar> available-hours ranges overlap or touch: "${value}". Write each bookable window once.`
151
+ );
152
+ }
153
+ return ranges;
154
+ }
155
+ function isDateWithinWindow(date, min, max) {
156
+ const target = normalizeDate(date).getTime();
157
+ if (min && target < normalizeDate(min).getTime()) return false;
158
+ if (max && target > normalizeDate(max).getTime()) return false;
159
+ return true;
160
+ }
161
+ function isDayAllowed(date, days) {
162
+ return days === null || days.includes(date.getDay());
163
+ }
164
+ function eventCoversDate(event, date) {
165
+ const start = normalizeDate(new Date(event.date)).getTime();
166
+ const target = normalizeDate(date).getTime();
167
+ if (Number.isNaN(start)) return false;
168
+ if (event.endDate === void 0 || event.endDate === null) {
169
+ return start === target;
170
+ }
171
+ const end = normalizeDate(new Date(event.endDate)).getTime();
172
+ if (Number.isNaN(end)) {
173
+ throw new Error(
174
+ `<kal-calendar> event ${event.id} has an unreadable endDate: ${String(event.endDate)}.`
175
+ );
176
+ }
177
+ if (end < start) {
178
+ throw new Error(
179
+ `<kal-calendar> event ${event.id} has an endDate before its date: ${String(event.endDate)} < ${String(event.date)}.`
180
+ );
181
+ }
182
+ return target >= start && target <= end;
183
+ }
184
+ function getEventsForDate(events, date) {
185
+ return events.filter((event) => eventCoversDate(event, date));
111
186
  }
112
187
  function hasEvents(events, date) {
113
188
  return getEventsForDate(events, date).length > 0;
@@ -266,6 +341,68 @@ function safeColor(value) {
266
341
  const trimmed = String(value).trim();
267
342
  return isValidHexColor(trimmed) || /^[a-z]+$/i.test(trimmed) ? trimmed : "#3b82f6";
268
343
  }
344
+ var MINUTES_PER_DAY = 1440;
345
+ var DEFAULT_SLOT_DURATION = 60;
346
+ function parseTimeToMinutes(time) {
347
+ if (typeof time !== "string") return null;
348
+ const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
349
+ if (!match) return null;
350
+ const hours = Number(match[1]);
351
+ const mins = Number(match[2]);
352
+ if (hours > 24 || mins > 59 || hours === 24 && mins > 0) return null;
353
+ return hours * 60 + mins;
354
+ }
355
+ function formatMinutes(total) {
356
+ const hours = Math.floor(total / 60);
357
+ const mins = total % 60;
358
+ return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
359
+ }
360
+ function eventInterval(event, slotDuration, onDay) {
361
+ const day = normalizeDate(onDay).getTime();
362
+ if (Number.isNaN(day)) return null;
363
+ const base = day / 6e4;
364
+ const wholeDay = [base, base + MINUTES_PER_DAY];
365
+ if (event.allDay || !event.startTime) return wholeDay;
366
+ const from = parseTimeToMinutes(event.startTime);
367
+ if (from === null) return wholeDay;
368
+ if (event.endTime === void 0 || event.endTime === null) {
369
+ return [base + from, base + from + slotDuration];
370
+ }
371
+ const to = parseTimeToMinutes(event.endTime);
372
+ if (to === null) return wholeDay;
373
+ return [base + from, base + (to <= from ? to + MINUTES_PER_DAY : to)];
374
+ }
375
+ function mergeIntervals(intervals) {
376
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
377
+ return sorted.reduce((merged, [start, end]) => {
378
+ const last = merged[merged.length - 1];
379
+ if (last && start <= last[1]) last[1] = Math.max(last[1], end);
380
+ else merged.push([start, end]);
381
+ return merged;
382
+ }, []);
383
+ }
384
+ function bookedSlots(events, date, slotDuration = DEFAULT_SLOT_DURATION) {
385
+ const base = normalizeDate(date).getTime() / 6e4;
386
+ const previousDay = new Date(date);
387
+ previousDay.setDate(previousDay.getDate() - 1);
388
+ const intervals = [];
389
+ for (const event of new Set(events)) {
390
+ for (const day of [previousDay, date]) {
391
+ if (!eventCoversDate(event, day)) continue;
392
+ const interval = eventInterval(event, slotDuration, day);
393
+ if (interval) intervals.push(interval);
394
+ }
395
+ }
396
+ const merged = mergeIntervals(intervals);
397
+ return Array.from(
398
+ { length: Math.floor(MINUTES_PER_DAY / slotDuration) },
399
+ (_, index) => {
400
+ const start = base + index * slotDuration;
401
+ const end = start + slotDuration;
402
+ return merged.some(([from, to]) => start < to && end > from);
403
+ }
404
+ );
405
+ }
269
406
 
270
407
  // src/core/calendar-engine.ts
271
408
  var CalendarEngine = class {
@@ -513,11 +650,17 @@ var CalendarEngine = class {
513
650
  CalendarEngine,
514
651
  DAYS,
515
652
  DEFAULT_CATEGORY_COLORS,
653
+ DEFAULT_SLOT_DURATION,
654
+ MINUTES_PER_DAY,
516
655
  MONTHS,
517
656
  MONTHS_FULL,
657
+ bookedSlots,
518
658
  escapeHtml,
659
+ eventCoversDate,
660
+ eventInterval,
519
661
  formatAttendees,
520
662
  formatDateForDisplay,
663
+ formatMinutes,
521
664
  formatTimeRange,
522
665
  generateCalendarDates,
523
666
  generateYears,
@@ -527,11 +670,16 @@ var CalendarEngine = class {
527
670
  getEventsForDate,
528
671
  getMonthYearText,
529
672
  hasEvents,
673
+ isDateWithinWindow,
674
+ isDayAllowed,
530
675
  isSameDay,
531
676
  isToday,
532
677
  isValidHexColor,
533
678
  mergeCategoryColors,
679
+ mergeIntervals,
534
680
  normalizeDate,
681
+ parseHourRanges,
682
+ parseTimeToMinutes,
535
683
  safeColor,
536
684
  safeUrl,
537
685
  slugifyToken,