/** * Timeslot Creator - Standalone JavaScript Library v2.0 * Supports multiple date ranges, each with custom settings * All UI elements can be shown/hidden via configuration * * Usage: * *
* */ (function(global) { 'use strict'; const STORAGE_KEY = 'timeslot_creator_config'; const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const HOLIDAYS = { US: [ {name: "New Year's Day", date: "01-01"}, {name: "Martin Luther King Jr. Day", date: "third-monday-january"}, {name: "Presidents' Day", date: "third-monday-february"}, {name: "Memorial Day", date: "last-monday-may"}, {name: "Independence Day", date: "07-04"}, {name: "Labor Day", date: "first-monday-september"}, {name: "Columbus Day", date: "second-monday-october"}, {name: "Veterans Day", date: "11-11"}, {name: "Thanksgiving", date: "fourth-thursday-november"}, {name: "Christmas Day", date: "12-25"} ], UK: [ {name: "New Year's Day", date: "01-01"}, {name: "Good Friday", date: "easter-2"}, {name: "Easter Monday", date: "easter+1"}, {name: "Early May Bank Holiday", date: "first-monday-may"}, {name: "Spring Bank Holiday", date: "last-monday-may"}, {name: "Summer Bank Holiday", date: "last-monday-august"}, {name: "Christmas Day", date: "12-25"}, {name: "Boxing Day", date: "12-26"} ], CA: [ {name: "New Year's Day", date: "01-01"}, {name: "Good Friday", date: "easter-2"}, {name: "Victoria Day", date: "monday-before-may-25"}, {name: "Canada Day", date: "07-01"}, {name: "Labour Day", date: "first-monday-september"}, {name: "Thanksgiving", date: "second-monday-october"}, {name: "Christmas Day", date: "12-25"}, {name: "Boxing Day", date: "12-26"} ] }; const TIMEZONES = [ 'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Toronto', 'America/Vancouver', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Rome', 'Europe/Madrid', 'Europe/Amsterdam', 'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Hong_Kong', 'Asia/Singapore', 'Asia/Dubai', 'Asia/Kolkata', 'Australia/Sydney', 'Australia/Melbourne', 'Pacific/Auckland', 'Pacific/Honolulu' ]; const CSS = ` .tsc-container { font-family: system-ui, -apple-system, sans-serif; background: #f3f4f6; padding: 2rem; } .tsc-container * { box-sizing: border-box; } .tsc-header { margin-bottom: 2rem; } .tsc-header h1 { font-size: 1.875rem; font-weight: bold; color: #1f2937; margin: 0 0 0.5rem; } .tsc-header p { color: #6b7280; margin: 0 0 1rem; } .tsc-header-buttons { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } .tsc-btn { padding: 0.5rem 1rem; border-radius: 0.5rem; border: none; cursor: pointer; font-size: 0.875rem; transition: background 0.2s; } .tsc-btn-primary { background: #3b82f6; color: white; } .tsc-btn-primary:hover { background: #2563eb; } .tsc-btn-purple { background: #8b5cf6; color: white; } .tsc-btn-purple:hover { background: #7c3aed; } .tsc-btn-gray { background: #6b7280; color: white; } .tsc-btn-gray:hover { background: #4b5563; } .tsc-btn-green { background: #10b981; color: white; } .tsc-btn-green:hover { background: #059669; } .tsc-btn-dark { background: #374151; color: white; } .tsc-btn-dark:hover { background: #1f2937; } .tsc-btn-red { background: #ef4444; color: white; } .tsc-btn-red:hover { background: #dc2626; } .tsc-btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; } .tsc-status { font-size: 0.875rem; color: #6b7280; } .tsc-tabs { display: flex; flex-wrap: wrap; gap: 0.5rem; border-bottom: 1px solid #e5e7eb; margin-bottom: 1.5rem; } .tsc-tab { padding: 0.5rem 1rem; background: none; border: none; cursor: pointer; font-weight: 500; color: #6b7280; border-bottom: 2px solid transparent; } .tsc-tab.active { color: #3b82f6; border-bottom-color: #3b82f6; } .tsc-tab:hover { color: #3b82f6; } .tsc-panel { display: none; background: white; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); padding: 1.5rem; } .tsc-panel.active { display: block; } .tsc-panel h2 { font-size: 1.25rem; font-weight: 600; margin: 0 0 1rem; } .tsc-panel p { color: #6b7280; margin: 0 0 1rem; } .tsc-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem; } .tsc-form-group { margin-bottom: 1rem; } .tsc-label { display: block; font-size: 0.875rem; font-weight: 500; color: #374151; margin-bottom: 0.5rem; } .tsc-input, .tsc-select { width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.5rem; font-size: 1rem; } .tsc-input:focus, .tsc-select:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.1); } .tsc-checkbox-group { display: flex; align-items: center; margin-bottom: 0.75rem; } .tsc-checkbox { width: 1rem; height: 1rem; margin-right: 0.5rem; } .tsc-rule { border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem; } .tsc-day-btns { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; } .tsc-day-btn { padding: 0.25rem 0.75rem; border: 1px solid #d1d5db; border-radius: 0.25rem; background: white; cursor: pointer; } .tsc-day-btn.selected { background: #3b82f6; color: white; border-color: #3b82f6; } .tsc-time-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } .tsc-time-input { width: 120px; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.5rem; } .tsc-remove-btn { color: #ef4444; background: none; border: none; cursor: pointer; margin-left: auto; } .tsc-add-link { color: #3b82f6; background: none; border: none; cursor: pointer; padding: 0; margin-top: 0.5rem; } .tsc-meeting-type { border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem; } .tsc-meeting-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 1rem; align-items: end; } .tsc-color-input { width: 100%; height: 2.5rem; border: 1px solid #d1d5db; border-radius: 0.5rem; cursor: pointer; } .tsc-exception { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; background: #f9fafb; border-radius: 0.5rem; margin-bottom: 0.5rem; } .tsc-exception-info span { margin-right: 0.5rem; } .tsc-blocked { color: #dc2626; } .tsc-limited { color: #d97706; } .tsc-calendar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } .tsc-calendar-nav { display: flex; gap: 0.5rem; align-items: center; } .tsc-nav-btn { padding: 0.25rem 0.75rem; border: 1px solid #d1d5db; border-radius: 0.25rem; background: white; cursor: pointer; } .tsc-legend { display: flex; gap: 1rem; margin-bottom: 1rem; font-size: 0.875rem; flex-wrap: wrap; } .tsc-legend-item { display: flex; align-items: center; } .tsc-legend-color { width: 1rem; height: 1rem; border-radius: 0.25rem; margin-right: 0.25rem; } .tsc-calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; background: #e5e7eb; } .tsc-calendar-header-cell { background: #f9fafb; padding: 0.5rem; text-align: center; font-weight: 500; } .tsc-calendar-day { background: white; padding: 0.5rem; min-height: 100px; } .tsc-calendar-day.weekend { background: #f3f4f6; } .tsc-calendar-day.blocked { background: #fee2e2; } .tsc-calendar-day.available { background: #dcfce7; } .tsc-calendar-day.exception { background: #fef3c7; } .tsc-calendar-day-num { font-weight: 500; margin-bottom: 0.25rem; } .tsc-calendar-day-num.other-month { color: #9ca3af; } .tsc-calendar-info { font-size: 0.75rem; } .tsc-calendar-slots { font-size: 0.65rem; padding: 2px 4px; margin: 1px 0; border-radius: 3px; background: #dbeafe; color: #1e40af; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tsc-list-controls { display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; align-items: end; } .tsc-list-day { border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem; } .tsc-list-day.blocked { background: #fee2e2; } .tsc-list-day.available { background: #dcfce7; } .tsc-list-day h3 { margin: 0 0 0.5rem; font-weight: 500; } .tsc-list-day p { margin: 0 0 0.5rem; font-size: 0.875rem; } .tsc-slots-wrap { display: flex; flex-wrap: wrap; } .tsc-slot { display: inline-block; padding: 0.25rem 0.5rem; margin: 0.25rem; font-size: 0.875rem; border-radius: 0.25rem; background: #e0e7ff; } .tsc-export-section { margin-bottom: 1.5rem; } .tsc-export-section h3 { font-weight: 500; margin: 0 0 0.5rem; } .tsc-json-preview { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow: auto; max-height: 400px; font-family: monospace; font-size: 0.75rem; white-space: pre-wrap; word-break: break-all; } .tsc-hidden { display: none !important; } .tsc-range-card { border: 2px solid #e5e7eb; border-radius: 0.75rem; padding: 1rem; margin-bottom: 1rem; transition: border-color 0.2s; } .tsc-range-card.selected { border-color: #3b82f6; background: #eff6ff; } .tsc-range-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; } .tsc-range-card-title { font-weight: 600; font-size: 1rem; margin: 0; } .tsc-range-card-dates { color: #6b7280; font-size: 0.875rem; } .tsc-range-card-stats { display: flex; gap: 1rem; font-size: 0.75rem; color: #6b7280; margin-top: 0.5rem; } .tsc-range-actions { display: flex; gap: 0.5rem; } .tsc-range-selector { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; padding: 0.75rem; background: #f9fafb; border-radius: 0.5rem; } .tsc-range-selector label { font-weight: 500; } .tsc-global-settings { background: #f0fdf4; border: 1px solid #86efac; border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem; } .tsc-global-settings h3 { margin: 0 0 0.75rem; font-size: 1rem; color: #166534; } `; function injectStyles() { if (document.getElementById('tsc-styles')) return; const style = document.createElement('style'); style.id = 'tsc-styles'; style.textContent = CSS; document.head.appendChild(style); } function generateId() { return 'range_' + Math.random().toString(36).substr(2, 9); } function formatDate(d) { return d.toISOString().split('T')[0]; } function convertFromUtc(utcIsoString, toTimezone) { try { const utcDate = new Date(utcIsoString); const options = { timeZone: toTimezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }; const parts = new Intl.DateTimeFormat('en-CA', options).formatToParts(utcDate); const get = (type) => parts.find(p => p.type === type)?.value || '00'; const year = get('year'); const month = get('month'); const day = get('day'); let hour = get('hour'); if (hour === '24') hour = '00'; const minute = get('minute'); const second = get('second'); return { date: `${year}-${month}-${day}`, time: `${hour}:${minute}`, datetime: `${year}-${month}-${day} ${hour}:${minute}:${second}` }; } catch (e) { console.error('Timezone conversion error:', e); return { date: utcIsoString.split('T')[0], time: '00:00', datetime: utcIsoString.replace('T', ' ').replace('Z', '') }; } } function getDefaultRange() { const today = new Date(); const future = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000); return { id: generateId(), name: 'Default Schedule', date_range_start: formatDate(today), date_range_end: formatDate(future), block_weekends: true, block_holidays: true, availability_rules: [ { days: [1, 2, 3, 4, 5], start_time: '09:00', end_time: '17:00' } ], meeting_types: [ { id: 1, name: '15 Minute Meeting', duration: 15, buffer: 5, color: '#3b82f6', active: true }, { id: 2, name: '30 Minute Meeting', duration: 30, buffer: 10, color: '#10b981', active: true }, { id: 3, name: '45 Minute Meeting', duration: 45, buffer: 15, color: '#8b5cf6', active: true } ], exceptions: [] }; } function getDefaultConfig() { const defaultRange = getDefaultRange(); return { timezone: 'America/New_York', holiday_country: 'US', ranges: [defaultRange], active_range_id: defaultRange.id }; } function loadConfig(storageKey) { try { const saved = localStorage.getItem(storageKey); if (saved) { const config = JSON.parse(saved); if (!config.ranges || !Array.isArray(config.ranges)) { const range = getDefaultRange(); range.date_range_start = config.date_range_start || range.date_range_start; range.date_range_end = config.date_range_end || range.date_range_end; range.block_weekends = config.block_weekends !== undefined ? config.block_weekends : true; range.block_holidays = config.block_holidays !== undefined ? config.block_holidays : true; range.availability_rules = config.availability_rules || range.availability_rules; range.meeting_types = config.meeting_types || range.meeting_types; range.exceptions = config.exceptions || []; return { timezone: config.timezone || 'America/New_York', holiday_country: config.holiday_country || 'US', ranges: [range], active_range_id: range.id }; } if (!config.ranges.length) { const defaultRange = getDefaultRange(); config.ranges = [defaultRange]; config.active_range_id = defaultRange.id; } if (!config.active_range_id || !config.ranges.find(r => r.id === config.active_range_id)) { config.active_range_id = config.ranges[0].id; } return config; } } catch (e) {} return getDefaultConfig(); } function saveConfig(config, storageKey, callback) { try { localStorage.setItem(storageKey, JSON.stringify(config)); if (callback) callback(config); return true; } catch (e) { return false; } } class SlotGenerator { constructor(globalConfig, rangeConfig) { this.timezone = globalConfig.timezone || 'UTC'; this.holidayCountry = globalConfig.holiday_country || 'US'; this.holidays = HOLIDAYS[this.holidayCountry] || []; this.availabilityRules = rangeConfig.availability_rules || []; this.exceptions = rangeConfig.exceptions || []; this.meetingTypes = rangeConfig.meeting_types || []; this.blockWeekends = rangeConfig.block_weekends !== false; this.blockHolidays = rangeConfig.block_holidays !== false; this.rangeStart = rangeConfig.date_range_start || null; this.rangeEnd = rangeConfig.date_range_end || null; } toUTC(dateStr, timeStr) { try { const [year, month, day] = dateStr.split('-').map(Number); const [hour, minute] = timeStr.split(':').map(Number); const utcEstimate = Date.UTC(year, month - 1, day, hour, minute, 0, 0); const formatter = new Intl.DateTimeFormat('en-US', { timeZone: this.timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); const parts = formatter.formatToParts(new Date(utcEstimate)); const getPart = (type) => { const p = parts.find(x => x.type === type); return p ? parseInt(p.value) : 0; }; const tzMonth = getPart('month'); const tzDay = getPart('day'); let tzHour = getPart('hour'); if (tzHour === 24) tzHour = 0; const tzMinute = getPart('minute'); const targetMinutes = day * 24 * 60 + hour * 60 + minute; const formattedMinutes = tzDay * 24 * 60 + tzHour * 60 + tzMinute; const offsetMinutes = (formattedMinutes - targetMinutes) * 60 * 1000; let correction = 0; if (tzMonth !== month) { correction = (tzMonth > month || (tzMonth === 1 && month === 12)) ? -24 * 60 * 60 * 1000 : 24 * 60 * 60 * 1000; } const correctedUtc = utcEstimate - offsetMinutes + correction; return new Date(correctedUtc).toISOString(); } catch (e) { const [year, month, day] = dateStr.split('-').map(Number); const [hour, minute] = timeStr.split(':').map(Number); return new Date(Date.UTC(year, month - 1, day, hour, minute)).toISOString(); } } generateSlots(startDate, endDate) { const slots = {}; const current = new Date(startDate + 'T00:00:00'); const end = new Date(endDate + 'T00:00:00'); while (current <= end) { const dateStr = formatDate(current); const dayOfWeek = current.getDay() === 0 ? 7 : current.getDay(); const daySlots = this.generateDaySlots(current, dateStr, dayOfWeek); if (daySlots) slots[dateStr] = daySlots; current.setDate(current.getDate() + 1); } return slots; } generateDaySlots(date, dateStr, dayOfWeek) { if (this.rangeStart && dateStr < this.rangeStart) { return { blocked: true, reason: 'out_of_range', slots: [] }; } if (this.rangeEnd && dateStr > this.rangeEnd) { return { blocked: true, reason: 'out_of_range', slots: [] }; } if (this.blockWeekends && (dayOfWeek === 6 || dayOfWeek === 7)) { return { blocked: true, reason: 'weekend', slots: [] }; } const holiday = this.getHoliday(date); if (this.blockHolidays && holiday) { return { blocked: true, reason: 'holiday', holiday_name: holiday.name, slots: [] }; } if (this.isBlockedException(dateStr)) { return { blocked: true, reason: 'exception', slots: [] }; } const dayRules = this.getDayRules(dateStr, dayOfWeek); if (!dayRules.length) { return { blocked: true, reason: 'no_availability', slots: [] }; } const availableSlots = []; for (const mt of this.meetingTypes) { if (mt.active === false) continue; const typeSlots = this.generateMeetingSlots(date, dateStr, dayRules, mt); availableSlots.push(...typeSlots); } availableSlots.sort((a, b) => a.start_time.localeCompare(b.start_time)); return { blocked: false, slots: availableSlots }; } generateMeetingSlots(date, dateStr, dayRules, meetingType) { const slots = []; const duration = meetingType.duration; const buffer = meetingType.buffer || 0; const increment = duration + buffer; for (const rule of dayRules) { const startTime = (rule.start_time || '09:00').substring(0, 5); const endTime = (rule.end_time || '17:00').substring(0, 5); let [startH, startM] = startTime.split(':').map(Number); const [endH, endM] = endTime.split(':').map(Number); const endMinutes = endH * 60 + endM; while (true) { const slotStartMin = startH * 60 + startM; const slotEndMin = slotStartMin + duration; if (slotEndMin > endMinutes) break; const slotStartTime = String(startH).padStart(2, '0') + ':' + String(startM).padStart(2, '0'); const slotEndH = Math.floor(slotEndMin / 60); const slotEndM = slotEndMin % 60; const slotEndTime = String(slotEndH).padStart(2, '0') + ':' + String(slotEndM).padStart(2, '0'); slots.push({ id: 'slot_' + Math.random().toString(36).substr(2, 9), date: dateStr, start_time: slotStartTime, end_time: slotEndTime, start_utc: this.toUTC(dateStr, slotStartTime), end_utc: this.toUTC(dateStr, slotEndTime), duration: duration, meeting_type: meetingType.name, meeting_type_id: meetingType.id, buffer_after: buffer, color: meetingType.color, timezone: this.timezone }); startM += increment; while (startM >= 60) { startH++; startM -= 60; } } } return slots; } getDayRules(dateStr, dayOfWeek) { const exRules = this.getExceptionRules(dateStr); if (exRules.length) return exRules; const rules = []; for (const rule of this.availabilityRules) { if (rule.days && rule.days.includes(dayOfWeek)) { rules.push({ start_time: rule.start_time, end_time: rule.end_time }); } } return rules; } isBlockedException(dateStr) { return this.exceptions.some(ex => ex.date === dateStr && (ex.type || 'blocked') === 'blocked'); } getExceptionRules(dateStr) { for (const ex of this.exceptions) { if (ex.date === dateStr && ex.type === 'limited') { return [{ start_time: ex.start_time, end_time: ex.end_time }]; } } return []; } getHoliday(date) { const year = date.getFullYear(); const monthDay = String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0'); for (const h of this.holidays) { const hDate = this.resolveHolidayDate(h.date, year); if (hDate === monthDay) return h; } return null; } resolveHolidayDate(pattern, year) { if (/^\d{2}-\d{2}$/.test(pattern)) return pattern; if (pattern.includes('easter')) return this.resolveEasterDate(pattern, year); if (pattern.includes('monday-before')) return this.resolveMondayBefore(pattern, year); const ordinals = { first: 1, second: 2, third: 3, fourth: 4, fifth: 5, last: 'last' }; const weekdays = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 }; const months = { january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, july: 6, august: 7, september: 8, october: 9, november: 10, december: 11 }; const parts = pattern.split('-'); if (parts.length >= 3) { const ord = ordinals[parts[0]]; const wd = weekdays[parts[1]]; const mo = months[parts[2]]; if (ord !== undefined && wd !== undefined && mo !== undefined) { if (ord === 'last') return this.getLastWeekdayOfMonth(year, mo, wd); return this.getNthWeekdayOfMonth(year, mo, wd, ord); } } return null; } resolveEasterDate(pattern, year) { const easter = this.calculateEaster(year); const match = pattern.match(/easter([+-])(\d+)/); if (match) { const days = parseInt(match[2]) * (match[1] === '-' ? -1 : 1); easter.setDate(easter.getDate() + days); } return String(easter.getMonth() + 1).padStart(2, '0') + '-' + String(easter.getDate()).padStart(2, '0'); } calculateEaster(year) { const a = year % 19; const b = Math.floor(year / 100); const c = year % 100; const d = Math.floor(b / 4); const e = b % 4; const f = Math.floor((b + 8) / 25); const g = Math.floor((b - f + 1) / 3); const h = (19 * a + b - d - g + 15) % 30; const i = Math.floor(c / 4); const k = c % 4; const l = (32 + 2 * e + 2 * i - h - k) % 7; const m = Math.floor((a + 11 * h + 22 * l) / 451); const month = Math.floor((h + l - 7 * m + 114) / 31) - 1; const day = ((h + l - 7 * m + 114) % 31) + 1; return new Date(year, month, day); } resolveMondayBefore(pattern, year) { const match = pattern.match(/monday-before-(\w+)-(\d+)/); if (!match) return null; const months = { january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, july: 6, august: 7, september: 8, october: 9, november: 10, december: 11 }; const mo = months[match[1]]; const day = parseInt(match[2]); const d = new Date(year, mo, day); while (d.getDay() !== 1) d.setDate(d.getDate() - 1); return String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } getNthWeekdayOfMonth(year, month, weekday, n) { const first = new Date(year, month, 1); const firstWd = first.getDay(); let daysUntil = (weekday - firstWd + 7) % 7; const day = 1 + daysUntil + ((n - 1) * 7); const d = new Date(year, month, day); if (d.getMonth() !== month) return null; return String(month + 1).padStart(2, '0') + '-' + String(day).padStart(2, '0'); } getLastWeekdayOfMonth(year, month, weekday) { const last = new Date(year, month + 1, 0); while (last.getDay() !== weekday) last.setDate(last.getDate() - 1); return String(month + 1).padStart(2, '0') + '-' + String(last.getDate()).padStart(2, '0'); } } function createTimeslotCreator(container, options) { const opts = options || {}; const storageKey = opts.storageKey || STORAGE_KEY; let config = loadConfig(storageKey); let currentMonth = new Date(); let generatedSlots = null; const uiOptions = { showTimezone: opts.showTimezone !== false, showHolidayCountry: opts.showHolidayCountry !== false, showBlockWeekends: opts.showBlockWeekends !== false, showBlockHolidays: opts.showBlockHolidays !== false, showAvailability: opts.showAvailability !== false, showMeetingTypes: opts.showMeetingTypes !== false, showExceptions: opts.showExceptions !== false, showCalendar: opts.showCalendar !== false, showListView: opts.showListView !== false, showExport: opts.showExport !== false, showRangeSelector: opts.showRangeSelector !== false, showDateRange: opts.showDateRange !== false, showRangeName: opts.showRangeName !== false, showGlobalSettings: opts.showGlobalSettings !== false, showHeaderButtons: opts.showHeaderButtons !== false }; const onSave = opts.onSave || function() {}; const onSlotsGenerated = opts.onSlotsGenerated || function() {}; const onManualSave = opts.onManualSave || null; function getActiveRange() { if (!config.ranges.length) { const newRange = getDefaultRange(); config.ranges.push(newRange); config.active_range_id = newRange.id; } let range = config.ranges.find(r => r.id === config.active_range_id); if (!range) { range = config.ranges[0]; config.active_range_id = range.id; } return range; } function setActiveRange(rangeId) { config.active_range_id = rangeId; save(); render(); } function addRange() { const newRange = getDefaultRange(); newRange.name = 'Schedule ' + (config.ranges.length + 1); config.ranges.push(newRange); config.active_range_id = newRange.id; save(); render(); } function deleteRange(rangeId) { if (config.ranges.length <= 1) { alert('Cannot delete the last schedule. You must have at least one.'); return; } config.ranges = config.ranges.filter(r => r.id !== rangeId); if (config.active_range_id === rangeId) { config.active_range_id = config.ranges[0].id; } save(); render(); } function duplicateRange(rangeId) { const source = config.ranges.find(r => r.id === rangeId); if (!source) return; const newRange = JSON.parse(JSON.stringify(source)); newRange.id = generateId(); newRange.name = source.name + ' (Copy)'; config.ranges.push(newRange); config.active_range_id = newRange.id; save(); render(); } function save() { saveConfig(config, storageKey, onSave); updateStatus('Saved!'); } function updateStatus(msg) { const el = container.querySelector('.tsc-status'); if (el) { el.textContent = msg; setTimeout(() => el.textContent = '', 2000); } } function generateSlots(startDate, endDate, rangeId) { const range = rangeId ? config.ranges.find(r => r.id === rangeId) : getActiveRange(); const gen = new SlotGenerator(config, range); const slots = gen.generateSlots(startDate, endDate); const result = { generated_at: new Date().toISOString(), timezone: config.timezone, range_id: range.id, range_name: range.name, date_range: { start: startDate, end: endDate }, settings: { block_weekends: range.block_weekends, block_holidays: range.block_holidays, holiday_country: config.holiday_country }, meeting_types: range.meeting_types, slots: slots, summary: { total_days: Object.keys(slots).length, available_days: Object.values(slots).filter(s => !s.blocked).length, blocked_days: Object.values(slots).filter(s => s.blocked).length, total_slots: Object.values(slots).reduce((sum, s) => sum + (s.slots ? s.slots.length : 0), 0) } }; generatedSlots = result; onSlotsGenerated(result); return result; } function generateAllRangesSlots() { const allResults = []; for (const range of config.ranges) { const result = generateSlots(range.date_range_start, range.date_range_end, range.id); allResults.push(result); } return { generated_at: new Date().toISOString(), timezone: config.timezone, holiday_country: config.holiday_country, ranges: allResults }; } function exportSlots(options = {}) { const { rangeId = null, convertToTimezone = 'America/New_York', metadata = {} } = options; const range = rangeId ? config.ranges.find(r => r.id === rangeId) : getActiveRange(); if (!range) return null; const gen = new SlotGenerator(config, range); const slotsData = gen.generateSlots(range.date_range_start, range.date_range_end); const originalTimezone = config.timezone; const flatSlots = []; for (const [dateStr, dayData] of Object.entries(slotsData)) { if (dayData.blocked || !dayData.slots || dayData.slots.length === 0) { continue; } for (const slot of dayData.slots) { const convertedStart = convertFromUtc(slot.start_utc, convertToTimezone); const convertedEnd = convertFromUtc(slot.end_utc, convertToTimezone); const originalStart = convertFromUtc(slot.start_utc, originalTimezone); const originalEnd = convertFromUtc(slot.end_utc, originalTimezone); flatSlots.push({ id: slot.id, original_start: originalStart.datetime, original_end: originalEnd.datetime, original_timezone: originalTimezone, converted_start: convertedStart.datetime, converted_end: convertedEnd.datetime, converted_timezone: convertToTimezone, start_utc: slot.start_utc.replace('T', ' ').replace('.000Z', ''), end_utc: slot.end_utc.replace('T', ' ').replace('.000Z', ''), duration: slot.duration, meeting_type: slot.meeting_type, meeting_type_id: slot.meeting_type_id, buffer_after: slot.buffer_after, color: slot.color }); } } flatSlots.sort((a, b) => { return a.original_start.localeCompare(b.original_start); }); return { generated_at: new Date().toISOString(), range_id: range.id, range_name: range.name, date_range: { start: range.date_range_start, end: range.date_range_end }, original_timezone: originalTimezone, converted_timezone: convertToTimezone, total_slots: flatSlots.length, ...metadata, slots: flatSlots }; } function exportAllSlots(options = {}) { const { convertToTimezone = 'America/New_York', metadata = {} } = options; const originalTimezone = config.timezone; const allSlots = []; const rangesInfo = []; for (const range of config.ranges) { rangesInfo.push({ id: range.id, name: range.name, date_range_start: range.date_range_start, date_range_end: range.date_range_end, block_weekends: range.block_weekends, block_holidays: range.block_holidays, availability_rules: range.availability_rules, meeting_types: range.meeting_types, exceptions: range.exceptions }); const gen = new SlotGenerator(config, range); const slotsData = gen.generateSlots(range.date_range_start, range.date_range_end); for (const [dateStr, dayData] of Object.entries(slotsData)) { if (dayData.blocked || !dayData.slots || dayData.slots.length === 0) { continue; } for (const slot of dayData.slots) { const convertedStart = convertFromUtc(slot.start_utc, convertToTimezone); const convertedEnd = convertFromUtc(slot.end_utc, convertToTimezone); const originalStart = convertFromUtc(slot.start_utc, originalTimezone); const originalEnd = convertFromUtc(slot.end_utc, originalTimezone); allSlots.push({ id: slot.id, range_id: range.id, range_name: range.name, original_start: originalStart.datetime, original_end: originalEnd.datetime, original_timezone: originalTimezone, converted_start: convertedStart.datetime, converted_end: convertedEnd.datetime, converted_timezone: convertToTimezone, start_utc: slot.start_utc.replace('T', ' ').replace('.000Z', ''), end_utc: slot.end_utc.replace('T', ' ').replace('.000Z', ''), duration: slot.duration, meeting_type: slot.meeting_type, meeting_type_id: slot.meeting_type_id, buffer_after: slot.buffer_after, color: slot.color }); } } } allSlots.sort((a, b) => a.original_start.localeCompare(b.original_start)); return { generated_at: new Date().toISOString(), original_timezone: originalTimezone, converted_timezone: convertToTimezone, total_ranges: rangesInfo.length, total_slots: allSlots.length, ...metadata, ranges: rangesInfo, slots: allSlots }; } function render() { const range = getActiveRange(); const tabs = []; if (uiOptions.showRangeSelector) tabs.push({ id: 'ranges', label: 'Schedules' }); if (uiOptions.showGlobalSettings) tabs.push({ id: 'settings', label: 'Settings' }); if (uiOptions.showAvailability) tabs.push({ id: 'availability', label: 'Availability' }); if (uiOptions.showMeetingTypes) tabs.push({ id: 'meeting-types', label: 'Meeting Types' }); if (uiOptions.showExceptions) tabs.push({ id: 'exceptions', label: 'Exceptions' }); if (uiOptions.showCalendar) tabs.push({ id: 'calendar', label: 'Calendar View' }); if (uiOptions.showListView) tabs.push({ id: 'list', label: 'List View' }); if (uiOptions.showExport) tabs.push({ id: 'export', label: 'Export' }); const activeTab = tabs.length > 0 ? tabs[0].id : 'settings'; container.innerHTML = `

Timeslot Creator

Configure your meeting availability and generate scheduling slots

${uiOptions.showHeaderButtons ? `
${onManualSave ? '' : ''}
` : ''}
${uiOptions.showRangeSelector && config.ranges.length > 1 ? `
` : ''}
${tabs.map((t, i) => ``).join('')}
${tabs.map((t, i) => `
`).join('')}
`; bindTabs(); if (uiOptions.showHeaderButtons) bindHeaderButtons(); if (uiOptions.showRangeSelector && config.ranges.length > 1) { container.querySelector('.tsc-range-select').addEventListener('change', e => setActiveRange(e.target.value)); } if (uiOptions.showRangeSelector) renderRanges(); if (uiOptions.showGlobalSettings) renderGlobalSettings(); if (uiOptions.showAvailability) renderAvailability(); if (uiOptions.showMeetingTypes) renderMeetingTypes(); if (uiOptions.showExceptions) renderExceptions(); if (uiOptions.showCalendar) renderCalendar(); if (uiOptions.showListView) renderList(); if (uiOptions.showExport) renderExport(); } function bindTabs() { container.querySelectorAll('.tsc-tab').forEach(tab => { tab.addEventListener('click', () => { container.querySelectorAll('.tsc-tab').forEach(t => t.classList.remove('active')); container.querySelectorAll('.tsc-panel').forEach(p => p.classList.remove('active')); tab.classList.add('active'); const panel = container.querySelector(`[data-panel="${tab.dataset.tab}"]`); if (panel) panel.classList.add('active'); if (tab.dataset.tab === 'calendar') renderCalendar(); if (tab.dataset.tab === 'list') renderList(); }); }); } function bindHeaderButtons() { const clearBtn = container.querySelector('.tsc-clear-config'); if (clearBtn) { clearBtn.addEventListener('click', () => { if (confirm('Clear all settings? This will reset everything to defaults.')) { localStorage.removeItem(storageKey); config = getDefaultConfig(); render(); updateStatus('Cleared'); } }); } const saveBtn = container.querySelector('.tsc-manual-save'); if (saveBtn && onManualSave) { saveBtn.addEventListener('click', () => { save(); const exportData = exportAllSlots(); onManualSave({ config: config, configJson: JSON.stringify(config), exportData: exportData, exportJson: JSON.stringify(exportData) }); updateStatus('Saved'); }); } } function renderRanges() { const panel = container.querySelector('[data-panel="ranges"]'); if (!panel) return; panel.innerHTML = `

Schedules

Create multiple schedules with different date ranges and availability settings.

${config.ranges.map(r => { const isActive = r.id === config.active_range_id; return `

${r.name}

${!isActive ? `` : 'Active'}
${r.date_range_start} to ${r.date_range_end}
${r.meeting_types?.filter(m => m.active !== false).length || 0} meeting types ${r.availability_rules?.length || 0} availability rules ${r.exceptions?.length || 0} exceptions
`; }).join('')}
`; panel.querySelector('.tsc-add-range').addEventListener('click', addRange); panel.querySelectorAll('.tsc-select-range').forEach(btn => { btn.addEventListener('click', () => setActiveRange(btn.dataset.id)); }); panel.querySelectorAll('.tsc-dup-range').forEach(btn => { btn.addEventListener('click', () => duplicateRange(btn.dataset.id)); }); panel.querySelectorAll('.tsc-del-range').forEach(btn => { btn.addEventListener('click', () => { if (confirm('Delete this schedule?')) deleteRange(btn.dataset.id); }); }); } function renderGlobalSettings() { const panel = container.querySelector('[data-panel="settings"]'); if (!panel) return; const range = getActiveRange(); panel.innerHTML = `

Global Settings

These settings apply to all schedules.

Shared Settings

${uiOptions.showTimezone ? `
` : ''} ${uiOptions.showHolidayCountry ? `
` : ''}

Current Schedule Settings: ${range.name}

${uiOptions.showRangeName ? `
` : ''} ${uiOptions.showDateRange ? `
` : ''}
${uiOptions.showBlockWeekends ? `
Block weekends (Saturday & Sunday)
` : ''} ${uiOptions.showBlockHolidays ? `
Block holidays
` : ''} `; panel.querySelector('.tsc-save-settings').addEventListener('click', () => { const range = getActiveRange(); if (uiOptions.showTimezone) config.timezone = panel.querySelector('.tsc-tz').value; if (uiOptions.showHolidayCountry) config.holiday_country = panel.querySelector('.tsc-hc').value; if (uiOptions.showRangeName) range.name = panel.querySelector('.tsc-rname').value; if (uiOptions.showDateRange) { range.date_range_start = panel.querySelector('.tsc-ds').value; range.date_range_end = panel.querySelector('.tsc-de').value; } if (uiOptions.showBlockWeekends) range.block_weekends = panel.querySelector('.tsc-bw').checked; if (uiOptions.showBlockHolidays) range.block_holidays = panel.querySelector('.tsc-bh').checked; save(); }); } function renderAvailability() { const panel = container.querySelector('[data-panel="availability"]'); if (!panel) return; const range = getActiveRange(); let rules = range.availability_rules || []; if (!rules.length) rules = [{ days: [1,2,3,4,5], start_time: '09:00', end_time: '17:00' }]; panel.innerHTML = `

Weekly Availability - ${range.name}

Set your regular working hours for each day of the week.

${rules.map((r, i) => renderAvailRule(r, i)).join('')}
`; bindAvailEvents(panel); } function renderAvailRule(rule, idx) { return `
${DAY_NAMES.map((d, i) => ``).join('')}
to
`; } function bindAvailEvents(panel) { panel.querySelectorAll('.tsc-day-btn').forEach(btn => { btn.addEventListener('click', () => btn.classList.toggle('selected')); }); panel.querySelectorAll('.tsc-remove-rule').forEach(btn => { btn.addEventListener('click', () => btn.closest('.tsc-rule').remove()); }); panel.querySelector('.tsc-add-rule').addEventListener('click', () => { const list = panel.querySelector('.tsc-rules-list'); const idx = list.children.length; const div = document.createElement('div'); div.innerHTML = renderAvailRule({ days: [], start_time: '09:00', end_time: '17:00' }, idx); const rule = div.firstElementChild; list.appendChild(rule); rule.querySelectorAll('.tsc-day-btn').forEach(btn => btn.addEventListener('click', () => btn.classList.toggle('selected'))); rule.querySelector('.tsc-remove-rule').addEventListener('click', () => rule.remove()); }); panel.querySelector('.tsc-save-avail').addEventListener('click', () => { const range = getActiveRange(); const rules = []; panel.querySelectorAll('.tsc-rule').forEach(el => { const days = Array.from(el.querySelectorAll('.tsc-day-btn.selected')).map(b => parseInt(b.dataset.day)); if (days.length) rules.push({ days, start_time: el.querySelector('.tsc-start').value, end_time: el.querySelector('.tsc-end').value }); }); range.availability_rules = rules; save(); }); } function renderMeetingTypes() { const panel = container.querySelector('[data-panel="meeting-types"]'); if (!panel) return; const range = getActiveRange(); let types = range.meeting_types || []; if (!types.length) types = [{ id: 1, name: '30 Minute Meeting', duration: 30, buffer: 10, color: '#3b82f6', active: true }]; panel.innerHTML = `

Meeting Types - ${range.name}

Define different types of meetings with their durations and buffer times.

${types.map((t, i) => renderMeetingType(t, i)).join('')}
`; bindTypeEvents(panel); } function renderMeetingType(type, idx) { return `
Active
`; } function bindTypeEvents(panel) { panel.querySelectorAll('.tsc-remove-type').forEach(btn => btn.addEventListener('click', () => btn.closest('.tsc-meeting-type').remove())); panel.querySelector('.tsc-add-type').addEventListener('click', () => { const list = panel.querySelector('.tsc-types-list'); const div = document.createElement('div'); div.innerHTML = renderMeetingType({ name: '', duration: 30, buffer: 0, color: '#3b82f6', active: true }, list.children.length); const mt = div.firstElementChild; list.appendChild(mt); mt.querySelector('.tsc-remove-type').addEventListener('click', () => mt.remove()); }); panel.querySelector('.tsc-save-types').addEventListener('click', () => { const range = getActiveRange(); const types = []; panel.querySelectorAll('.tsc-meeting-type').forEach((el, idx) => { const name = el.querySelector('.tsc-tname').value; if (name) types.push({ id: idx + 1, name, duration: parseInt(el.querySelector('.tsc-tdur').value) || 30, buffer: parseInt(el.querySelector('.tsc-tbuf').value) || 0, color: el.querySelector('.tsc-tcol').value, active: el.querySelector('.tsc-tact').checked }); }); range.meeting_types = types; save(); }); } function renderExceptions() { const panel = container.querySelector('[data-panel="exceptions"]'); if (!panel) return; const range = getActiveRange(); const exceptions = range.exceptions || []; panel.innerHTML = `

Exceptions - ${range.name}

Add specific dates that are blocked or have limited availability.

to
${exceptions.map((ex, i) => renderException(ex, i)).join('') || '

No exceptions added yet.

'}
`; panel.querySelector('.tsc-extype').addEventListener('change', e => { panel.querySelector('.tsc-limited-times').classList.toggle('tsc-hidden', e.target.value !== 'limited'); }); panel.querySelector('.tsc-add-ex').addEventListener('click', () => { const range = getActiveRange(); const date = panel.querySelector('.tsc-exdate').value; if (!date) { alert('Please select a date'); return; } const type = panel.querySelector('.tsc-extype').value; const ex = { date, type, note: panel.querySelector('.tsc-exnote').value }; if (type === 'limited') { ex.start_time = panel.querySelector('.tsc-exstart').value; ex.end_time = panel.querySelector('.tsc-exend').value; } range.exceptions = range.exceptions || []; range.exceptions.push(ex); save(); renderExceptions(); }); panel.querySelectorAll('.tsc-del-ex').forEach(btn => { btn.addEventListener('click', () => { const range = getActiveRange(); range.exceptions.splice(parseInt(btn.dataset.idx), 1); save(); renderExceptions(); }); }); } function renderException(ex, idx) { return `
${ex.date} ${ex.type === 'blocked' ? 'Blocked' : `Limited: ${ex.start_time} - ${ex.end_time}`} ${ex.note ? `(${ex.note})` : ''}
`; } function renderCalendar() { const panel = container.querySelector('[data-panel="calendar"]'); if (!panel) return; const range = getActiveRange(); const year = currentMonth.getFullYear(); const month = currentMonth.getMonth(); const monthName = currentMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const startOffset = (firstDay.getDay() + 6) % 7; const startDate = new Date(year, month, 1 - startOffset); const data = generateSlots(formatDate(startDate), formatDate(lastDay), range.id); let cells = DAY_NAMES.map(d => `
${d}
`).join(''); let current = new Date(startDate); while (current <= lastDay || current.getDay() !== 1) { const dateStr = formatDate(current); const dayData = data.slots[dateStr]; const isCurrentMonth = current.getMonth() === month; let cls = 'tsc-calendar-day'; let info = ''; if (dayData) { if (dayData.blocked) { if (dayData.reason === 'out_of_range') { cls += ' blocked'; info = '
Outside Range
'; } else if (dayData.reason === 'weekend') { cls += ' weekend'; info = '
Weekend
'; } else if (dayData.reason === 'holiday') { cls += ' blocked'; info = `
${dayData.holiday_name || 'Holiday'}
`; } else if (dayData.reason === 'exception') { cls += ' exception'; info = '
Blocked
'; } else { cls += ' blocked'; } } else { cls += ' available'; const slots = dayData.slots || []; info = `
${slots.length} slots
`; const grouped = {}; slots.forEach(s => grouped[s.meeting_type] = (grouped[s.meeting_type] || 0) + 1); Object.entries(grouped).slice(0, 2).forEach(([t, c]) => info += `
${c}x ${t}
`); } } cells += `
${current.getDate()}
${info}
`; current.setDate(current.getDate() + 1); if (current > lastDay && current.getDay() === 1) break; } panel.innerHTML = `

Calendar View - ${range.name}

${monthName}
Available
Blocked
Exception
Weekend
${cells}
`; panel.querySelector('.tsc-prev').addEventListener('click', () => { currentMonth.setMonth(currentMonth.getMonth() - 1); renderCalendar(); }); panel.querySelector('.tsc-next').addEventListener('click', () => { currentMonth.setMonth(currentMonth.getMonth() + 1); renderCalendar(); }); } function renderList() { const panel = container.querySelector('[data-panel="list"]'); if (!panel) return; const range = getActiveRange(); const startDate = range.date_range_start || formatDate(new Date()); const endDate = range.date_range_end || formatDate(new Date(Date.now() + 30*24*60*60*1000)); panel.innerHTML = `

List View - ${range.name}

`; function refresh() { const start = panel.querySelector('.tsc-list-start').value; const end = panel.querySelector('.tsc-list-end').value; const data = generateSlots(start, end, range.id); const results = panel.querySelector('.tsc-list-results'); results.innerHTML = Object.entries(data.slots).map(([date, dayData]) => { const d = new Date(date + 'T00:00:00'); const dateStr = d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); if (dayData.blocked) { const reason = dayData.reason === 'out_of_range' ? 'Outside configured date range' : dayData.reason === 'weekend' ? 'Weekend' : dayData.reason === 'holiday' ? `Holiday: ${dayData.holiday_name || ''}` : dayData.reason === 'exception' ? 'Blocked (exception)' : 'No availability'; return `

${dateStr}

${reason}

`; } const slots = (dayData.slots || []).map(s => `${s.start_time} - ${s.end_time} (${s.duration}min ${s.meeting_type})`).join(''); return `

${dateStr}

${dayData.slots.length} available slots

${slots}
`; }).join(''); } panel.querySelector('.tsc-refresh-list').addEventListener('click', refresh); refresh(); } function renderExport() { const panel = container.querySelector('[data-panel="export"]'); if (!panel) return; const range = getActiveRange(); panel.innerHTML = `

Export

Export Current Schedule: ${range.name}


                

Export All Schedules

`; panel.querySelector('.tsc-gen-preview').addEventListener('click', () => { const range = getActiveRange(); const data = exportSlots({ rangeId: range.id }); panel.querySelector('.tsc-json-preview').textContent = JSON.stringify(data, null, 2); }); panel.querySelector('.tsc-download').addEventListener('click', () => { const range = getActiveRange(); const data = exportSlots({ rangeId: range.id }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `timeslots-${range.name.replace(/\s+/g, '-')}-${range.date_range_start}-to-${range.date_range_end}.json`; a.click(); URL.revokeObjectURL(url); }); panel.querySelector('.tsc-gen-all').addEventListener('click', () => { const data = exportAllSlots(); panel.querySelector('.tsc-json-preview').textContent = JSON.stringify(data, null, 2); }); panel.querySelector('.tsc-download-all').addEventListener('click', () => { const data = exportAllSlots(); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `all-schedules-${formatDate(new Date())}.json`; a.click(); URL.revokeObjectURL(url); }); } render(); return { getConfig: () => config, setConfig: (newConfig) => { config = { ...config, ...newConfig }; save(); render(); }, generateSlots, generateAllRangesSlots, exportSlots, exportAllSlots, refresh: render, getActiveRange, setActiveRange, addRange, deleteRange, duplicateRange }; } const TimeslotCreator = { init: function(selector, options) { injectStyles(); const container = typeof selector === 'string' ? document.querySelector(selector) : selector; if (!container) { console.error('TimeslotCreator: Container not found:', selector); return null; } return createTimeslotCreator(container, options); }, version: '2.0.0' }; global.TimeslotCreator = TimeslotCreator; })(typeof window !== 'undefined' ? window : this);