kalendly 0.3.0 → 0.3.1

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,28 @@ 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 eventCoversDate(event, date) {
82
+ const start = normalizeDate(new Date(event.date)).getTime();
83
+ const target = normalizeDate(date).getTime();
84
+ if (Number.isNaN(start)) return false;
85
+ if (event.endDate === void 0 || event.endDate === null) {
86
+ return start === target;
87
+ }
88
+ const end = normalizeDate(new Date(event.endDate)).getTime();
89
+ if (Number.isNaN(end)) {
90
+ throw new Error(
91
+ `<kal-calendar> event ${event.id} has an unreadable endDate: ${String(event.endDate)}.`
92
+ );
93
+ }
94
+ if (end < start) {
95
+ throw new Error(
96
+ `<kal-calendar> event ${event.id} has an endDate before its date: ${String(event.endDate)} < ${String(event.date)}.`
97
+ );
98
+ }
99
+ return target >= start && target <= end;
100
+ }
81
101
  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();
86
- });
102
+ return events.filter((event) => eventCoversDate(event, date));
87
103
  }
88
104
  function generateCalendarDates(year, month, events = [], weekStartsOn = 0) {
89
105
  const firstDay = new Date(year, month, 1);
@@ -222,6 +238,68 @@ var Kalendly = (() => {
222
238
  const trimmed = String(value).trim();
223
239
  return isValidHexColor(trimmed) || /^[a-z]+$/i.test(trimmed) ? trimmed : "#3b82f6";
224
240
  }
241
+ var MINUTES_PER_DAY = 1440;
242
+ var DEFAULT_SLOT_DURATION = 60;
243
+ function parseTimeToMinutes(time) {
244
+ if (typeof time !== "string") return null;
245
+ const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
246
+ if (!match) return null;
247
+ const hours = Number(match[1]);
248
+ const mins = Number(match[2]);
249
+ if (hours > 24 || mins > 59 || hours === 24 && mins > 0) return null;
250
+ return hours * 60 + mins;
251
+ }
252
+ function formatMinutes(total) {
253
+ const hours = Math.floor(total / 60);
254
+ const mins = total % 60;
255
+ return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
256
+ }
257
+ function eventInterval(event, slotDuration, onDay) {
258
+ const day = normalizeDate(onDay).getTime();
259
+ if (Number.isNaN(day)) return null;
260
+ const base = day / 6e4;
261
+ const wholeDay = [base, base + MINUTES_PER_DAY];
262
+ if (event.allDay || !event.startTime) return wholeDay;
263
+ const from = parseTimeToMinutes(event.startTime);
264
+ if (from === null) return wholeDay;
265
+ if (event.endTime === void 0 || event.endTime === null) {
266
+ return [base + from, base + from + slotDuration];
267
+ }
268
+ const to = parseTimeToMinutes(event.endTime);
269
+ if (to === null) return wholeDay;
270
+ return [base + from, base + (to <= from ? to + MINUTES_PER_DAY : to)];
271
+ }
272
+ function mergeIntervals(intervals) {
273
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
274
+ return sorted.reduce((merged, [start, end]) => {
275
+ const last = merged[merged.length - 1];
276
+ if (last && start <= last[1]) last[1] = Math.max(last[1], end);
277
+ else merged.push([start, end]);
278
+ return merged;
279
+ }, []);
280
+ }
281
+ function bookedSlots(events, date, slotDuration = DEFAULT_SLOT_DURATION) {
282
+ const base = normalizeDate(date).getTime() / 6e4;
283
+ const previousDay = new Date(date);
284
+ previousDay.setDate(previousDay.getDate() - 1);
285
+ const intervals = [];
286
+ for (const event of new Set(events)) {
287
+ for (const day of [previousDay, date]) {
288
+ if (!eventCoversDate(event, day)) continue;
289
+ const interval = eventInterval(event, slotDuration, day);
290
+ if (interval) intervals.push(interval);
291
+ }
292
+ }
293
+ const merged = mergeIntervals(intervals);
294
+ return Array.from(
295
+ { length: Math.floor(MINUTES_PER_DAY / slotDuration) },
296
+ (_, index) => {
297
+ const start = base + index * slotDuration;
298
+ const end = start + slotDuration;
299
+ return merged.some(([from, to]) => start < to && end > from);
300
+ }
301
+ );
302
+ }
225
303
 
226
304
  // src/core/calendar-engine.ts
227
305
  var CalendarEngine = class {
@@ -600,6 +678,29 @@ var Kalendly = (() => {
600
678
  }
601
679
  return title;
602
680
  }
681
+ get slotDuration() {
682
+ const raw = this.getAttribute("slot-duration");
683
+ if (raw === null) return DEFAULT_SLOT_DURATION;
684
+ const minutes = Number(raw);
685
+ if (Number.isInteger(minutes) && minutes > 0 && MINUTES_PER_DAY % minutes === 0) {
686
+ return minutes;
687
+ }
688
+ console.warn(
689
+ `<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}.`
690
+ );
691
+ return DEFAULT_SLOT_DURATION;
692
+ }
693
+ warnMissingEndTime(events) {
694
+ for (const event of events) {
695
+ if (!event.startTime || event.endTime) continue;
696
+ const key = String(event.id);
697
+ if (_CalendarElement.warnedMissingEnd.has(key)) continue;
698
+ _CalendarElement.warnedMissingEnd.add(key);
699
+ console.warn(
700
+ `<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.`
701
+ );
702
+ }
703
+ }
603
704
  get monthCount() {
604
705
  const requested = Number(this.getAttribute("months") ?? 1);
605
706
  if (!Number.isInteger(requested) || requested < 1) return 1;
@@ -861,42 +962,39 @@ var Kalendly = (() => {
861
962
  const defaultRenderNoEvents = () => '<div class="no-events-message">No events scheduled for this day.</div>';
862
963
  const renderEvent = this._renderEvent || defaultRenderEvent;
863
964
  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;
965
+ const renderTimeGrid = (date) => {
966
+ const slotDuration = this.slotDuration;
967
+ const previousDay = new Date(date);
968
+ previousDay.setDate(previousDay.getDate() - 1);
969
+ const events = [
970
+ ...this.engine.getEventsForDate(previousDay),
971
+ ...this.engine.getEventsForDate(date)
972
+ ];
973
+ this.warnMissingEndTime(events);
974
+ const booked = bookedSlots(events, date, slotDuration);
975
+ const slots = booked.map((isBooked, index) => {
976
+ const startTime = formatMinutes(index * slotDuration);
977
+ const endTime = formatMinutes((index + 1) * slotDuration);
978
+ const inTimeRange = !isBooked && selectable && this._timeRangeStart !== null && this._timeRangeEnd !== null && isSameDay2(this._timeRangeDate, date) && startTime >= this._timeRangeStart && endTime <= this._timeRangeEnd;
882
979
  const isRangeStart = inTimeRange && startTime === this._timeRangeStart;
883
980
  const isRangeEnd = inTimeRange && endTime === this._timeRangeEnd;
884
981
  const isInRange = inTimeRange && !isRangeStart && !isRangeEnd;
885
982
  const slotClasses = [
886
983
  "time-grid-slot",
887
- booked ? "time-grid-slot-blocked" : "time-grid-slot-open",
984
+ isBooked ? "time-grid-slot-blocked" : "time-grid-slot-open",
888
985
  isRangeStart ? "time-grid-slot-range-start" : "",
889
986
  isRangeEnd ? "time-grid-slot-range-end" : "",
890
987
  isInRange ? "time-grid-slot-in-range" : ""
891
988
  ].filter(Boolean).join(" ");
892
- const slotAttrs = !booked && selectable ? `data-action="select-slot" data-start-time="${startTime}" data-end-time="${endTime}" data-date="${date.toISOString()}"` : "";
989
+ const slotAttrs = `data-action="select-slot" data-start-time="${startTime}" data-end-time="${endTime}" data-date="${date.toISOString()}" data-booked="${isBooked}"`;
893
990
  return `
894
991
  <div class="${slotClasses}" ${slotAttrs}>
895
992
  <span class="time-grid-label">${startTime}</span>
896
- <span class="time-grid-status">${booked ? "Booked" : "Available"}</span>
993
+ <span class="time-grid-status">${isBooked ? "Booked" : "Available"}</span>
897
994
  </div>`;
898
995
  });
899
- return `<div class="time-grid">${slots.join("")}</div>`;
996
+ const gridClasses = selectable ? "time-grid time-grid-selectable" : "time-grid";
997
+ return `<div class="${gridClasses}">${slots.join("")}</div>`;
900
998
  };
901
999
  const multiMonth = viewModel.panes.length > 1;
902
1000
  const renderPane = (pane) => `
@@ -921,6 +1019,12 @@ var Kalendly = (() => {
921
1019
  ${week.map((calendarDate, dayIndex) => {
922
1020
  const classes = getCellClasses(calendarDate);
923
1021
  const cellAttrs = [];
1022
+ if (viewModel.selectedDate && isSameDay2(
1023
+ calendarDate.date,
1024
+ viewModel.selectedDate
1025
+ )) {
1026
+ classes.push("calendar-cell-selected");
1027
+ }
924
1028
  if (availabilityMode && calendarDate.isCurrentMonth) {
925
1029
  const bucket = this.resolveBucket(
926
1030
  calendarDate.date
@@ -1073,7 +1177,7 @@ var Kalendly = (() => {
1073
1177
  ` : ""}
1074
1178
 
1075
1179
  <div class="events-container">
1076
- ${availabilityMode === "time" ? renderTimeGrid(viewModel.tasks, viewModel.selectedDate) : viewModel.tasks.length > 0 ? viewModel.tasks.map((event) => renderEvent(event)).join("") : renderNoEvents()}
1180
+ ${availabilityMode === "time" ? renderTimeGrid(viewModel.selectedDate) : viewModel.tasks.length > 0 ? viewModel.tasks.map((event) => renderEvent(event)).join("") : renderNoEvents()}
1077
1181
  </div>
1078
1182
  </div>
1079
1183
  ` : ""}
@@ -1234,6 +1338,20 @@ var Kalendly = (() => {
1234
1338
  const slotStart = actionEl.dataset.startTime;
1235
1339
  const slotEnd = actionEl.dataset.endTime;
1236
1340
  const slotDate = new Date(actionEl.dataset.date);
1341
+ const slotBooked = actionEl.dataset.booked === "true";
1342
+ this.dispatchEvent(
1343
+ new CustomEvent("cal-slot-select", {
1344
+ bubbles: true,
1345
+ composed: true,
1346
+ detail: {
1347
+ date: slotDate,
1348
+ startTime: slotStart,
1349
+ endTime: slotEnd,
1350
+ booked: slotBooked
1351
+ }
1352
+ })
1353
+ );
1354
+ if (slotBooked || !this.hasAttribute("selectable")) break;
1237
1355
  if (this._timeRangeComplete) {
1238
1356
  this._timeRangeDate = slotDate;
1239
1357
  this._timeRangeStart = slotStart;
@@ -1374,6 +1492,7 @@ var Kalendly = (() => {
1374
1492
  "week-starts-on",
1375
1493
  "heading",
1376
1494
  "months",
1495
+ "slot-duration",
1377
1496
  "title",
1378
1497
  "use-short-month-names",
1379
1498
  "availability-mode",
@@ -1425,6 +1544,7 @@ var Kalendly = (() => {
1425
1544
  badgeTentativeBg: "--calendar-badge-tentative-bg",
1426
1545
  badgeTentativeText: "--calendar-badge-tentative-text"
1427
1546
  };
1547
+ _CalendarElement.warnedMissingEnd = /* @__PURE__ */ new Set();
1428
1548
  _CalendarElement.BUILT_IN_BUCKETS = ["blocked", "conditional", "open"];
1429
1549
  _CalendarElement.titleDeprecationWarned = false;
1430
1550
  var CalendarElement = _CalendarElement;