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})();