Hyatt Points Calendar Visualizer

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

Per 03-10-2025. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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

})();