Hyatt Points Calendar Visualizer

Displays an availability-aware Hyatt points calendar in a modal window. Supports all room types.

Od 03.10.2025.. Pogledajte najnovija verzija.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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 = `&times;`;
        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();

})();