noahzark / Rewardpoints per Dollar

// ==UserScript==
// @name         Rewardpoints per Dollar
// @namespace    https://openuserjs.org/users/noahzark
// @version      3.3
// @description  calculates the value of Marriott Bonvoy points when redeeming to free nights, gold means points are a great deal, red means below their going value, green means pay cash instead
// @author       Feliciano Long
// @match        *://www.marriott.com/search/*
// @match        *://www.marriott.com/reservation/*
// @match        *://www.marriott.com.cn/search/*
// @match        *://www.marriott.com.cn/reservation/*
// @run-at       document-idle
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      open.er-api.com
// @connect      cdn.jsdelivr.net
// @connect      api.exchangerate-api.com
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  var CONFIG = {
    // US cents per point, as the two cut-offs between the three badge colours.
    // Published 2026 baselines run 0.77-0.90 (FrequentMiler/Gondola median 0.77
    // over ~3M redemptions, TPG 0.80, WalletHub 0.79), but mainland China rarely
    // clears them — cheap cash rates against global award pricing put Shanghai
    // around 0.50. Calibrated for booking inside China; raise both toward the
    // published baselines when shopping US/Europe.
    gold: 0.65,  // at or above this, redeeming is a good deal here
    green: 0.45, // at or below this, pay cash and keep the points
    fxTtlHours: 12,
    debug: false
  };

  // Gold only reads as gold when it is a filled chip: as text on white it dulls
  // to olive well before it gets dark enough to be legible.
  var TIER_STYLE = {
    gold: 'background:#FFD700;color:#3D2E00;padding:1px 6px;border-radius:3px;',
    red: 'color:#B3261E;',
    green: 'color:#1A7F37;'
  };

  // Fallback only: used before live rates arrive, or if every endpoint fails.
  // Units of local currency per 1 USD, snapshot 2026-08-03.
  var STATIC_RATES = {
    USD: 1, CNY: 6.76, HKD: 7.842, MOP: 8.078, TWD: 32.3, JPY: 158, KRW: 1441,
    EUR: 0.867, GBP: 0.742, CHF: 0.807, SGD: 1.282, AUD: 1.42, NZD: 1.697,
    CAD: 1.401, THB: 33.4, MYR: 4.087, IDR: 18076, PHP: 61.2, VND: 26180,
    INR: 95.5, AED: 3.672, SAR: 3.75, QAR: 3.64, TRY: 47.5, ZAR: 16.5,
    BRL: 5.071, MXN: 17.3, SEK: 9.512, NOK: 9.486, DKK: 6.467, PLN: 3.735,
    CZK: 21, RUB: 79.6, EGP: 50.8, ILS: 3.052, LKR: 335, NPR: 153, BND: 1.282
  };

  var FX_ENDPOINTS = [
    {
      host: 'open.er-api.com',
      url: 'https://open.er-api.com/v6/latest/USD',
      parse: function (json) {
        if (!json || !json.rates) {
          return null;
        }
        return { rates: json.rates, date: json.time_last_update_utc || '' };
      }
    },
    {
      host: 'cdn.jsdelivr.net',
      url: 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json',
      parse: function (json) {
        if (!json || !json.usd) {
          return null;
        }
        var rates = {};
        for (var key in json.usd) {
          rates[key.toUpperCase()] = json.usd[key];
        }
        return { rates: rates, date: json.date || '' };
      }
    },
    {
      host: 'api.exchangerate-api.com',
      url: 'https://api.exchangerate-api.com/v4/latest/USD',
      parse: function (json) {
        if (!json || !json.rates) {
          return null;
        }
        return { rates: json.rates, date: json.date || '' };
      }
    }
  ];

  var CACHE_KEY = 'cpp-fx-usd';
  var MARKER = 'data-cpp-signature';
  var BADGE_CLASS = 'cpp-value-badge';
  var PER_STAY_WORDS = ['住宿', 'stay', 'séjour', 'sejour', 'aufenthalt', 'estancia', 'soggiorno'];
  var PER_NIGHT_WORDS = ['晚', 'night', 'nuit', 'nacht', 'noche', 'notte', '泊'];

  var fx = { rates: STATIC_RATES, source: 'built-in snapshot', date: '2026-08-03' };

  function log() {
    if (CONFIG.debug) {
      console.log.apply(console, ['[c/p]'].concat([].slice.call(arguments)));
    }
  }

  function text(node) {
    return node ? (node.textContent || '').trim() : '';
  }

  // Handles "16,500", "1.234,56" and "1,234.56" alike.
  function parseAmount(raw) {
    var cleaned = String(raw == null ? '' : raw).replace(/[^\d.,]/g, '');
    if (!cleaned) {
      return NaN;
    }
    var lastComma = cleaned.lastIndexOf(',');
    var lastDot = cleaned.lastIndexOf('.');
    var normalized;
    if (lastComma > lastDot && cleaned.length - lastComma - 1 <= 2) {
      normalized = cleaned.replace(/\./g, '').replace(',', '.');
    } else {
      normalized = cleaned.replace(/,/g, '');
    }
    var value = parseFloat(normalized);
    return isFinite(value) ? value : NaN;
  }

  function containsAny(haystack, words) {
    for (var i = 0; i < words.length; i++) {
      if (haystack.indexOf(words[i]) > -1) {
        return true;
      }
    }
    return false;
  }

  // Marriott labels each figure as either per night ("╱晚") or per stay ("每次住宿").
  function detectBasis(label, fallback) {
    var lowered = label.toLowerCase();
    if (containsAny(lowered, PER_STAY_WORDS)) {
      return 'stay';
    }
    if (containsAny(lowered, PER_NIGHT_WORDS)) {
      return 'night';
    }
    return fallback;
  }

  function toStayTotal(value, basis, nights) {
    return basis === 'night' ? value * nights : value;
  }

  var SYMBOL_TO_CODE = {
    '$': 'USD', '¥': 'CNY', '¥': 'CNY', '€': 'EUR', '£': 'GBP', '₩': 'KRW', '฿': 'THB', '₫': 'VND', '₹': 'INR'
  };

  function detectCurrency(label) {
    var code = /\b([A-Z]{3})\b/.exec(label);
    if (code && fx.rates[code[1]] !== undefined) {
      return code[1];
    }
    for (var symbol in SYMBOL_TO_CODE) {
      if (label.indexOf(symbol) > -1) {
        return SYMBOL_TO_CODE[symbol];
      }
    }
    return code ? code[1] : 'USD';
  }

  function parseDateParam(value) {
    if (!value) {
      return null;
    }
    var iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value);
    if (iso) {
      return Date.UTC(+iso[1], +iso[2] - 1, +iso[3]);
    }
    var us = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(value);
    if (us) {
      return Date.UTC(+us[3], +us[1] - 1, +us[2]);
    }
    return null;
  }

  function nightsFromPageData() {
    var scripts = document.querySelectorAll('script[type="application/json"], script#__NEXT_DATA__');
    for (var i = 0; i < scripts.length; i++) {
      var match = /"lengthOfStay"\s*:\s*"?(\d+)"?/.exec(scripts[i].textContent || '');
      if (match && +match[1] > 0) {
        return +match[1];
      }
    }
    return 0;
  }

  function getNights() {
    var params = new URLSearchParams(location.search);
    var from = parseDateParam(params.get('fromDate') || params.get('fromDateDefaultFormat'));
    var to = parseDateParam(params.get('toDate') || params.get('toDateDefaultFormat'));
    if (from !== null && to !== null) {
      var diff = Math.round((to - from) / 86400000);
      if (diff > 0) {
        return diff;
      }
    }
    return nightsFromPageData() || 1;
  }

  function valueTier(ratio) {
    if (ratio >= CONFIG.gold) {
      return 'gold';
    }
    if (ratio <= CONFIG.green) {
      return 'green';
    }
    return 'red';
  }

  // ---- exchange rates -------------------------------------------------

  function storageGet(key) {
    try {
      if (typeof GM_getValue === 'function') {
        return GM_getValue(key, null);
      }
      return localStorage.getItem(key);
    } catch (e) {
      return null;
    }
  }

  function storageSet(key, value) {
    try {
      if (typeof GM_setValue === 'function') {
        GM_setValue(key, value);
      } else {
        localStorage.setItem(key, value);
      }
    } catch (e) {
      log('cache write failed', e);
    }
  }

  // GM_xmlhttpRequest sidesteps CORS and any page CSP; fetch covers other managers.
  function httpJson(url) {
    return new Promise(function (resolve, reject) {
      if (typeof GM_xmlhttpRequest === 'function') {
        GM_xmlhttpRequest({
          method: 'GET',
          url: url,
          timeout: 12000,
          onload: function (response) {
            try {
              resolve(JSON.parse(response.responseText));
            } catch (e) {
              reject(e);
            }
          },
          onerror: function () { reject(new Error('network error')); },
          ontimeout: function () { reject(new Error('timeout')); }
        });
        return;
      }
      fetch(url, { credentials: 'omit', cache: 'no-store' })
        .then(function (response) { return response.json(); })
        .then(resolve, reject);
    });
  }

  function ratesLookSane(rates) {
    if (!rates || typeof rates !== 'object') {
      return false;
    }
    if (!(rates.USD > 0.99 && rates.USD < 1.01)) {
      return false;
    }
    return rates.CNY > 0 && rates.EUR > 0 && Object.keys(rates).length >= 20;
  }

  function fetchRatesFrom(index) {
    if (index >= FX_ENDPOINTS.length) {
      return Promise.reject(new Error('all FX endpoints failed'));
    }
    var endpoint = FX_ENDPOINTS[index];
    return httpJson(endpoint.url)
      .then(function (json) {
        var parsed = endpoint.parse(json);
        if (!parsed || !ratesLookSane(parsed.rates)) {
          throw new Error('unusable payload');
        }
        parsed.source = endpoint.host;
        return parsed;
      })
      .catch(function (error) {
        log(endpoint.host, 'failed:', error.message);
        return fetchRatesFrom(index + 1);
      });
  }

  function adoptRates(payload) {
    fx = { rates: payload.rates, source: payload.source, date: payload.date };
    log('rates from', payload.source, payload.date, '- CNY', payload.rates.CNY);
  }

  function loadRates() {
    var cached = null;
    try {
      cached = JSON.parse(storageGet(CACHE_KEY));
    } catch (e) {
      cached = null;
    }

    if (cached && ratesLookSane(cached.rates)) {
      adoptRates(cached);
      if (Date.now() - cached.ts < CONFIG.fxTtlHours * 3600000) {
        return;
      }
    }

    fetchRatesFrom(0).then(function (payload) {
      adoptRates(payload);
      payload.ts = Date.now();
      storageSet(CACHE_KEY, JSON.stringify(payload));
      schedule();
    }, function (error) {
      log('keeping fallback rates:', error.message);
    });
  }

  // ---- annotation -----------------------------------------------------

  function readRate(container) {
    var pointsNode = container.querySelector('.points-value');
    var priceNode = container.querySelector('.price-value');
    if (!pointsNode || !priceNode) {
      return null;
    }

    var points = parseAmount(text(pointsNode));
    var price = parseAmount(text(priceNode));
    if (!(points > 0) || !(price > 0)) {
      return null;
    }

    var pointsLabel = text(container.querySelector('.points-currency-label'));
    var currencyLabel = text(container.querySelector('.currency-section .currency-label')) ||
      text(container.querySelector('.currency-label'));

    return {
      points: points,
      price: price,
      pointsBasis: detectBasis(pointsLabel, 'stay'),
      priceBasis: detectBasis(currencyLabel, 'night'),
      currency: detectCurrency(currencyLabel),
      taxInclusive: !!container.querySelector('.taxes-and-fees-included')
    };
  }

  function renderBadge(container, ratio, rate, nights, ratePerUsd) {
    var badge = container.querySelector('.' + BADGE_CLASS);
    if (!badge) {
      badge = document.createElement('div');
      badge.className = BADGE_CLASS;
      container.appendChild(badge);
    }
    // Tier off the rounded figure, so a badge reading exactly 0.450 can never
    // be coloured as though it were above the cut-off.
    var shown = ratio.toFixed(3);
    var tier = valueTier(parseFloat(shown));
    badge.textContent = shown + ' c/p';
    // width/align-self keep the gold chip hugging its text even if Marriott
    // turns the surrounding container into a flex column.
    badge.style.cssText =
      'margin-top:4px;display:inline-block;width:fit-content;align-self:flex-start;' +
      'font-weight:700;font-size:12px;line-height:1.4;white-space:nowrap;' + TIER_STYLE[tier];

    var verdict = {
      gold: 'great value — redeem',
      red: 'below what a point is worth',
      green: 'poor value — pay cash'
    };
    var cash = toStayTotal(rate.price, rate.priceBasis, nights);
    var points = toStayTotal(rate.points, rate.pointsBasis, nights);
    badge.title =
      verdict[tier] +
      '\n' + cash.toLocaleString() + ' ' + rate.currency + ' vs ' + points.toLocaleString() + ' points' +
      ' over ' + nights + ' night' + (nights === 1 ? '' : 's') +
      (rate.taxInclusive ? ' (cash incl. taxes & fees)' : '') +
      (rate.currency === 'USD' ? '' :
        '\nFX ' + ratePerUsd + ' ' + rate.currency + '/USD') +
      '\n' + fx.source + (fx.date ? ' · ' + fx.date : '');
  }

  function analyze() {
    var containers = document.querySelectorAll('.rate-container');
    if (!containers.length) {
      return;
    }

    var nights = getNights();
    var buckets = {};
    var counted = 0;

    for (var i = 0; i < containers.length; i++) {
      var container = containers[i];
      var rate = readRate(container);
      if (!rate) {
        continue;
      }

      var ratePerUsd = fx.rates[rate.currency];
      if (!ratePerUsd) {
        log('unknown currency', rate.currency);
        continue;
      }

      var cashUsd = toStayTotal(rate.price, rate.priceBasis, nights) / ratePerUsd;
      var totalPoints = toStayTotal(rate.points, rate.pointsBasis, nights);
      var ratio = (cashUsd / totalPoints) * 100;
      if (!isFinite(ratio) || ratio <= 0) {
        continue;
      }

      var bucket = (Math.floor(ratio * 10) / 10).toFixed(1);
      buckets[bucket] = (buckets[bucket] || 0) + 1;
      counted++;

      // React reuses these nodes, and rates arrive asynchronously, so the
      // signature covers both the card contents and the FX rate applied.
      var signature = [rate.points, rate.price, rate.currency, nights, ratePerUsd].join('|');
      if (container.getAttribute(MARKER) !== signature) {
        container.setAttribute(MARKER, signature);
        renderBadge(container, ratio, rate, nights, ratePerUsd);
      }
    }

    if (counted && CONFIG.debug) {
      var sorted = Object.keys(buckets)
        .sort(function (a, b) { return parseFloat(a) - parseFloat(b); })
        .reduce(function (acc, key) { acc[key] = buckets[key]; return acc; }, {});
      log(counted + ' rates over ' + nights + ' night(s)', sorted);
    }
  }

  var pending = null;
  function schedule() {
    clearTimeout(pending);
    pending = setTimeout(analyze, 200);
  }

  loadRates();
  schedule();
  new MutationObserver(schedule).observe(document.documentElement, {
    childList: true,
    subtree: true
  });
})();