// ==UserScript==
// @name Hyatt Points Calendar Visualizer
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Displays an availability-aware Hyatt points calendar in a modal window. Supports all room types.
// @author Wolrd of Haiyaa
// @match https://www.hyatt.com/explore-hotels/rate-calendar*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect www.hyatt.com
// ==/UserScript==
(function() {
'use strict';
GM_addStyle(`
.haiyaa-trigger-btn {
background-color: #0D2D52; color: white; border: none; border-radius: 6px;
padding: 6px 14px; font-size: 14px; font-weight: bold; cursor: pointer;
margin-left: 20px; transition: all 0.2s;
}
.haiyaa-trigger-btn:hover { background-color: #1a4a87; }
.haiyaa-trigger-btn:disabled { background-color: #888; cursor: not-allowed; }
.visualizer-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background-color: rgba(0, 0, 0, 0.6); z-index: 9999; display: flex;
justify-content: center; align-items: center;
}
.visualizer-modal {
background-color: #ffffff; padding: 25px; border-radius: 12px;
box-shadow: 0 5px 20px rgba(0,0,0,0.3); width: auto; max-width: 95vw;
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #e0e0e0;
}
.modal-header h3 { margin: 0; font-size: 18px; }
.modal-header select { margin-left: 20px; padding: 5px; border-radius: 5px; border: 1px solid #ccc; }
.modal-close-btn { font-size: 24px; font-weight: bold; color: #888; cursor: pointer; border: none; background: none; }
.modal-close-btn:hover { color: #000; }
.calendar-chart-container {
display: grid; grid-template-areas: "empty months" "weeks grid";
grid-template-columns: auto 1fr; grid-template-rows: auto 1fr;
gap: 5px 3px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
.calendar-months {
grid-area: months; position: relative; height: 15px;
}
.calendar-months .month {
position: absolute; top: 0; font-size: 11px; color: #555;
}
.calendar-weeks { grid-area: weeks; font-size: 11px; color: #555; }
.points-calendar-grid { grid-area: grid; }
.calendar-weeks .week-day { height: 14px; margin-bottom: 3px; display: flex; align-items: center; }
.points-calendar-grid {
display: grid; grid-template-columns: repeat(53, 14px); grid-template-rows: repeat(7, 14px);
grid-auto-flow: column; grid-gap: 3px;
}
.calendar-day {
width: 14px; height: 14px; background-color: transparent;
border: 1px solid #e1e4e8; border-radius: 3px; transition: all 0.1s;
}
.calendar-day.clickable:hover {
transform: scale(1.2); box-shadow: 0 0 5px rgba(0,0,0,0.5); cursor: pointer;
}
.calendar-day.unavailable {
background-image: repeating-linear-gradient(45deg, rgba(255,255,255,0.7), rgba(255,255,255,0.7) 2px, transparent 2px, transparent 4px);
cursor: not-allowed;
}
.calendar-day.off-peak { background-color: #216e39; border-color: transparent; }
.calendar-day.standard { background-color: #9be9a8; border-color: transparent; }
.calendar-day.peak { background-color: #ff9800; border-color: transparent; }
.calendar-legend {
display: flex; justify-content: center; align-items: center;
font-size: 12px; margin-top: 20px; color: #555;
}
.legend-item { display: flex; align-items: center; margin: 0 8px; }
.legend-color-box { width: 14px; height: 14px; border-radius: 3px; margin-right: 5px; }
.availability-disclaimer {
font-size: 11px;
color: #888;
text-align: center;
margin-top: 15px;
padding-top: 10px;
border-top: 1px solid #eee;
}
.hidden { display: none !important; }
`);
function getHotelCodeFromURL() {
return new URLSearchParams(window.location.search).get('spiritCode');
}
async function fetchPriceData(hotelCode) {
const CHUNK_DAYS = 70;
const promises = [];
let currentDate = new Date();
for (let i = 0; i < 6; i++) {
const startDate = new Date(currentDate);
const endDate = new Date(currentDate);
endDate.setDate(endDate.getDate() + CHUNK_DAYS);
const startDateStr = startDate.toISOString().split('T')[0];
const endDateStr = endDate.toISOString().split('T')[0];
const apiUrl = `https://www.hyatt.com/explore-hotels/service/avail?spiritCode=${hotelCode}&startDate=${startDateStr}&endDate=${endDateStr}`;
promises.push(fetchSingleChunk(apiUrl));
currentDate.setDate(currentDate.getDate() + CHUNK_DAYS + 1);
}
const settledResults = await Promise.all(promises);
const successfulResults = settledResults.filter(Boolean);
const allData = { STANDARD_ROOM: new Map(), CLUB: new Map(), STANDARD_SUITE: new Map(), PREMIUM_SUITE: new Map() };
successfulResults.forEach(chunkData => {
if (chunkData.STANDARD_ROOM) chunkData.STANDARD_ROOM.forEach((v, k) => allData.STANDARD_ROOM.set(k, v));
if (chunkData.CLUB) chunkData.CLUB.forEach((v, k) => allData.CLUB.set(k, v));
if (chunkData.STANDARD_SUITE) chunkData.STANDARD_SUITE.forEach((v, k) => allData.STANDARD_SUITE.set(k, v));
if (chunkData.PREMIUM_SUITE) chunkData.PREMIUM_SUITE.forEach((v, k) => allData.PREMIUM_SUITE.set(k, v));
});
return allData;
}
function fetchSingleChunk(apiUrl) {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET", url: apiUrl, headers: { "Accept": "application/json" },
onload: function(response) {
if (response.status === 200) {
const data = JSON.parse(response.responseText);
const roomCategories = data?.roomCategories;
const chunkData = {};
if (roomCategories?.STANDARD_ROOM) chunkData.STANDARD_ROOM = parseRoomData(roomCategories.STANDARD_ROOM);
if (roomCategories?.CLUB) chunkData.CLUB = parseRoomData(roomCategories.CLUB);
if (roomCategories?.STANDARD_SUITE) chunkData.STANDARD_SUITE = parseRoomData(roomCategories.STANDARD_SUITE);
if (roomCategories?.PREMIUM_SUITE) chunkData.PREMIUM_SUITE = parseRoomData(roomCategories.PREMIUM_SUITE);
resolve(chunkData);
} else { resolve(null); }
},
onerror: function() { resolve(null); }
});
});
}
function parseRoomData(roomData) {
const priceMap = new Map();
for (const date in roomData) {
const details = roomData[date];
const type = details.pointsLevel ? details.pointsLevel.toLowerCase() : 'unknown';
priceMap.set(date, { type: type, points: details.pointsValue });
}
return priceMap;
}
async function fetchAvailabilityData(hotelCode) {
const today = new Date();
const endDate = new Date();
endDate.setDate(today.getDate() + 365);
const startDateStr = today.toISOString().split('T')[0];
const endDateStr = endDate.toISOString().split('T')[0];
const roomTypes = ['STANDARD_ROOM', 'CLUB', 'STANDARD_SUITE', 'PREMIUM_SUITE'];
const promises = roomTypes.map(roomType => {
const apiUrl = `https://www.hyatt.com/explore-hotels/service/avail/days?spiritCode=${hotelCode}&startDate=${startDateStr}&endDate=${endDateStr}&roomCategory=${roomType}&numAdults=1&numChildren=0&roomQuantity=1&los=1&isMock=false`;
return new Promise(resolve => {
GM_xmlhttpRequest({
method: "GET", url: apiUrl, headers: { "Accept": "application/json" },
onload: response => {
if (response.status === 200) {
const data = JSON.parse(response.responseText);
const days = data.days || {};
const availableDates = new Set();
for (const date in days) {
if (days[date] && days[date][roomType]) {
availableDates.add(date);
}
}
resolve({ roomType, availableDates });
} else { resolve({ roomType, availableDates: new Set() }); }
},
onerror: () => resolve({ roomType, availableDates: new Set() })
});
});
});
const results = await Promise.all(promises);
const availabilityByRoom = {};
results.forEach(({ roomType, availableDates }) => {
availabilityByRoom[roomType] = availableDates;
});
return availabilityByRoom;
}
function getPointsTiers(priceMap) {
const tiers = {};
for (const item of priceMap.values()) {
if (item.type && item.points && !tiers[item.type]) {
tiers[item.type] = item.points;
}
if (tiers.off_peak && tiers.standard && tiers.peak) break;
}
return tiers;
}
function createVisualizerUI(priceData, availabilityData) {
const hotelCode = getHotelCodeFromURL();
const overlay = document.createElement('div');
overlay.className = 'visualizer-overlay hidden';
const modal = document.createElement('div');
modal.className = 'visualizer-modal';
const header = document.createElement('div');
header.className = 'modal-header';
header.innerHTML = `<h3>Points Calendar Quick View</h3>`;
const selectorContainer = document.createElement('div');
const selector = document.createElement('select');
let optionsHTML = '';
if (priceData.STANDARD_ROOM?.size > 0) optionsHTML += `<option value="STANDARD_ROOM">Standard Room</option>`;
if (priceData.CLUB?.size > 0) optionsHTML += `<option value="CLUB">Club Access</option>`;
if (priceData.STANDARD_SUITE?.size > 0) optionsHTML += `<option value="STANDARD_SUITE">Standard Suite</option>`;
if (priceData.PREMIUM_SUITE?.size > 0) optionsHTML += `<option value="PREMIUM_SUITE">Premium Suite</option>`;
selector.innerHTML = optionsHTML;
if (optionsHTML) {
selectorContainer.appendChild(selector);
header.appendChild(selectorContainer);
}
const closeBtn = document.createElement('button');
closeBtn.className = 'modal-close-btn';
closeBtn.innerHTML = `×`;
header.appendChild(closeBtn);
const chartContainer = document.createElement('div');
chartContainer.className = 'calendar-chart-container';
const monthsContainer = document.createElement('div');
monthsContainer.className = 'calendar-months';
const weeksContainer = document.createElement('div');
weeksContainer.className = 'calendar-weeks';
const grid = document.createElement('div');
grid.className = 'points-calendar-grid';
chartContainer.append(monthsContainer, weeksContainer, grid);
const legend = document.createElement('div');
legend.className = 'calendar-legend';
const disclaimer = document.createElement('div');
disclaimer.className = 'availability-disclaimer';
disclaimer.textContent = 'Availability is based on a one-night stay.';
modal.append(header, chartContainer, legend, disclaimer);
overlay.appendChild(modal);
const weekDays = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
weekDays.forEach(day => { weeksContainer.innerHTML += `<div class="week-day">${day}</div>`; });
function renderGrid(roomType) {
grid.innerHTML = '';
const priceMap = priceData[roomType];
const availabilitySet = availabilityData[roomType] || new Set();
if (!priceMap) return;
let monthsHTML = '', lastMonth = -1, lastMonthLabelWeekIndex = -10;
const startDate = new Date();
startDate.setDate(startDate.getDate() - startDate.getDay());
for (let i = 0; i < 371; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + i);
const dayElement = document.createElement('div');
dayElement.className = 'calendar-day';
if (currentDate.getDay() === 0) {
const currentMonth = currentDate.getMonth();
if (currentMonth !== lastMonth) {
const weekIndex = Math.floor(i / 7);
if (weekIndex > lastMonthLabelWeekIndex + 1) {
const monthName = currentDate.toLocaleString('default', { month: 'short' });
const leftPosition = weekIndex * 17;
monthsHTML += `<div class="month" style="left: ${leftPosition}px;">${monthName}</div>`;
lastMonth = currentMonth;
lastMonthLabelWeekIndex = weekIndex;
}
}
}
if (currentDate >= new Date(new Date().setHours(0, 0, 0, 0))) {
const dateString = currentDate.toISOString().split('T')[0];
const dayData = priceMap.get(dateString);
let title = dateString;
if (dayData) {
dayElement.classList.add(dayData.type.replace('_', '-'));
title += `\n${dayData.points.toLocaleString()} Points (${dayData.type})`;
const isAvailable = availabilitySet.has(dateString);
if (isAvailable) {
dayElement.classList.add('clickable');
title += `\nClick to book!`;
dayElement.addEventListener('click', () => {
const checkoutDate = new Date(currentDate);
checkoutDate.setDate(checkoutDate.getDate() + 1);
const checkoutDateString = checkoutDate.toISOString().split('T')[0];
const bookingUrl = `https://www.hyatt.com/shop/rooms/${hotelCode}?checkinDate=${dateString}&checkoutDate=${checkoutDateString}&rooms=1&adults=1&kids=0&rateFilter=woh`;
window.open(bookingUrl, '_blank');
});
} else {
dayElement.classList.add('unavailable');
title += `\n(Not Available)`;
}
} else {
title += `\nNot priced`;
}
dayElement.title = title;
}
grid.appendChild(dayElement);
}
monthsContainer.innerHTML = monthsHTML;
const pointTiers = getPointsTiers(priceMap);
const legendOrder = [
{ key: 'off-peak', label: 'Off-Peak' }, { key: 'standard', label: 'Standard' }, { key: 'peak', label: 'Peak' }
];
let legendHTML = '';
for (const item of legendOrder) {
const tierKey = item.key.replace('-', '_');
const points = pointTiers[tierKey];
if (points) {
legendHTML += `<div class="legend-item"><div class="legend-color-box calendar-day ${item.key}"></div> ${item.label} (${points.toLocaleString()})</div>`;
}
}
legend.innerHTML = legendHTML;
}
selector.addEventListener('change', () => renderGrid(selector.value));
closeBtn.addEventListener('click', () => overlay.classList.add('hidden'));
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.add('hidden'); });
if(selector.value) renderGrid(selector.value);
return overlay;
}
function waitForElement(selector) {
return new Promise(resolve => {
const el = document.querySelector(selector);
if (el) return resolve(el);
const observer = new MutationObserver(() => {
const targetEl = document.querySelector(selector);
if (targetEl) { observer.disconnect(); resolve(targetEl); }
});
observer.observe(document.body, { childList: true, subtree: true });
});
}
async function main() {
const hotelCode = getHotelCodeFromURL();
if (!hotelCode) return;
const injectionSelector = 'body > div.explore-hotels-content > main > div.vrc-cal-container > div.vrc-calendar > div.calendar-body-header > div.calendar-date-container';
const injectionPoint = await waitForElement(injectionSelector);
if (!injectionPoint) return;
const triggerBtn = document.createElement('button');
triggerBtn.className = 'haiyaa-trigger-btn';
triggerBtn.textContent = 'Haiyaa!';
injectionPoint.appendChild(triggerBtn);
triggerBtn.addEventListener('click', async () => {
const existingModal = document.querySelector('.visualizer-overlay');
if (existingModal) existingModal.remove();
triggerBtn.textContent = '...';
triggerBtn.disabled = true;
try {
const [priceData, availabilityData] = await Promise.all([
fetchPriceData(hotelCode),
fetchAvailabilityData(hotelCode)
]);
const visualizerModal = createVisualizerUI(priceData, availabilityData);
document.body.appendChild(visualizerModal);
visualizerModal.classList.remove('hidden');
} catch (error) {
console.error('[Hyatt Visualizer] A critical error occurred:', error);
triggerBtn.textContent = 'Error!';
setTimeout(() => { triggerBtn.textContent = 'Haiyaa!'; triggerBtn.disabled = false; }, 2000);
return;
}
triggerBtn.textContent = 'Haiyaa!';
triggerBtn.disabled = false;
});
}
main();
})();