Haiyaa Points!

Visualize a year of points availability with quickbook function on Hyatt points calendar

Från och med 2025-10-04. Se den senaste versionen.

// ==UserScript==
// @name         Haiyaa Points!
// @namespace    http://tampermonkey.net/
// @version      1.7
// @description  Visualize a year of points availability with quickbook function on Hyatt points calendar
// @author       World of Haiyaa
// @match        https://www.hyatt.com/explore-hotels/rate-calendar*
// @match        https://www.hyatt.com/*/explore-hotels/rate-calendar*
// @grant        GM_xmlhttpRequest
// @connect      www.hyatt.com
// ==/UserScript==

(function() {
    'use strict';

    const priveHotelCodes = new Set([
        "menph", "suzph", "tusob", "abdcc", "jedph", "istph", "dohph", "selaz", "sanrs", "cgkub", "saogh", "flrub",
        "apcal", "riogh", "seagh", "selrs", "lisaz", "shagh", "sinrs", "taigh", "tparw", "tyogh", "caict", "paraz",
        "phlph", "ausra", "bnaub", "bcnub", "msyrf", "balgh", "nasgh", "jcagh", "dohgh", "goagh", "kauai", "musca",
        "mexhr", "aruba", "chesa", "naprn", "danhr", "guamh", "sanhc", "huahi", "newpo", "hunrh", "champ", "tvllt",
        "auslp", "oggrm", "ncehr", "phuhr", "scott", "nbsph", "tamay", "thess", "hnlrw", "nycam", "amsaz", "delaz",
        "longe", "oggaw", "apcrn", "yowaz", "liraz", "sanas", "savrd", "phxaz", "shaaz", "tyoaz", "laxss", "abuph",
        "sanpa", "bkkph", "beave", "beiph", "bueph", "istct", "mvdhy", "busph", "canbe", "cheph", "chiph", "dxbph",
        "guaph", "hydph", "mldph", "zuhal", "melph", "milph", "limct", "sclct", "kulph", "nycph", "ninph", "parph",
        "saiph", "sanph", "selph", "shaph", "repph", "sydph", "tyoph", "romrt", "vieph", "wasph", "madct", "guact",
        "znzph", "zurph", "goial", "jaial", "cabph", "mxpct", "lgapr", "tyoct", "phlct", "madrp", "aushd", "phxub",
        "msyum", "skbph", "sinaz", "ammgh", "atlgh", "beigh", "hanph", "cgkph", "bergh", "dxbgh", "bangh", "nycuc",
        "secim", "dpsbl", "msyub", "colog", "zocur", "zvrim", "cania", "shang", "hkggh", "vcect", "westl", "adbri",
        "kuagh", "macgh", "melbo", "nayrw", "auhgh", "ptyrp", "satgh", "delrh", "calrc", "chenn", "pierc", "mlact",
        "dxbhc", "dusse", "hakhr", "kwest", "seplc", "honhr", "drrpc", "kievh", "kyoto", "lonch", "drcfu", "mainz",
        "bkkhr", "boggh", "cokgh", "coral", "ctgrc", "darhr", "denrd", "guagh", "mucaz", "chiub", "lgatg", "okaro",
        "satrs", "savrs", "sfors", "vieaz", "madel", "lgajd", "sjcjc", "nycts", "isthr", "szxph", "lgath", "cslth",
        "bnath", "seath", "zihth", "budub", "chith", "sfojd", "kulal", "mctal", "dpsak", "dpsas", "socal", "dpsau",
        "dpsav", "hghaw", "sjcal", "itmph", "aklph", "lhrub", "ausob", "egegh", "dxbct", "madrm", "bcnrb", "agart",
        "sbars", "ctsph", "ctugh", "amdhr", "rdudc", "btvdh", "sepbc", "sofrs", "setpc", "iadth", "pekal", "aqjra",
        "bnarn", "tyoty", "ruhhr", "secbr", "bosto", "sanen", "szxaz", "xmnaz", "oggal", "biqub", "mcijd", "miact",
        "dusxd", "addra", "searl", "mrydm", "albob", "rnodh", "dfwth", "satth", "dendv", "sllal", "dpsaz", "austh",
        "bnerb", "ctuub", "mlarm", "pekub", "pnhrp", "savth", "torph", "zrhrz", "chsdb", "sando", "sanjo", "ibzdh",
        "laxuf", "usmrk", "dmmgh", "denth", "bnact", "oakub", "laxdi", "canif", "lgatp", "atlct", "melct", "prgaz",
        "romjd", "arnub", "csxgh", "shegh", "chsdv", "drmpc", "zoehm", "zocdm", "searm", "cunia", "pvrif", "sjdif",
        "smfct", "jdzub", "dxbcl", "dubct", "ptyub", "jtrub", "sardh", "agpub", "lishr", "mldal", "zoapc", "sepdc",
        "seccc", "secpm", "sebmi", "drepm", "drbmi", "fswub", "marph", "salct", "xiygh", "utpaz", "jairj", "mexaz",
        "fukgh", "semrc", "sfous", "bodjd", "edidr", "sford", "iadct", "torjd", "nkgaz", "davub", "pspaz", "pdxct",
        "seiim", "lhrph", "csxph", "shaal", "fraum", "dohaz", "slcgp", "slcgr", "kmggh", "grggh", "miaob", "johpj",
        "yulct", "yyzjd", "tivrk", "ausct", "rmugh", "gdlrg", "iahth", "kwigh", "swfuc", "bnadz", "bjxub", "dpaal",
        "hourw", "sydrs", "slcpc", "bkict", "kmqct", "laxdz", "pmijc", "mangh", "okcub", "tyoub", "lonrb", "bkksb",
        "huash", "ibzsi", "lgase", "lgash", "lonsl", "melsx", "mldsm", "yvrph", "sinss", "ptyuv", "sepcr", "sjdkp",
        "zadrz", "satjf", "iahkb", "auskd"
    ]);

    const futureOpeningHotels = {
      "tyocp": "2025-10-07", "sydcp": "2025-10-13", "uc037": "2025-10-13", "cokrm": "2025-10-15", "torza": "2025-10-29",
      "cabph": "2025-10-30", "shath": "2025-10-30", "hyvpc": "2025-11-01", "uc155": "2025-11-03", "pdszp": "2025-11-04",
      "mklzj": "2025-11-12", "cunza": "2025-11-13", "houkh": "2025-11-18", "geozd": "2025-11-19", "hsvqh": "2025-11-19",
      "midzm": "2025-11-20", "ywgct": "2025-11-25", "bp024": "2025-12-01", "sjuct": "2025-12-02", "yqtxt": "2025-12-03",
      "qroct": "2025-12-09", "tyoph": "2025-12-09", "romrt": "2025-12-10", "bznza": "2025-12-11", "gegzp": "2025-12-17",
      "cprzc": "2025-12-18", "lgajl": "2025-12-18", "rsigh": "2026-01-20", "gcmgh": "2026-02-03", "fraxg": "2026-02-06",
      "romth": "2026-02-06", "cxrrn": "2026-02-10", "fchxn": "2026-02-11", "albxy": "2026-02-25", "waczw": "2026-02-26",
      "lisaz": "2026-03-04", "sjozc": "2026-03-11", "bkkaz": "2026-03-15", "chacp": "2026-03-19", "rsiob": "2026-03-30",
      "cangh": "2026-04-01", "rjkdh": "2026-04-01", "choqh": "2026-04-08", "mexph": "2026-05-12", "iagub": "2026-05-14",
      "plsaz": "2026-05-14", "lonry": "2026-05-26", "ecpxs": "2026-05-27", "lrdql": "2026-06-10", "jaxst": "2026-08-19",
      "lhrzp": "2026-09-01", "uc089": "2026-10-01"
    };

    function addGlobalStyle(css) {
        const head = document.head || document.getElementsByTagName('head')[0];
        if (!head) { return; }
        const style = document.createElement('style');
        style.type = 'text/css';
        style.innerHTML = css;
        head.appendChild(style);
    }

    addGlobalStyle(`
        .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; white-space: nowrap; }
        .modal-header-right { display: flex; align-items: center; gap: 15px; }
        .modal-header select { padding: 5px; border-radius: 5px; border: 1px solid #ccc; }
        .prive-link-btn { font-size: 12px; color: #0D2D52; text-decoration: none; font-weight: bold; border: 1px solid #0D2D52; padding: 5px 10px; border-radius: 5px; transition: all 0.2s; white-space: nowrap; }
        .prive-link-btn:hover { background-color: #0D2D52; color: white; }
        .modal-close-btn { font-size: 24px; font-weight: bold; color: #888; cursor: pointer; border: none; background: none; padding: 0; }
        .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; position: relative; }
        .calendar-day.is-holiday { border-width: 2px; box-sizing: border-box; }
        .day-number { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 9px; font-weight: bold; color: white; text-shadow: 0 0 2px rgba(0,0,0,0.7); }
        .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, .holiday-legend { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; font-size: 12px; margin-top: 15px; color: #555; }
        .legend-item { display: flex; align-items: center; margin: 2px 8px; }
        .legend-color-box { width: 14px; height: 14px; border-radius: 3px; margin-right: 5px; }
        .holiday-legend .legend-color-box { border-width: 2px; border-style: solid; background-color: transparent; }
        .availability-disclaimer { font-size: 10px; font-style: italic; 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 = toLocalDateString(startDate);
            const endDateStr = toLocalDateString(endDate);
            const apiUrl = `https://www.hyatt.com/explore-hotels/service/avail?spiritCode=${hotelCode}&startDate=${startDateStr}&endDate=${endDateStr}`;
            let promise = fetchSingleChunk(apiUrl);
            if (i === 0) {
                promise = promise.catch(error => {
                    if (error && error.retryable) {
                        console.warn('[Hyatt Visualizer] First price chunk failed, retrying with start date +1 day.');
                        const tomorrow = new Date();
                        tomorrow.setDate(tomorrow.getDate() + 1);
                        const newStartDateStr = toLocalDateString(tomorrow);
                        const retryApiUrl = `https://www.hyatt.com/explore-hotels/service/avail?spiritCode=${hotelCode}&startDate=${newStartDateStr}&endDate=${endDateStr}`;
                        return fetchSingleChunk(retryApiUrl);
                    }
                    return Promise.reject(error);
                });
            }
            promises.push(promise);
            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, reject) => {
            GM_xmlhttpRequest({
                method: "GET", url: apiUrl, headers: { "Accept": "application/json" },
                onload: function(response) {
                    if (response.status === 200) {
                        const data = JSON.parse(response.responseText);
                        if (data.status === "ERROR" && data.error === "[object Object]") {
                            return reject({ retryable: true });
                        }
                        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 if (response.status === 500) {
                        return reject({ retryable: true });
                    }
                    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);
        let startDateStr = toLocalDateString(today);
        const endDateStr = toLocalDateString(endDate);
        const openingDateStr = futureOpeningHotels[hotelCode];
        if (openingDateStr && new Date(openingDateStr) > today) {
            startDateStr = openingDateStr;
        }
        const apiUrl = `https://www.hyatt.com/explore-hotels/service/avail/days?spiritCode=${hotelCode}&startDate=${startDateStr}&endDate=${endDateStr}&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 => {
                    const availabilityByRoom = { STANDARD_ROOM: new Set(), CLUB: new Set(), STANDARD_SUITE: new Set(), PREMIUM_SUITE: new Set() };
                    if (response.status === 200) {
                        const data = JSON.parse(response.responseText);
                        const days = data.days || {};
                        for (const date in days) {
                            const roomsOnDate = days[date];
                            if (roomsOnDate) {
                                if (roomsOnDate.STANDARD_ROOM) availabilityByRoom.STANDARD_ROOM.add(date);
                                if (roomsOnDate.CLUB) availabilityByRoom.CLUB.add(date);
                                if (roomsOnDate.STANDARD_SUITE) availabilityByRoom.STANDARD_SUITE.add(date);
                                if (roomsOnDate.PREMIUM_SUITE) availabilityByRoom.PREMIUM_SUITE.add(date);
                            }
                        }
                    }
                    resolve(availabilityByRoom);
                },
                onerror: () => resolve({ STANDARD_ROOM: new Set(), CLUB: new Set(), STANDARD_SUITE: new Set(), PREMIUM_SUITE: new Set() })
            });
        });
    }

    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 toLocalDateString(date) {
        const year = date.getFullYear();
        const month = String(date.getMonth() + 1).padStart(2, '0');
        const day = String(date.getDate()).padStart(2, '0');
        return `${year}-${month}-${day}`;
    }

    function getHolidays(year) {
        const holidays = new Map();
        const nthWeekdayOfMonth = (n, weekday, month, year) => {
            let count = 0;
            const date = new Date(year, month, 1);
            while (count < n) {
                if (date.getDay() === weekday) count++;
                if (count < n) date.setDate(date.getDate() + 1);
            }
            return date;
        };
        const lastWeekdayOfMonth = (weekday, month, year) => {
            const date = new Date(year, month + 1, 0);
            while (date.getDay() !== weekday) {
                date.setDate(date.getDate() - 1);
            }
            return date;
        };
        const formatDate = (date) => toLocalDateString(date);
        holidays.set(formatDate(new Date(year, 0, 1)), { name: "New Year's Day", color: '#FFD700' });
        holidays.set(formatDate(nthWeekdayOfMonth(3, 1, 0, year)), { name: 'MLK Day', color: '#483D8B' });
        holidays.set(formatDate(lastWeekdayOfMonth(1, 4, year)), { name: 'Memorial Day', color: '#000080' });
        holidays.set(formatDate(new Date(year, 6, 4)), { name: 'Independence Day', color: '#B22222' });
        holidays.set(formatDate(nthWeekdayOfMonth(1, 1, 8, year)), { name: 'Labor Day', color: '#1E90FF' });
        holidays.set(formatDate(nthWeekdayOfMonth(4, 4, 10, year)), { name: 'Thanksgiving', color: '#A0522D' });
        holidays.set(formatDate(new Date(year, 11, 25)), { name: 'Christmas Day', color: '#228B22' });
        return holidays;
    }

    function createVisualizerUI(priceData, availabilityData) {
        const hotelCode = getHotelCodeFromURL();
        const currentYear = new Date().getFullYear();
        const holidays = new Map([...getHolidays(currentYear), ...getHolidays(currentYear + 1)]);
        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';
        const title = document.createElement('h3');
        title.textContent = 'Points Calendar Quick View';
        header.appendChild(title);
        const headerRight = document.createElement('div');
        headerRight.className = 'modal-header-right';
        if (priveHotelCodes.has(hotelCode)) {
            const priveLink = document.createElement('a');
            priveLink.href = 'https://www.hotelft.com/preferred-program/hyatt-prive';
            priveLink.textContent = 'Enjoy Privé treatment!';
            priveLink.target = '_blank';
            priveLink.className = 'prive-link-btn';
            headerRight.appendChild(priveLink);
        }
        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) {
             headerRight.appendChild(selector);
        }
        const closeBtn = document.createElement('button');
        closeBtn.className = 'modal-close-btn';
        closeBtn.innerHTML = `&times;`;
        headerRight.appendChild(closeBtn);
        header.appendChild(headerRight);
        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 holidayLegend = document.createElement('div');
        holidayLegend.className = 'holiday-legend';
        const disclaimer = document.createElement('div');
        disclaimer.className = 'availability-disclaimer';
        disclaimer.textContent = 'Availability based on one-night stay';
        modal.append(header, chartContainer, legend, holidayLegend, disclaimer);
        overlay.appendChild(modal);
        const weekDays = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
        weekDays.forEach(day => { weeksContainer.innerHTML += `<div class="week-day">${day}</div>`; });
        let holidayLegendHTML = '';
        const sortedHolidays = Array.from(holidays.values()).sort((a,b) => a.name.localeCompare(b.name));
        const addedHolidays = new Set();
        for(const holiday of sortedHolidays){
            if(!addedHolidays.has(holiday.name)){
                holidayLegendHTML += `<div class="legend-item"><div class="legend-color-box" style="border-color: ${holiday.color};"></div> ${holiday.name}</div>`;
                addedHolidays.add(holiday.name);
            }
        }
        holidayLegend.innerHTML = holidayLegendHTML;

        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;
                        }
                    }
                }
                const dateString = toLocalDateString(currentDate);
                if (new Date(dateString) >= new Date(toLocalDateString(new Date()))) {
                    const dayData = priceMap.get(dateString);
                    let title = dateString;
                    const holiday = holidays.get(dateString);
                    if (currentDate.getDate() === 1) {
                        dayElement.innerHTML = `<div class="day-number">1</div>`;
                    }
                    if (dayData) {
                        dayElement.classList.add(dayData.type.replace('_', '-'));
                        title += `\n${dayData.points.toLocaleString()} Points (${dayData.type})`;
                        if(holiday){
                            dayElement.classList.add('is-holiday');
                            dayElement.style.borderColor = holiday.color;
                            title += `\n${holiday.name}`;
                        }
                        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 = toLocalDateString(checkoutDate);
                                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 {
                        if(holiday){
                            dayElement.classList.add('is-holiday');
                            dayElement.style.borderColor = holiday.color;
                            title += `\n${holiday.name}`;
                        }
                        dayElement.classList.add('unavailable');
                        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();

})();