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.umd.js CHANGED
@@ -78,12 +78,76 @@ var Kalendly = (() => {
78
78
  const max = maxYear ?? currentYear + 10;
79
79
  return Array.from({ length: max - min + 1 }, (_, i) => min + i);
80
80
  }
81
- function getEventsForDate(events, date) {
82
- const normalizedTargetDate = normalizeDate(date);
83
- return events.filter((event) => {
84
- const eventDate = normalizeDate(new Date(event.date));
85
- return eventDate.getTime() === normalizedTargetDate.getTime();
81
+ function parseHourRanges(value, slotDuration) {
82
+ const parts = value.split(",").map((part) => part.trim()).filter(Boolean);
83
+ if (parts.length === 0) {
84
+ throw new Error(
85
+ `<kal-calendar> available-hours is empty. Omit the attribute to allow every hour, or name at least one HH:MM-HH:MM range.`
86
+ );
87
+ }
88
+ const ranges = parts.map((part) => {
89
+ const halves = part.split("-");
90
+ if (halves.length !== 2) {
91
+ throw new Error(
92
+ `<kal-calendar> available-hours range "${part}" is not HH:MM-HH:MM.`
93
+ );
94
+ }
95
+ const [start, end] = halves.map((half) => parseTimeToMinutes(half.trim()));
96
+ if (start === null || end === null) {
97
+ throw new Error(
98
+ `<kal-calendar> available-hours range "${part}" has an unreadable time. Use 24-hour HH:MM.`
99
+ );
100
+ }
101
+ if (start >= end) {
102
+ throw new Error(
103
+ `<kal-calendar> available-hours range "${part}" starts at or after it ends.`
104
+ );
105
+ }
106
+ if (start % slotDuration !== 0 || end % slotDuration !== 0) {
107
+ throw new Error(
108
+ `<kal-calendar> available-hours range "${part}" does not land on a ${slotDuration}-minute slot boundary.`
109
+ );
110
+ }
111
+ return [start, end];
86
112
  });
113
+ if (mergeIntervals(ranges).length !== ranges.length) {
114
+ throw new Error(
115
+ `<kal-calendar> available-hours ranges overlap or touch: "${value}". Write each bookable window once.`
116
+ );
117
+ }
118
+ return ranges;
119
+ }
120
+ function isDateWithinWindow(date, min, max) {
121
+ const target = normalizeDate(date).getTime();
122
+ if (min && target < normalizeDate(min).getTime()) return false;
123
+ if (max && target > normalizeDate(max).getTime()) return false;
124
+ return true;
125
+ }
126
+ function isDayAllowed(date, days) {
127
+ return days === null || days.includes(date.getDay());
128
+ }
129
+ function eventCoversDate(event, date) {
130
+ const start = normalizeDate(new Date(event.date)).getTime();
131
+ const target = normalizeDate(date).getTime();
132
+ if (Number.isNaN(start)) return false;
133
+ if (event.endDate === void 0 || event.endDate === null) {
134
+ return start === target;
135
+ }
136
+ const end = normalizeDate(new Date(event.endDate)).getTime();
137
+ if (Number.isNaN(end)) {
138
+ throw new Error(
139
+ `<kal-calendar> event ${event.id} has an unreadable endDate: ${String(event.endDate)}.`
140
+ );
141
+ }
142
+ if (end < start) {
143
+ throw new Error(
144
+ `<kal-calendar> event ${event.id} has an endDate before its date: ${String(event.endDate)} < ${String(event.date)}.`
145
+ );
146
+ }
147
+ return target >= start && target <= end;
148
+ }
149
+ function getEventsForDate(events, date) {
150
+ return events.filter((event) => eventCoversDate(event, date));
87
151
  }
88
152
  function generateCalendarDates(year, month, events = [], weekStartsOn = 0) {
89
153
  const firstDay = new Date(year, month, 1);
@@ -222,6 +286,68 @@ var Kalendly = (() => {
222
286
  const trimmed = String(value).trim();
223
287
  return isValidHexColor(trimmed) || /^[a-z]+$/i.test(trimmed) ? trimmed : "#3b82f6";
224
288
  }
289
+ var MINUTES_PER_DAY = 1440;
290
+ var DEFAULT_SLOT_DURATION = 60;
291
+ function parseTimeToMinutes(time) {
292
+ if (typeof time !== "string") return null;
293
+ const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
294
+ if (!match) return null;
295
+ const hours = Number(match[1]);
296
+ const mins = Number(match[2]);
297
+ if (hours > 24 || mins > 59 || hours === 24 && mins > 0) return null;
298
+ return hours * 60 + mins;
299
+ }
300
+ function formatMinutes(total) {
301
+ const hours = Math.floor(total / 60);
302
+ const mins = total % 60;
303
+ return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
304
+ }
305
+ function eventInterval(event, slotDuration, onDay) {
306
+ const day = normalizeDate(onDay).getTime();
307
+ if (Number.isNaN(day)) return null;
308
+ const base = day / 6e4;
309
+ const wholeDay = [base, base + MINUTES_PER_DAY];
310
+ if (event.allDay || !event.startTime) return wholeDay;
311
+ const from = parseTimeToMinutes(event.startTime);
312
+ if (from === null) return wholeDay;
313
+ if (event.endTime === void 0 || event.endTime === null) {
314
+ return [base + from, base + from + slotDuration];
315
+ }
316
+ const to = parseTimeToMinutes(event.endTime);
317
+ if (to === null) return wholeDay;
318
+ return [base + from, base + (to <= from ? to + MINUTES_PER_DAY : to)];
319
+ }
320
+ function mergeIntervals(intervals) {
321
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
322
+ return sorted.reduce((merged, [start, end]) => {
323
+ const last = merged[merged.length - 1];
324
+ if (last && start <= last[1]) last[1] = Math.max(last[1], end);
325
+ else merged.push([start, end]);
326
+ return merged;
327
+ }, []);
328
+ }
329
+ function bookedSlots(events, date, slotDuration = DEFAULT_SLOT_DURATION) {
330
+ const base = normalizeDate(date).getTime() / 6e4;
331
+ const previousDay = new Date(date);
332
+ previousDay.setDate(previousDay.getDate() - 1);
333
+ const intervals = [];
334
+ for (const event of new Set(events)) {
335
+ for (const day of [previousDay, date]) {
336
+ if (!eventCoversDate(event, day)) continue;
337
+ const interval = eventInterval(event, slotDuration, day);
338
+ if (interval) intervals.push(interval);
339
+ }
340
+ }
341
+ const merged = mergeIntervals(intervals);
342
+ return Array.from(
343
+ { length: Math.floor(MINUTES_PER_DAY / slotDuration) },
344
+ (_, index) => {
345
+ const start = base + index * slotDuration;
346
+ const end = start + slotDuration;
347
+ return merged.some(([from, to]) => start < to && end > from);
348
+ }
349
+ );
350
+ }
225
351
 
226
352
  // src/core/calendar-engine.ts
227
353
  var CalendarEngine = class {
@@ -600,6 +726,29 @@ var Kalendly = (() => {
600
726
  }
601
727
  return title;
602
728
  }
729
+ get slotDuration() {
730
+ const raw = this.getAttribute("slot-duration");
731
+ if (raw === null) return DEFAULT_SLOT_DURATION;
732
+ const minutes = Number(raw);
733
+ if (Number.isInteger(minutes) && minutes > 0 && MINUTES_PER_DAY % minutes === 0) {
734
+ return minutes;
735
+ }
736
+ console.warn(
737
+ `<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}.`
738
+ );
739
+ return DEFAULT_SLOT_DURATION;
740
+ }
741
+ warnMissingEndTime(events) {
742
+ for (const event of events) {
743
+ if (!event.startTime || event.endTime) continue;
744
+ const key = String(event.id);
745
+ if (_CalendarElement.warnedMissingEnd.has(key)) continue;
746
+ _CalendarElement.warnedMissingEnd.add(key);
747
+ console.warn(
748
+ `<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.`
749
+ );
750
+ }
751
+ }
603
752
  get monthCount() {
604
753
  const requested = Number(this.getAttribute("months") ?? 1);
605
754
  if (!Number.isInteger(requested) || requested < 1) return 1;
@@ -613,6 +762,41 @@ var Kalendly = (() => {
613
762
  const val = this.getAttribute("max-year");
614
763
  return val ? parseInt(val, 10) : (/* @__PURE__ */ new Date()).getFullYear() + 10;
615
764
  }
765
+ boundaryDate(attr) {
766
+ const val = this.getAttribute(attr);
767
+ if (!val) return null;
768
+ const parsed = new Date(val);
769
+ if (Number.isNaN(parsed.getTime())) {
770
+ throw new Error(
771
+ `<kal-calendar> ${attr} is unreadable: "${val}". Use a value Date can parse, the same as initial-date.`
772
+ );
773
+ }
774
+ return parsed;
775
+ }
776
+ get availableDays() {
777
+ const val = this.getAttribute("available-days");
778
+ if (val === null) return null;
779
+ const parts = val.split(",").map((part) => part.trim()).filter(Boolean);
780
+ if (parts.length === 0) {
781
+ throw new Error(
782
+ `<kal-calendar> available-days is empty. Omit the attribute to allow every day, or name at least one weekday.`
783
+ );
784
+ }
785
+ return parts.map((part) => {
786
+ const day = Number(part);
787
+ if (!Number.isInteger(day) || day < 0 || day > 6) {
788
+ throw new Error(
789
+ `<kal-calendar> available-days must list integers 0-6, 0 = Sunday. Got "${part}".`
790
+ );
791
+ }
792
+ return day;
793
+ });
794
+ }
795
+ get availableHours() {
796
+ const val = this.getAttribute("available-hours");
797
+ if (val === null) return null;
798
+ return parseHourRanges(val, this.slotDuration);
799
+ }
616
800
  initEngine() {
617
801
  const initialDateAttr = this.getAttribute("initial-date");
618
802
  const initialDate = initialDateAttr ? new Date(initialDateAttr) : void 0;
@@ -731,8 +915,26 @@ var Kalendly = (() => {
731
915
  if (declared.size === 0) return "open";
732
916
  return this.knownBuckets.find((bucket) => declared.has(bucket));
733
917
  }
918
+ assertHorizonOrdered() {
919
+ const min = this.boundaryDate("min-date");
920
+ const max = this.boundaryDate("max-date");
921
+ if (min && max && min.getTime() > max.getTime()) {
922
+ throw new Error(
923
+ `<kal-calendar> min-date is after max-date: "${this.getAttribute("min-date")}" > "${this.getAttribute("max-date")}".`
924
+ );
925
+ }
926
+ }
927
+ // Whether the vendor offers this day at all, before any event is considered.
928
+ isDateOffered(date) {
929
+ return isDateWithinWindow(
930
+ date,
931
+ this.boundaryDate("min-date"),
932
+ this.boundaryDate("max-date")
933
+ ) && isDayAllowed(date, this.availableDays);
934
+ }
734
935
  isDateSelectable(date) {
735
936
  if (!this.engine) return false;
937
+ if (!this.isDateOffered(date)) return false;
736
938
  if (this._selectableStatuses) {
737
939
  return this._selectableStatuses.includes(this.resolveBucket(date));
738
940
  }
@@ -752,6 +954,11 @@ var Kalendly = (() => {
752
954
  this.assertStatusesDeclared();
753
955
  this.assertStatusesKnown();
754
956
  }
957
+ this.boundaryDate("min-date");
958
+ this.boundaryDate("max-date");
959
+ void this.availableDays;
960
+ void this.availableHours;
961
+ this.assertHorizonOrdered();
755
962
  const today = /* @__PURE__ */ new Date();
756
963
  const todayMonth = today.getMonth();
757
964
  const todayYear = today.getFullYear();
@@ -795,7 +1002,7 @@ var Kalendly = (() => {
795
1002
  return `
796
1003
  <div class="event-card" style="border-left-color: ${borderColor}">
797
1004
  <div class="event-header">
798
- <div class="event-title">${escapeHtml(event.name)}</div>
1005
+ ${event.name ? `<div class="event-title">${escapeHtml(event.name)}</div>` : ""}
799
1006
  <div class="event-badges">
800
1007
  ${event.category ? `<span class="badge category category-${slugifyToken(event.category)}">${escapeHtml(getCategoryLabel(event.category))}</span>` : ""}
801
1008
  ${event.priority ? `<span class="badge priority priority-${slugifyToken(event.priority)}">${escapeHtml(getPriorityLabel(event.priority))}</span>` : ""}
@@ -861,42 +1068,45 @@ var Kalendly = (() => {
861
1068
  const defaultRenderNoEvents = () => '<div class="no-events-message">No events scheduled for this day.</div>';
862
1069
  const renderEvent = this._renderEvent || defaultRenderEvent;
863
1070
  const renderNoEvents = this._renderNoEvents || defaultRenderNoEvents;
864
- const renderTimeGrid = (events, date) => {
865
- const startHour = (time) => {
866
- if (typeof time !== "string") return null;
867
- const hour = Number(time.split(":")[0]);
868
- return Number.isInteger(hour) && hour >= 0 && hour <= 24 ? hour : null;
869
- };
870
- const isHourBooked = (hour) => events.some((event) => {
871
- if (!event.startTime) return true;
872
- const from = startHour(event.startTime);
873
- const to = event.endTime ? startHour(event.endTime) : 24;
874
- if (from === null || to === null) return true;
875
- return hour >= from && hour < to;
876
- });
877
- const slots = Array.from({ length: 24 }, (_, hour) => {
878
- const startTime = `${String(hour).padStart(2, "0")}:00`;
879
- const endTime = `${String(hour + 1).padStart(2, "0")}:00`;
880
- const booked = isHourBooked(hour);
881
- const inTimeRange = !booked && selectable && this._timeRangeStart !== null && this._timeRangeEnd !== null && isSameDay2(this._timeRangeDate, date) && startTime >= this._timeRangeStart && endTime <= this._timeRangeEnd;
1071
+ const renderTimeGrid = (date) => {
1072
+ const slotDuration = this.slotDuration;
1073
+ const previousDay = new Date(date);
1074
+ previousDay.setDate(previousDay.getDate() - 1);
1075
+ const events = [
1076
+ ...this.engine.getEventsForDate(previousDay),
1077
+ ...this.engine.getEventsForDate(date)
1078
+ ];
1079
+ this.warnMissingEndTime(events);
1080
+ const booked = bookedSlots(events, date, slotDuration);
1081
+ const hours = this.availableHours;
1082
+ const slots = booked.map((isBooked, index) => {
1083
+ const slotStart = index * slotDuration;
1084
+ const slotEnd = slotStart + slotDuration;
1085
+ const startTime = formatMinutes(slotStart);
1086
+ const endTime = formatMinutes(slotEnd);
1087
+ const offered = hours === null || hours.some(([from, to]) => slotStart >= from && slotEnd <= to);
1088
+ const inTimeRange = offered && !isBooked && selectable && this._timeRangeStart !== null && this._timeRangeEnd !== null && isSameDay2(this._timeRangeDate, date) && startTime >= this._timeRangeStart && endTime <= this._timeRangeEnd;
882
1089
  const isRangeStart = inTimeRange && startTime === this._timeRangeStart;
883
1090
  const isRangeEnd = inTimeRange && endTime === this._timeRangeEnd;
884
1091
  const isInRange = inTimeRange && !isRangeStart && !isRangeEnd;
885
1092
  const slotClasses = [
886
1093
  "time-grid-slot",
887
- booked ? "time-grid-slot-blocked" : "time-grid-slot-open",
1094
+ offered ? "" : "time-grid-slot-out-of-range",
1095
+ isBooked ? "time-grid-slot-blocked" : "time-grid-slot-open",
888
1096
  isRangeStart ? "time-grid-slot-range-start" : "",
889
1097
  isRangeEnd ? "time-grid-slot-range-end" : "",
890
1098
  isInRange ? "time-grid-slot-in-range" : ""
891
1099
  ].filter(Boolean).join(" ");
892
- const slotAttrs = !booked && selectable ? `data-action="select-slot" data-start-time="${startTime}" data-end-time="${endTime}" data-date="${date.toISOString()}"` : "";
1100
+ const slotAttrs = `${offered ? 'data-action="select-slot" ' : ""}data-start-time="${startTime}" data-end-time="${endTime}" data-date="${date.toISOString()}" data-booked="${isBooked}"`;
1101
+ const status = !offered ? "Closed" : isBooked ? "Booked" : "Available";
893
1102
  return `
894
1103
  <div class="${slotClasses}" ${slotAttrs}>
895
1104
  <span class="time-grid-label">${startTime}</span>
896
- <span class="time-grid-status">${booked ? "Booked" : "Available"}</span>
1105
+ <span class="time-grid-status">${status}</span>
897
1106
  </div>`;
898
1107
  });
899
- return `<div class="time-grid">${slots.join("")}</div>`;
1108
+ const gridClasses = selectable ? "time-grid time-grid-selectable" : "time-grid";
1109
+ return `<div class="${gridClasses}">${slots.join("")}</div>`;
900
1110
  };
901
1111
  const multiMonth = viewModel.panes.length > 1;
902
1112
  const renderPane = (pane) => `
@@ -921,6 +1131,12 @@ var Kalendly = (() => {
921
1131
  ${week.map((calendarDate, dayIndex) => {
922
1132
  const classes = getCellClasses(calendarDate);
923
1133
  const cellAttrs = [];
1134
+ if (viewModel.selectedDate && isSameDay2(
1135
+ calendarDate.date,
1136
+ viewModel.selectedDate
1137
+ )) {
1138
+ classes.push("calendar-cell-selected");
1139
+ }
924
1140
  if (availabilityMode && calendarDate.isCurrentMonth) {
925
1141
  const bucket = this.resolveBucket(
926
1142
  calendarDate.date
@@ -954,12 +1170,18 @@ var Kalendly = (() => {
954
1170
  }
955
1171
  }
956
1172
  const dateString = calendarDate.date.toISOString();
1173
+ const offered = this.isDateOffered(
1174
+ calendarDate.date
1175
+ );
1176
+ if (!offered) {
1177
+ classes.push("calendar-cell-out-of-range");
1178
+ }
957
1179
  return `
958
1180
  <td
959
1181
  class="${classes.join(" ")}"
960
1182
  data-date="${dateString}"
961
1183
  data-day-index="${dayIndex}"
962
- data-clickable="true"
1184
+ ${offered ? 'data-clickable="true"' : ""}
963
1185
  ${cellAttrs.join(" ")}
964
1186
  >
965
1187
  ${calendarDate.date.getDate()}
@@ -1073,7 +1295,7 @@ var Kalendly = (() => {
1073
1295
  ` : ""}
1074
1296
 
1075
1297
  <div class="events-container">
1076
- ${availabilityMode === "time" ? renderTimeGrid(viewModel.tasks, viewModel.selectedDate) : viewModel.tasks.length > 0 ? viewModel.tasks.map((event) => renderEvent(event)).join("") : renderNoEvents()}
1298
+ ${availabilityMode === "time" ? renderTimeGrid(viewModel.selectedDate) : viewModel.tasks.length > 0 ? viewModel.tasks.map((event) => renderEvent(event)).join("") : renderNoEvents()}
1077
1299
  </div>
1078
1300
  </div>
1079
1301
  ` : ""}
@@ -1234,6 +1456,20 @@ var Kalendly = (() => {
1234
1456
  const slotStart = actionEl.dataset.startTime;
1235
1457
  const slotEnd = actionEl.dataset.endTime;
1236
1458
  const slotDate = new Date(actionEl.dataset.date);
1459
+ const slotBooked = actionEl.dataset.booked === "true";
1460
+ this.dispatchEvent(
1461
+ new CustomEvent("cal-slot-select", {
1462
+ bubbles: true,
1463
+ composed: true,
1464
+ detail: {
1465
+ date: slotDate,
1466
+ startTime: slotStart,
1467
+ endTime: slotEnd,
1468
+ booked: slotBooked
1469
+ }
1470
+ })
1471
+ );
1472
+ if (slotBooked || !this.hasAttribute("selectable")) break;
1237
1473
  if (this._timeRangeComplete) {
1238
1474
  this._timeRangeDate = slotDate;
1239
1475
  this._timeRangeStart = slotStart;
@@ -1371,9 +1607,14 @@ var Kalendly = (() => {
1371
1607
  "initial-date",
1372
1608
  "min-year",
1373
1609
  "max-year",
1610
+ "min-date",
1611
+ "max-date",
1612
+ "available-days",
1613
+ "available-hours",
1374
1614
  "week-starts-on",
1375
1615
  "heading",
1376
1616
  "months",
1617
+ "slot-duration",
1377
1618
  "title",
1378
1619
  "use-short-month-names",
1379
1620
  "availability-mode",
@@ -1391,6 +1632,8 @@ var Kalendly = (() => {
1391
1632
  borderColor: "--calendar-border-color",
1392
1633
  todayOutline: "--calendar-today-outline",
1393
1634
  selectedBg: "--calendar-selected-bg",
1635
+ outOfRangeBg: "--calendar-out-of-range-bg",
1636
+ outOfRangeFg: "--calendar-out-of-range-fg",
1394
1637
  headerBg: "--calendar-header-bg",
1395
1638
  popupBg: "--calendar-popup-bg",
1396
1639
  pickerBg: "--calendar-picker-bg",
@@ -1425,6 +1668,7 @@ var Kalendly = (() => {
1425
1668
  badgeTentativeBg: "--calendar-badge-tentative-bg",
1426
1669
  badgeTentativeText: "--calendar-badge-tentative-text"
1427
1670
  };
1671
+ _CalendarElement.warnedMissingEnd = /* @__PURE__ */ new Set();
1428
1672
  _CalendarElement.BUILT_IN_BUCKETS = ["blocked", "conditional", "open"];
1429
1673
  _CalendarElement.titleDeprecationWarned = false;
1430
1674
  var CalendarElement = _CalendarElement;