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/dist/index.mjs CHANGED
@@ -31,12 +31,76 @@ function generateYears(minYear, maxYear) {
31
31
  const max = maxYear ?? currentYear + 10;
32
32
  return Array.from({ length: max - min + 1 }, (_, i) => min + i);
33
33
  }
34
- function getEventsForDate(events, date) {
35
- const normalizedTargetDate = normalizeDate(date);
36
- return events.filter((event) => {
37
- const eventDate = normalizeDate(new Date(event.date));
38
- return eventDate.getTime() === normalizedTargetDate.getTime();
34
+ function parseHourRanges(value, slotDuration) {
35
+ const parts = value.split(",").map((part) => part.trim()).filter(Boolean);
36
+ if (parts.length === 0) {
37
+ throw new Error(
38
+ `<kal-calendar> available-hours is empty. Omit the attribute to allow every hour, or name at least one HH:MM-HH:MM range.`
39
+ );
40
+ }
41
+ const ranges = parts.map((part) => {
42
+ const halves = part.split("-");
43
+ if (halves.length !== 2) {
44
+ throw new Error(
45
+ `<kal-calendar> available-hours range "${part}" is not HH:MM-HH:MM.`
46
+ );
47
+ }
48
+ const [start, end] = halves.map((half) => parseTimeToMinutes(half.trim()));
49
+ if (start === null || end === null) {
50
+ throw new Error(
51
+ `<kal-calendar> available-hours range "${part}" has an unreadable time. Use 24-hour HH:MM.`
52
+ );
53
+ }
54
+ if (start >= end) {
55
+ throw new Error(
56
+ `<kal-calendar> available-hours range "${part}" starts at or after it ends.`
57
+ );
58
+ }
59
+ if (start % slotDuration !== 0 || end % slotDuration !== 0) {
60
+ throw new Error(
61
+ `<kal-calendar> available-hours range "${part}" does not land on a ${slotDuration}-minute slot boundary.`
62
+ );
63
+ }
64
+ return [start, end];
39
65
  });
66
+ if (mergeIntervals(ranges).length !== ranges.length) {
67
+ throw new Error(
68
+ `<kal-calendar> available-hours ranges overlap or touch: "${value}". Write each bookable window once.`
69
+ );
70
+ }
71
+ return ranges;
72
+ }
73
+ function isDateWithinWindow(date, min, max) {
74
+ const target = normalizeDate(date).getTime();
75
+ if (min && target < normalizeDate(min).getTime()) return false;
76
+ if (max && target > normalizeDate(max).getTime()) return false;
77
+ return true;
78
+ }
79
+ function isDayAllowed(date, days) {
80
+ return days === null || days.includes(date.getDay());
81
+ }
82
+ function eventCoversDate(event, date) {
83
+ const start = normalizeDate(new Date(event.date)).getTime();
84
+ const target = normalizeDate(date).getTime();
85
+ if (Number.isNaN(start)) return false;
86
+ if (event.endDate === void 0 || event.endDate === null) {
87
+ return start === target;
88
+ }
89
+ const end = normalizeDate(new Date(event.endDate)).getTime();
90
+ if (Number.isNaN(end)) {
91
+ throw new Error(
92
+ `<kal-calendar> event ${event.id} has an unreadable endDate: ${String(event.endDate)}.`
93
+ );
94
+ }
95
+ if (end < start) {
96
+ throw new Error(
97
+ `<kal-calendar> event ${event.id} has an endDate before its date: ${String(event.endDate)} < ${String(event.date)}.`
98
+ );
99
+ }
100
+ return target >= start && target <= end;
101
+ }
102
+ function getEventsForDate(events, date) {
103
+ return events.filter((event) => eventCoversDate(event, date));
40
104
  }
41
105
  function hasEvents(events, date) {
42
106
  return getEventsForDate(events, date).length > 0;
@@ -180,7 +244,67 @@ function safeColor(value) {
180
244
  const trimmed = String(value).trim();
181
245
  return isValidHexColor(trimmed) || /^[a-z]+$/i.test(trimmed) ? trimmed : "#3b82f6";
182
246
  }
183
- var MONTHS, MONTHS_FULL, DAYS, DEFAULT_CATEGORY_COLORS, HTML_ESCAPES;
247
+ function parseTimeToMinutes(time) {
248
+ if (typeof time !== "string") return null;
249
+ const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
250
+ if (!match) return null;
251
+ const hours = Number(match[1]);
252
+ const mins = Number(match[2]);
253
+ if (hours > 24 || mins > 59 || hours === 24 && mins > 0) return null;
254
+ return hours * 60 + mins;
255
+ }
256
+ function formatMinutes(total) {
257
+ const hours = Math.floor(total / 60);
258
+ const mins = total % 60;
259
+ return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
260
+ }
261
+ function eventInterval(event, slotDuration, onDay) {
262
+ const day = normalizeDate(onDay).getTime();
263
+ if (Number.isNaN(day)) return null;
264
+ const base = day / 6e4;
265
+ const wholeDay = [base, base + MINUTES_PER_DAY];
266
+ if (event.allDay || !event.startTime) return wholeDay;
267
+ const from = parseTimeToMinutes(event.startTime);
268
+ if (from === null) return wholeDay;
269
+ if (event.endTime === void 0 || event.endTime === null) {
270
+ return [base + from, base + from + slotDuration];
271
+ }
272
+ const to = parseTimeToMinutes(event.endTime);
273
+ if (to === null) return wholeDay;
274
+ return [base + from, base + (to <= from ? to + MINUTES_PER_DAY : to)];
275
+ }
276
+ function mergeIntervals(intervals) {
277
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
278
+ return sorted.reduce((merged, [start, end]) => {
279
+ const last = merged[merged.length - 1];
280
+ if (last && start <= last[1]) last[1] = Math.max(last[1], end);
281
+ else merged.push([start, end]);
282
+ return merged;
283
+ }, []);
284
+ }
285
+ function bookedSlots(events, date, slotDuration = DEFAULT_SLOT_DURATION) {
286
+ const base = normalizeDate(date).getTime() / 6e4;
287
+ const previousDay = new Date(date);
288
+ previousDay.setDate(previousDay.getDate() - 1);
289
+ const intervals = [];
290
+ for (const event of new Set(events)) {
291
+ for (const day of [previousDay, date]) {
292
+ if (!eventCoversDate(event, day)) continue;
293
+ const interval = eventInterval(event, slotDuration, day);
294
+ if (interval) intervals.push(interval);
295
+ }
296
+ }
297
+ const merged = mergeIntervals(intervals);
298
+ return Array.from(
299
+ { length: Math.floor(MINUTES_PER_DAY / slotDuration) },
300
+ (_, index) => {
301
+ const start = base + index * slotDuration;
302
+ const end = start + slotDuration;
303
+ return merged.some(([from, to]) => start < to && end > from);
304
+ }
305
+ );
306
+ }
307
+ var MONTHS, MONTHS_FULL, DAYS, DEFAULT_CATEGORY_COLORS, HTML_ESCAPES, MINUTES_PER_DAY, DEFAULT_SLOT_DURATION;
184
308
  var init_utils = __esm({
185
309
  "src/core/utils.ts"() {
186
310
  "use strict";
@@ -236,6 +360,8 @@ var init_utils = __esm({
236
360
  '"': "&quot;",
237
361
  "'": "&#39;"
238
362
  };
363
+ MINUTES_PER_DAY = 1440;
364
+ DEFAULT_SLOT_DURATION = 60;
239
365
  }
240
366
  });
241
367
 
@@ -649,6 +775,29 @@ var init_CalendarElement = __esm({
649
775
  }
650
776
  return title;
651
777
  }
778
+ get slotDuration() {
779
+ const raw = this.getAttribute("slot-duration");
780
+ if (raw === null) return DEFAULT_SLOT_DURATION;
781
+ const minutes = Number(raw);
782
+ if (Number.isInteger(minutes) && minutes > 0 && MINUTES_PER_DAY % minutes === 0) {
783
+ return minutes;
784
+ }
785
+ console.warn(
786
+ `<kal-calendar> slot-duration="${raw}" must be a positive whole number of minutes that divides ${MINUTES_PER_DAY} \u2014 falling back to ${DEFAULT_SLOT_DURATION}.`
787
+ );
788
+ return DEFAULT_SLOT_DURATION;
789
+ }
790
+ warnMissingEndTime(events) {
791
+ for (const event of events) {
792
+ if (!event.startTime || event.endTime) continue;
793
+ const key = String(event.id);
794
+ if (_CalendarElement.warnedMissingEnd.has(key)) continue;
795
+ _CalendarElement.warnedMissingEnd.add(key);
796
+ console.warn(
797
+ `<kal-calendar> event ${key} has startTime but no endTime \u2014 occupying one ${this.slotDuration}-minute slot. Send an endTime to say how long it runs.`
798
+ );
799
+ }
800
+ }
652
801
  get monthCount() {
653
802
  const requested = Number(this.getAttribute("months") ?? 1);
654
803
  if (!Number.isInteger(requested) || requested < 1) return 1;
@@ -662,6 +811,41 @@ var init_CalendarElement = __esm({
662
811
  const val = this.getAttribute("max-year");
663
812
  return val ? parseInt(val, 10) : (/* @__PURE__ */ new Date()).getFullYear() + 10;
664
813
  }
814
+ boundaryDate(attr) {
815
+ const val = this.getAttribute(attr);
816
+ if (!val) return null;
817
+ const parsed = new Date(val);
818
+ if (Number.isNaN(parsed.getTime())) {
819
+ throw new Error(
820
+ `<kal-calendar> ${attr} is unreadable: "${val}". Use a value Date can parse, the same as initial-date.`
821
+ );
822
+ }
823
+ return parsed;
824
+ }
825
+ get availableDays() {
826
+ const val = this.getAttribute("available-days");
827
+ if (val === null) return null;
828
+ const parts = val.split(",").map((part) => part.trim()).filter(Boolean);
829
+ if (parts.length === 0) {
830
+ throw new Error(
831
+ `<kal-calendar> available-days is empty. Omit the attribute to allow every day, or name at least one weekday.`
832
+ );
833
+ }
834
+ return parts.map((part) => {
835
+ const day = Number(part);
836
+ if (!Number.isInteger(day) || day < 0 || day > 6) {
837
+ throw new Error(
838
+ `<kal-calendar> available-days must list integers 0-6, 0 = Sunday. Got "${part}".`
839
+ );
840
+ }
841
+ return day;
842
+ });
843
+ }
844
+ get availableHours() {
845
+ const val = this.getAttribute("available-hours");
846
+ if (val === null) return null;
847
+ return parseHourRanges(val, this.slotDuration);
848
+ }
665
849
  initEngine() {
666
850
  const initialDateAttr = this.getAttribute("initial-date");
667
851
  const initialDate = initialDateAttr ? new Date(initialDateAttr) : void 0;
@@ -780,8 +964,26 @@ var init_CalendarElement = __esm({
780
964
  if (declared.size === 0) return "open";
781
965
  return this.knownBuckets.find((bucket) => declared.has(bucket));
782
966
  }
967
+ assertHorizonOrdered() {
968
+ const min = this.boundaryDate("min-date");
969
+ const max = this.boundaryDate("max-date");
970
+ if (min && max && min.getTime() > max.getTime()) {
971
+ throw new Error(
972
+ `<kal-calendar> min-date is after max-date: "${this.getAttribute("min-date")}" > "${this.getAttribute("max-date")}".`
973
+ );
974
+ }
975
+ }
976
+ // Whether the vendor offers this day at all, before any event is considered.
977
+ isDateOffered(date) {
978
+ return isDateWithinWindow(
979
+ date,
980
+ this.boundaryDate("min-date"),
981
+ this.boundaryDate("max-date")
982
+ ) && isDayAllowed(date, this.availableDays);
983
+ }
783
984
  isDateSelectable(date) {
784
985
  if (!this.engine) return false;
986
+ if (!this.isDateOffered(date)) return false;
785
987
  if (this._selectableStatuses) {
786
988
  return this._selectableStatuses.includes(this.resolveBucket(date));
787
989
  }
@@ -801,6 +1003,11 @@ var init_CalendarElement = __esm({
801
1003
  this.assertStatusesDeclared();
802
1004
  this.assertStatusesKnown();
803
1005
  }
1006
+ this.boundaryDate("min-date");
1007
+ this.boundaryDate("max-date");
1008
+ void this.availableDays;
1009
+ void this.availableHours;
1010
+ this.assertHorizonOrdered();
804
1011
  const today = /* @__PURE__ */ new Date();
805
1012
  const todayMonth = today.getMonth();
806
1013
  const todayYear = today.getFullYear();
@@ -844,7 +1051,7 @@ var init_CalendarElement = __esm({
844
1051
  return `
845
1052
  <div class="event-card" style="border-left-color: ${borderColor}">
846
1053
  <div class="event-header">
847
- <div class="event-title">${escapeHtml(event.name)}</div>
1054
+ ${event.name ? `<div class="event-title">${escapeHtml(event.name)}</div>` : ""}
848
1055
  <div class="event-badges">
849
1056
  ${event.category ? `<span class="badge category category-${slugifyToken(event.category)}">${escapeHtml(getCategoryLabel(event.category))}</span>` : ""}
850
1057
  ${event.priority ? `<span class="badge priority priority-${slugifyToken(event.priority)}">${escapeHtml(getPriorityLabel(event.priority))}</span>` : ""}
@@ -910,42 +1117,45 @@ var init_CalendarElement = __esm({
910
1117
  const defaultRenderNoEvents = () => '<div class="no-events-message">No events scheduled for this day.</div>';
911
1118
  const renderEvent = this._renderEvent || defaultRenderEvent;
912
1119
  const renderNoEvents = this._renderNoEvents || defaultRenderNoEvents;
913
- const renderTimeGrid = (events, date) => {
914
- const startHour = (time) => {
915
- if (typeof time !== "string") return null;
916
- const hour = Number(time.split(":")[0]);
917
- return Number.isInteger(hour) && hour >= 0 && hour <= 24 ? hour : null;
918
- };
919
- const isHourBooked = (hour) => events.some((event) => {
920
- if (!event.startTime) return true;
921
- const from = startHour(event.startTime);
922
- const to = event.endTime ? startHour(event.endTime) : 24;
923
- if (from === null || to === null) return true;
924
- return hour >= from && hour < to;
925
- });
926
- const slots = Array.from({ length: 24 }, (_, hour) => {
927
- const startTime = `${String(hour).padStart(2, "0")}:00`;
928
- const endTime = `${String(hour + 1).padStart(2, "0")}:00`;
929
- const booked = isHourBooked(hour);
930
- const inTimeRange = !booked && selectable && this._timeRangeStart !== null && this._timeRangeEnd !== null && isSameDay2(this._timeRangeDate, date) && startTime >= this._timeRangeStart && endTime <= this._timeRangeEnd;
1120
+ const renderTimeGrid = (date) => {
1121
+ const slotDuration = this.slotDuration;
1122
+ const previousDay = new Date(date);
1123
+ previousDay.setDate(previousDay.getDate() - 1);
1124
+ const events = [
1125
+ ...this.engine.getEventsForDate(previousDay),
1126
+ ...this.engine.getEventsForDate(date)
1127
+ ];
1128
+ this.warnMissingEndTime(events);
1129
+ const booked = bookedSlots(events, date, slotDuration);
1130
+ const hours = this.availableHours;
1131
+ const slots = booked.map((isBooked, index) => {
1132
+ const slotStart = index * slotDuration;
1133
+ const slotEnd = slotStart + slotDuration;
1134
+ const startTime = formatMinutes(slotStart);
1135
+ const endTime = formatMinutes(slotEnd);
1136
+ const offered = hours === null || hours.some(([from, to]) => slotStart >= from && slotEnd <= to);
1137
+ const inTimeRange = offered && !isBooked && selectable && this._timeRangeStart !== null && this._timeRangeEnd !== null && isSameDay2(this._timeRangeDate, date) && startTime >= this._timeRangeStart && endTime <= this._timeRangeEnd;
931
1138
  const isRangeStart = inTimeRange && startTime === this._timeRangeStart;
932
1139
  const isRangeEnd = inTimeRange && endTime === this._timeRangeEnd;
933
1140
  const isInRange = inTimeRange && !isRangeStart && !isRangeEnd;
934
1141
  const slotClasses = [
935
1142
  "time-grid-slot",
936
- booked ? "time-grid-slot-blocked" : "time-grid-slot-open",
1143
+ offered ? "" : "time-grid-slot-out-of-range",
1144
+ isBooked ? "time-grid-slot-blocked" : "time-grid-slot-open",
937
1145
  isRangeStart ? "time-grid-slot-range-start" : "",
938
1146
  isRangeEnd ? "time-grid-slot-range-end" : "",
939
1147
  isInRange ? "time-grid-slot-in-range" : ""
940
1148
  ].filter(Boolean).join(" ");
941
- const slotAttrs = !booked && selectable ? `data-action="select-slot" data-start-time="${startTime}" data-end-time="${endTime}" data-date="${date.toISOString()}"` : "";
1149
+ const slotAttrs = `${offered ? 'data-action="select-slot" ' : ""}data-start-time="${startTime}" data-end-time="${endTime}" data-date="${date.toISOString()}" data-booked="${isBooked}"`;
1150
+ const status = !offered ? "Closed" : isBooked ? "Booked" : "Available";
942
1151
  return `
943
1152
  <div class="${slotClasses}" ${slotAttrs}>
944
1153
  <span class="time-grid-label">${startTime}</span>
945
- <span class="time-grid-status">${booked ? "Booked" : "Available"}</span>
1154
+ <span class="time-grid-status">${status}</span>
946
1155
  </div>`;
947
1156
  });
948
- return `<div class="time-grid">${slots.join("")}</div>`;
1157
+ const gridClasses = selectable ? "time-grid time-grid-selectable" : "time-grid";
1158
+ return `<div class="${gridClasses}">${slots.join("")}</div>`;
949
1159
  };
950
1160
  const multiMonth = viewModel.panes.length > 1;
951
1161
  const renderPane = (pane) => `
@@ -970,6 +1180,12 @@ var init_CalendarElement = __esm({
970
1180
  ${week.map((calendarDate, dayIndex) => {
971
1181
  const classes = getCellClasses(calendarDate);
972
1182
  const cellAttrs = [];
1183
+ if (viewModel.selectedDate && isSameDay2(
1184
+ calendarDate.date,
1185
+ viewModel.selectedDate
1186
+ )) {
1187
+ classes.push("calendar-cell-selected");
1188
+ }
973
1189
  if (availabilityMode && calendarDate.isCurrentMonth) {
974
1190
  const bucket = this.resolveBucket(
975
1191
  calendarDate.date
@@ -1003,12 +1219,18 @@ var init_CalendarElement = __esm({
1003
1219
  }
1004
1220
  }
1005
1221
  const dateString = calendarDate.date.toISOString();
1222
+ const offered = this.isDateOffered(
1223
+ calendarDate.date
1224
+ );
1225
+ if (!offered) {
1226
+ classes.push("calendar-cell-out-of-range");
1227
+ }
1006
1228
  return `
1007
1229
  <td
1008
1230
  class="${classes.join(" ")}"
1009
1231
  data-date="${dateString}"
1010
1232
  data-day-index="${dayIndex}"
1011
- data-clickable="true"
1233
+ ${offered ? 'data-clickable="true"' : ""}
1012
1234
  ${cellAttrs.join(" ")}
1013
1235
  >
1014
1236
  ${calendarDate.date.getDate()}
@@ -1122,7 +1344,7 @@ var init_CalendarElement = __esm({
1122
1344
  ` : ""}
1123
1345
 
1124
1346
  <div class="events-container">
1125
- ${availabilityMode === "time" ? renderTimeGrid(viewModel.tasks, viewModel.selectedDate) : viewModel.tasks.length > 0 ? viewModel.tasks.map((event) => renderEvent(event)).join("") : renderNoEvents()}
1347
+ ${availabilityMode === "time" ? renderTimeGrid(viewModel.selectedDate) : viewModel.tasks.length > 0 ? viewModel.tasks.map((event) => renderEvent(event)).join("") : renderNoEvents()}
1126
1348
  </div>
1127
1349
  </div>
1128
1350
  ` : ""}
@@ -1283,6 +1505,20 @@ var init_CalendarElement = __esm({
1283
1505
  const slotStart = actionEl.dataset.startTime;
1284
1506
  const slotEnd = actionEl.dataset.endTime;
1285
1507
  const slotDate = new Date(actionEl.dataset.date);
1508
+ const slotBooked = actionEl.dataset.booked === "true";
1509
+ this.dispatchEvent(
1510
+ new CustomEvent("cal-slot-select", {
1511
+ bubbles: true,
1512
+ composed: true,
1513
+ detail: {
1514
+ date: slotDate,
1515
+ startTime: slotStart,
1516
+ endTime: slotEnd,
1517
+ booked: slotBooked
1518
+ }
1519
+ })
1520
+ );
1521
+ if (slotBooked || !this.hasAttribute("selectable")) break;
1286
1522
  if (this._timeRangeComplete) {
1287
1523
  this._timeRangeDate = slotDate;
1288
1524
  this._timeRangeStart = slotStart;
@@ -1420,9 +1656,14 @@ var init_CalendarElement = __esm({
1420
1656
  "initial-date",
1421
1657
  "min-year",
1422
1658
  "max-year",
1659
+ "min-date",
1660
+ "max-date",
1661
+ "available-days",
1662
+ "available-hours",
1423
1663
  "week-starts-on",
1424
1664
  "heading",
1425
1665
  "months",
1666
+ "slot-duration",
1426
1667
  "title",
1427
1668
  "use-short-month-names",
1428
1669
  "availability-mode",
@@ -1440,6 +1681,8 @@ var init_CalendarElement = __esm({
1440
1681
  borderColor: "--calendar-border-color",
1441
1682
  todayOutline: "--calendar-today-outline",
1442
1683
  selectedBg: "--calendar-selected-bg",
1684
+ outOfRangeBg: "--calendar-out-of-range-bg",
1685
+ outOfRangeFg: "--calendar-out-of-range-fg",
1443
1686
  headerBg: "--calendar-header-bg",
1444
1687
  popupBg: "--calendar-popup-bg",
1445
1688
  pickerBg: "--calendar-picker-bg",
@@ -1474,6 +1717,7 @@ var init_CalendarElement = __esm({
1474
1717
  badgeTentativeBg: "--calendar-badge-tentative-bg",
1475
1718
  badgeTentativeText: "--calendar-badge-tentative-text"
1476
1719
  };
1720
+ _CalendarElement.warnedMissingEnd = /* @__PURE__ */ new Set();
1477
1721
  _CalendarElement.BUILT_IN_BUCKETS = ["blocked", "conditional", "open"];
1478
1722
  _CalendarElement.titleDeprecationWarned = false;
1479
1723
  CalendarElement = _CalendarElement;
@@ -1495,12 +1739,18 @@ export {
1495
1739
  CalendarEngine,
1496
1740
  DAYS,
1497
1741
  DEFAULT_CATEGORY_COLORS,
1742
+ DEFAULT_SLOT_DURATION,
1743
+ MINUTES_PER_DAY,
1498
1744
  MONTHS,
1499
1745
  MONTHS_FULL,
1746
+ bookedSlots,
1500
1747
  defineCalendarElement,
1501
1748
  escapeHtml,
1749
+ eventCoversDate,
1750
+ eventInterval,
1502
1751
  formatAttendees,
1503
1752
  formatDateForDisplay,
1753
+ formatMinutes,
1504
1754
  formatTimeRange,
1505
1755
  generateCalendarDates,
1506
1756
  generateYears,
@@ -1510,11 +1760,16 @@ export {
1510
1760
  getEventsForDate,
1511
1761
  getMonthYearText,
1512
1762
  hasEvents,
1763
+ isDateWithinWindow,
1764
+ isDayAllowed,
1513
1765
  isSameDay,
1514
1766
  isToday,
1515
1767
  isValidHexColor,
1516
1768
  mergeCategoryColors,
1769
+ mergeIntervals,
1517
1770
  normalizeDate,
1771
+ parseHourRanges,
1772
+ parseTimeToMinutes,
1518
1773
  safeColor,
1519
1774
  safeUrl,
1520
1775
  slugifyToken,