Skip to content

Instantly share code, notes, and snippets.

@xbalajipge
Last active June 18, 2025 07:58
Show Gist options
  • Select an option

  • Save xbalajipge/e2039227ca57bfd12c3cd81eb281ef81 to your computer and use it in GitHub Desktop.

Select an option

Save xbalajipge/e2039227ca57bfd12c3cd81eb281ef81 to your computer and use it in GitHub Desktop.
html-pagerduty-schedule-calendar.html
<!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