Hyatt Points Calendar Visualizer

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

当前为 2025-10-03 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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

})();