Calendar Availability Sharer

Generate shareable availability strings from your Google Calendar

Size

26.3 KB

Version

1.1.1

Created

Jan 14, 2026

Updated

3 months ago

1// ==UserScript==
2// @name		Calendar Availability Sharer
3// @description		Generate shareable availability strings from your Google Calendar
4// @version		1.1.1
5// @match		https://*.calendar.google.com/*
6// @icon		https://calendar.google.com/googlecalendar/images/favicons_2020q4/calendar_8.ico
7// ==/UserScript==
8(function() {
9    'use strict';
10
11    console.log('Calendar Availability Sharer extension loaded');
12
13    // Default settings
14    const DEFAULT_SETTINGS = {
15        startHour: 9,
16        endHour: 17,
17        slotDensity: 'medium' // light, medium, high
18    };
19
20    // Debounce function for MutationObserver
21    function debounce(func, wait) {
22        let timeout;
23        return function executedFunction(...args) {
24            const later = () => {
25                clearTimeout(timeout);
26                func(...args);
27            };
28            clearTimeout(timeout);
29            timeout = setTimeout(later, wait);
30        };
31    }
32
33    // Initialize the extension
34    async function init() {
35        console.log('Initializing Calendar Availability Sharer');
36        
37        // Wait for the calendar interface to load
38        await waitForElement('.T5GQA');
39        
40        // Add the availability button to the toolbar
41        addAvailabilityButton();
42        
43        // Observe DOM changes to re-add button if needed
44        observeCalendarChanges();
45    }
46
47    // Wait for an element to appear in the DOM
48    function waitForElement(selector, timeout = 10000) {
49        return new Promise((resolve, reject) => {
50            const element = document.querySelector(selector);
51            if (element) {
52                console.log('Element found immediately:', selector);
53                return resolve(element);
54            }
55
56            const observer = new MutationObserver((mutations, obs) => {
57                const element = document.querySelector(selector);
58                if (element) {
59                    console.log('Element found after mutation:', selector);
60                    obs.disconnect();
61                    resolve(element);
62                }
63            });
64
65            observer.observe(document.body, {
66                childList: true,
67                subtree: true
68            });
69
70            setTimeout(() => {
71                observer.disconnect();
72                reject(new Error(`Timeout waiting for element: ${selector}`));
73            }, timeout);
74        });
75    }
76
77    // Add the availability button to the calendar toolbar
78    function addAvailabilityButton() {
79        // Check if button already exists
80        if (document.getElementById('availability-sharer-btn')) {
81            console.log('Availability button already exists');
82            return;
83        }
84
85        const toolbar = document.querySelector('.T5GQA');
86        if (!toolbar) {
87            console.error('Toolbar not found');
88            return;
89        }
90
91        // Create button container
92        const buttonContainer = document.createElement('span');
93        buttonContainer.setAttribute('data-is-tooltip-wrapper', 'true');
94        buttonContainer.style.marginLeft = '8px';
95
96        // Create the button
97        const button = document.createElement('button');
98        button.id = 'availability-sharer-btn';
99        button.className = 'AeBiU-LgbsSe AeBiU-LgbsSe-OWXEXe-SfQLQb-suEOdc AeBiU-LgbsSe-OWXEXe-dgl2Hf AeBiU-kSE8rc-FoKg4d-sLO9V-YoZ4jf UZLCCd';
100        button.setAttribute('aria-label', 'Share Availability');
101        button.setAttribute('data-tooltip-enabled', 'true');
102        
103        button.innerHTML = `
104            <span class="UTNHae"></span>
105            <span class="AeBiU-RLmnJb"></span>
106            <span class="AeBiU-vQzf8d" aria-hidden="true">Share Availability</span>
107        `;
108
109        button.addEventListener('click', handleAvailabilityButtonClick);
110
111        buttonContainer.appendChild(button);
112        toolbar.appendChild(buttonContainer);
113
114        console.log('Availability button added to toolbar');
115    }
116
117    // Handle availability button click
118    async function handleAvailabilityButtonClick(e) {
119        e.preventDefault();
120        e.stopPropagation();
121        console.log('Availability button clicked');
122        
123        // Show settings panel
124        showSettingsPanel();
125    }
126
127    // Show settings panel
128    async function showSettingsPanel() {
129        // Remove existing panel if any
130        const existingPanel = document.getElementById('availability-settings-panel');
131        if (existingPanel) {
132            existingPanel.remove();
133        }
134
135        // Load saved settings
136        const settings = await loadSettings();
137
138        // Create overlay
139        const overlay = document.createElement('div');
140        overlay.id = 'availability-settings-panel';
141        overlay.style.cssText = `
142            position: fixed;
143            top: 0;
144            left: 0;
145            width: 100%;
146            height: 100%;
147            background: rgba(0, 0, 0, 0.5);
148            z-index: 10000;
149            display: flex;
150            align-items: center;
151            justify-content: center;
152        `;
153
154        // Create panel
155        const panel = document.createElement('div');
156        panel.style.cssText = `
157            background: white;
158            border-radius: 8px;
159            padding: 24px;
160            width: 400px;
161            max-width: 90%;
162            box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
163        `;
164
165        panel.innerHTML = `
166            <h2 style="margin: 0 0 20px 0; font-size: 20px; font-weight: 500; color: #202124;">Share Your Availability</h2>
167            
168            <div style="margin-bottom: 16px;">
169                <label style="display: block; margin-bottom: 8px; font-size: 14px; color: #5f6368;">Start Hour (24h format)</label>
170                <input type="number" id="start-hour-input" min="0" max="23" value="${settings.startHour}" 
171                    style="width: 100%; padding: 8px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;">
172            </div>
173
174            <div style="margin-bottom: 16px;">
175                <label style="display: block; margin-bottom: 8px; font-size: 14px; color: #5f6368;">End Hour (24h format)</label>
176                <input type="number" id="end-hour-input" min="0" max="23" value="${settings.endHour}" 
177                    style="width: 100%; padding: 8px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;">
178            </div>
179
180            <div style="margin-bottom: 24px;">
181                <label style="display: block; margin-bottom: 8px; font-size: 14px; color: #5f6368;">Slot Density</label>
182                <select id="slot-density-select" style="width: 100%; padding: 8px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;">
183                    <option value="light" ${settings.slotDensity === 'light' ? 'selected' : ''}>Light (2-3 hours per day)</option>
184                    <option value="medium" ${settings.slotDensity === 'medium' ? 'selected' : ''}>Medium (4-5 hours per day)</option>
185                    <option value="high" ${settings.slotDensity === 'high' ? 'selected' : ''}>High (6+ hours per day)</option>
186                </select>
187            </div>
188
189            <div style="display: flex; gap: 12px; justify-content: flex-end;">
190                <button id="cancel-btn" style="padding: 8px 16px; border: 1px solid #dadce0; background: white; border-radius: 4px; cursor: pointer; font-size: 14px; color: #5f6368;">Cancel</button>
191                <button id="generate-btn" style="padding: 8px 16px; border: none; background: #1a73e8; color: white; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500;">Generate Availability</button>
192            </div>
193        `;
194
195        overlay.appendChild(panel);
196        document.body.appendChild(overlay);
197
198        // Add event listeners
199        document.getElementById('cancel-btn').addEventListener('click', () => {
200            overlay.remove();
201        });
202
203        document.getElementById('generate-btn').addEventListener('click', async () => {
204            const startHour = parseInt(document.getElementById('start-hour-input').value);
205            const endHour = parseInt(document.getElementById('end-hour-input').value);
206            const slotDensity = document.getElementById('slot-density-select').value;
207
208            if (startHour >= endHour) {
209                alert('Start hour must be before end hour');
210                return;
211            }
212
213            // Save settings
214            await saveSettings({ startHour, endHour, slotDensity });
215
216            // Generate availability
217            overlay.remove();
218            await generateAvailability({ startHour, endHour, slotDensity });
219        });
220
221        // Close on overlay click
222        overlay.addEventListener('click', (e) => {
223            if (e.target === overlay) {
224                overlay.remove();
225            }
226        });
227    }
228
229    // Load settings from storage
230    async function loadSettings() {
231        try {
232            const saved = await GM.getValue('availability_settings');
233            if (saved) {
234                return JSON.parse(saved);
235            }
236        } catch (error) {
237            console.error('Error loading settings:', error);
238        }
239        return DEFAULT_SETTINGS;
240    }
241
242    // Save settings to storage
243    async function saveSettings(settings) {
244        try {
245            await GM.setValue('availability_settings', JSON.stringify(settings));
246            console.log('Settings saved:', settings);
247        } catch (error) {
248            console.error('Error saving settings:', error);
249        }
250    }
251
252    // Generate availability string
253    async function generateAvailability(settings) {
254        console.log('Generating availability with settings:', settings);
255
256        try {
257            // Show loading indicator
258            showLoadingIndicator();
259
260            // Get calendar events for the next 7 days
261            const events = await getCalendarEvents();
262            console.log('Found events:', events);
263
264            // Analyze availability
265            const availability = analyzeAvailability(events, settings);
266            console.log('Analyzed availability:', availability);
267
268            // Format availability string
269            const availabilityString = formatAvailabilityString(availability);
270            console.log('Formatted availability string:', availabilityString);
271
272            // Hide loading indicator
273            hideLoadingIndicator();
274
275            // Show result panel
276            showResultPanel(availabilityString);
277
278        } catch (error) {
279            console.error('Error generating availability:', error);
280            hideLoadingIndicator();
281            alert('Error generating availability. Please try again.');
282        }
283    }
284
285    // Get calendar events from the current view
286    function getCalendarEvents() {
287        const events = [];
288        
289        // Find all event elements in the calendar - updated selector
290        const eventElements = document.querySelectorAll('[data-eventchip][role="button"]');
291        
292        console.log(`Found ${eventElements.length} event elements`);
293
294        eventElements.forEach(eventEl => {
295            try {
296                // Get event details from the XuJrye div which contains accessible text
297                const accessibleText = eventEl.querySelector('.XuJrye')?.textContent || '';
298                
299                if (!accessibleText) return;
300
301                console.log('Processing event:', accessibleText);
302
303                // Parse event information from accessible text
304                const event = parseEventFromAccessibleText(eventEl, accessibleText);
305                if (event) {
306                    events.push(event);
307                    console.log('Parsed event:', event);
308                }
309            } catch (error) {
310                console.error('Error parsing event:', error);
311            }
312        });
313
314        return events;
315    }
316
317    // Parse event from accessible text
318    function parseEventFromAccessibleText(element, accessibleText) {
319        try {
320            // Extract time range (e.g., "2pm to 3:30pm")
321            const timeRangeMatch = accessibleText.match(/(\d{1,2}(?::\d{2})?(?:am|pm)?)\s+to\s+(\d{1,2}(?::\d{2})?(?:am|pm)?)/i);
322            
323            if (!timeRangeMatch) {
324                console.log('No time range found in:', accessibleText);
325                return null;
326            }
327
328            const startTimeStr = timeRangeMatch[1];
329            const endTimeStr = timeRangeMatch[2];
330
331            // Extract date (e.g., "January 14" or just the month/year)
332            const dateMatch = accessibleText.match(/([A-Z][a-z]+)\s+(\d{1,2})/);
333            const yearMatch = accessibleText.match(/\b(20\d{2})\b/);
334            
335            let eventDate;
336            if (dateMatch && yearMatch) {
337                const monthName = dateMatch[1];
338                const day = parseInt(dateMatch[2]);
339                const year = parseInt(yearMatch[1]);
340                
341                const monthIndex = new Date(Date.parse(monthName + ' 1, 2000')).getMonth();
342                eventDate = new Date(year, monthIndex, day);
343            } else {
344                // If we can't parse the date, try to find it from the parent column
345                const column = element.closest('[data-datekey]');
346                if (column) {
347                    const dateKey = parseInt(column.getAttribute('data-datekey'));
348                    eventDate = dateKeyToDate(dateKey);
349                } else {
350                    console.log('Could not determine event date');
351                    return null;
352                }
353            }
354
355            // Parse start and end times
356            const startTime = parseTimeString(startTimeStr);
357            const endTime = parseTimeString(endTimeStr);
358
359            if (startTime === null || endTime === null) {
360                console.log('Could not parse times:', startTimeStr, endTimeStr);
361                return null;
362            }
363
364            // Extract title
365            const titleElement = element.querySelector('.I0UMhf');
366            const title = titleElement?.textContent || 'Event';
367
368            return {
369                date: eventDate,
370                startTime: startTime,
371                endTime: endTime,
372                title: title
373            };
374        } catch (error) {
375            console.error('Error parsing event from accessible text:', error);
376            return null;
377        }
378    }
379
380    // Parse time string to hours (24h format)
381    function parseTimeString(timeStr) {
382        // Handle formats like "2pm", "3:30pm", "11am", "12:15pm"
383        const match = timeStr.match(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
384        if (!match) return null;
385
386        let hours = parseInt(match[1]);
387        const minutes = match[2] ? parseInt(match[2]) : 0;
388        const period = match[3] ? match[3].toLowerCase() : null;
389
390        // Convert to 24-hour format
391        if (period === 'pm' && hours !== 12) {
392            hours += 12;
393        } else if (period === 'am' && hours === 12) {
394            hours = 0;
395        }
396
397        return hours + minutes / 60;
398    }
399
400    // Parse event from element
401    function parseEventFromElement(element, ariaLabel) {
402        try {
403            // Get the date from the column
404            const column = element.closest('[data-datekey]');
405            if (!column) return null;
406
407            const dateKey = column.getAttribute('data-datekey');
408            
409            // Get time information from the element's position and aria-label
410            const timeMatch = ariaLabel.match(/(\d{1,2}):?(\d{2})?\s*(AM|PM)?/gi);
411            
412            if (!timeMatch || timeMatch.length < 1) return null;
413
414            // Parse start and end times
415            const startTime = parseTime(timeMatch[0]);
416            const endTime = timeMatch.length > 1 ? parseTime(timeMatch[1]) : null;
417
418            // Convert dateKey to actual date
419            const date = dateKeyToDate(parseInt(dateKey));
420
421            return {
422                date: date,
423                startTime: startTime,
424                endTime: endTime,
425                title: ariaLabel.split(',')[0] || 'Event'
426            };
427        } catch (error) {
428            console.error('Error parsing event from element:', error);
429            return null;
430        }
431    }
432
433    // Convert dateKey to Date object
434    function dateKeyToDate(dateKey) {
435        // Google Calendar dateKey is days since epoch (Jan 1, 1970)
436        const date = new Date(1970, 0, 1);
437        date.setDate(date.getDate() + dateKey);
438        return date;
439    }
440
441    // Parse time string to hours (24h format)
442    function parseTime(timeStr) {
443        const match = timeStr.match(/(\d{1,2}):?(\d{2})?\s*(AM|PM)?/i);
444        if (!match) return null;
445
446        let hours = parseInt(match[1]);
447        const minutes = match[2] ? parseInt(match[2]) : 0;
448        const period = match[3] ? match[3].toUpperCase() : null;
449
450        if (period === 'PM' && hours !== 12) {
451            hours += 12;
452        } else if (period === 'AM' && hours === 12) {
453            hours = 0;
454        }
455
456        return hours + minutes / 60;
457    }
458
459    // Analyze availability based on events and settings
460    function analyzeAvailability(events, settings) {
461        const { startHour, endHour, slotDensity } = settings;
462        const availability = [];
463
464        // Get total hours per day based on density
465        const hoursPerDay = {
466            'light': 2.5,
467            'medium': 4.5,
468            'high': 7
469        }[slotDensity];
470
471        // Get next 7 days starting from today
472        const today = new Date();
473        today.setHours(0, 0, 0, 0);
474
475        for (let i = 0; i < 7; i++) {
476            const date = new Date(today);
477            date.setDate(date.getDate() + i);
478
479            // Skip weekends for default behavior (can be customized)
480            const dayOfWeek = date.getDay();
481            if (dayOfWeek === 0 || dayOfWeek === 6) continue;
482
483            // Get events for this day
484            const dayEvents = events.filter(event => {
485                return event.date.toDateString() === date.toDateString();
486            });
487
488            // Find available slots
489            const daySlots = findAvailableSlots(date, dayEvents, startHour, endHour, hoursPerDay);
490            
491            if (daySlots.length > 0) {
492                availability.push({
493                    date: date,
494                    slots: daySlots
495                });
496            }
497        }
498
499        return availability;
500    }
501
502    // Find available slots for a specific day
503    function findAvailableSlots(date, events, startHour, endHour, maxHours) {
504        const slots = [];
505        const slotDuration = 1; // 1 hour slots
506
507        // Create array of busy times
508        const busyTimes = events.map(event => ({
509            start: event.startTime || 0,
510            end: event.endTime || event.startTime + 1
511        })).sort((a, b) => a.start - b.start);
512
513        // Find free slots
514        let currentTime = startHour;
515        let totalHours = 0;
516
517        while (currentTime < endHour && totalHours < maxHours) {
518            const slotEnd = currentTime + slotDuration;
519
520            // Check if this slot overlaps with any busy time
521            const isBusy = busyTimes.some(busy => {
522                return (currentTime >= busy.start && currentTime < busy.end) ||
523                       (slotEnd > busy.start && slotEnd <= busy.end) ||
524                       (currentTime <= busy.start && slotEnd >= busy.end);
525            });
526
527            if (!isBusy && slotEnd <= endHour) {
528                slots.push({
529                    start: currentTime,
530                    end: slotEnd
531                });
532                totalHours += slotDuration;
533                currentTime = slotEnd;
534            } else {
535                currentTime += 0.5; // Move forward by 30 minutes
536            }
537        }
538
539        // Merge continuous slots
540        return mergeContinuousSlots(slots);
541    }
542
543    // Merge continuous time slots
544    function mergeContinuousSlots(slots) {
545        if (slots.length === 0) return slots;
546
547        const merged = [];
548        let current = { ...slots[0] };
549
550        for (let i = 1; i < slots.length; i++) {
551            if (slots[i].start === current.end) {
552                // Continuous slot, extend the current one
553                current.end = slots[i].end;
554            } else {
555                // Gap found, save current and start new
556                merged.push(current);
557                current = { ...slots[i] };
558            }
559        }
560        
561        // Don't forget to add the last slot
562        merged.push(current);
563
564        return merged;
565    }
566
567    // Format availability string
568    function formatAvailabilityString(availability) {
569        if (availability.length === 0) {
570            return 'No availability found in the next 7 days.';
571        }
572
573        const lines = [];
574        const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
575        const timezoneAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop();
576
577        availability.forEach(day => {
578            const dayName = day.date.toLocaleDateString('en-US', { weekday: 'long' });
579            const dateStr = day.date.toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
580            
581            const timeSlots = day.slots.map(slot => {
582                const startTime = formatTime(slot.start);
583                const endTime = formatTime(slot.end);
584                return `${startTime}-${endTime}`;
585            }).join(' ');
586
587            lines.push(`${dayName} (${dateStr}): ${timeSlots}`);
588        });
589
590        return lines.join('\n') + `\n\nTimezone: ${timezoneAbbr}`;
591    }
592
593    // Format time in 12-hour format
594    function formatTime(hours) {
595        const h = Math.floor(hours);
596        const m = Math.round((hours - h) * 60);
597        const period = h >= 12 ? 'PM' : 'AM';
598        const displayHour = h === 0 ? 12 : h > 12 ? h - 12 : h;
599        const displayMinute = m > 0 ? `:${m.toString().padStart(2, '0')}` : '';
600        return `${displayHour}${displayMinute}${period}`;
601    }
602
603    // Show loading indicator
604    function showLoadingIndicator() {
605        const loader = document.createElement('div');
606        loader.id = 'availability-loader';
607        loader.style.cssText = `
608            position: fixed;
609            top: 50%;
610            left: 50%;
611            transform: translate(-50%, -50%);
612            background: white;
613            padding: 24px;
614            border-radius: 8px;
615            box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
616            z-index: 10001;
617            text-align: center;
618        `;
619        loader.innerHTML = `
620            <div style="font-size: 16px; color: #202124;">Analyzing your calendar...</div>
621            <div style="margin-top: 12px; color: #5f6368; font-size: 14px;">Please wait</div>
622        `;
623        document.body.appendChild(loader);
624    }
625
626    // Hide loading indicator
627    function hideLoadingIndicator() {
628        const loader = document.getElementById('availability-loader');
629        if (loader) {
630            loader.remove();
631        }
632    }
633
634    // Show result panel with availability string
635    function showResultPanel(availabilityString) {
636        // Create overlay
637        const overlay = document.createElement('div');
638        overlay.id = 'availability-result-panel';
639        overlay.style.cssText = `
640            position: fixed;
641            top: 0;
642            left: 0;
643            width: 100%;
644            height: 100%;
645            background: rgba(0, 0, 0, 0.5);
646            z-index: 10000;
647            display: flex;
648            align-items: center;
649            justify-content: center;
650        `;
651
652        // Create panel
653        const panel = document.createElement('div');
654        panel.style.cssText = `
655            background: white;
656            border-radius: 8px;
657            padding: 24px;
658            width: 500px;
659            max-width: 90%;
660            box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
661        `;
662
663        panel.innerHTML = `
664            <h2 style="margin: 0 0 16px 0; font-size: 20px; font-weight: 500; color: #202124;">Your Availability</h2>
665            
666            <textarea id="availability-text" readonly style="
667                width: 100%;
668                height: 200px;
669                padding: 12px;
670                border: 1px solid #dadce0;
671                border-radius: 4px;
672                font-family: monospace;
673                font-size: 13px;
674                resize: vertical;
675                background: #f8f9fa;
676                color: #202124;
677            ">${availabilityString}</textarea>
678
679            <div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 16px;">
680                <button id="close-result-btn" style="padding: 8px 16px; border: 1px solid #dadce0; background: white; border-radius: 4px; cursor: pointer; font-size: 14px; color: #5f6368;">Close</button>
681                <button id="copy-result-btn" style="padding: 8px 16px; border: none; background: #1a73e8; color: white; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500;">Copy to Clipboard</button>
682            </div>
683        `;
684
685        overlay.appendChild(panel);
686        document.body.appendChild(overlay);
687
688        // Add event listeners
689        document.getElementById('close-result-btn').addEventListener('click', () => {
690            overlay.remove();
691        });
692
693        document.getElementById('copy-result-btn').addEventListener('click', async () => {
694            try {
695                await GM.setClipboard(availabilityString);
696                const btn = document.getElementById('copy-result-btn');
697                btn.textContent = 'Copied!';
698                btn.style.background = '#0d652d';
699                setTimeout(() => {
700                    btn.textContent = 'Copy to Clipboard';
701                    btn.style.background = '#1a73e8';
702                }, 2000);
703            } catch (error) {
704                console.error('Error copying to clipboard:', error);
705                alert('Failed to copy to clipboard');
706            }
707        });
708
709        // Close on overlay click
710        overlay.addEventListener('click', (e) => {
711            if (e.target === overlay) {
712                overlay.remove();
713            }
714        });
715    }
716
717    // Observe calendar changes to re-add button if needed
718    function observeCalendarChanges() {
719        const debouncedCheck = debounce(() => {
720            if (!document.getElementById('availability-sharer-btn')) {
721                console.log('Button removed, re-adding...');
722                addAvailabilityButton();
723            }
724        }, 1000);
725
726        const observer = new MutationObserver(debouncedCheck);
727        
728        observer.observe(document.body, {
729            childList: true,
730            subtree: true
731        });
732
733        console.log('Calendar observer initialized');
734    }
735
736    // Start the extension
737    TM_runBody(init);
738
739})();