Last active
June 18, 2025 07:58
-
-
Save xbalajipge/e2039227ca57bfd12c3cd81eb281ef81 to your computer and use it in GitHub Desktop.
html-pagerduty-schedule-calendar.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>PagerDuty Schedule Calendar</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet"> | |
| <style> | |
| /* Generated by Copilot */ | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Roboto', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background: linear-gradient(135deg, #4e54c8 0%, #8f94fb 100%); | |
| min-height: 100vh; | |
| padding: 20px; | |
| } | |
| .container { | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| background: rgba(255, 255, 255, 1); | |
| border-radius: 20px; | |
| box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); | |
| overflow: hidden; | |
| backdrop-filter: none; | |
| } | |
| .header { | |
| background: linear-gradient(135deg, #36D1DC, #5B86E5); | |
| color: white; | |
| padding: 30px; | |
| text-align: left; | |
| } | |
| .header h1 { | |
| font-size: 2.5rem; | |
| margin-bottom: 10px; | |
| font-weight: 700; | |
| } | |
| .header p { | |
| font-size: 1.1rem; | |
| opacity: 0.9; | |
| } | |
| .api-section { | |
| background: linear-gradient(135deg, #009639, #06c755); | |
| padding: 30px; | |
| color: white; | |
| } | |
| .api-container { | |
| display: flex; | |
| gap: 15px; | |
| margin-bottom: 20px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| } | |
| .api-input { | |
| flex: 1; | |
| min-width: 300px; | |
| padding: 15px 20px; | |
| border: none; | |
| border-radius: 25px; | |
| font-size: 1.1rem; | |
| background: rgba(255, 255, 255, 0.1); | |
| color: white; | |
| backdrop-filter: blur(10px); | |
| transition: all 0.3s ease; | |
| } | |
| .api-input::placeholder { | |
| color: rgba(255, 255, 255, 0.7); | |
| } | |
| .api-input:focus { | |
| outline: none; | |
| background: rgba(255, 255, 255, 0.2); | |
| box-shadow: 0 0 20px rgba(255, 255, 255, 0.1); | |
| } | |
| .connect-btn { | |
| background: linear-gradient(135deg, #2c3e50, #34495e); | |
| color: white; | |
| border: none; | |
| padding: 15px 30px; | |
| border-radius: 25px; | |
| cursor: pointer; | |
| font-size: 1.1rem; | |
| font-weight: 600; | |
| transition: all 0.3s ease; | |
| min-width: 150px; | |
| } | |
| .connect-btn:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 5px 15px rgba(44, 62, 80, 0.4); | |
| } | |
| .connect-btn:disabled { | |
| opacity: 0.6; | |
| cursor: not-allowed; | |
| transform: none; | |
| } | |
| .status-indicator { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-top: 15px; | |
| } | |
| .status-dot { | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 50%; | |
| background: #e74c3c; | |
| transition: all 0.3s ease; | |
| } | |
| .status-dot.connected { | |
| background: #27ae60; | |
| box-shadow: 0 0 10px rgba(39, 174, 96, 0.5); | |
| } | |
| .status-text { | |
| font-size: 1rem; | |
| opacity: 0.9; | |
| } | |
| .calendar-controls { | |
| background: #f8f9fa; | |
| padding: 20px 30px; | |
| border-bottom: 1px solid #e9ecef; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| gap: 15px; | |
| } | |
| .month-navigation { | |
| display: flex; | |
| align-items: center; | |
| gap: 20px; | |
| } | |
| .nav-btn { | |
| background: linear-gradient(135deg, #3498db, #2980b9); | |
| color: white; | |
| border: none; | |
| padding: 10px 15px; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| font-size: 1.2rem; | |
| width: 45px; | |
| height: 45px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.3s ease; | |
| } | |
| .nav-btn:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4); | |
| } | |
| .current-month { | |
| font-size: 1.5rem; | |
| font-weight: 600; | |
| color: #2c3e50; | |
| min-width: 200px; | |
| text-align: center; | |
| } | |
| .view-controls { | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| } | |
| .refresh-btn { | |
| background: linear-gradient(135deg, #e67e22, #d35400); | |
| color: white; | |
| border: none; | |
| padding: 10px 20px; | |
| border-radius: 20px; | |
| cursor: pointer; | |
| font-size: 1rem; | |
| font-weight: 600; | |
| transition: all 0.3s ease; | |
| } | |
| .refresh-btn:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 5px 15px rgba(230, 126, 34, 0.4); | |
| } | |
| .legend { | |
| display: flex; | |
| gap: 15px; | |
| flex-wrap: wrap; | |
| } | |
| .legend-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 0.9rem; | |
| color: #7f8c8d; | |
| } | |
| .legend-color { | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 4px; | |
| } | |
| .calendar-container { | |
| padding: 30px; | |
| background: white; | |
| } | |
| .calendar { | |
| width: 100%; | |
| border-collapse: collapse; | |
| border-radius: 10px; | |
| overflow: hidden; | |
| box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); | |
| } | |
| .calendar-header { | |
| background: linear-gradient(135deg, #34495e, #2c3e50); | |
| color: white; | |
| } | |
| .calendar-header th { | |
| padding: 20px 10px; | |
| text-align: center; | |
| font-weight: 600; | |
| font-size: 1.1rem; | |
| } | |
| .calendar-day { | |
| border: 1px solid #e9ecef; | |
| vertical-align: top; | |
| height: 120px; | |
| position: relative; | |
| background: white; | |
| transition: all 0.3s ease; | |
| } | |
| .calendar-day:hover { | |
| background: #f8f9fa; | |
| box-shadow: inset 0 0 10px rgba(52, 152, 219, 0.1); | |
| } | |
| .calendar-day.other-month { | |
| background: #f8f9fa; | |
| color: #bdc3c7; | |
| } | |
| .calendar-day.today { | |
| background: white; | |
| color: #2980b9; | |
| border: 2px double #2980b9; | |
| font-weight: bold; | |
| } | |
| .calendar-day.today:hover { | |
| background: #f0f8ff; | |
| } | |
| .day-number { | |
| position: absolute; | |
| top: 8px; | |
| left: 12px; | |
| font-weight: 600; | |
| font-size: 1rem; | |
| } | |
| .today-text { | |
| font-size: 0.7rem; | |
| font-weight: 700; | |
| margin-left: 4px; | |
| } | |
| .day-content { | |
| margin-top: 30px; | |
| padding: 8px; | |
| height: calc(100% - 38px); | |
| overflow-y: auto; | |
| } | |
| .calendar-week { | |
| position: relative; | |
| height: 180px; | |
| } | |
| .schedule-band { | |
| position: absolute; | |
| height: 22px; | |
| margin-top: -1px; | |
| padding: 0 8px; | |
| border-radius: 4px; | |
| color: white; | |
| font-size: 0.85em; | |
| font-weight: 500; | |
| overflow: hidden; | |
| cursor: pointer; | |
| z-index: 0; | |
| display: flex; | |
| align-items: center; | |
| box-sizing: border-box; | |
| border: 1px solid rgba(0,0,0,0.1); | |
| transition: all 0.2s ease; | |
| } | |
| .schedule-band:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 3px 8px rgba(0,0,0,0.2); | |
| z-index: 1; | |
| } | |
| .custom-tooltip { | |
| position: fixed; | |
| display: none; | |
| background: rgba(44, 62, 80, 0.95); | |
| color: white; | |
| padding: 10px 15px; | |
| border-radius: 8px; | |
| font-size: 0.95rem; | |
| z-index: 2000; | |
| pointer-events: none; | |
| box-shadow: 0 5px 15px rgba(0,0,0,0.25); | |
| white-space: nowrap; | |
| transition: opacity 0.2s ease-in-out; | |
| backdrop-filter: blur(5px); | |
| } | |
| .schedule-band-text { | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .schedule-entry p { | |
| margin: 0; | |
| line-height: 1.3; | |
| } | |
| .schedule-name { | |
| font-size: 0.9em; | |
| color: #555; | |
| } | |
| .schedule-details { | |
| position: fixed; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| background: white; | |
| border-radius: 15px; | |
| padding: 25px; | |
| box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); | |
| z-index: 1000; | |
| max-width: 400px; | |
| width: 90%; | |
| display: none; | |
| } | |
| .schedule-details h3 { | |
| color: #2c3e50; | |
| margin-bottom: 15px; | |
| font-size: 1.3rem; | |
| } | |
| .detail-item { | |
| margin-bottom: 10px; | |
| padding: 8px 0; | |
| border-bottom: 1px solid #ecf0f1; | |
| } | |
| .detail-label { | |
| font-weight: 600; | |
| color: #7f8c8d; | |
| margin-bottom: 5px; | |
| } | |
| .detail-value { | |
| color: #2c3e50; | |
| } | |
| .close-details { | |
| position: absolute; | |
| top: 15px; | |
| right: 20px; | |
| background: none; | |
| border: none; | |
| font-size: 1.5rem; | |
| cursor: pointer; | |
| color: #bdc3c7; | |
| transition: color 0.3s ease; | |
| } | |
| .close-details:hover { | |
| color: #e74c3c; | |
| } | |
| .modal-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.5); | |
| z-index: 999; | |
| display: none; | |
| } | |
| .loading { | |
| text-align: center; | |
| padding: 40px; | |
| color: #7f8c8d; | |
| font-size: 1.2rem; | |
| } | |
| .error { | |
| text-align: center; | |
| padding: 20px; | |
| color: #e74c3c; | |
| background: #fdf2f2; | |
| border-radius: 10px; | |
| margin: 20px; | |
| } | |
| .no-data { | |
| text-align: left; | |
| padding: 40px; | |
| color: #7f8c8d; | |
| } | |
| .last-updated { | |
| position: absolute; | |
| top: 10px; | |
| right: 10px; | |
| font-size: 0.8rem; | |
| color: #7f8c8d; | |
| } | |
| @media (max-width: 1024px) { | |
| .calendar-day { | |
| height: 100px; | |
| } | |
| .schedule-item { | |
| font-size: 0.7rem; | |
| padding: 2px 6px; | |
| } | |
| .current-month { | |
| font-size: 1.3rem; | |
| } | |
| } | |
| @media (max-width: 768px) { | |
| .calendar-controls { | |
| flex-direction: column; | |
| gap: 15px; | |
| } | |
| .month-navigation { | |
| gap: 15px; | |
| } | |
| .current-month { | |
| font-size: 1.2rem; | |
| min-width: auto; | |
| } | |
| .calendar-day { | |
| height: 80px; | |
| } | |
| .day-content { | |
| margin-top: 25px; | |
| } | |
| .schedule-item { | |
| font-size: 0.6rem; | |
| padding: 1px 4px; | |
| margin: 1px 0; | |
| } | |
| .container { | |
| margin: 10px; | |
| border-radius: 15px; | |
| } | |
| .legend { | |
| justify-content: center; | |
| } | |
| } | |
| @media (max-width: 480px) { | |
| body { | |
| padding: 10px; | |
| } | |
| .header { | |
| padding: 20px; | |
| } | |
| .header h1 { | |
| font-size: 2rem; | |
| } | |
| .api-section { | |
| padding: 20px; | |
| } | |
| .calendar-container { | |
| padding: 15px; | |
| } | |
| .calendar-header th { | |
| padding: 15px 5px; | |
| font-size: 1rem; | |
| } | |
| .calendar-day { | |
| height: 70px; | |
| } | |
| .day-number { | |
| font-size: 0.9rem; | |
| top: 5px; | |
| left: 8px; | |
| } | |
| .day-content { | |
| margin-top: 20px; | |
| padding: 4px; | |
| } | |
| .schedule-item { | |
| font-size: 0.5rem; | |
| padding: 1px 3px; | |
| } | |
| .api-container { | |
| flex-direction: column; | |
| } | |
| .api-input { | |
| min-width: 100%; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="header"> | |
| <h1>📅 PagerDuty Schedule Calendar</h1> | |
| <p>View and manage your on-call schedules in a beautiful monthly calendar</p> | |
| </div> | |
| <div class="api-section"> | |
| <div class="api-container"> | |
| <input type="password" id="apiKeyInput" class="api-input" | |
| placeholder="Enter your PagerDuty API key..." | |
| maxlength="100"> | |
| <input type="text" id="scheduleIdsInput" class="api-input" | |
| placeholder="Comma-separated schedule IDs (e.g., P123456,PABCDEF)..."> | |
| <button class="connect-btn" id="connectBtn"> | |
| 🔗 Connect | |
| </button> | |
| <button class="connect-btn" id="disconnectBtn" style="display: none; background: linear-gradient(135deg, #e74c3c, #c0392b);"> | |
| 🔌 Disconnect | |
| </button> | |
| <button class="connect-btn" id="clearBtn" style="background: #6c757d;"> | |
| ✖️ Clear | |
| </button> | |
| </div> | |
| <div class="status-indicator"> | |
| <div class="status-dot" id="statusDot"></div> | |
| <span class="status-text" id="statusText">Not connected</span> | |
| </div> | |
| </div> | |
| <div class="calendar-controls" id="calendarControls" style="display: none;"> | |
| <div class="month-navigation"> | |
| <button class="nav-btn" id="prevBtn">❮</button> | |
| <div class="current-month" id="currentMonth">June 2025</div> | |
| <button class="nav-btn" id="nextBtn">❯</button> | |
| </div> | |
| </div> | |
| <div class="calendar-container" id="calendarContainer" style="display: none;"> | |
| <table class="calendar" id="calendar"> | |
| <thead class="calendar-header"> | |
| <tr> | |
| <th>Sunday</th> | |
| <th>Monday</th> | |
| <th>Tuesday</th> | |
| <th>Wednesday</th> | |
| <th>Thursday</th> | |
| <th>Friday</th> | |
| <th>Saturday</th> | |
| </tr> | |
| </thead> | |
| <tbody id="calendarBody"> | |
| <!-- Calendar days will be populated here --> | |
| </tbody> | |
| </table> | |
| <div class="last-updated" id="lastUpdated"> | |
| Last updated: -- | |
| </div> | |
| </div> | |
| <div id="errorBanner" class="error-banner" style="display: none;"></div> | |
| <div id="welcomeMessage" class="no-data"> | |
| <h2>🔐 Connect to PagerDuty</h2> | |
| <p>Enter your PagerDuty API key and a comma-separated list of schedule IDs above to view your team's on-call schedules.</p> | |
| <br> | |
| <h4 id="toggleHelp" style="cursor: pointer; color: #3498db;">▼ How to get your API key & Schedule ID</h4> | |
| <div id="helpContent" style="display: none; text-align: left; margin-top: 15px;"> | |
| <p>1. Log in to your PagerDuty account</p> | |
| <p>2. For API Key: Go to Configuration → API Access and create a new key.</p> | |
| <p>3. For Schedule ID: Go to People → On-Call Schedules. The ID (e.g., P123456) is in the URL of the schedule page.</p> | |
| <p>4. Copy and paste the key and IDs above</p> | |
| <br> | |
| <p><strong>Alternative Usage (URL Hash):</strong></p> | |
| <p>You can also provide credentials directly in the URL hash:</p> | |
| <p><code>#apiKey=YOUR_API_KEY&schedules=ID1,ID2</code></p> | |
| <p>To display only the calendar, add `&view=calendar` to the URL hash.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Modal for schedule details --> | |
| <div class="modal-overlay" id="modalOverlay"></div> | |
| <div class="schedule-details" id="scheduleDetails"> | |
| <button class="close-details">×</button> | |
| <h3 id="detailTitle">Schedule Details</h3> | |
| <div id="detailContent"> | |
| <!-- Details will be populated here --> | |
| </div> | |
| </div> | |
| <div id="customTooltip" class="custom-tooltip"></div> | |
| <script> | |
| // Generated by Copilot | |
| class CalendarApp { | |
| constructor() { | |
| this._apiKey = ''; | |
| this._scheduleIds = []; | |
| this._isConnected = false; | |
| this._currentDate = new Date(); | |
| this._processedSchedules = []; | |
| this._scheduleNames = new Map(); | |
| this._lastUpdated = null; | |
| this._isLoading = false; | |
| this._autoRefreshInterval = null; | |
| this._debounceTimeout = null; | |
| this.cacheDom(); | |
| this.bindEvents(); | |
| this.init(); | |
| } | |
| cacheDom() { | |
| this._apiKeyInput = document.getElementById('apiKeyInput'); | |
| this._scheduleIdsInput = document.getElementById('scheduleIdsInput'); | |
| this._connectButton = document.getElementById('connectBtn'); | |
| this._disconnectButton = document.getElementById('disconnectBtn'); | |
| this._clearButton = document.getElementById('clearBtn'); | |
| this._statusDot = document.getElementById('statusDot'); | |
| this._statusText = document.getElementById('statusText'); | |
| this._calendarContainer = document.getElementById('calendarContainer'); | |
| this._calendarBody = document.getElementById('calendarBody'); | |
| this._monthYear = document.getElementById('currentMonth'); | |
| this._prevMonthBtn = document.getElementById('prevBtn'); | |
| this._nextMonthBtn = document.getElementById('nextBtn'); | |
| this._modal = document.getElementById('scheduleDetails'); | |
| this._modalContent = document.getElementById('detailContent'); | |
| this._modalClose = document.querySelector('.close-details'); | |
| this._modalOverlay = document.getElementById('modalOverlay'); | |
| this._welcomeMessage = document.getElementById('welcomeMessage'); | |
| this._calendarControls = document.getElementById('calendarControls'); | |
| this._lastUpdated = document.getElementById('lastUpdated'); | |
| this._loadingIndicator = document.getElementById('loadingIndicator'); | |
| this._errorBanner = document.getElementById('errorBanner'); | |
| this._toggleHelp = document.getElementById('toggleHelp'); | |
| this._helpContent = document.getElementById('helpContent'); | |
| this._tooltip = document.getElementById('customTooltip'); | |
| } | |
| bindEvents() { | |
| this._connectButton.addEventListener('click', () => this.connectToPagerDuty()); | |
| this._disconnectButton.addEventListener('click', () => this.disconnect()); | |
| this._clearButton.addEventListener('click', () => this.clearForm()); | |
| this._apiKeyInput.addEventListener('input', () => this.handleInputChange()); | |
| this._scheduleIdsInput.addEventListener('input', () => this.handleInputChange()); | |
| this._apiKeyInput.addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') this.connectToPagerDuty(); | |
| }); | |
| this._scheduleIdsInput.addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') this.connectToPagerDuty(); | |
| }); | |
| this._prevMonthBtn.addEventListener('click', () => this.previousMonth()); | |
| this._nextMonthBtn.addEventListener('click', () => this.nextMonth()); | |
| this._modalClose.addEventListener('click', () => this.closeScheduleDetails()); | |
| this._toggleHelp.addEventListener('click', () => this.toggleHelp()); | |
| this._calendarBody.addEventListener('mouseover', (e) => this.handleBandMouseOver(e)); | |
| this._calendarBody.addEventListener('mouseout', (e) => this.handleBandMouseOut(e)); | |
| this._calendarBody.addEventListener('mousemove', (e) => this.handleBandMouseMove(e)); | |
| this._calendarBody.addEventListener('click', (e) => { | |
| const scheduleBand = e.target.closest('.schedule-band'); | |
| if (scheduleBand && scheduleBand.dataset.scheduleId) { | |
| this.showScheduleDetails(scheduleBand.dataset.scheduleId); | |
| } | |
| }); | |
| document.addEventListener('keydown', (e) => this.handleKeyboardShortcuts(e)); | |
| document.addEventListener('click', (e) => this.handleOutsideClick(e)); | |
| } | |
| handleKeyboardShortcuts(e) { | |
| if (this._isLoading) return; | |
| const activeEl = document.activeElement; | |
| if (activeEl === this._apiKeyInput || activeEl === this._scheduleIdsInput) { | |
| return; | |
| } | |
| if (this._isConnected) { | |
| if (e.key === 'ArrowLeft') { | |
| e.preventDefault(); | |
| this.previousMonth(); | |
| } | |
| if (e.key === 'ArrowRight') { | |
| e.preventDefault(); | |
| this.nextMonth(); | |
| } | |
| if (e.key === 'Escape') { | |
| this.closeScheduleDetails(); | |
| } | |
| } | |
| } | |
| handleOutsideClick(e) { | |
| if (e.target === this._modalOverlay) { | |
| this.closeScheduleDetails(); | |
| } | |
| } | |
| handleInputChange() { | |
| clearTimeout(this._debounceTimeout); | |
| this._debounceTimeout = setTimeout(() => { | |
| const apiKey = this._apiKeyInput.value.trim(); | |
| const scheduleIds = this._scheduleIdsInput.value.trim(); | |
| if (apiKey && scheduleIds) { | |
| this.connectToPagerDuty(); | |
| } | |
| }, 1200); | |
| } | |
| handleBandMouseOver(e) { | |
| const scheduleBand = e.target.closest('.schedule-band'); | |
| if (scheduleBand) { | |
| const scheduleId = scheduleBand.dataset.scheduleId; | |
| const schedule = this._processedSchedules.find(s => s.id === scheduleId); | |
| if (schedule) { | |
| const scheduleName = this._scheduleNames.get(schedule.scheduleId) || 'Unnamed Schedule'; | |
| this._tooltip.innerHTML = `<strong>${scheduleName}</strong><br>${schedule.person}`; | |
| this._tooltip.style.display = 'block'; | |
| } | |
| } | |
| } | |
| handleBandMouseOut(e) { | |
| const scheduleBand = e.target.closest('.schedule-band'); | |
| if (scheduleBand) { | |
| this._tooltip.style.display = 'none'; | |
| } | |
| } | |
| handleBandMouseMove(e) { | |
| if (this._tooltip.style.display === 'block') { | |
| this._tooltip.style.left = `${e.clientX + 15}px`; | |
| this._tooltip.style.top = `${e.clientY + 15}px`; | |
| } | |
| } | |
| toggleHelp() { | |
| const isHidden = this._helpContent.style.display === 'none'; | |
| this._helpContent.style.display = isHidden ? 'block' : 'none'; | |
| this._toggleHelp.textContent = isHidden ? '▲ How to get your API key & Schedule ID' : '▼ How to get your API key & Schedule ID'; | |
| } | |
| async init() { | |
| const fromUrl = this.readUrlHash(); | |
| const fromCookie = this.readCookie(); | |
| if (fromUrl.view === 'calendar') { | |
| document.querySelector('.header').style.display = 'none'; | |
| document.querySelector('.api-section').style.display = 'none'; | |
| this._welcomeMessage.style.display = 'none'; | |
| } | |
| const apiKey = fromUrl.apiKey || fromCookie.apiKey; | |
| const schedules = fromUrl.schedules || fromCookie.schedules; | |
| if (apiKey) { | |
| this._apiKeyInput.value = apiKey; | |
| } | |
| if (schedules) { | |
| this._scheduleIdsInput.value = schedules; | |
| } | |
| if (apiKey && schedules) { | |
| await this.connectToPagerDuty(); | |
| } | |
| } | |
| readUrlHash() { | |
| if (!window.location.hash) return { apiKey: null, schedules: null, view: null }; | |
| const hash = window.location.hash.substring(1); | |
| const params = new URLSearchParams(hash); | |
| const apiKey = params.get('apiKey'); | |
| const schedules = params.get('schedules'); | |
| const view = params.get('view'); | |
| return { apiKey, schedules, view }; | |
| } | |
| clearForm() { | |
| this._apiKeyInput.value = ''; | |
| this._scheduleIdsInput.value = ''; | |
| } | |
| saveToCookie() { | |
| document.cookie = `pagerduty_apiKey=${this._apiKey};max-age=31536000;path=/`; | |
| document.cookie = `pagerduty_scheduleIds=${this._scheduleIds.join(',')};max-age=31536000;path=/`; | |
| } | |
| readCookie() { | |
| const cookies = document.cookie.split(';').reduce((acc, cookie) => { | |
| const [key, value] = cookie.trim().split('='); | |
| acc[key] = value; | |
| return acc; | |
| }, {}); | |
| return { apiKey: cookies.pagerduty_apiKey, schedules: cookies.pagerduty_scheduleIds }; | |
| } | |
| async connectToPagerDuty() { | |
| const apiKey = this._apiKeyInput.value.trim(); | |
| const scheduleIds = this._scheduleIdsInput.value.trim(); | |
| console.log("Attempting to connect to PagerDuty..."); | |
| if (!apiKey) { | |
| this.showError('Please enter your PagerDuty API Key.'); | |
| return; | |
| } | |
| this._apiKey = apiKey; | |
| this._scheduleIds = scheduleIds.split(',').map(s => s.trim()).filter(Boolean); | |
| if (this._scheduleIds.length === 0) { | |
| this.showError('Please enter at least one valid Schedule ID.'); | |
| return; | |
| } | |
| this._isLoading = true; | |
| this.showLoadingState(); | |
| this.hideError(); | |
| try { | |
| await this.validateAndFetchScheduleNames(); | |
| this._isConnected = true; | |
| this.saveToCookie(); | |
| this.showConnectedState(); | |
| await this.loadSchedules(); | |
| } catch (error) { | |
| this.showError(error.message); | |
| this._isConnected = false; | |
| this.showDisconnectedState(); | |
| } finally { | |
| this._isLoading = false; | |
| this.hideLoadingState(); | |
| } | |
| } | |
| async validateAndFetchScheduleNames() { | |
| console.log("Validating API key and fetching schedule names..."); | |
| this._scheduleNames.clear(); | |
| const promises = this._scheduleIds.map(async (id, index) => { | |
| const url = `https://api.pagerduty.com/schedules/${id}`; | |
| console.log(`Fetching schedule details from ${url}`); | |
| const response = await fetch(url, { | |
| headers: { | |
| 'Authorization': `Token token=${this._apiKey}`, | |
| 'Accept': 'application/vnd.pagerduty+json;version=2', | |
| 'Content-Type': 'application/json' | |
| } | |
| }); | |
| if (response.status === 401) { | |
| throw new Error('Authentication failed. Please check your API Key.'); | |
| } | |
| if (!response.ok) { | |
| console.error(`Failed to fetch schedule ${id}: ${response.statusText}`); | |
| throw new Error(`Could not fetch details for schedule ID ${id}. Please verify the ID and your API key permissions.`); | |
| } | |
| const data = await response.json(); | |
| const scheduleName = data.schedule.name; | |
| console.log(`Fetched name for schedule ${id}: ${scheduleName}`); | |
| this._scheduleNames.set(id, scheduleName); | |
| }); | |
| await Promise.all(promises); | |
| console.log("Successfully fetched all schedule names."); | |
| } | |
| disconnect() { | |
| this._isConnected = false; | |
| this._apiKey = ''; | |
| this._scheduleIds = []; | |
| this._apiKeyInput.value = ''; | |
| this._scheduleIdsInput.value = ''; | |
| document.cookie = 'pagerduty_apiKey=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/'; | |
| document.cookie = 'pagerduty_scheduleIds=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/'; | |
| this.showDisconnectedState(); | |
| } | |
| showConnectedState() { | |
| this._statusDot.classList.add('connected'); | |
| this._statusText.textContent = 'Connected to PagerDuty'; | |
| this._calendarControls.style.display = 'flex'; | |
| this._calendarContainer.style.display = 'block'; | |
| this._welcomeMessage.style.display = 'none'; | |
| this._connectButton.style.display = 'none'; | |
| this._disconnectButton.style.display = 'block'; | |
| } | |
| showDisconnectedState() { | |
| this._statusDot.classList.remove('connected'); | |
| this._statusText.textContent = 'Not connected'; | |
| this._calendarControls.style.display = 'none'; | |
| this._calendarContainer.style.display = 'none'; | |
| this._welcomeMessage.style.display = 'block'; | |
| this._connectButton.style.display = 'block'; | |
| this._disconnectButton.style.display = 'none'; | |
| } | |
| showError(message) { | |
| this._errorBanner.textContent = message; | |
| this._errorBanner.style.display = 'block'; | |
| console.error("Error displayed:", message); | |
| } | |
| hideError() { | |
| this._errorBanner.style.display = 'none'; | |
| } | |
| showLoadingState() { | |
| this._connectButton.disabled = true; | |
| } | |
| hideLoadingState() { | |
| this._connectButton.textContent = '🔗 Connect'; | |
| this._connectButton.disabled = false; | |
| } | |
| renderCalendar() { | |
| this._calendarBody.innerHTML = ''; | |
| this.updateCurrentMonthDisplay(); | |
| const year = this._currentDate.getFullYear(); | |
| const month = this._currentDate.getMonth(); | |
| const firstDayOfMonth = new Date(year, month, 1); | |
| const calendarStartDate = new Date(firstDayOfMonth); | |
| calendarStartDate.setDate(calendarStartDate.getDate() - firstDayOfMonth.getDay()); | |
| const dateGrid = []; | |
| const weekElements = []; | |
| for (let week = 0; week < 6; week++) { | |
| const row = document.createElement('tr'); | |
| row.className = 'calendar-week'; | |
| const weekGrid = []; | |
| for (let day = 0; day < 7; day++) { | |
| const cellDate = new Date(calendarStartDate); | |
| cellDate.setDate(calendarStartDate.getDate() + (week * 7) + day); | |
| const cell = this.createCalendarCell(cellDate, month); | |
| row.appendChild(cell); | |
| weekGrid.push({ date: cellDate, element: cell }); | |
| } | |
| this._calendarBody.appendChild(row); | |
| dateGrid.push(weekGrid); | |
| weekElements.push(row); | |
| } | |
| this.renderScheduleBands(dateGrid, weekElements); | |
| } | |
| createCalendarCell(date, currentMonth) { | |
| const cell = document.createElement('td'); | |
| cell.className = 'calendar-day'; | |
| cell.dataset.date = this.formatDateKey(date); | |
| if (date.getMonth() !== currentMonth) { | |
| cell.classList.add('other-month'); | |
| } | |
| const today = new Date(); | |
| if (this.isSameDay(date, today)) { | |
| cell.classList.add('today'); | |
| } | |
| const dayNumber = document.createElement('div'); | |
| dayNumber.className = 'day-number'; | |
| dayNumber.textContent = date.getDate(); | |
| if (this.isSameDay(date, today)) { | |
| const todayText = document.createElement('span'); | |
| todayText.className = 'today-text'; | |
| todayText.textContent = 'Today'; | |
| dayNumber.appendChild(todayText); | |
| } | |
| cell.appendChild(dayNumber); | |
| return cell; | |
| } | |
| renderScheduleBands(dateGrid, weekElements) { | |
| const sortedSchedules = this._processedSchedules; | |
| sortedSchedules.forEach(schedule => { | |
| let loopStart = new Date(schedule.start); | |
| while (loopStart < schedule.end) { | |
| const calendarStart = dateGrid[0][0].date; | |
| const weekIndex = this.getWeekIndex(loopStart, calendarStart); | |
| if (weekIndex < 0 || weekIndex >= 6) { | |
| const d = new Date(loopStart); | |
| d.setDate(d.getDate() + (7 - d.getDay())); | |
| d.setHours(0, 0, 0, 0); | |
| if (d <= loopStart) { | |
| d.setDate(d.getDate() + 7); | |
| } | |
| loopStart = d; | |
| continue; | |
| } | |
| const weekStart = dateGrid[weekIndex][0].date; | |
| const weekEnd = new Date(dateGrid[weekIndex][6].date); | |
| weekEnd.setHours(23, 59, 59, 999); | |
| const segmentStart = loopStart > weekStart ? loopStart : weekStart; | |
| const segmentEnd = schedule.end < weekEnd ? schedule.end : weekEnd; | |
| const slotIndex = schedule.slotIndex; | |
| const band = document.createElement('div'); | |
| band.className = 'schedule-band'; | |
| band.style.backgroundColor = schedule.color; | |
| band.style.top = `${35 + slotIndex * 24}px`; | |
| band.dataset.scheduleId = schedule.id; | |
| const totalHoursInWeek = 7 * 24; | |
| const startHourInWeek = segmentStart.getDay() * 24 + segmentStart.getHours() + segmentStart.getMinutes() / 60; | |
| const endHourInWeek = segmentEnd.getDay() * 24 + segmentEnd.getHours() + segmentEnd.getMinutes() / 60; | |
| const leftPercent = (startHourInWeek / totalHoursInWeek) * 100; | |
| let widthPercent = ((endHourInWeek - startHourInWeek) / totalHoursInWeek) * 100; | |
| if (widthPercent < 0) { widthPercent = 0; } // Should not happen with correct logic | |
| band.style.left = `${leftPercent}%`; | |
| band.style.width = `${widthPercent}%`; | |
| if (schedule.person) { | |
| const bandText = document.createElement('span'); | |
| bandText.className = 'schedule-band-text'; | |
| bandText.textContent = schedule.person; | |
| band.appendChild(bandText); | |
| } | |
| weekElements[weekIndex].appendChild(band); | |
| loopStart = new Date(segmentEnd); | |
| if (loopStart.getTime() === weekEnd.getTime()) { | |
| loopStart.setMilliseconds(loopStart.getMilliseconds() + 1); | |
| } | |
| } | |
| }); | |
| } | |
| prepareSchedulesForRender() { | |
| const sortedSchedules = [...this._processedSchedules].sort((a, b) => a.start - b.start); | |
| // --- Slot Assignment --- | |
| const slots = []; | |
| sortedSchedules.forEach(schedule => { | |
| let assignedToSlot = false; | |
| for (let i = 0; i < slots.length; i++) { | |
| const lastScheduleInSlot = slots[i][slots[i].length - 1]; | |
| if (lastScheduleInSlot.end.getTime() <= schedule.start.getTime()) { | |
| schedule.slotIndex = i; | |
| slots[i].push(schedule); | |
| assignedToSlot = true; | |
| break; | |
| } | |
| } | |
| if (!assignedToSlot) { | |
| schedule.slotIndex = slots.length; | |
| slots.push([schedule]); | |
| } | |
| }); | |
| // --- Color Assignment --- | |
| const colorPalette = ['#e6194B', '#3cb44b', '#ffc107', '#4363d8', '#f58231', '#911eb4', '#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', '#dcbeff', '#9A6324', '#800000', '#aaffc3', '#808000', '#ffd8b1', '#000075', '#a9a9a9']; | |
| const personLastColor = new Map(); | |
| const activeBands = []; | |
| sortedSchedules.forEach(schedule => { | |
| let i = activeBands.length; | |
| while (i--) { | |
| if (activeBands[i].end < schedule.start) { | |
| activeBands.splice(i, 1); | |
| } | |
| } | |
| const unavailableColors = new Set(activeBands.map(s => s.color)); | |
| const lastColor = personLastColor.get(schedule.person); | |
| if (lastColor) { | |
| unavailableColors.add(lastColor); | |
| } | |
| let assignedColor = colorPalette[0]; | |
| for (const color of colorPalette) { | |
| if (!unavailableColors.has(color)) { | |
| assignedColor = color; | |
| break; | |
| } | |
| } | |
| schedule.color = assignedColor; | |
| personLastColor.set(schedule.person, assignedColor); | |
| activeBands.push(schedule); | |
| }); | |
| this._processedSchedules = sortedSchedules; | |
| } | |
| getWeekIndex(date, calendarStartDate) { | |
| const diffTime = new Date(date).setHours(0,0,0,0) - new Date(calendarStartDate).setHours(0,0,0,0); | |
| return Math.floor((diffTime / (1000 * 60 * 60 * 24)) / 7); | |
| } | |
| isSameDayOrBefore(d1, d2) { | |
| const date1 = new Date(d1); date1.setHours(0,0,0,0); | |
| const date2 = new Date(d2); date2.setHours(0,0,0,0); | |
| return date1 <= date2; | |
| } | |
| isSameDayOrAfter(d1, d2) { | |
| const date1 = new Date(d1); date1.setHours(0,0,0,0); | |
| const date2 = new Date(d2); date2.setHours(0,0,0,0); | |
| return date1 >= date2; | |
| } | |
| updateCurrentMonthDisplay() { | |
| this._monthYear.textContent = this._currentDate.toLocaleString('default', { month: 'long', year: 'numeric' }); | |
| } | |
| previousMonth() { | |
| this._currentDate.setMonth(this._currentDate.getMonth() - 1); | |
| this.loadSchedules(); | |
| } | |
| nextMonth() { | |
| this._currentDate.setMonth(this._currentDate.getMonth() + 1); | |
| this.loadSchedules(); | |
| } | |
| refreshSchedules() { | |
| console.log("Refreshing schedules..."); | |
| this.loadSchedules(); | |
| } | |
| showScheduleDetails(scheduleId) { | |
| const schedule = this._processedSchedules.find(s => s.id === scheduleId); | |
| if (schedule) { | |
| const scheduleName = this._scheduleNames.get(schedule.scheduleId) || 'Unknown Schedule'; | |
| this._modalContent.innerHTML = ` | |
| <h3>${schedule.person}</h3> | |
| <p><strong>Schedule:</strong> ${scheduleName} (${schedule.scheduleId})</p> | |
| <p><strong>On-Call Time (UTC):</strong></p> | |
| <p>${new Date(schedule.fullStart).toUTCString()}</p> | |
| <p>to</p> | |
| <p>${new Date(schedule.fullEnd).toUTCString()}</p> | |
| <p><a href="${schedule.contactUrl}" target="_blank" rel="noopener noreferrer">Contact via PagerDuty</a></p> | |
| `; | |
| this._modal.style.display = 'block'; | |
| this._modalOverlay.style.display = 'block'; | |
| } | |
| } | |
| closeScheduleDetails() { | |
| this._modal.style.display = 'none'; | |
| this._modalOverlay.style.display = 'none'; | |
| } | |
| updateLastUpdatedTime() { | |
| this._lastUpdated.textContent = `Last updated: ${new Date().toLocaleTimeString()}`; | |
| } | |
| async loadSchedules() { | |
| if (!this._isConnected) return; | |
| this._processedSchedules = []; | |
| console.log(`Loading schedules for month: ${this._currentDate.toLocaleString('default', { month: 'long', year: 'numeric' })}`); | |
| try { | |
| const schedulePromises = this._scheduleIds.map(id => this.fetchScheduleEntries(id)); | |
| await Promise.all(schedulePromises); | |
| this.prepareSchedulesForRender(); | |
| console.log("All schedules loaded and processed."); | |
| this.renderCalendar(); | |
| this.updateLastUpdatedTime(); | |
| } catch (error) { | |
| this.showError(error.message || 'Failed to load schedules from PagerDuty'); | |
| } | |
| } | |
| async fetchScheduleEntries(scheduleId) { | |
| const year = this._currentDate.getFullYear(); | |
| const month = this._currentDate.getMonth(); | |
| // Get the start of the first day of the week of the first of the month | |
| const firstDayOfMonth = new Date(year, month, 1); | |
| const sinceDate = new Date(firstDayOfMonth); | |
| sinceDate.setDate(sinceDate.getDate() - firstDayOfMonth.getDay()); | |
| // Get the end of the last day of the week of the last of the month | |
| const lastDayOfMonth = new Date(year, month + 1, 0); | |
| const untilDate = new Date(lastDayOfMonth); | |
| untilDate.setDate(untilDate.getDate() + (6 - lastDayOfMonth.getDay())); | |
| untilDate.setHours(23, 59, 59, 999); | |
| const since = sinceDate.toISOString(); | |
| const until = untilDate.toISOString(); | |
| const url = `https://api.pagerduty.com/schedules/${scheduleId}?since=${since}&until=${until}&time_zone=UTC&limit=1000`; | |
| console.log(`Fetching schedule entries for ID ${scheduleId} with URL: ${url}`); | |
| const response = await fetch(url, { | |
| headers: { | |
| 'Authorization': `Token token=${this._apiKey}`, | |
| 'Accept': 'application/vnd.pagerduty+json;version=2', | |
| 'Content-Type': 'application/json' | |
| } | |
| }); | |
| console.log(`Response status for ${scheduleId}: ${response.status}`); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| console.error(`Error fetching schedule ${scheduleId}:`, errorText); | |
| throw new Error(`Failed to fetch schedule ${scheduleId}: ${response.statusText}`); | |
| } | |
| const data = await response.json(); | |
| console.log(`Received data for schedule ${scheduleId}:`, data); | |
| this.processScheduleEntries(data.schedule.final_schedule.rendered_schedule_entries, scheduleId); | |
| } | |
| processScheduleEntries(entries, scheduleId) { | |
| console.log(`Processing ${entries.length} entries for schedule ${scheduleId}`); | |
| entries.forEach(entry => { | |
| const startDate = new Date(entry.start); | |
| const endDate = new Date(entry.end); | |
| this._processedSchedules.push({ | |
| id: `${scheduleId}-${entry.user.id}-${entry.start}`, | |
| person: entry.user.summary, | |
| start: startDate, | |
| end: endDate, | |
| scheduleId: scheduleId, | |
| scheduleName: this._scheduleNames.get(scheduleId) || 'Unknown', | |
| contactUrl: entry.user.html_url, | |
| fullStart: entry.start, | |
| fullEnd: entry.end | |
| }); | |
| }); | |
| console.log(`Finished processing entries for schedule ${scheduleId}`); | |
| } | |
| isSameDay(d1, d2) { | |
| return d1.getFullYear() === d2.getFullYear() && | |
| d1.getMonth() === d2.getMonth() && | |
| d1.getDate() === d2.getDate(); | |
| } | |
| formatDateKey(date) { | |
| return date.toISOString().split('T')[0]; | |
| } | |
| } | |
| document.addEventListener('DOMContentLoaded', () => { | |
| const app = new CalendarApp(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment