Amex Assistant

Pick an Amex Offer and add it to multiple cards from one panel; verifies which cards actually got it. Local-only, no telemetry.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Advertisement:

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

Advertisement:

// ==UserScript==
// @name         Amex Assistant
// @namespace    https://github.com/olddonkey/amex-assistant
// @version      0.20.0
// @description  Pick an Amex Offer and add it to multiple cards from one panel; verifies which cards actually got it. Local-only, no telemetry.
// @author       olddonkey
// @match        https://global.americanexpress.com/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// @homepageURL  https://github.com/olddonkey/amex-assistant
// @supportURL   https://github.com/olddonkey/amex-assistant/issues
// ==/UserScript==

/**
 * @fileoverview Amex Assistant is a Tampermonkey userscript that lets you
 * pick an American Express Offer and enroll it onto several cards at once,
 * then re-reads each card to report which cards actually received it.
 *
 * It talks only to americanexpress.com using the logged-in browser session
 * (`credentials: 'include'`). It makes no third-party requests, collects no
 * credentials, reads no cookies, and has no backend — hence `@grant none`.
 *
 * The whole script is a single IIFE so it can be installed directly. Under
 * Node (the test suite) there is no `window`/`document`, so it skips all DOM
 * work and instead exports its testable functions via `module.exports`.
 *
 * Two ids matter and must not be confused (both come from the offers hub):
 * - `offerId`: an opaque per-card token. This is what you enroll with, and it
 *   differs for the same merchant offer across cards.
 * - `pznAnalyticsId`: a stable analytics id shared by the same offer across
 *   cards. Used only to group/deduplicate offers for display.
 *
 * The Amex endpoints and payloads used here are undocumented and were derived
 * by observing the public web app; see docs/FINDINGS.md.
 */

/* global module */

(function() {
  'use strict';

  /**
   * @typedef {{token: string, tag: string, name: string, shortName: string,
   *            family: string, digits: string, relationship: string,
   *            art: string, eligible: !Array<!Object>,
   *            enrolled: !Array<!Object>, enrolledKeys: !Set<string>,
   *            readFailed: boolean}}
   *     CardSnapshot
   */

  /**
   * A merchant offer grouped across the cards that can add it. `cards[i]`
   * carries that card's own `offerId` (the token to enroll with).
   * @typedef {{key: string, name: string, description: string, image: string,
   *            expiry: string, cards: !Array<{token: string, offerId: string,
   *                           enrolled: boolean}>}} OfferGroup
   */

  /**
   * @typedef {{token: string, offerId: string, key: string,
   *            name: string}} Task
   */

  /**
   * @typedef {{key: string, name: string, token: string, offerId: string,
   *            state: string, message: string, httpStatus: number,
   *            blocked: boolean}} EnrollResult
   */

  /** Origin that serves Amex's internal "functions" RPC endpoints. */
  const FUNCTIONS_ORIGIN = 'https://functions.americanexpress.com';

  /** Endpoint that lists the logged-in member's card accounts. */
  const MEMBER_URL =
      'https://global.americanexpress.com/api/servicing/v1/member';

  /** Endpoint that reads the offers hub (eligible / added-to-card lists). */
  const READ_OFFERS_URL =
      `${FUNCTIONS_ORIGIN}/ReadOffersHubPresentation.web.v1`;

  /** Endpoint that enrolls a single offer onto a single card. */
  const ENROLL_URL = `${FUNCTIONS_ORIGIN}/CreateOffersHubEnrollment.web.v1`;

  /** Endpoint that reads a card's loyalty benefit trackers (credits/perks). */
  const READ_BENEFITS_URL =
      `${FUNCTIONS_ORIGIN}/ReadBestLoyaltyBenefitsTrackers.v1`;

  /**
   * Endpoint that reads a card's full benefit catalog (every perk, keyed by
   * slug, with enrollment status). Joined to the trackers by `sorBenefitId`
   * to add not-yet-enrolled benefits and cleaner titles.
   */
  const READ_CATALOG_URL = `${FUNCTIONS_ORIGIN}/ReadLoyaltyBenefits.v2`;

  /** `limit` value that asks the benefits endpoint for every tracker. */
  const BENEFIT_LIMIT = 'ALL';

  /** Locale sent with every offers request. */
  const LOCALE = 'en-US';

  /** Max cards read from Amex at once, to stay gentle on big accounts. */
  const MAX_CONCURRENT_READS = 4;

  /** Only these offer types are shown; matches the web app's default filter. */
  const OFFER_TYPE = 'MERCHANT';

  /** Highest offers-hub page index to walk before giving up. */
  const MAX_PAGES = 20;

  /** Minimum and maximum delay (ms) between enroll calls. */
  const MIN_DELAY_MS = 1500;
  const MAX_DELAY_MS = 4000;

  /**
   * Max automatic re-sends after a transient enroll failure (network error or
   * 5xx). Retries are fast on purpose: if the same offer just succeeded on a
   * sibling card, a slow retry could fall outside the window in which Amex
   * still accepts the offer on this card.
   */
  const MAX_ENROLL_RETRIES = 2;

  /** Minimum and maximum delay (ms) before re-sending a transient failure. */
  const RETRY_MIN_MS = 300;
  const RETRY_MAX_MS = 600;

  /**
   * Delay (ms) before re-reading added-to-card lists to verify a run. Enrolls
   * take a moment to become visible on the added list; reading too early
   * misclassifies real successes as GHOST.
   */
  const VERIFY_SETTLE_MS = 2500;

  /**
   * Age (ms) beyond which the snapshot behind a submit is considered stale:
   * per-card offerIds may have rotated since it was read, so the involved
   * cards are re-read and the tasks re-resolved just before sending.
   */
  const SNAPSHOT_MAX_AGE_MS = 10 * 60 * 1000;

  /**
   * `requestType` values understood by the offers hub. `OFFERSHUB_LANDING`
   * returns eligible offers; `ADDEDTOCARD_LANDING` returns already-added ones.
   * @enum {string}
   */
  const RequestType = {
    ELIGIBLE: 'OFFERSHUB_LANDING',
    ENROLLED: 'ADDEDTOCARD_LANDING',
    /** Offers with posted savings (the hub's "redeemed" list). */
    REDEEMED: 'SAVINGS_LANDING',
  };

  /**
   * Final classification of a single (offer, card) enrollment attempt.
   * @enum {string}
   */
  const ResultState = {
    /** The enroll request errored or did not report success. */
    FAILED: 'failed',
    /** Enroll reported success and the offer was found on re-read. */
    VERIFIED: 'verified',
    /** Enroll reported success but the offer was absent on re-read. */
    GHOST: 'ghost',
    /** Enroll reported success but the re-read could not be performed. */
    UNVERIFIED: 'unverified',
    /** Never submitted: the run stopped early after a blocked request. */
    SKIPPED: 'skipped',
  };

  // ---------------------------------------------------------------------------
  // I18N: every user-facing panel string lives here, in both languages. The
  // catalogs must carry exactly the same keys (unit-tested). Diagnostic
  // strings that end up in results / CSV exports (server messages, HTTP
  // statuses) are NOT translated — they are data and stay stable for
  // analysis across languages.
  // ---------------------------------------------------------------------------

  /** @const {!Object<string, !Object<string, string>>} */
  const MESSAGES = {
    zh: {
      panelTitle: 'Amex 助手',
      launcherTitle: 'Amex 助手',
      refresh: '刷新',
      close: '关闭',
      // Target-language on purpose (pairs with the 'EN' glyph at the call
      // site): whoever needs this control may not read Chinese.
      switchLang: 'Switch to English',
      // Offers list
      listSubtitle: '{offers} 个 offer · {cards} 张卡',
      searchOffers: '搜索商家或 offer',
      allCards: '所有卡',
      multiOnly: '只看多卡可加',
      selectAllAddable: '全选当前可加',
      clearSelection: '清空',
      cardsReadFailed: '{n} 张卡读取失败。',
      cardsReadFailedNote:
          '{names} 的 offer 本次未能读取,列表暂不含这些卡;点右上角 ↻ 重试。',
      noMatchingOffers: '没有匹配的 offer',
      addToSelected: '加到所选卡',
      footerSelPrefix: '已选 ',
      footerSelMid: ' 个 offer,将尝试添加到 ',
      footerSelSuffix: ' 张卡',
      addedToAll: '已添加到 {n} 张卡',
      addableN: '可加 {n}',
      addedN: '已加 {n}',
      chooseCards: '选择要加到哪些卡',
      addedMark: '已加 ✓',
      expiresShort: '至 {date}',
      // Last-run strip
      lastRunPrefix: '上次添加:',
      lastRunConfirmed: '{n} 确认已加',
      lastRunFailed: '{n} 失败',
      lastRunDedupe: '{n} 疑似去重',
      view: '查看',
      today: '今天 {time}',
      yesterday: '昨天 {time}',
      // Loading
      loadingSubtitle: '一个 offer,加到多张卡',
      loadingOffers: '正在读取每张卡的 offer…',
      loadingCardN: '第 {done} / {total} 张卡',
      loadingCardList: '正在读取卡列表…',
      loadingReadOnly: '这里只读取 offer 列表,不会改动账户',
      // Confirm dialog
      confirmTitle: '确认添加到 {n} 张卡?',
      confirmBody1: '已选 {offers} 个 offer,共 {cards} 张卡。系统会',
      confirmBodyBold: '一次性提交这些请求',
      confirmBody2: ';提交后不能撤销。完成后会重新读取已添加列表,逐张卡核对。',
      confirmRowCards: '{name} → {n} 张卡',
      cancel: '取消',
      confirmSubmit: '确认提交',
      // Running
      runningTitle: '正在添加到卡上…',
      runningSubtitle: '已提交 {n} 个添加请求',
      processedOf: '已处理 {done} / {total}',
      submitOk: '提交成功',
      submitFail: '提交失败',
      submitting: '提交中',
      notSubmitted: '未提交',
      runningNote: '提交完成后会重新读取已添加列表,逐张卡核对结果',
      // Result
      resultTitleOk: '添加完成,已核对',
      resultTitleStopped: '已停止本轮添加',
      resultSubtitle: '已处理 {n} 个添加请求',
      confirmedAdded: '确认已加',
      addFailed: '添加失败',
      dedupeOrUnknown: '疑似去重/无法确认',
      throttledTitle: '检测到限流或拦截。',
      throttledBody: '收到 429/403 或异常响应后,剩余请求已经停止,' +
          '也没有继续复查。建议等几分钟再点「重试未完成项」,不要马上反复提交。',
      dedupeHelpTitle: '什么是疑似去重?',
      dedupeHelpBody: 'Amex 接口返回添加成功,但重新读取已添加列表时,' +
          '这张卡上没有看到该 offer,就会归到这里。常见原因是同一个 offer ' +
          '可能只能加到一张卡;「无法确认」表示复查未完成。',
      secSkipped: '未提交 — 检测到限流后中止',
      secGhost: '疑似去重 — 接口成功,但复查没看到',
      secUnverified: '无法确认 — 复查未完成',
      retryUnfinished: '重试未完成项',
      exportCsv: '导出 CSV',
      backToList: '返回列表',
      runInterrupted: '添加过程中断:{msg}',
      // Empty / error
      emptyTitle: '暂无可加的 offer',
      emptyBody: '当前 offer 都已添加到可用的卡上。',
      reload: '↻ 重新读取',
      errorTitle: '读取 offer 列表失败',
      errorSessionHint: '登录状态可能已过期。请先在当前页面登录 Amex,再重试。',
      retry: '重试',
      cardsReadAllFailed: '卡片 offer 读取失败,请稍后重试。',
      // Benefits
      primaryCardsN: '{n} 张主卡',
      updatedJustNow: '刚刚更新',
      updatedMinsAgo: '{n} 分钟前更新',
      updatedHoursAgo: '{n} 小时前更新',
      loadingBenefits: '正在读取每张卡的 benefit 信息…',
      benefitsReadOnly: '只读查看,不会改动账户',
      benefitsReadFailed: '读取失败',
      searchBenefits: '搜索 benefit 或卡',
      sortByExpiry: '按到期时间排序 ',
      unusedOnly: '只看还有余额的',
      benefitsFootnote: '金额来自 Amex 的 benefit 进度 · 只读查看,不在本地保存数据',
      noBenefitsMatch: '没有匹配「{q}」的 benefit',
      noBenefits: '这些卡上没有可追踪的 benefit',
      leftThisMonth: '本月剩余额度',
      redeemedYtd: '今年已抵扣',
      feeOffset: '已抵年费 {spent}/{fee}',
      trackedOnly: '仅含可自动追踪的项目',
      trackedOnly2: '按可追踪项目',
      usedUpSection: '本周期已用完',
      xCards: '×{n} 张卡',
      notActivated: '待开启',
      activate: '去开启 ↗',
      expired: '已过期',
      daysLeft: '还剩 {n} 天',
      // Added (redeem-tracking) sub-view
      subAddable: '可加',
      subAdded: '已加',
      sortShortExpiry: '按到期',
      statRedeemed: '已返现',
      statPending: '待消费',
      statExpiring: '7 天内过期',
      redeemedOfCards: '{x} / {y} 卡已返现',
      totalRedeemed: '共返 {amt}',
      noCashbackSeen: '未见返现',
      cashbackPosted: '✓ 已返现',
      pointsAmount: '{n} 点',
      noAddedOffers: '还没有已加的 offer',
      addedLoading: '正在读取返现记录…',
      addedError: '返现记录读取失败。',
      addedFootnote:
          '消费状态按返现入账记录归类,入账通常延迟 1–5 天 · 以 Amex 为准',
      period_month: '月',
      period_quarter: '季',
      period_half: '半年',
      period_year: '年',
    },
    en: {
      panelTitle: 'Amex Assistant',
      launcherTitle: 'Amex Assistant',
      refresh: 'Refresh',
      close: 'Close',
      // Target-language on purpose; see zh.switchLang.
      switchLang: '切换到中文',
      // Offers list
      listSubtitle: '{offers} offers · {cards} cards',
      searchOffers: 'Search merchants or offers',
      allCards: 'All cards',
      multiOnly: 'Multi-card only',
      selectAllAddable: 'Select all eligible',
      clearSelection: 'Clear',
      cardsReadFailed: '{n} card(s) could not be read.',
      cardsReadFailedNote: 'Offers on {names} could not be read this time ' +
          'and are not listed; click ↻ (top right) to retry.',
      noMatchingOffers: 'No matching offers',
      addToSelected: 'Add to selected cards',
      footerSelPrefix: 'Selected ',
      footerSelMid: ' offer(s) · will try adding to ',
      footerSelSuffix: ' card(s)',
      addedToAll: 'Added to all {n} cards',
      addableN: '{n} eligible',
      addedN: '{n} added',
      chooseCards: 'Choose which cards to add to',
      addedMark: 'Added ✓',
      expiresShort: 'Expires {date}',
      // Last-run strip
      lastRunPrefix: 'Last run: ',
      lastRunConfirmed: '{n} confirmed',
      lastRunFailed: '{n} failed',
      lastRunDedupe: '{n} possible duplicate(s)',
      view: 'View',
      today: 'Today {time}',
      yesterday: 'Yesterday {time}',
      // Loading
      loadingSubtitle: 'One offer, multiple cards',
      loadingOffers: 'Reading offers on each card…',
      loadingCardN: 'Card {done} / {total}',
      loadingCardList: 'Reading your card list…',
      loadingReadOnly: 'Read-only at this step — nothing on the account ' +
          'changes',
      // Confirm dialog
      confirmTitle: 'Add to {n} cards?',
      confirmBody1: '{offers} offer(s) selected across {cards} card(s). ' +
          'This will ',
      confirmBodyBold: 'submit all requests at once',
      confirmBody2: ' and cannot be undone. Afterwards the added list is ' +
          're-read to verify each card.',
      confirmRowCards: '{name} → {n} card(s)',
      cancel: 'Cancel',
      confirmSubmit: 'Confirm & submit',
      // Running
      runningTitle: 'Adding to cards…',
      runningSubtitle: '{n} add requests submitted',
      processedOf: '{done} / {total} processed',
      submitOk: 'Submitted',
      submitFail: 'Failed',
      submitting: 'Submitting',
      notSubmitted: 'Not submitted',
      runningNote: 'When done, added lists are re-read to verify each card',
      // Result
      resultTitleOk: 'Done — verified',
      resultTitleStopped: 'Run stopped early',
      resultSubtitle: '{n} add requests processed',
      confirmedAdded: 'Confirmed',
      addFailed: 'Failed',
      dedupeOrUnknown: 'Possible duplicate / unconfirmed',
      throttledTitle: 'Throttling or interception detected.',
      throttledBody: 'After a 429/403 or an abnormal response, the ' +
          'remaining requests were stopped and verification was skipped. ' +
          'Wait a few minutes before pressing "Retry unfinished" — do not ' +
          'resubmit right away.',
      dedupeHelpTitle: 'What is "possible duplicate"?',
      dedupeHelpBody: 'Amex reported success, but on re-reading the ' +
          'added list the offer was not on this card. Usually the same ' +
          'offer can only be added to one card. "Unconfirmed" means ' +
          'verification could not finish.',
      secSkipped: 'Not submitted — stopped after a throttle signal',
      secGhost: 'Possible duplicate — success reported, absent on re-read',
      secUnverified: 'Unconfirmed — verification incomplete',
      retryUnfinished: 'Retry unfinished',
      exportCsv: 'Export CSV',
      backToList: 'Back to list',
      runInterrupted: 'Run interrupted: {msg}',
      // Empty / error
      emptyTitle: 'No offers to add',
      emptyBody: 'Every offer is already on the cards it can go to.',
      reload: '↻ Reload',
      errorTitle: 'Could not read the offers list',
      errorSessionHint: 'Your session may have expired. Sign in to Amex on ' +
          'this page, then retry.',
      retry: 'Retry',
      cardsReadAllFailed: 'Could not read card offers. Please try again ' +
          'later.',
      // Benefits
      primaryCardsN: '{n} primary card(s)',
      updatedJustNow: 'Updated just now',
      updatedMinsAgo: 'Updated {n} min ago',
      updatedHoursAgo: 'Updated {n} h ago',
      loadingBenefits: 'Reading benefits on each card…',
      benefitsReadOnly: 'Read-only — nothing on the account changes',
      benefitsReadFailed: 'Read failed',
      searchBenefits: 'Search benefits or cards',
      sortByExpiry: 'Sorted by expiry ',
      unusedOnly: 'Unused balance only',
      benefitsFootnote: 'Amounts come from Amex benefit trackers · ' +
          'read-only, nothing stored locally',
      noBenefitsMatch: 'No benefits match "{q}"',
      noBenefits: 'No trackable benefits on these cards',
      leftThisMonth: 'Left this month',
      redeemedYtd: 'Redeemed this year',
      feeOffset: 'Fee offset {spent}/{fee}',
      trackedOnly: 'Auto-tracked credits only',
      trackedOnly2: 'Tracked credits only',
      usedUpSection: 'Used up this period',
      xCards: '×{n} cards',
      notActivated: 'Not activated',
      activate: 'Activate ↗',
      expired: 'Expired',
      daysLeft: '{n} days left',
      // Added (redeem-tracking) sub-view
      subAddable: 'Addable',
      subAdded: 'Added',
      sortShortExpiry: 'By expiry',
      statRedeemed: 'Cashback posted',
      statPending: 'To spend',
      statExpiring: 'Expiring in 7 days',
      redeemedOfCards: '{x} / {y} cards posted',
      totalRedeemed: '{amt} total back',
      noCashbackSeen: 'No cashback yet',
      cashbackPosted: '✓ Posted',
      pointsAmount: '{n} pts',
      noAddedOffers: 'No added offers yet',
      addedLoading: 'Reading cashback records…',
      addedError: 'Could not read cashback records.',
      addedFootnote: 'Spend status comes from posted cashback records, ' +
          'which usually lag 1–5 days · Amex is authoritative',
      period_month: 'mo',
      period_quarter: 'qtr',
      period_half: '6 mo',
      period_year: 'yr',
    },
  };

  /** The active panel language ('zh' | 'en'). */
  let currentLang = 'zh';

  /**
   * Sets the active language; unknown codes fall back to English.
   * @param {string} lang Language code.
   */
  function setLanguage(lang) {
    currentLang = MESSAGES[lang] ? lang : 'en';
  }

  /** @return {string} The active language code. */
  function getLanguage() {
    return currentLang;
  }

  /**
   * Looks up a UI string in the active language, interpolating `{name}`
   * placeholders from `params`. Unknown keys return the key itself so a
   * missing translation degrades visibly instead of crashing.
   * @param {string} key Catalog key.
   * @param {!Object<string, *>=} params Placeholder values.
   * @return {string} The localized string.
   */
  function t(key, params) {
    const table = MESSAGES[currentLang] || MESSAGES.zh;
    let text = table[key] != null ? table[key] :
      (MESSAGES.zh[key] != null ? MESSAGES.zh[key] : key);
    if (params) {
      for (const [name, value] of Object.entries(params)) {
        text = text.split(`{${name}}`).join(String(value));
      }
    }
    return text;
  }

  // ---------------------------------------------------------------------------
  // Pure helpers
  // ---------------------------------------------------------------------------

  /**
   * Returns the key used to group/deduplicate an offer across cards.
   *
   * Prefers the stable `pznAnalyticsId` (shared by the same offer on every
   * card) and falls back to the per-card `offerId` when it is absent. This is
   * NOT the value to enroll with — enroll uses the raw per-card `offerId`.
   *
   * @param {!Object} offer Raw offer object from the offers hub.
   * @return {?string} The group key, or null if the offer has neither id.
   */
  function offerGroupKey(offer) {
    const id = offer.pznAnalyticsId || offer.offerId;
    return id ? String(id) : null;
  }

  /**
   * Safely reads a dotted path out of a nested object.
   *
   * @param {?Object} obj Object to read from.
   * @param {string} path Dot-separated path, e.g. `'a.b.c'`.
   * @return {*} The value at `path`, or undefined if any segment is missing.
   */
  function getPath(obj, path) {
    return path.split('.').reduce(
      (value, key) => (value == null ? value : value[key]), obj);
  }

  /**
   * Builds an offer-centric index from a per-card snapshot.
   *
   * Offers that appear on more than one card are merged into a single group
   * keyed by {@link offerGroupKey}. Each group keeps, per card, that card's own
   * `offerId` (needed to enroll) and whether the card already has the offer.
   *
   * @param {!Array<!CardSnapshot>} cards Per-card snapshot.
   * @return {!Array<!OfferGroup>} Grouped offers, most widely eligible first.
   */
  function buildOfferIndex(cards) {
    /** @type {!Map<string, !OfferGroup>} */
    const byKey = new Map();
    for (const card of cards) {
      // Include both eligible and already-added offers so the panel can show
      // offers a card already has, deduping within the card by group key.
      const seen = new Set();
      for (const offer of [...card.eligible, ...(card.enrolled || [])]) {
        const key = offerGroupKey(offer);
        if (!key || !offer.offerId || seen.has(key)) continue;
        seen.add(key);
        let group = byKey.get(key);
        if (!group) {
          group = {
            key,
            name: offer.title || offer.name || key,
            description: offer.shortDescription || '',
            image: offer.image || '',
            expiry: getPath(offer, 'expiration.text') || '',
            cards: [],
          };
          byKey.set(key, group);
        }
        group.cards.push({
          token: card.token,
          offerId: String(offer.offerId),
          enrolled: card.enrolledKeys.has(key),
        });
      }
    }
    const addable = (g) => g.cards.filter((c) => !c.enrolled).length;
    return [...byKey.values()].sort((a, b) => {
      // Fully-added offers sink to the bottom; otherwise most-eligible first.
      const aDone = addable(a) === 0;
      const bDone = addable(b) === 0;
      if (aDone !== bDone) return aDone ? 1 : -1;
      return b.cards.length - a.cards.length;
    });
  }

  /**
   * Cards in a group that can still receive the offer (not already added).
   * @param {!OfferGroup} group Offer group.
   * @return {!Array<{token: string, offerId: string, enrolled: boolean}>} The
   *     addable cards.
   */
  function addableCards(group) {
    return group.cards.filter((card) => !card.enrolled);
  }

  /**
   * Classifies enrollment attempts using a re-read of each card's added
   * offers. Pure: does no I/O.
   *
   * @param {!Array<!Object>} attempts Attempts tagged with `reportedOk`.
   * @param {!Map<string, !Set<string>>} enrolledByToken Added-offer group keys
   *     per card token, read after enrolling. A token absent from the map means
   *     its re-read failed (its reported-ok attempts are then UNVERIFIED).
   * @return {!Array<!EnrollResult>} One result per attempt with a final state.
   */
  function classifyAttempts(attempts, enrolledByToken) {
    return attempts.map((attempt) => {
      let state;
      if (attempt.skipped) {
        state = ResultState.SKIPPED;
      } else if (!attempt.reportedOk) {
        state = ResultState.FAILED;
      } else if (!enrolledByToken.has(attempt.token)) {
        state = ResultState.UNVERIFIED;
      } else if (enrolledByToken.get(attempt.token).has(attempt.key)) {
        state = ResultState.VERIFIED;
      } else {
        state = ResultState.GHOST;
      }
      return {
        key: attempt.key,
        name: attempt.name,
        token: attempt.token,
        offerId: attempt.offerId,
        state,
        httpStatus: attempt.httpStatus || 0,
        blocked: !!attempt.blocked,
        message: attempt.message || '',
      };
    });
  }

  // ---------------------------------------------------------------------------
  // Network layer (uses the logged-in session; talks only to Amex)
  // ---------------------------------------------------------------------------

  /**
   * Performs a JSON request with the logged-in session and returns the parsed
   * body. Throws a classified error on failure, tagged with:
   * - `httpStatus`: the HTTP status (0 for network-level failures).
   * - `transient`: worth an automatic quick retry (network error or 5xx).
   * - `blocked`: the session is being throttled or intercepted (429, 401/403,
   *   or a non-JSON body such as an interstitial page) — the caller should
   *   stop sending further requests rather than push through.
   *
   * @param {string} url Request URL.
   * @param {!Object} options `fetch` options (method/headers/body).
   * @return {!Promise<!Object>} Parsed JSON response.
   */
  async function requestJson(url, options) {
    let response;
    try {
      response = await fetch(url, options);
    } catch (cause) {
      throw Object.assign(new Error(`network error: ${cause.message}`),
        {httpStatus: 0, transient: true, blocked: false});
    }
    if (!response.ok) {
      const status = response.status;
      throw Object.assign(new Error(`HTTP ${status}`), {
        httpStatus: status,
        transient: status >= 500,
        blocked: status === 429 || status === 403 || status === 401,
      });
    }
    try {
      return await response.json();
    } catch {
      // A 2xx that isn't JSON is an interstitial (login / challenge) page.
      throw Object.assign(
        new Error('non-JSON response (blocked or logged out?)'),
        {httpStatus: response.status, transient: false, blocked: true});
    }
  }

  /**
   * Issues a GET carrying the session cookies.
   * @param {string} url Request URL.
   * @return {!Promise<!Object>} Parsed JSON response.
   */
  function getJson(url) {
    return requestJson(url, {
      credentials: 'include',
      headers: {'Accept': 'application/json'},
    });
  }

  /**
   * Issues a POST carrying the session cookies. `Origin` is intentionally not
   * set — the browser adds it automatically on cross-origin requests.
   * @param {string} url Request URL.
   * @param {!Object} body JSON-serializable request body.
   * @return {!Promise<!Object>} Parsed JSON response.
   */
  function postJson(url, body) {
    return requestJson(url, {
      method: 'POST',
      credentials: 'include',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
      body: JSON.stringify(body),
    });
  }

  /**
   * Runs a request, re-sending it quickly when it fails with a transient
   * error (network hiccup or 5xx). Definitive answers — other 4xx, blocked
   * signals, non-JSON — are thrown through untouched. For idempotent reads
   * only; enroll has its own window-sensitive retry policy in
   * {@link attemptEnroll}.
   *
   * @param {function(): !Promise<T>} fn The request to run.
   * @param {function(): !Promise<void>=} retryDelay Pause between tries.
   * @param {number=} retries Extra attempts after the first.
   * @return {!Promise<T>} `fn`'s result.
   * @template T
   */
  async function retryTransient(fn, retryDelay = randomRetryDelay,
    retries = 1) {
    for (let attempt = 0; ; attempt++) {
      try {
        return await fn();
      } catch (error) {
        if (!error.transient || attempt >= retries) throw error;
        await retryDelay();
      }
    }
  }

  /**
   * Reads one page of the offers hub for a card.
   *
   * @param {string} token The card's `account_token`.
   * @param {RequestType} requestType Which list to read.
   * @param {string=} page Page name such as `'page1'`; omit for the default
   *     (non-paginated) read.
   * @return {!Promise<!Object>} Parsed offers-hub response.
   */
  function readOffersHub(token, requestType, page) {
    const body = {accountNumberProxy: token, locale: LOCALE, requestType};
    if (page) body.offerPage = page;
    return postJson(READ_OFFERS_URL, body);
  }

  /**
   * Lists the logged-in member's card accounts.
   * @param {function(): !Promise<void>=} retryDelay Pause between read tries.
   * @return {!Promise<!Array<!Object>>} Raw account objects.
   */
  async function fetchAccounts(retryDelay = randomRetryDelay) {
    const data = await retryTransient(() => getJson(MEMBER_URL), retryDelay);
    return Array.isArray(data.accounts) ? data.accounts : [];
  }

  /**
   * Flattens the member's accounts into one entry per enrollable card,
   * including supplementary / authorized-user cards nested under
   * `supplementary_accounts[].account`. Supplementary cards inherit the parent
   * card's product for display. Cards without an `account_token` are skipped.
   *
   * @param {!Array<!Object>} accounts Raw account objects from the member API.
   * @return {!Array<!Object>} One account-like object per card, each with an
   *     `account_token`.
   */
  function flattenAccounts(accounts) {
    const cards = [];
    for (const account of accounts) {
      if (account.account_token) cards.push(account);
      for (const supp of account.supplementary_accounts || []) {
        const sub = supp && supp.account;
        if (sub && sub.account_token) {
          cards.push({...sub, product: sub.product || account.product});
        }
      }
    }
    return cards;
  }

  /**
   * Fetches every eligible MERCHANT offer for a card, walking pages until an
   * empty page is returned. Each page read retries once on a transient error.
   *
   * @param {string} token The card's `account_token`.
   * @param {function(): !Promise<void>=} retryDelay Pause between read tries.
   * @return {!Promise<!Array<!Object>>} Raw eligible offers.
   */
  async function fetchEligibleOffers(token, retryDelay = randomRetryDelay) {
    const offers = [];
    for (let i = 1; i <= MAX_PAGES; i++) {
      const page = `page${i}`;
      const data = await retryTransient(
        () => readOffersHub(token, RequestType.ELIGIBLE, page), retryDelay);
      const items = getPath(data, `recommendedOffers.offersList.${page}`);
      if (!Array.isArray(items) || items.length === 0) break;
      for (const offer of items) {
        if (offer.offerType === OFFER_TYPE) offers.push(offer);
      }
    }
    return offers;
  }

  /**
   * Fetches the offers already added to a card (single page). Retries once on
   * a transient error.
   *
   * @param {string} token The card's `account_token`.
   * @param {function(): !Promise<void>=} retryDelay Pause between read tries.
   * @return {!Promise<!Array<!Object>>} Raw added-to-card offers.
   */
  async function fetchEnrolledOffers(token, retryDelay = randomRetryDelay) {
    const data = await retryTransient(
      () => readOffersHub(token, RequestType.ENROLLED), retryDelay);
    const items = getPath(data, 'addedToCardViewAll.offersList.page1');
    return Array.isArray(items) ? items : [];
  }

  /**
   * Reads a card's added offers and returns their group keys as a set.
   * @param {string} token The card's `account_token`.
   * @param {function(): !Promise<void>=} retryDelay Pause between read tries.
   * @return {!Promise<!Set<string>>} Added-offer group keys.
   */
  async function fetchEnrolledKeys(token, retryDelay = randomRetryDelay) {
    const offers = await fetchEnrolledOffers(token, retryDelay);
    return new Set(offers.map(offerGroupKey).filter(Boolean));
  }

  /**
   * Enrolls a single offer onto a single card.
   *
   * @param {string} token The card's `account_token`.
   * @param {string} offerId The card's own opaque `offerId` (NOT the group
   *     key).
   * @return {!Promise<!Object>} Parsed enroll response.
   */
  function enrollOffer(token, offerId) {
    return postJson(ENROLL_URL, {
      accountNumberProxy: token,
      offerId,
      locale: LOCALE,
      enrollmentTrigger: 'OFFERSHUB_TILE',
      requestType: 'OFFERSHUB_ENROLLMENT',
      synchronizeOnly: false,
      offerUnencrypted: false,
    });
  }

  /**
   * Whether an enroll response reports success.
   * @param {!Object} response Parsed enroll response.
   * @return {boolean} True if the server reported success.
   */
  function isEnrollSuccess(response) {
    return String(getPath(response, 'status.purpose') || '').toUpperCase() ===
        'SUCCESS';
  }

  // ---------------------------------------------------------------------------
  // Snapshot + execution
  // ---------------------------------------------------------------------------

  /**
   * Builds a human label for a card from the best fields available, falling
   * back to the last 4 of the account token.
   * @param {!Object} account Raw account object.
   * @param {string} token The card's `account_token`.
   * @return {string} Display label.
   */
  function cardName(account, token) {
    const product =
        getPath(account, 'product.description') ||
        getPath(account, 'product.product_description') ||
        getPath(account, 'profile.embossed_name') || '';
    const digits = cardDisplayDigits(account);
    const suffix = digits ? `••${digits}` : `…${String(token).slice(-4)}`;
    return `${product} ${suffix}`.trim();
  }

  /**
   * Finds a card's display digits (last 4-5) from whatever member field holds
   * them, tolerating masking characters.
   * @param {!Object} account Raw account object.
   * @return {string} Last 4-5 digits, or '' if none found.
   */
  function cardDisplayDigits(account) {
    const candidates = [
      // Amex nests the display number under an inner `account` object.
      getPath(account, 'account.display_account_number'),
      getPath(account, 'account.account_number'),
      account.display_account_number,
      account.account_number,
      account.display_number,
      account.card_number,
    ];
    for (const value of candidates) {
      if (value) {
        const digits = String(value).replace(/\D/g, '');
        if (digits) return digits.slice(-5);
      }
    }
    return '';
  }

  /**
   * Builds a compact card label (product family + last digits) for tabs and
   * chips, e.g. `Platinum ···31004`.
   * @param {!Object} account Raw account object.
   * @param {string} token The card's `account_token`.
   * @return {string} Compact display label.
   */
  function cardShortName(account, token) {
    const base = cardFamily(account, token);
    const digits = cardDisplayDigits(account);
    return digits ? `${base} ···${digits}` : base;
  }

  /**
   * The card's product family alone (no digits), e.g. `Platinum`,
   * `Blue Cash Preferred`. Falls back to `…1234` when the product is unknown.
   * @param {!Object} account Raw account object.
   * @param {string} token The card's `account_token`.
   * @return {string} Product family label.
   */
  function cardFamily(account, token) {
    const product = getPath(account, 'product.description') ||
        getPath(account, 'profile.embossed_name') || '';
    const family = product.replace(/\s*Card\b/gi, '').replace(/[®™]/g, '').trim();
    return family || `…${String(token).slice(-4)}`;
  }

  /**
   * Maps `fn` over `items` with at most `limit` running concurrently,
   * preserving input order. Caps how many cards we read from Amex at once.
   * @param {!Array<T>} items Items to map.
   * @param {number} limit Max concurrent invocations.
   * @param {function(T, number): !Promise<R>} fn Async mapper.
   * @return {!Promise<!Array<R>>} Results in input order.
   * @template T, R
   */
  async function mapLimit(items, limit, fn) {
    const results = new Array(items.length);
    let next = 0;
    const worker = async () => {
      while (next < items.length) {
        const i = next++;
        results[i] = await fn(items[i], i);
      }
    };
    const count = Math.max(1, Math.min(limit, items.length));
    await Promise.all(Array.from({length: count}, () => worker()));
    return results;
  }

  /**
   * Reads all cards and their eligible offers plus already-added group keys.
   *
   * The account list is fetched first so the real card count is known before
   * any card is read; `onProgress` then fires once per card (starting at 0)
   * with the running total, letting the loading UI show honest progress.
   *
   * Reads retry once on transient errors. A card whose reads still fail comes
   * back with empty lists and `readFailed: true` instead of sinking the whole
   * load — except on a blocked signal (throttle / interception), which aborts
   * the snapshot so we stop reading into a wall.
   *
   * @param {function(number, number)=} onProgress Called `(done, total)` as
   *     each card finishes reading. Defaults to a no-op.
   * @param {{retryDelay: (function(): !Promise<void>|undefined)}=} options
   *     Behavior overrides (tests inject a no-op delay).
   * @return {!Promise<!Array<!CardSnapshot>>} Per-card snapshot.
   */
  async function snapshot(onProgress = () => {}, options = {}) {
    const {retryDelay = randomRetryDelay} = options;
    const accounts = flattenAccounts(await fetchAccounts(retryDelay))
      .filter((account) => account.account_token);
    const total = accounts.length;
    onProgress(0, total);
    // Read cards concurrently but capped (was fully serial, i.e. slow on an
    // 8-card account). Reads within a card stay sequential, so at most
    // MAX_CONCURRENT_READS requests are in flight.
    let done = 0;
    return mapLimit(accounts, MAX_CONCURRENT_READS, async (account) => {
      const token = account.account_token;
      let eligible = [];
      let enrolled = [];
      let readFailed = false;
      try {
        eligible = await fetchEligibleOffers(token, retryDelay);
        enrolled = await fetchEnrolledOffers(token, retryDelay);
      } catch (error) {
        // A blocked signal means every further read would hit the same wall
        // — abort the whole load. Anything else skips just this card.
        if (error.blocked) throw error;
        readFailed = true;
      }
      onProgress(++done, total);
      return {
        token,
        tag: String(token).slice(-4),
        name: cardName(account, token),
        shortName: cardShortName(account, token),
        family: cardFamily(account, token),
        digits: cardDisplayDigits(account) || String(token).slice(-4),
        relationship: account.relationship || 'BASIC',
        art: getPath(account, 'product.small_card_art') || '',
        eligible,
        enrolled,
        enrolledKeys: new Set(enrolled.map(offerGroupKey).filter(Boolean)),
        readFailed,
      };
    });
  }

  // ---------------------------------------------------------------------------
  // Benefits (loyalty benefit trackers): read each card's credits/perks and
  // aggregate them across cards. Read-only — no write endpoint exists here.
  // ---------------------------------------------------------------------------

  /**
   * Published annual fees (USD) by product family, used only to show the
   * "annual-fee payback" stat. Not available from any API, so this is a small
   * static table; a family not listed contributes 0 (the stat degrades
   * gracefully). Matched by exact family first, then substring.
   * @const {!Object<string, number>}
   */
  const ANNUAL_FEES = {
    'Platinum': 695,
    'Business Platinum': 695,
    'Gold': 325,
    'Business Gold': 375,
    'Green': 150,
    'Blue Cash Preferred': 95,
    'Delta SkyMiles Gold': 150,
    'Delta SkyMiles Platinum': 350,
    'Delta SkyMiles Reserve': 650,
    'Hilton Honors Surpass': 150,
    'Hilton Honors Aspire': 550,
    'Marriott Bonvoy Brilliant': 650,
  };

  /**
   * Rounds a currency amount to whole cents (kills float drift from summing).
   * @param {number} n Amount.
   * @return {number} Amount rounded to two decimals.
   */
  function round2(n) {
    return Math.round(n * 100) / 100;
  }

  /**
   * Looks up a card's published annual fee.
   * @param {string} family Product family (e.g. `Platinum`).
   * @return {number} Annual fee in USD, or 0 when unknown.
   */
  function annualFeeFor(family) {
    if (!family) return 0;
    if (ANNUAL_FEES[family] != null) return ANNUAL_FEES[family];
    const hit = Object.keys(ANNUAL_FEES).find((k) => family.includes(k));
    return hit ? ANNUAL_FEES[hit] : 0;
  }

  /**
   * Reads one card's benefit trackers. The endpoint takes an array with a
   * single request object and returns an array of `{trackers: [...]}` blocks.
   * @param {string} token The card's `account_token`.
   * @return {!Promise<!Array<!Object>>} Raw tracker objects for the card.
   */
  async function fetchAccountBenefits(token) {
    const data = await postJson(READ_BENEFITS_URL,
      [{accountToken: token, locale: LOCALE, limit: BENEFIT_LIMIT}]);
    const blocks = Array.isArray(data) ? data : [];
    return blocks.flatMap((block) => block.trackers || []);
  }

  /**
   * Whole days from `now` until an ISO date string (negative once past).
   * @param {string} dateStr A date the endpoint returns (`periodEndDate`).
   * @param {number=} now Epoch ms treated as "today"; defaults to real time.
   * @return {number} Days remaining, or `Infinity` when the date is unparsable.
   */
  function daysUntil(dateStr, now = Date.now()) {
    const end = Date.parse(dateStr);
    if (Number.isNaN(end)) return Infinity;
    return Math.ceil((end - now) / 86400000);
  }

  /**
   * A human period label for a tracker, from its reset cadence. Uses the span
   * between period dates (robust to unknown `trackerDuration` enum values),
   * falling back to the raw duration string.
   * Returns a language-neutral cadence key; the UI localizes it via the
   * `period_*` catalog entries.
   * @param {!Object} tracker Raw tracker object.
   * @return {string} One of `month` / `quarter` / `half` / `year`, or `''`.
   */
  function benefitPeriodLabel(tracker) {
    const start = Date.parse(tracker.periodStartDate);
    const end = Date.parse(tracker.periodEndDate);
    if (!Number.isNaN(start) && !Number.isNaN(end)) {
      const days = Math.round((end - start) / 86400000);
      if (days <= 45) return 'month';
      if (days <= 135) return 'quarter';
      if (days <= 250) return 'half';
      return 'year';
    }
    const dur = String(tracker.trackerDuration || '').toUpperCase();
    if (dur.includes('MONTH')) return 'month';
    if (dur.includes('QUARTER')) return 'quarter';
    if (dur.includes('SEMI') || dur.includes('HALF')) return 'half';
    if (dur.includes('YEAR') || dur.includes('ANNUAL')) return 'year';
    return '';
  }

  /**
   * Normalizes a raw tracker into our benefit shape, tagged with its card.
   * @param {!Object} tracker Raw tracker object.
   * @param {!Object} card A snapshot card (`token`, `family`, `digits`, `art`).
   * @return {!Object} Normalized benefit.
   */
  function normalizeBenefit(tracker, card) {
    const tr = tracker.tracker || {};
    const target = round2(parseFloat(tr.targetAmount) || 0);
    const spent = round2(parseFloat(tr.spentAmount) || 0);
    const remaining = tr.remainingAmount != null ?
      round2(parseFloat(tr.remainingAmount) || 0) :
      round2(Math.max(0, target - spent));
    return {
      sorBenefitId: tracker.sorBenefitId || tracker.benefitId || '',
      benefitId: tracker.benefitId || '',
      name: tracker.benefitName || '',
      category: tracker.category || '',
      status: tracker.status || '',
      period: benefitPeriodLabel(tracker),
      periodEnd: tracker.periodEndDate || '',
      symbol: tr.targetCurrencySymbol || '$',
      target,
      spent,
      remaining,
      token: card.token,
      family: card.family,
      digits: card.digits,
      art: card.art,
    };
  }

  /**
   * A stable grouping key for a benefit, derived from its display name. The
   * same benefit has a different `sorBenefitId` on each card product, so
   * grouping by (normalized) name is what merges e.g. the ChatGPT credit across
   * a Platinum and a Business Gold into one row.
   * @param {string} name Benefit display name.
   * @return {string} Normalized key.
   */
  function benefitNameKey(name) {
    return String(name || '').replace(/\s+/g, ' ').trim().toLowerCase();
  }

  /**
   * Whether a tracker is a dollar credit we should show (and count in stats).
   * Excludes `category === "spend"` (spend-to-unlock milestones like Centurion
   * / Delta Sky Club, whose target is a huge spend goal, not a credit) and
   * pass-based perks (`targetUnit === "PASSES"`, e.g. lounge visits) that are
   * not dollar amounts.
   * @param {!Object} tracker Raw tracker object.
   * @return {boolean} True for a spendable dollar credit.
   */
  function isTrackedCredit(tracker) {
    const cat = String(tracker.category || '').toLowerCase();
    const unit = String((tracker.tracker || {}).targetUnit || '').toUpperCase();
    return cat !== 'spend' && unit !== 'PASSES';
  }

  /**
   * Reads a card's benefit catalog (every perk keyed by slug). The body is a
   * plain object (the array form is rejected). Returns {} on any failure so the
   * tracker view still works without it.
   * @param {string} token The card's `account_token`.
   * @return {!Promise<!Object>} The `benefits` dict, or {} on failure.
   */
  async function fetchCardCatalog(token) {
    try {
      const data = await postJson(READ_CATALOG_URL,
        {accountToken: token, locale: LOCALE});
      return data && data.benefits ? data.benefits : {};
    } catch {
      return {};
    }
  }

  /**
   * Strips tags and decodes HTML entities from an API title for plain-text
   * display (rendered via textContent, so this is for readability, not safety).
   * @param {string} s Raw title.
   * @return {string} Decoded plain text.
   */
  function decodeHtml(s) {
    return String(s || '')
      .replace(/<[^>]+>/g, '')
      .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
      .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
      .replace(/&nbsp;/g, ' ')
      .trim();
  }

  /**
   * Extracts the dollar amount from a benefit title (e.g. "$209 CLEAR+ Credit"
   * → 209), so a not-yet-enrolled benefit can show its credit value.
   * @param {string} title Benefit title.
   * @return {number} Parsed amount, or 0.
   */
  function parseCreditAmount(title) {
    const m = /\$([0-9][0-9,]*)/.exec(String(title));
    return m ? parseFloat(m[1].replace(/,/g, '')) : 0;
  }

  /**
   * Builds a benefit entry for a not-yet-enrolled catalog benefit.
   * @param {!Object} entry Raw catalog benefit.
   * @param {!Object} card A snapshot card.
   * @return {!Object} Normalized (not-enrolled) benefit.
   */
  function catalogBenefit(entry, card) {
    const name = decodeHtml(
      entry.benefitShortTitle || entry.benefitTitle || entry.benefitName || '');
    const target = parseCreditAmount(name);
    return {
      sorBenefitId: entry.sorBenefitId || '',
      benefitId: entry.sorBenefitId || '',
      name,
      category: '',
      status: 'NOTENROLLED',
      period: 'year',
      periodEnd: '',
      symbol: '$',
      target,
      spent: 0,
      remaining: target,
      token: card.token,
      family: card.family,
      digits: card.digits,
      art: card.art,
    };
  }

  /**
   * Reads benefits for every BASIC (owned) card: the spend trackers plus the
   * catalog. The catalog supplies cleaner titles (joined by `sorBenefitId`) and
   * the not-yet-enrolled benefits (`layoutType === 'NOTENROLLED'`) that never
   * appear as trackers. `onProgress(done, total)` fires per card.
   * @param {!Array<!Object>} cards Snapshot cards.
   * @param {function(number, number)=} onProgress Progress callback.
   * @return {!Promise<!Array<!Object>>} Card-tagged benefits.
   */
  async function fetchAllBenefits(cards, onProgress = () => {}) {
    const owned = cards.filter((c) => (c.relationship || 'BASIC') === 'BASIC');
    const total = owned.length;
    onProgress(0, total);
    // Read cards concurrently but capped; reads within a card stay sequential,
    // so at most MAX_CONCURRENT_READS requests are in flight at once.
    let done = 0;
    const perCard = await mapLimit(owned, MAX_CONCURRENT_READS,
      async (card) => {
        const trackers = await retryTransient(
          () => fetchAccountBenefits(card.token));
        const catalog = await retryTransient(
          () => fetchCardCatalog(card.token));
        onProgress(++done, total);
        return {card, trackers, catalog};
      });
    const catalogs = perCard.map(({card, catalog}) => ({card, catalog}));
    const benefits = [];
    for (const {card, trackers} of perCard) {
      for (const t of trackers) {
        if (isTrackedCredit(t)) benefits.push(normalizeBenefit(t, card));
      }
    }

    // Prefer the catalog's clean title where it maps to a tracker (fixes names
    // like "Congratulations!" that the tracker returns for achieved benefits).
    const titleBySor = new Map();
    for (const {catalog} of catalogs) {
      for (const slug of Object.keys(catalog)) {
        const c = catalog[slug];
        if (c.sorBenefitId && c.benefitTitle) {
          titleBySor.set(c.sorBenefitId, decodeHtml(c.benefitTitle));
        }
      }
    }
    for (const b of benefits) {
      const title = titleBySor.get(b.sorBenefitId);
      if (title) b.name = title;
    }

    // Add not-yet-enrolled benefits (no tracker on any card) as "去激活" rows,
    // deduped by name so a benefit enrollable on several cards shows once.
    const tracked = new Set(benefits.map((b) => benefitNameKey(b.name)));
    const added = new Set();
    for (const {card, catalog} of catalogs) {
      for (const slug of Object.keys(catalog)) {
        const c = catalog[slug];
        if (c.layoutType !== 'NOTENROLLED' || !c.isEnrollable) continue;
        if (!c.sorBenefitId) continue;
        const b = catalogBenefit(c, card);
        // Only surface dollar-credit perks (e.g. CLEAR $189), not status/link
        // benefits like "Link Your Resy Profile" that have no amount.
        if (b.target <= 0) continue;
        const nameKey = benefitNameKey(b.name);
        if (tracked.has(nameKey) || added.has(nameKey)) continue;
        added.add(nameKey);
        benefits.push(b);
      }
    }
    return benefits;
  }

  /**
   * Finalizes a benefit group: sums amounts across its cards, picks the
   * soonest period end, and flags multi-card / fully-used.
   * @param {!Object} group Partial group with `entries`.
   * @param {number} now Epoch ms treated as "today".
   * @return {!Object} Finalized group.
   */
  function finalizeBenefitGroup(group, now) {
    const spent = round2(group.entries.reduce((s, e) => s + e.spent, 0));
    const target = round2(group.entries.reduce((s, e) => s + e.target, 0));
    const ends = group.entries.map((e) => e.periodEnd).filter(Boolean).sort();
    const periodEnd = ends[0] || '';
    return {
      ...group,
      spent,
      target,
      remaining: round2(Math.max(0, target - spent)),
      periodEnd,
      daysLeft: daysUntil(periodEnd, now),
      multiCard: group.entries.length > 1,
      fullyUsed: target > 0 && spent >= target,
      symbol: group.entries[0] ? group.entries[0].symbol : '$',
    };
  }

  /**
   * Groups card-tagged benefits across cards by normalized name (so the same
   * benefit on different card products merges) and sorts soonest-expiry first.
   * @param {!Array<!Object>} benefits Card-tagged benefits.
   * @param {number=} now Epoch ms treated as "today".
   * @return {!Array<!Object>} Grouped benefits, soonest-to-expire first.
   */
  function buildBenefitIndex(benefits, now = Date.now()) {
    const byKey = new Map();
    for (const b of benefits) {
      const key = benefitNameKey(b.name) ||
          b.sorBenefitId || `${b.token}:${b.benefitId}`;
      let group = byKey.get(key);
      if (!group) {
        group = {key, name: b.name, category: b.category, period: b.period,
          status: b.status, entries: []};
        byKey.set(key, group);
      }
      group.entries.push(b);
    }
    return [...byKey.values()]
      .map((group) => finalizeBenefitGroup(group, now))
      .sort((a, b) => a.daysLeft - b.daysLeft);
  }

  /**
   * Computes the benefits header stats from grouped benefits.
   * @param {!Array<!Object>} groups Grouped benefits.
   * @param {!Array<!Object>} cards Snapshot cards (for annual-fee lookup).
   * @param {number=} now Epoch ms treated as "today".
   * @return {{thisMonthUnused: number, redeemedYtd: number,
   *           annualFee: number, paybackPct: number}} Stats.
   */
  function benefitStats(groups, cards, now = Date.now()) {
    const nowDate = new Date(now);
    let thisMonthUnused = 0;
    let redeemedYtd = 0;
    for (const g of groups) {
      redeemedYtd += g.spent;
      const end = new Date(g.periodEnd);
      if (!Number.isNaN(end.getTime()) &&
          end.getFullYear() === nowDate.getFullYear() &&
          end.getMonth() === nowDate.getMonth()) {
        thisMonthUnused += g.remaining;
      }
    }
    const owned = cards.filter((c) => (c.relationship || 'BASIC') === 'BASIC');
    const annualFee = owned.reduce((s, c) => s + annualFeeFor(c.family), 0);
    const paybackPct =
        annualFee > 0 ? Math.round(redeemedYtd / annualFee * 100) : 0;
    return {
      thisMonthUnused: round2(thisMonthUnused),
      redeemedYtd: round2(redeemedYtd),
      annualFee,
      paybackPct,
    };
  }

  // ---------------------------------------------------------------------------
  // Redeem tracking (the "added" sub-view): group the added offers across
  // cards and mark each (offer, card) pair by whether a cashback posting
  // exists for it. Honest framing throughout: we only see posting records —
  // a pair is "no cashback seen", never "not spent" (postings lag spend by
  // days, and some offers credit in other ways).
  // ---------------------------------------------------------------------------

  /**
   * Fetches the offers with posted savings for a card. Retries once on a
   * transient error. Tolerates both the paginated (`page1`) and flat-array
   * shapes of the savings list.
   * @param {string} token The card's `account_token`.
   * @param {function(): !Promise<void>=} retryDelay Pause between read tries.
   * @return {!Promise<!Array<!Object>>} Raw redeemed offers.
   */
  async function fetchRedeemedOffers(token, retryDelay = randomRetryDelay) {
    const data = await retryTransient(
      () => readOffersHub(token, RequestType.REDEEMED), retryDelay);
    const node =
        getPath(data, 'offersSavingsViewAll.savingsOffers.offersList');
    if (Array.isArray(node)) return node;
    const page = getPath(node, 'page1');
    return Array.isArray(page) ? page : [];
  }

  /** Candidate paths that may hold a redemption's credited amount. */
  const REDEEM_AMOUNT_PATHS = ['savingsAmount', 'creditAmount', 'savedAmount',
    'statementCreditAmount', 'amount', 'savings.amount'];

  /** Candidate paths that may hold a redemption's posting date. */
  const REDEEM_DATE_PATHS = ['redemptionDate', 'creditDate', 'postedDate',
    'transactionDate', 'savings.date', 'date'];

  /** Candidate paths whose presence marks a points-based posting. */
  const REDEEM_POINTS_PATHS = ['points', 'pointsEarned', 'rewardPoints',
    'membershipRewardsPoints', 'totalPoints'];

  /**
   * Whether a raw redeemed offer credits points/miles rather than dollars
   * (e.g. "Earn +1 Membership Rewards point per eligible dollar"). Points
   * values must never be summed as money. Checks points-ish fields first,
   * then the offer's own wording.
   * @param {!Object} offer Raw redeemed offer.
   * @return {boolean} True for a points/miles posting.
   */
  function isPointsRedemption(offer) {
    for (const path of REDEEM_POINTS_PATHS) {
      const value = getPath(offer, path);
      if (value == null || value === '') continue;
      const n = parseFloat(String(value).replace(/[^0-9.]/g, ''));
      if (Number.isFinite(n) && n > 0) return true;
    }
    const text = [offer.title, offer.name, offer.shortDescription,
      getPath(offer, 'rewardType'), getPath(offer, 'offerRewardType'),
      getPath(offer, 'rewardsType')].filter(Boolean).join(' ');
    return /point|membership rewards|\bmiles\b/i.test(text);
  }

  /**
   * Extracts the credited amount from a raw redeemed offer, tolerating both
   * numeric and `"$12.50"`-style fields across the candidate paths.
   * @param {!Object} offer Raw redeemed offer.
   * @return {number} Credited amount, or 0 when unknown.
   */
  function redemptionAmount(offer) {
    for (const path of REDEEM_AMOUNT_PATHS) {
      const value = getPath(offer, path);
      if (value == null || value === '') continue;
      const n = typeof value === 'number' ? value :
        parseFloat(String(value).replace(/[^0-9.]/g, ''));
      if (Number.isFinite(n) && n > 0) return n;
    }
    return 0;
  }

  /**
   * Extracts a redemption's posting date as `M/D`. Parses ISO and `M/D`
   * shapes with regexes (never `Date.parse`, whose UTC midnight shifts the
   * day in western timezones).
   * @param {!Object} offer Raw redeemed offer.
   * @return {string} `M/D`, or '' when unknown.
   */
  function redemptionDate(offer) {
    for (const path of REDEEM_DATE_PATHS) {
      const value = getPath(offer, path);
      if (!value) continue;
      const s = String(value);
      let m = /(\d{4})-(\d{2})-(\d{2})/.exec(s);
      if (m) return `${Number(m[2])}/${Number(m[3])}`;
      m = /(\d{1,2})\/(\d{1,2})/.exec(s);
      if (m) return `${Number(m[1])}/${Number(m[2])}`;
    }
    return '';
  }

  /**
   * Days until an added offer expires, from whichever expiration field is
   * present. Text like `7/9` without a year is assumed to be the upcoming
   * occurrence (offers expire in the future).
   * @param {!Object} offer Raw offer object.
   * @param {number=} now Epoch ms treated as "today".
   * @return {number} Whole days left (negative when past), or Infinity when
   *     unparsable — unknown expiries sort last, they don't shout "urgent".
   */
  function offerExpiryDays(offer, now = Date.now()) {
    // Calendar-day difference (expiring the day after tomorrow = 2), the
    // colloquial reading of "N days left".
    const nowDate = new Date(now);
    const today = new Date(nowDate.getFullYear(), nowDate.getMonth(),
      nowDate.getDate()).getTime();
    const dayDiff = (end) => Math.round((end.getTime() - today) / 86400000);
    const iso = String(getPath(offer, 'expiration.date') ||
        offer.expirationDate || '');
    let m = /(\d{4})-(\d{2})-(\d{2})/.exec(iso);
    if (m) return dayDiff(new Date(+m[1], +m[2] - 1, +m[3]));
    m = /(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?/
      .exec(getPath(offer, 'expiration.text') || '');
    if (!m) return Infinity;
    let year = m[3] ? Number(m[3]) : nowDate.getFullYear();
    if (year < 100) year += 2000;
    let end = new Date(year, Number(m[1]) - 1, Number(m[2]));
    if (!m[3] && dayDiff(end) < 0) {
      end = new Date(year + 1, Number(m[1]) - 1, Number(m[2]));
    }
    return dayDiff(end);
  }

  /**
   * Builds the "added" view's index: every offer present on any card's added
   * list OR savings list, grouped across cards by {@link offerGroupKey}.
   * Redeemed-only offers are included so a fully-redeemed offer still shows
   * after Amex drops it from the added list. Each group's `cards[i]` carries
   * that card's redemption record (null when no posting was seen). Groups
   * sort by expiry urgency; fully-redeemed groups sink to the bottom.
   *
   * @param {!Array<!CardSnapshot>} cards Per-card snapshot (uses `enrolled`).
   * @param {!Map<string, !Array<!Object>>=} redeemedByToken Raw redeemed
   *     offers per card token; empty → everything shows as no-posting-seen.
   * @param {number=} now Epoch ms treated as "today".
   * @return {!Array<!Object>} Added-offer groups.
   */
  function buildAddedIndex(cards, redeemedByToken = new Map(),
    now = Date.now()) {
    const byKey = new Map();
    const groupFor = (offer) => {
      const key = offerGroupKey(offer);
      if (!key) return null;
      let g = byKey.get(key);
      if (!g) {
        g = {key, name: offer.title || offer.name || key,
          description: offer.shortDescription || '',
          image: offer.image || '',
          expiry: getPath(offer, 'expiration.text') || '',
          daysLeft: offerExpiryDays(offer, now),
          cards: [], cardIndex: new Map()};
        byKey.set(key, g);
      } else {
        // Later sightings (e.g. the savings copy) may carry fields the first
        // one lacked.
        if (!g.description && offer.shortDescription) {
          g.description = offer.shortDescription;
        }
        if (!Number.isFinite(g.daysLeft)) {
          g.daysLeft = offerExpiryDays(offer, now);
        }
      }
      return g;
    };
    const entryFor = (g, token) => {
      let e = g.cardIndex.get(token);
      if (!e) {
        e = {token, redeemed: null};
        g.cardIndex.set(token, e);
        g.cards.push(e);
      }
      return e;
    };
    for (const card of cards) {
      const seen = new Set();
      for (const offer of card.enrolled || []) {
        const g = groupFor(offer);
        if (!g || seen.has(g.key)) continue;
        seen.add(g.key);
        entryFor(g, card.token);
      }
      for (const offer of redeemedByToken.get(card.token) || []) {
        const g = groupFor(offer);
        if (!g) continue;
        const e = entryFor(g, card.token);
        if (!e.redeemed) {
          e.redeemed = {amount: redemptionAmount(offer),
            unit: isPointsRedemption(offer) ? 'points' : 'usd',
            date: redemptionDate(offer)};
        }
      }
    }
    const groups = [...byKey.values()];
    for (const g of groups) {
      delete g.cardIndex;
      g.redeemedCount = g.cards.filter((c) => c.redeemed).length;
      const sum = (unit) => round2(g.cards.reduce((total, c) =>
        total + (c.redeemed && c.redeemed.unit === unit ?
          c.redeemed.amount : 0), 0));
      // Dollars and points are separate ledgers — never added together.
      g.totalRedeemedUsd = sum('usd');
      g.totalRedeemedPoints = sum('points');
      g.fullyRedeemed =
          g.cards.length > 0 && g.redeemedCount === g.cards.length;
    }
    const order = (g) => Number.isFinite(g.daysLeft) ? g.daysLeft : 1e9;
    return groups.sort((a, b) => {
      if (a.fullyRedeemed !== b.fullyRedeemed) return a.fullyRedeemed ? 1 : -1;
      return order(a) - order(b);
    });
  }

  /**
   * Header stats for the added view. `redeemedAmount` is dollars only —
   * points postings are tallied separately and never converted to money.
   * @param {!Array<!Object>} groups Added-offer groups.
   * @return {{redeemedAmount: number, redeemedPoints: number,
   *           pending: number, expiring: number}} Posted cashback dollars,
   *     posted points, groups not yet fully redeemed, and how many of those
   *     expire within 7 days.
   */
  function addedStats(groups) {
    let redeemedAmount = 0;
    let redeemedPoints = 0;
    let pending = 0;
    let expiring = 0;
    for (const g of groups) {
      redeemedAmount += g.totalRedeemedUsd;
      redeemedPoints += g.totalRedeemedPoints;
      if (!g.fullyRedeemed) {
        pending++;
        if (Number.isFinite(g.daysLeft) && g.daysLeft >= 0 &&
            g.daysLeft <= 7) {
          expiring++;
        }
      }
    }
    return {redeemedAmount: round2(redeemedAmount),
      redeemedPoints: Math.round(redeemedPoints), pending, expiring};
  }

  /**
   * Sleeps for a random duration within `[min, max]` ms.
   * @param {number} min Minimum delay.
   * @param {number} max Maximum delay.
   * @return {!Promise<void>} Resolves after the delay.
   */
  function sleepBetween(min, max) {
    const ms = min + Math.random() * (max - min);
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  /**
   * Default pacing between offer groups. Kept as a default so callers (tests)
   * can inject a no-op.
   * @return {!Promise<void>} Resolves after the delay.
   */
  function randomDelay() {
    return sleepBetween(MIN_DELAY_MS, MAX_DELAY_MS);
  }

  /** @return {!Promise<void>} Short pause before re-sending a failure. */
  function randomRetryDelay() {
    return sleepBetween(RETRY_MIN_MS, RETRY_MAX_MS);
  }

  /** @return {!Promise<void>} Pause for the server to settle before verify. */
  function verifySettleDelay() {
    return sleepBetween(VERIFY_SETTLE_MS, VERIFY_SETTLE_MS);
  }

  /**
   * Enrolls one task, never throwing; returns a tagged attempt.
   *
   * Transient failures (network error, 5xx) are re-sent quickly up to
   * {@link MAX_ENROLL_RETRIES} times so a hiccup does not permanently lose a
   * card that is still inside the enrollment window. Definitive server answers
   * — a 2xx business rejection, or a blocked signal (429/401/403/non-JSON) —
   * are never re-sent.
   *
   * @param {!Task} task The (offer, card) work item.
   * @param {function(): !Promise<void>=} retryDelay Pause between re-sends.
   * @return {!Promise<!Object>} Attempt tagged with `reportedOk`, `message`,
   *     and on failure `httpStatus` and `blocked`.
   */
  async function attemptEnroll(task, retryDelay = randomRetryDelay) {
    for (let attempt = 0; ; attempt++) {
      try {
        const response = await enrollOffer(task.token, task.offerId);
        return {
          ...task,
          reportedOk: isEnrollSuccess(response),
          message: getPath(response, 'status.message') || '',
        };
      } catch (error) {
        if (error.transient && attempt < MAX_ENROLL_RETRIES) {
          await retryDelay();
          continue;
        }
        return {...task, reportedOk: false,
          httpStatus: error.httpStatus || 0, blocked: !!error.blocked,
          message: error.message || 'request failed'};
      }
    }
  }

  /**
   * Groups tasks by offer group key, preserving first-seen order.
   * @param {!Array<!Task>} tasks Tasks.
   * @return {!Array<!Array<!Task>>} Tasks grouped by offer.
   */
  function groupTasksByOffer(tasks) {
    const byKey = new Map();
    for (const task of tasks) {
      if (!byKey.has(task.key)) byKey.set(task.key, []);
      byKey.get(task.key).push(task);
    }
    return [...byKey.values()];
  }

  /**
   * Reads a card's added-offer keys, tolerating failure. Transient errors
   * already retry inside {@link fetchEnrolledKeys}; anything that still fails
   * returns null so the card's attempts classify UNVERIFIED instead of the
   * whole run throwing.
   *
   * @param {string} token The card's `account_token`.
   * @param {function(): !Promise<void>} retryDelay Pause between read tries.
   * @return {!Promise<?Set<string>>} Added-offer keys, or null if unreadable.
   */
  async function readEnrolledKeysSafe(token, retryDelay) {
    try {
      return await fetchEnrolledKeys(token, retryDelay);
    } catch {
      return null;
    }
  }

  /**
   * Enrolls the given (offer, card) tasks, then re-reads the affected cards and
   * classifies each attempt.
   *
   * Each task enrolls with the card's own `offerId`. All cards for the SAME
   * offer are fired concurrently: once one card enrolls an offer, Amex can make
   * it ineligible on the others, so a sequential pass would let only the first
   * card win. Different offers are paced apart by `delay`.
   *
   * A single failure is recorded and does not abort the run — but a blocked
   * signal (429 / interception) does: remaining groups are not submitted (their
   * tasks come back SKIPPED) and the verification re-read is skipped too, so
   * the tool never pushes through a throttle.
   *
   * Verification waits `settleDelay` for the server to settle, then re-reads
   * each card that reported success. Cards whose reported-ok offers are still
   * missing get one more settle + re-read before being classified GHOST, since
   * a fresh enrollment can take a moment to appear on the added list.
   *
   * @param {!Array<!Task>} tasks Flattened (offer, card) work items.
   * @param {{onProgress: (function(number, number)|undefined),
   *          onSettle: (function(!Object)|undefined),
   *          delay: (function(): !Promise<void>|undefined),
   *          retryDelay: (function(): !Promise<void>|undefined),
   *          settleDelay: (function(): !Promise<void>|undefined)}=} options
   *     Behavior overrides. `onSettle` fires with each attempt as its enroll
   *     settles (before verification). `delay` (between offers) defaults to
   *     {@link randomDelay}; `retryDelay` (before re-sends/re-reads) to
   *     {@link randomRetryDelay}; `settleDelay` (before verification reads) to
   *     {@link verifySettleDelay}.
   * @return {!Promise<!Array<!EnrollResult>>} One result per task.
   */
  async function executeSelected(tasks, options = {}) {
    const {
      onProgress = () => {},
      onSettle = () => {},
      delay = randomDelay,
      retryDelay = randomRetryDelay,
      settleDelay = verifySettleDelay,
    } = options;

    const offerGroups = groupTasksByOffer(tasks);
    const attempts = [];
    let done = 0;
    let blocked = false;
    for (let i = 0; i < offerGroups.length; i++) {
      if (blocked) {
        for (const task of offerGroups[i]) {
          const attempt = {...task, reportedOk: false, skipped: true,
            message: 'not submitted: run stopped after a blocked request'};
          attempts.push(attempt);
          onSettle(attempt);
          onProgress(++done, tasks.length);
        }
        continue;
      }
      const settled = await Promise.all(offerGroups[i].map(async (task) => {
        const attempt = await attemptEnroll(task, retryDelay);
        onSettle(attempt);
        onProgress(++done, tasks.length);
        return attempt;
      }));
      attempts.push(...settled);
      if (settled.some((attempt) => attempt.blocked)) blocked = true;
      else if (i < offerGroups.length - 1) await delay();
    }

    const enrolledByToken = new Map();
    if (!blocked) {
      // Only cards that reported a success need a re-read; failed attempts
      // classify as FAILED regardless of what the added list says.
      const okTokens =
          new Set(attempts.filter((a) => a.reportedOk).map((a) => a.token));
      if (okTokens.size) await settleDelay();
      for (const token of okTokens) {
        const keys = await readEnrolledKeysSafe(token, retryDelay);
        if (keys) enrolledByToken.set(token, keys);
      }
      // Second chance for ghost candidates: re-read once more after another
      // settle, merging (an offer seen in either read counts as enrolled).
      const ghostTokens = new Set(attempts
        .filter((a) => a.reportedOk && enrolledByToken.has(a.token) &&
                !enrolledByToken.get(a.token).has(a.key))
        .map((a) => a.token));
      if (ghostTokens.size) {
        await settleDelay();
        for (const token of ghostTokens) {
          const keys = await readEnrolledKeysSafe(token, retryDelay);
          if (keys) {
            const merged = enrolledByToken.get(token);
            for (const key of keys) merged.add(key);
          }
        }
      }
    }
    return classifyAttempts(attempts, enrolledByToken);
  }

  /**
   * Plans a retry of the failed and never-submitted pairs from a prior run
   * against a fresh offer index. Pure: does no I/O.
   *
   * Per-card offerIds can rotate between reads, so re-sending the token stored
   * in an old result can fail for the wrong reason; each pair is re-resolved
   * to the card's current `offerId` by group key instead. The fresh index also
   * settles two cases without a resend:
   * - `landed`: the card now shows the offer as added — the old "failure"
   *   actually made it (e.g. a timed-out enroll that succeeded server-side).
   *   Returned reclassified as VERIFIED.
   * - `gone`: the card no longer lists the offer at all — the enrollment
   *   window is closed and a resend cannot succeed. Returned with the original
   *   state, a note appended to the message, and `gone: true` so callers stop
   *   offering to retry it.
   *
   * @param {!Array<!EnrollResult>} results A prior run's results.
   * @param {!Array<!OfferGroup>} offers The current offer index.
   * @return {{tasks: !Array<!Task>, landed: !Array<!EnrollResult>,
   *           gone: !Array<!EnrollResult>}} Tasks to resend, plus the pairs
   *     settled without resending.
   */
  function planRetry(results, offers) {
    const retryable = results.filter((result) =>
      (result.state === ResultState.FAILED ||
       result.state === ResultState.SKIPPED) && !result.gone);
    const {tasks, landed, gone} = resolveTasks(retryable, offers);
    const note = 'offer no longer listed for this card';
    return {
      tasks,
      landed: landed.map((r) => ({...r, state: ResultState.VERIFIED,
        message: 'already on the card (found before retrying)'})),
      gone: gone.map((r) => ({...r, gone: true,
        message: r.message ? `${r.message} · ${note}` : note})),
    };
  }

  /**
   * Re-resolves task-like items against a fresh offer index. Pure: no I/O.
   *
   * Per-card offerIds can rotate between reads, so an item built from an older
   * snapshot may carry a stale token; each one is re-resolved to the card's
   * current `offerId` by group key. Items whose card now shows the offer as
   * added come back in `landed`; items whose card no longer lists the offer
   * come back in `gone` (the enrollment window is closed). Both are returned
   * as given, untouched.
   *
   * @param {!Array<!Object>} items Items with `token`, `key`, `name`.
   * @param {!Array<!OfferGroup>} offers The current offer index.
   * @return {{tasks: !Array<!Task>, landed: !Array<!Object>,
   *           gone: !Array<!Object>}} Re-resolved tasks plus the items that
   *     need no send.
   */
  function resolveTasks(items, offers) {
    const tasks = [];
    const landed = [];
    const gone = [];
    for (const item of items) {
      const group = offers.find((g) => g.key === item.key);
      const card = group && group.cards.find((c) => c.token === item.token);
      if (card && card.enrolled) {
        landed.push(item);
      } else if (!card) {
        gone.push(item);
      } else {
        tasks.push({token: item.token, offerId: card.offerId,
          key: item.key, name: item.name});
      }
    }
    return {tasks, landed, gone};
  }

  /** Functions exposed for reuse and unit testing. */
  const api = {
    offerGroupKey,
    getPath,
    cardName,
    cardShortName,
    cardDisplayDigits,
    buildOfferIndex,
    addableCards,
    classifyAttempts,
    fetchAccounts,
    flattenAccounts,
    fetchEligibleOffers,
    fetchEnrolledOffers,
    fetchEnrolledKeys,
    enrollOffer,
    isEnrollSuccess,
    mapLimit,
    retryTransient,
    snapshot,
    executeSelected,
    planRetry,
    resolveTasks,
    fetchRedeemedOffers,
    buildAddedIndex,
    addedStats,
    offerExpiryDays,
    fetchAccountBenefits,
    fetchCardCatalog,
    fetchAllBenefits,
    buildBenefitIndex,
    benefitStats,
    annualFeeFor,
    benefitPeriodLabel,
    daysUntil,
    decodeHtml,
    parseCreditAmount,
    MESSAGES,
    t,
    setLanguage,
    getLanguage,
    RequestType,
    ResultState,
  };

  const isBrowser =
      typeof window !== 'undefined' && typeof document !== 'undefined';

  if (!isBrowser) {
    // Node / test environment: export and stop (no DOM).
    if (typeof module !== 'undefined' && module.exports) {
      module.exports = api;
    }
    return;
  }

  // ---------------------------------------------------------------------------
  // UI (browser only): a self-contained Shadow DOM panel styled after the "2a"
  // design — Amex-official white header, single bright-blue accent, square
  // corners, underline card tabs. It renders our own data built from the API;
  // it never touches Amex's native tiles.
  // ---------------------------------------------------------------------------

  /**
   * Shared UI state.
   * @type {{cards: !Array<!CardSnapshot>, offers: !Array<!OfferGroup>,
   *         selected: !Map<string, !Set<string>>, query: string,
   *         cardFilter: string, multiOnly: boolean,
   *         lastResults: !Map<string, !EnrollResult>, view: string,
   *         run: ?Object, errorMessage: string}}
   */
  const state = {
    cards: [],
    offers: [],
    selected: new Map(),
    query: '',
    cardFilter: 'all',
    multiOnly: false,
    lastResults: new Map(),
    view: 'list',
    run: null,
    errorMessage: '',
    // When the offers snapshot was last read (epoch ms); a submit against a
    // stale snapshot re-reads the involved cards first (offerIds rotate).
    snapshotAt: 0,
    // Tasks awaiting the confirm dialog, and the last run's summary strip.
    pendingTasks: null,
    lastRun: null,
    // Which top-level tab is showing: 'offers' or 'benefits'.
    tab: 'offers',
    // Offers sub-view: 'addable' (the enroll list) or 'added' (redeem
    // tracking).
    offersSub: 'addable',
    // Redeemed (savings) records per card token, loaded lazily the first
    // time the added sub-view opens; cleared on refresh.
    redeemed: {byToken: new Map(), loaded: false, loading: false, error: '',
      readAt: 0},
    // Benefits tab state (loaded lazily on first switch).
    benefits: [],
    benefitStats: null,
    benefitsLoaded: false,
    benefitsError: '',
    benefitsRun: null,
    benefitsReadAt: 0,
    benefitsExpanded: new Set(),
    benefitUnusedOnly: true,
    benefitDoneOpen: false,
    benefitQuery: '',
  };

  /** Panel host + shadow root, created lazily and reused across opens. */
  let panelHost = null;
  let panelRoot = null;
  /** Launcher button; hidden while the panel is open. */
  let launcherButton = null;
  /** Whether the offer snapshot has been loaded at least once. */
  let loaded = false;
  /** The view key of the last render, to preserve scroll across rebuilds. */
  let lastViewKey = '';

  // ---- language persistence -------------------------------------------------
  // `@grant none` means no GM storage, but the script only ever runs on the
  // Amex origin, so the page's localStorage is a natural per-user store.

  /** localStorage key holding the chosen panel language. */
  const LANG_STORAGE_KEY = 'amexAssistantLang';

  /** Whether the user has explicitly picked a language (vs. auto-detected). */
  let langChosen = false;

  /** @return {?string} The saved language choice, or null. */
  function savedLanguage() {
    try {
      const value = localStorage.getItem(LANG_STORAGE_KEY);
      return MESSAGES[value] ? value : null;
    } catch {
      return null;
    }
  }

  /** @return {string} Best-guess language from the browser locale. */
  function detectLanguage() {
    const locale = typeof navigator !== 'undefined' ?
      navigator.language || '' : '';
    return String(locale).toLowerCase().startsWith('zh') ? 'zh' : 'en';
  }

  /** Loads the saved language (or the browser guess) at startup. */
  function initLanguage() {
    const saved = savedLanguage();
    langChosen = saved != null;
    setLanguage(saved || detectLanguage());
  }

  /**
   * Activates and persists a language, updating the launcher label.
   * @param {string} lang Language code.
   */
  function applyLanguage(lang) {
    setLanguage(lang);
    try {
      localStorage.setItem(LANG_STORAGE_KEY, lang);
    } catch { /* private mode etc.; the choice just won't stick */ }
    langChosen = true;
    updateLauncherText();
  }

  /**
   * First-run choice from the language view: persist, then load the panel.
   * @param {string} lang Language code.
   */
  function chooseLanguage(lang) {
    applyLanguage(lang);
    if (!loaded) refresh(); else render();
  }

  /** Flips zh ↔ en from the header toggle. */
  function toggleLanguage() {
    applyLanguage(getLanguage() === 'zh' ? 'en' : 'zh');
    render();
  }

  /** Re-labels the launcher pill after a language change. */
  function updateLauncherText() {
    if (!launcherButton) return;
    const label = launcherButton.shadowRoot.querySelector('.t');
    if (label) label.textContent = t('launcherTitle');
  }

  /**
   * Tiny DOM builder. `props`: `class`/`style`/`text` plus `on*` handlers and
   * any attribute; data goes through `text`/children as text nodes (never
   * innerHTML), so merchant/card strings can't inject markup.
   * @param {string} tag Element tag.
   * @param {!Object=} props Properties/attributes.
   * @param {...(Node|string|null)} children Child nodes or text.
   * @return {!Element} The element.
   */
  function el(tag, props = {}, ...children) {
    const node = document.createElement(tag);
    for (const [key, value] of Object.entries(props)) {
      if (value == null) continue;
      if (key === 'class') node.className = value;
      else if (key === 'style') node.style.cssText = value;
      else if (key === 'text') node.textContent = value;
      else if (key.startsWith('on')) node[key.toLowerCase()] = value;
      else node.setAttribute(key, value);
    }
    for (const child of children) {
      if (child == null) continue;
      node.append(child.nodeType ? child : document.createTextNode(child));
    }
    return node;
  }

  /** @param {string} token Card token. @return {?CardSnapshot} The card. */
  function cardOf(token) {
    return state.cards.find((c) => c.token === token) || null;
  }

  /** @param {string} token Card token. @return {string} Compact card label. */
  function cardLabel(token) {
    const card = cardOf(token);
    return card ? card.shortName : `…${String(token).slice(-4)}`;
  }

  /**
   * First 1-2 letters of a merchant name, for the logo fallback.
   * @param {string} name Merchant name.
   * @return {string} Initials.
   */
  function merchantInitials(name) {
    const words = String(name).replace(/[^\p{L}\p{N} ]/gu, '').trim().split(/\s+/);
    const letters = words.slice(0, 2).map((w) => w[0] || '').join('');
    return (letters || String(name).slice(0, 2)).toUpperCase();
  }

  /**
   * Initials for a benefit square, ignoring a leading "$300" so the letters
   * come from the name ("$300 Digital Entertainment" → "DE", not "3D").
   * @param {string} name Benefit name.
   * @return {string} Letter initials.
   */
  function benefitInitials(name) {
    return merchantInitials(String(name).replace(/^\s*\$[\d,]+\s*/, ''));
  }

  /** @param {!OfferGroup} g Offer group. @return {string} Short expiry, e.g. */
  /**   "至 7/7". */
  function expiryLabel(g) {
    const match = /(\d{1,2})\/(\d{1,2})/.exec(g.expiry || '');
    return match ?
      t('expiresShort', {date: `${match[1]}/${match[2]}`}) : '';
  }

  /**
   * Localized short label for a benefit cadence key from
   * {@link benefitPeriodLabel} (e.g. `month` → `月` / `mo`).
   * @param {string} period Cadence key, possibly ''.
   * @return {string} Localized label, or ''.
   */
  function periodText(period) {
    return period ? t(`period_${period}`) : '';
  }

  /** Soft [background, foreground] pairs for the initials-logo fallback. */
  const LOGO_PALETTE = [
    ['#E7F0FA', '#1B62A8'], ['#FBEDE3', '#B4551E'], ['#EDEDF0', '#3A3D42'],
    ['#E9F3EC', '#1B7A44'], ['#F3EAF6', '#7A3E8E'], ['#FDECEC', '#B4283B'],
    ['#FBF3E0', '#8A6D1C'],
  ];

  /**
   * Stable [bg, fg] colors for a merchant, so the logo squares vary like the
   * design when no real logo image is available.
   * @param {string} name Merchant name.
   * @return {!Array<string>} [background, foreground].
   */
  function logoColors(name) {
    let h = 0;
    for (const ch of String(name)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
    return LOGO_PALETTE[h % LOGO_PALETTE.length];
  }

  /**
   * A card-colored gradient for the swatch when no card-art image is available.
   * @param {string} token Card token.
   * @return {string} A CSS gradient.
   */
  function swatchStyle(token) {
    const family = (cardOf(token)?.shortName || '').toLowerCase();
    const grad = (a, b) => `linear-gradient(135deg,${a},${b})`;
    if (family.includes('platinum')) return grad('#dfe2e6', '#b3b9c1');
    if (family.includes('gold')) return grad('#e9cd85', '#c29a45');
    if (family.includes('blue') || family.includes('cash')) {
      return grad('#3a86c8', '#154e88');
    }
    if (family.includes('green')) return grad('#5aa06e', '#2f6b45');
    return grad('#c9ccd0', '#9aa0a8');
  }

  const PANEL_STYLE = `
    :host { all: initial; }
    * { box-sizing: border-box; }
    .p {
      --blue: #006FCF; --navy: #00175A; --green: #0B7A3E; --red: #C8102E;
      --amber: #9A6A00; --ink: #26282A; --sub: #55585D; --mut: #7A7D82;
      --fog: #9A9DA2; --line: #E7E8EA; --line2: #EFF0F1; --card: #F2F3F4;
      font: 13px/1.4 'Helvetica Neue', Helvetica, system-ui, sans-serif;
      color: var(--ink); background: #fff; border: 1px solid #d9dbde;
      border-radius: 6px; width: 400px; max-height: 80vh; display: flex;
      flex-direction: column; overflow: hidden;
      box-shadow: 0 8px 30px rgba(0,23,90,.18);
      transform-origin: top right;
      transition: opacity .18s ease, transform .18s ease;
    }
    /* Collapsed state used to grow-in on open and shrink-out on close. */
    .p.closing { opacity: 0; transform: scale(.9); }
    .hd {
      display: flex; flex-direction: column; background: #fff;
      border-bottom: 2px solid var(--blue); flex: none;
    }
    .hd.err { border-bottom-color: var(--red); }
    .hd.tabbed { border-bottom: 1px solid var(--line); }
    /* Drag handle: the title row moves the panel; its buttons keep pointer. */
    .hrow { display: flex; align-items: center; gap: 11px; padding: 14px 18px;
      cursor: move; user-select: none; }
    .hd.tabbed .hrow { padding: 14px 18px 12px; }
    .mtabs { display: flex; gap: 22px; padding: 0 18px; font-size: 12.5px; }
    .mtab { color: var(--sub); padding-bottom: 10px; cursor: pointer;
      border-bottom: 2px solid transparent; margin-bottom: -1px; }
    .mtab.on { font-weight: 700; color: var(--navy);
      border-bottom-color: var(--blue); }
    .ic {
      width: 30px; height: 30px; border-radius: 5px; background: var(--blue);
      color: #fff; display: flex; align-items: center; justify-content: center;
      font-size: 17px; font-weight: 600; line-height: 1; flex: none;
    }
    .hd .tt { flex: 1; min-width: 0; }
    .t1 { font-size: 14.5px; font-weight: 800; color: var(--navy);
      letter-spacing: .1px; }
    .t2 { font-size: 10.5px; color: var(--mut); margin-top: 1px;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .rf {
      width: 28px; height: 28px; border-radius: 50%; border: 1px solid #E3E5E8;
      background: #fff; color: #53565A; font-size: 15px; cursor: pointer;
      display: flex; align-items: center; justify-content: center; flex: none;
    }
    .cl { font-size: 18px; color: #8B8E93; cursor: pointer; line-height: 1;
      background: none; border: none; padding: 0 2px; flex: none; }
    .rf.lang { font-size: 10.5px; font-weight: 700; letter-spacing: .3px; }
    .body { overflow-y: auto; }
    .sr { padding: 12px 18px 0; }
    .sr input {
      width: 100%; border: 1px solid #D5D7DB; border-radius: 4px;
      padding: 8px 12px; font: inherit; color: var(--ink); outline: none;
    }
    .sr input:focus { border-color: var(--blue); }
    .tabwrap { position: relative; }
    /* Fade the right edge so the overflowing card tabs read as scrollable
       without a scrollbar chrome. */
    .tabwrap::after {
      content: ''; position: absolute; top: 0; right: 0; bottom: 1px;
      width: 28px; pointer-events: none;
      background: linear-gradient(90deg, rgba(255, 255, 255, 0), #fff);
    }
    .tabs {
      display: flex; gap: 18px; padding: 12px 18px 0; overflow-x: auto;
      border-bottom: 1px solid var(--line); font-size: 12px;
      scrollbar-width: none; -ms-overflow-style: none;
    }
    .tabs::-webkit-scrollbar { display: none; }
    .tab { padding-bottom: 9px; color: var(--sub); cursor: pointer;
      white-space: nowrap; border-bottom: 2px solid transparent; }
    .tab.on { font-weight: 700; color: var(--navy); border-bottom-color: var(--blue); }
    .tab .n { color: var(--fog); }
    .tb {
      display: flex; align-items: center; padding: 9px 18px; font-size: 11.5px;
      border-bottom: 1px solid var(--line2);
    }
    .tb label { display: flex; align-items: center; gap: 6px; color: var(--sub);
      cursor: pointer; }
    .tb .sp { flex: 1; }
    .tb .ac { display: flex; gap: 14px; font-weight: 600; }
    .tb .ac a { cursor: pointer; }
    .a-blue { color: var(--blue); }
    .a-mut { color: var(--fog); }
    .list { display: flex; flex-direction: column; }
    .row { display: flex; gap: 11px; padding: 13px 18px; align-items: center;
      border-bottom: 1px solid var(--line2); }
    .grp.done { opacity: .5; }
    .grp.exp { box-shadow: inset 2px 0 0 var(--blue); background: #FBFDFF; }
    .grp.exp > .row { border-bottom: none; padding-bottom: 8px; }
    .logo {
      width: 40px; height: 40px; border-radius: 4px; flex: none; overflow: hidden;
      display: flex; align-items: center; justify-content: center;
      font-size: 12px; font-weight: 700; background: #E7F0FA; color: #1B62A8;
    }
    .logo img { width: 100%; height: 100%; object-fit: contain; background: #fff; }
    .mn { flex: 1; min-width: 0; }
    .nm { font-size: 13px; font-weight: 700; color: var(--ink);
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .ds { font-size: 12px; color: var(--sub); margin-top: 1px; overflow: hidden;
      display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
    .rt { text-align: right; flex: none; }
    .bd { font-size: 11px; font-weight: 600; white-space: nowrap; cursor: pointer;
      font-variant-numeric: tabular-nums; color: var(--blue); }
    .bd .dot { color: #C9CCD0; }
    .bd .en { color: var(--green); }
    .bd .car { font-size: 9px; margin-left: 2px; }
    .ex { font-size: 10.5px; color: var(--fog); margin-top: 2px;
      font-variant-numeric: tabular-nums; }
    .done-tag { font-size: 11px; color: var(--green); white-space: nowrap; }
    .cards { margin: 0 18px 13px 67px; display: flex; flex-direction: column; }
    .cards .lb { font-size: 10px; font-weight: 700; color: var(--fog);
      letter-spacing: .6px; padding: 4px 0 7px; }
    .ccard { display: flex; align-items: center; gap: 9px; font-size: 12px;
      color: var(--ink); padding: 4px 0; cursor: pointer; }
    .ccard.off { color: #B4B7BB; cursor: default; }
    .sw { width: 28px; height: 18px; border-radius: 2.5px; flex: none;
      overflow: hidden; background: linear-gradient(135deg,#dfe2e6,#b3b9c1); }
    .sw img { width: 100%; height: 100%; object-fit: cover; }
    .ccard .mk { margin-left: auto; font-size: 10.5px; font-weight: 600; }
    .ccard .mk.en { color: var(--green); }
    .ccard .mk.r-failed { color: var(--red); }
    .ccard .mk.r-ghost { color: var(--amber); }
    .ccard .mk.r-unverified { color: var(--mut); }
    .ccard .mk.r-skipped { color: var(--amber); }
    input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--blue);
      flex: none; }
    .ccard input[type=checkbox] { width: 14px; height: 14px; }
    .ft { border-top: 1px solid var(--line); padding: 12px 18px; display: flex;
      align-items: center; gap: 12px; background: #fff; flex: none; }
    .ft .sm { flex: 1; font-size: 12px; color: var(--sub); }
    .ft .sm b { color: var(--ink); }
    .go { background: var(--blue); color: #fff; font-size: 13px; font-weight: 700;
      border: none; border-radius: 4px; padding: 10px 22px; cursor: pointer;
      white-space: nowrap; }
    .go:disabled { opacity: .55; cursor: default; }
    .cnt { display: flex; background: #fff; border-bottom: 1px solid var(--line); }
    .cnt .c { flex: 1; padding: 14px 0; text-align: center; }
    .cnt .c .n { font-size: 21px; font-weight: 800;
      font-variant-numeric: tabular-nums; }
    .cnt .c .l { font-size: 10.5px; color: var(--sub); margin-top: 2px; }
    .cnt .sep { width: 1px; background: var(--line); margin: 12px 0; }
    .g { color: var(--green); } .r { color: var(--red); }
    .b { color: var(--blue); } .am { color: var(--amber); }
    .bar { height: 4px; border-radius: 2px; background: #EDEEF0; overflow: hidden; }
    .bar > div { height: 100%; background: var(--blue); transition: width .2s; }
    .note { font-size: 11px; color: var(--mut); }
    .info { margin: 12px 18px 0; background: #F7F8F9; border: 1px solid #EDEEF0;
      border-radius: 4px; padding: 9px 12px; font-size: 11px; color: var(--sub);
      line-height: 1.55; }
    .info b { color: var(--amber); }
    .sh { font-size: 10px; font-weight: 700; color: var(--fog);
      letter-spacing: .6px; padding: 10px 0 5px; }
    .si { display: flex; justify-content: space-between; gap: 8px; padding: 6px 0;
      align-items: baseline;
      border-bottom: 1px solid var(--line2); font-size: 12px; color: var(--ink); }
    .si:last-child { border-bottom: none; }
    /* Failed rows carry a long message — stack it under the card, left-aligned,
       instead of wrapping ragged beside it. */
    .si.col { flex-direction: column; align-items: stretch;
      justify-content: flex-start; gap: 3px; }
    .si.col .si-msg { font-size: 11px; line-height: 1.45; text-align: left; }
    .ri { display: flex; align-items: center; gap: 10px; padding: 8px 0;
      border-bottom: 1px solid var(--line2); font-size: 12px; color: var(--ink); }
    .ri .txt { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden;
      text-overflow: ellipsis; }
    .ri .st { font-size: 11px; font-weight: 600; }
    .spin { width: 16px; height: 16px; border-radius: 50%; flex: none;
      border: 2px solid #D4E8F8; border-top-color: var(--blue);
      animation: dvspin .9s linear infinite; }
    @keyframes dvspin { to { transform: rotate(360deg); } }
    .msg { padding: 34px 24px; text-align: center; }
    .msg .cir { width: 44px; height: 44px; border-radius: 50%; margin: 0 auto 12px;
      display: flex; align-items: center; justify-content: center; font-size: 20px; }
    .msg .cir.ok { background: #E9F5EE; color: var(--green); }
    .msg .cir.bad { background: #FCEDEF; color: var(--red); font-weight: 700; }
    .msg .h { font-size: 13.5px; font-weight: 700; color: var(--ink); }
    .msg .txt { font-size: 12px; color: var(--mut); margin-top: 5px;
      line-height: 1.55; }
    .msg .btn { display: inline-flex; align-items: center; gap: 6px; margin-top: 16px;
      border: 1px solid #D5D7DB; border-radius: 4px; padding: 8px 16px;
      font-size: 12px; font-weight: 600; color: var(--blue); cursor: pointer; }
    .msg .btn.pri { background: var(--blue); color: #fff; border-color: var(--blue); }
    .lnk { font-size: 12px; font-weight: 600; color: var(--blue); cursor: pointer; }
    .lnk.rerun { border: 1px solid var(--red); color: var(--red); border-radius: 4px;
      padding: 8px 16px; font-weight: 700; }
    /* Benefits tab */
    .bstats { display: flex; background: #fff;
      border-bottom: 1px solid var(--line); }
    .bcol { padding: 13px 0; }
    .bcol.l { flex: 1.2; padding-left: 18px; }
    .bcol.m { flex: 1; text-align: center; }
    .bcol.r { flex: 1.2; padding-right: 18px; text-align: right; }
    .bval { font-size: 19px; font-weight: 800;
      font-variant-numeric: tabular-nums; }
    .bval.navy { color: var(--navy); }
    .bval.green { color: var(--green); }
    .bval.ink { color: var(--ink); }
    .blbl { font-size: 10.5px; color: var(--sub); margin-top: 2px; }
    .bsub2 { font-size: 9.5px; color: var(--fog); margin-top: 1px; }
    .vsep { width: 1px; background: var(--line); margin: 12px 0; }
    .btb { display: flex; align-items: center; padding: 9px 18px; background: #fff;
      border-bottom: 1px solid var(--line2); font-size: 11.5px; }
    .btb .sp { flex: 1; }
    .sortlbl { font-weight: 600; color: var(--ink); cursor: pointer; }
    .caret { font-size: 10px; color: var(--fog); }
    .unused { display: flex; align-items: center; gap: 6px; color: var(--sub);
      cursor: pointer; }
    .unused input[type=checkbox] { width: 13px; height: 13px; }
    .blist { display: flex; flex-direction: column; background: #fff; }
    .brow { display: flex; gap: 11px; padding: 12px 18px; align-items: center;
      border-bottom: 1px solid var(--line2); }
    .blogo { width: 40px; height: 40px; border-radius: 4px; flex: none;
      display: flex; align-items: center; justify-content: center;
      font-size: 12px; font-weight: 700; }
    .bmn { flex: 1; min-width: 0; }
    .btitle { display: flex; align-items: baseline; gap: 6px; }
    .bname { font-size: 13px; font-weight: 700; color: var(--ink);
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .bp { font-size: 10px; color: var(--fog); border: 1px solid var(--line);
      border-radius: 2px; padding: 0 4px; flex: none; }
    .bx { font-size: 10px; font-weight: 700; color: #005EB0; background: #EAF4FC;
      border-radius: 2px; padding: 0 5px; flex: none; }
    .bcard { font-size: 11px; color: var(--mut); margin-top: 2px;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .bbar { height: 3px; border-radius: 1.5px; background: #EDEEF0;
      margin-top: 6px; overflow: hidden; }
    .bbar > div { height: 100%; background: var(--green); }
    .brt { text-align: right; flex: none; }
    .bamt { font-size: 12.5px; font-weight: 700; color: var(--ink);
      font-variant-numeric: tabular-nums; white-space: nowrap; }
    .bamt .of { font-size: 10.5px; font-weight: 400; color: var(--fog); }
    .bdays { font-size: 10.5px; color: var(--fog); margin-top: 2px; }
    .bdays.urgent { font-weight: 700; color: var(--red); }
    .bcaret { font-size: 10px; color: var(--blue); flex: none; }
    .binact { font-size: 11px; font-weight: 700; color: var(--amber); }
    .bwhen { font-size: 10.5px; color: var(--fog); margin-top: 2px;
      font-variant-numeric: tabular-nums; }
    .bactivate { border: 1px solid #D5D7DB; border-radius: 4px;
      padding: 5px 10px; font-size: 11px; font-weight: 600; color: var(--blue);
      cursor: pointer; white-space: nowrap; flex: none; }
    .bgrp { border-bottom: 1px solid var(--line2); }
    .bgrp.exp { box-shadow: inset 2px 0 0 var(--blue); background: #FBFDFF; }
    .bgrp > .brow { border-bottom: none; }
    .bgrp.exp > .brow { padding: 12px 18px 8px 16px; }
    .bsub { margin: 0 18px 12px 69px; display: flex; flex-direction: column;
      gap: 8px; }
    .bsubrow { display: flex; align-items: center; gap: 9px; }
    .bsubcard { font-size: 11.5px; color: var(--ink); width: 56px; flex: none; }
    .bbar.grow { flex: 1; margin-top: 0; background: #E4E6E9; }
    .bsubamt { font-size: 11px; color: var(--sub); width: 74px;
      text-align: right; flex: none; font-variant-numeric: tabular-nums; }
    .bsec { display: flex; align-items: center; gap: 8px; padding: 11px 18px;
      background: #FAFBFC; border-bottom: 1px solid var(--line2); cursor: pointer; }
    .bsec .sp { flex: 1; }
    .bsec-t { font-size: 12px; font-weight: 600; color: var(--sub); }
    .bsec-n { font-size: 11px; color: var(--fog); background: #EDEEF0;
      border-radius: 9px; padding: 1px 8px; font-variant-numeric: tabular-nums; }
    .bfoot { border-top: 1px solid var(--line); padding: 10px 18px;
      background: #fff; flex: none; }
    /* Added (redeem-tracking) sub-view */
    .sortmini { color: var(--sub); font-size: 11.5px; padding-bottom: 9px;
      white-space: nowrap; }
    .navy { color: var(--navy); }
    .agrp { border-bottom: 1px solid var(--line2); background: #fff; }
    .agrp.dim .arow, .agrp.dim .acards { opacity: .65; }
    .arow { display: flex; gap: 11px; padding: 12px 18px 6px;
      align-items: center; }
    .acards { margin: 0 18px 12px 69px; display: flex;
      flex-direction: column; gap: 7px; }
    .acrow { display: flex; align-items: center; gap: 9px; }
    .acdig { font-size: 11.5px; color: var(--ink); width: 56px; flex: none; }
    .acst { font-size: 11px; color: var(--fog); white-space: nowrap; }
    .acst.ok { font-weight: 600; color: var(--green);
      font-variant-numeric: tabular-nums; }
    .aday { font-size: 10.5px; color: var(--fog);
      font-variant-numeric: tabular-nums; }
    .aday.urgent { font-weight: 700; color: var(--red); }
    .aday.ok { font-weight: 600; color: var(--green); }
    .asub { font-size: 10.5px; color: var(--fog); margin-top: 2px;
      font-variant-numeric: tabular-nums; }
    .asub.ok { color: var(--green); }
    /* Last-run strip */
    .lastrun { display: flex; align-items: center; gap: 5px; padding: 7px 18px;
      background: #FAFBFC; border-bottom: 1px solid var(--line2);
      font-size: 11px; color: var(--mut); font-variant-numeric: tabular-nums; }
    .lastrun .sp { flex: 1; }
    .lastrun .when { color: var(--mut); }
    /* Confirm dialog */
    .cfwrap { position: relative; }
    .cfdim { opacity: .4; pointer-events: none; }
    .cfsk { padding: 14px 18px; display: flex; flex-direction: column;
      gap: 12px; background: #fff; }
    .skrow { display: flex; gap: 11px; align-items: center; }
    .sklogo { width: 40px; height: 40px; border-radius: 4px; flex: none;
      background: var(--card); }
    .skmn { flex: 1; display: flex; flex-direction: column; gap: 7px; }
    .skl { height: 10px; border-radius: 2px; background: var(--card); }
    .skl.a { width: 52%; }
    .skl.b { width: 74%; height: 9px; background: #F7F8F9; }
    .cfov { position: absolute; inset: 0; background: rgba(0, 23, 90, .30);
      display: flex; align-items: center; justify-content: center;
      padding: 26px; }
    .cfdlg { background: #fff; border-radius: 6px; width: 100%; padding: 20px;
      box-shadow: 0 12px 32px rgba(0, 23, 90, .35); }
    .cf-t { font-size: 14px; font-weight: 800; color: var(--navy); }
    .cf-d { font-size: 12px; color: var(--sub); line-height: 1.6;
      margin-top: 7px; }
    .cf-d b { color: var(--ink); }
    .cf-sum { margin-top: 10px; background: #F7F8F9; border: 1px solid #EDEEF0;
      border-radius: 4px; padding: 9px 12px; font-size: 11.5px;
      color: var(--sub); line-height: 1.7; font-variant-numeric: tabular-nums; }
    .cf-btns { display: flex; gap: 10px; margin-top: 16px;
      justify-content: flex-end; }
    .cf-cancel { border: 1px solid #D5D7DB; color: var(--sub); font-size: 12.5px;
      font-weight: 600; border-radius: 4px; padding: 9px 18px; cursor: pointer; }
    .cf-ok { background: var(--blue); color: #fff; font-size: 12.5px;
      font-weight: 700; border-radius: 4px; padding: 9px 18px; cursor: pointer; }
  `;

  /**
   * Expands the current selection into flat enroll tasks, resolving each card's
   * own `offerId` and skipping cards that already have the offer.
   * @return {!Array<!Task>} Tasks to run.
   */
  function buildTasks() {
    const tasks = [];
    for (const [key, tokens] of state.selected) {
      const group = state.offers.find((g) => g.key === key);
      if (!group) continue;
      for (const token of tokens) {
        const card = group.cards.find((c) => c.token === token);
        if (card && !card.enrolled) {
          tasks.push({token, offerId: card.offerId, key, name: group.name});
        }
      }
    }
    return tasks;
  }

  /** @return {!Array<!OfferGroup>} Offers matching filters + query. */
  function visibleOffers() {
    const q = state.query;
    return state.offers.filter((g) => {
      if (q && !g.name.toLowerCase().includes(q)) return false;
      if (state.cardFilter !== 'all' &&
          !g.cards.some((c) => c.token === state.cardFilter)) {
        return false;
      }
      if (state.multiOnly && addableCards(g).length < 2) return false;
      return true;
    });
  }

  /** @return {number} Total selected (offer, card) pairs. */
  function selectedCount() {
    let n = 0;
    for (const set of state.selected.values()) n += set.size;
    return n;
  }

  // ---- rendering -----------------------------------------------------------

  /** @param {!OfferGroup} group Group. @return {!DocumentFragment} Row. */
  function renderOfferRow(group) {
    const frag = document.createDocumentFragment();
    const wrap = el('div', {class: 'grp'});
    const addable = addableCards(group);
    const addedCount = group.cards.length - addable.length;
    const fullyAdded = addable.length === 0;

    const logo = el('div', {class: 'logo'});
    if (group.image) {
      logo.append(el('img', {src: group.image, alt: '', loading: 'lazy'}));
    } else {
      const [bg, fg] = logoColors(group.name);
      logo.style.background = bg;
      logo.style.color = fg;
      logo.textContent = merchantInitials(group.name);
    }

    const pick = el('input', {type: 'checkbox'});
    pick.disabled = fullyAdded;
    pick.checked = (state.selected.get(group.key)?.size || 0) > 0;

    const main = el('div', {class: 'mn'},
      el('div', {class: 'nm', text: group.name}),
      group.description ? el('div', {class: 'ds', text: group.description}) :
        null);

    const box = el('div', {class: 'cards'});
    box.style.display = 'none';

    const rt = el('div', {class: 'rt'});
    if (fullyAdded) {
      rt.append(el('div', {class: 'done-tag',
        text: t('addedToAll', {n: group.cards.length})}));
    } else {
      const badge = el('div', {class: 'bd'});
      if (addedCount) {
        badge.append(
          el('span', {text: t('addableN', {n: addable.length})}),
          el('span', {class: 'dot', text: ' · '}),
          el('span', {class: 'en', text: t('addedN', {n: addedCount})}),
          el('span', {class: 'car', text: ' ▾'}));
      } else {
        badge.append(el('span', {text: t('addableN', {n: addable.length})}),
          el('span', {class: 'car', text: ' ▾'}));
      }
      badge.onclick = () => toggleExpand(wrap, box, badge, group);
      rt.append(badge);
    }
    const exp = expiryLabel(group);
    if (exp) rt.append(el('div', {class: 'ex', text: exp}));

    const runStates = group.cards
      .map((c) => state.lastResults.get(`${group.key}|${c.token}`))
      .filter(Boolean).map((res) => res.state);
    if (runStates.length) rt.prepend(renderRunBadge(runStates));

    pick.onchange = () => {
      if (pick.checked) {
        state.selected.set(group.key, new Set(addable.map((c) => c.token)));
      } else {
        state.selected.delete(group.key);
      }
      if (box.style.display !== 'none') renderCardBox(box, group, pick);
      refreshFooter();
    };

    if (fullyAdded) wrap.classList.add('done');
    wrap.append(el('div', {class: 'row'}, pick, logo, main, rt), box);
    frag.append(wrap);
    return frag;
  }

  /**
   * @param {!Array<string>} states Result states for a group.
   * @return {!Element} The per-offer outcome badge.
   */
  function renderRunBadge(states) {
    const n = (s) => states.filter((x) => x === s).length;
    const badge = el('div', {class: 'bd', style: 'cursor:default'});
    badge.append(el('span', {class: 'en', text: `✓${n(ResultState.VERIFIED)}`}),
      el('span', {class: 'dot', text: ' '}),
      el('span', {class: 'am', text: `⚠${n(ResultState.GHOST)}`}),
      el('span', {class: 'dot', text: ' '}),
      el('span', {class: 'r', text: `✗${n(ResultState.FAILED)}`}));
    return badge;
  }

  /**
   * Toggles the per-card list under an offer row.
   * @param {!Element} wrap Group wrapper. @param {!Element} box Card box.
   * @param {!Element} badge Badge (for the caret). @param {!OfferGroup} group
   *     Offer group.
   */
  function toggleExpand(wrap, box, badge, group) {
    const open = box.style.display === 'none';
    box.style.display = open ? 'flex' : 'none';
    wrap.classList.toggle('exp', open);
    const caret = badge.querySelector('.car');
    if (caret) caret.textContent = open ? ' ▴' : ' ▾';
    if (open) renderCardBox(box, group, wrap.querySelector('.row input'));
  }

  /**
   * Renders the per-card checkboxes for an expanded offer.
   * @param {!Element} box Card box. @param {!OfferGroup} group Offer group.
   * @param {!Element} pick The offer-row checkbox (kept in sync).
   */
  function renderCardBox(box, group, pick) {
    box.textContent = '';
    box.append(el('div', {class: 'lb', text: t('chooseCards')}));
    const chosen = state.selected.get(group.key) || new Set();
    for (const card of group.cards) {
      const result = state.lastResults.get(`${group.key}|${card.token}`);
      const cb = el('input', {type: 'checkbox'});
      cb.checked = chosen.has(card.token);
      cb.disabled = card.enrolled;
      const sw = el('span', {class: 'sw'});
      const cardData = cardOf(card.token);
      if (cardData?.art) sw.append(el('img', {src: cardData.art, alt: ''}));
      else sw.style.background = swatchStyle(card.token);
      const label = el('label', {class: card.enrolled ? 'ccard off' : 'ccard'},
        cb, sw, cardLabel(card.token));
      if (card.enrolled) {
        label.append(el('span', {class: 'mk en', text: t('addedMark')}));
      } else if (result) {
        const mk = {[ResultState.FAILED]: '✗', [ResultState.GHOST]: '⚠',
          [ResultState.UNVERIFIED]: '?', [ResultState.VERIFIED]: '✓',
          [ResultState.SKIPPED]: '⊘'};
        label.append(el('span', {class: `mk r-${result.state}`,
          text: mk[result.state] || ''}));
      }
      cb.onchange = () => {
        const set = state.selected.get(group.key) || new Set();
        if (cb.checked) set.add(card.token); else set.delete(card.token);
        if (set.size) state.selected.set(group.key, set);
        else state.selected.delete(group.key);
        pick.checked = (state.selected.get(group.key)?.size || 0) > 0;
        refreshFooter();
      };
      box.append(label);
    }
  }

  /**
   * The header refresh control's glyph: a clean stroked "reload" icon that
   * inherits the button's text color via `currentColor`.
   * @const {string}
   */
  const REFRESH_SVG =
      '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" ' +
      'stroke="currentColor" stroke-width="2.4" stroke-linecap="round" ' +
      'stroke-linejoin="round" style="display:block">' +
      '<path d="M21 12a9 9 0 1 1-2.64-6.36"></path>' +
      '<polyline points="21 3 21 9 15 9"></polyline></svg>';

  /**
   * Builds the header row.
   * @param {{glyph: (string|Node), title: string, subtitle: string,
   *          refresh: (boolean|undefined), close: (boolean|undefined),
   *          err: (boolean|undefined), right: (Node|undefined)}} opts Header
   *     options.
   * @return {!Element} The header element.
   */
  function renderHeader(opts) {
    let cls = 'hd';
    if (opts.err) cls += ' err';
    if (opts.tabs) cls += ' tabbed';
    const hd = el('div', {class: cls});
    const row = el('div', {class: 'hrow'});
    row.append(el('div', {class: 'ic'}, opts.glyph));
    row.append(el('div', {class: 'tt'},
      el('div', {class: 't1', text: opts.title}),
      opts.subtitle ? el('div', {class: 't2', text: opts.subtitle}) : null));
    if (opts.right) row.append(opts.right);
    if (opts.lang) {
      // Shows the language it switches TO (the classic i18n toggle pattern).
      row.append(el('button', {class: 'rf lang', title: t('switchLang'),
        text: getLanguage() === 'zh' ? 'EN' : '中',
        onclick: () => toggleLanguage()}));
    }
    if (opts.refresh) {
      const rf = el('button', {class: 'rf', title: t('refresh'),
        onclick: opts.onRefresh || (() => refresh())});
      // Static author-controlled markup (no interpolation): safe to inline.
      rf.innerHTML = REFRESH_SVG;
      row.append(rf);
    }
    if (opts.close) {
      row.append(el('button', {class: 'cl', title: t('close'), text: '×',
        onclick: () => hidePanel()}));
    }
    hd.append(row);
    if (opts.tabs) hd.append(renderMainTabs());
    return hd;
  }

  /** @return {!Element} The Offers | Benefits tab row. */
  function renderMainTabs() {
    const mk = (key, label) => {
      const tab = el('div',
        {class: state.tab === key ? 'mtab on' : 'mtab', text: label});
      tab.onclick = () => switchTab(key);
      return tab;
    };
    return el('div', {class: 'mtabs'},
      mk('offers', 'Offers'), mk('benefits', 'Benefits'));
  }

  /**
   * Switches the top-level tab, lazily loading benefits the first time.
   * @param {string} tab `'offers'` or `'benefits'`.
   */
  function switchTab(tab) {
    if (state.tab === tab) return;
    state.tab = tab;
    if (tab === 'benefits' && !state.benefitsLoaded && !state.benefitsError) {
      loadBenefits();
      return;
    }
    render();
  }

  /** Rebuilds the whole panel for the current `state.view`. */
  function render() {
    if (!panelRoot) return;
    const shell = panelRoot.getElementById('shell');
    // Preserve the scroll position across a same-view rebuild (e.g. expanding a
    // row or toggling a filter), so the list doesn't jump back to the top.
    const viewKey = state.tab === 'benefits' ? 'benefits' : state.view;
    const oldBody = shell.querySelector('.body');
    const keepScroll =
        oldBody && viewKey === lastViewKey ? oldBody.scrollTop : 0;
    lastViewKey = viewKey;
    shell.textContent = '';
    if (state.tab === 'benefits') {
      renderBenefitsTab(shell);
    } else {
      const views = {list: renderListView, loading: renderLoadingView,
        running: renderRunningView, result: renderResultView,
        empty: renderEmptyView, error: renderErrorView,
        confirm: renderConfirmView, language: renderLanguageView};
      (views[state.view] || renderListView)(shell);
    }
    const newBody = shell.querySelector('.body');
    if (newBody && keepScroll) newBody.scrollTop = keepScroll;
  }

  /**
   * Reads benefits for every card and builds the aggregated view. Cached so
   * re-opening the tab is instant; `force` re-reads from Amex.
   * @param {boolean=} force Re-read even if already loaded.
   * @return {!Promise<void>} Resolves when rendered.
   */
  async function loadBenefits(force = false) {
    if (state.benefitsLoaded && !force) {
      render();
      return;
    }
    state.benefitsError = '';
    state.benefitsLoaded = false;
    const owned = state.cards.filter(
      (c) => (c.relationship || 'BASIC') === 'BASIC');
    state.benefitsRun = {done: 0, total: owned.length};
    render();
    try {
      const benefits = await fetchAllBenefits(state.cards, (done, total) => {
        state.benefitsRun = {done, total};
        if (state.tab === 'benefits' && !state.benefitsLoaded) render();
      });
      state.benefits = buildBenefitIndex(benefits);
      state.benefitStats = benefitStats(state.benefits, state.cards);
      state.benefitsReadAt = Date.now();
      state.benefitsLoaded = true;
    } catch (error) {
      state.benefitsError = error.message || t('benefitsReadFailed');
    }
    render();
  }

  /**
   * A short relative-time label, e.g. `刚刚` / `3 分钟前`.
   * @param {number} ts Epoch ms.
   * @return {string} Relative label.
   */
  function agoLabel(ts) {
    if (!ts) return '';
    const mins = Math.floor((Date.now() - ts) / 60000);
    if (mins < 1) return t('updatedJustNow');
    if (mins < 60) return t('updatedMinsAgo', {n: mins});
    return t('updatedHoursAgo', {n: Math.floor(mins / 60)});
  }

  /** @param {!Element} shell Panel content root. */
  function renderListView(shell) {
    shell.append(renderHeader({
      glyph: '+', title: t('panelTitle'),
      subtitle: t('listSubtitle',
        {offers: state.offers.length, cards: state.cards.length}),
      lang: true, refresh: true, close: true, tabs: true,
    }));

    const body = el('div', {class: 'body'});

    const search = el('input', {type: 'search', value: state.query,
      placeholder: t('searchOffers')});
    search.oninput = (e) => {
      state.query = e.target.value.trim().toLowerCase();
      if (state.offersSub === 'added') renderAddedRows(body);
      else renderRows(body);
    };
    body.append(el('div', {class: 'sr'}, search));
    body.append(renderOffersSubTabs());

    if (state.offersSub === 'added') {
      renderAddedView(body);
      shell.append(body);
      shell.append(el('div', {class: 'bfoot'},
        el('div', {class: 'note', text: t('addedFootnote')})));
      return;
    }

    const tabs = el('div', {class: 'tabs'});
    const addTab = (key, label, sub) => {
      const cls = state.cardFilter === key ? 'tab on' : 'tab';
      const tab = el('div', {class: cls}, label);
      if (sub) tab.append(el('span', {class: 'n', text: ` ${sub}`}));
      tab.onclick = () => {
        state.cardFilter = key;
        render();
      };
      tabs.append(tab);
    };
    addTab('all', t('allCards'));
    for (const card of state.cards) {
      const digits = cardDisplayDigits(cardRaw(card)) ||
          String(card.token).slice(-4);
      addTab(card.token, familyOf(card), `…${digits}`);
    }
    body.append(el('div', {class: 'tabwrap'}, tabs));

    const tb = el('div', {class: 'tb'});
    const multi = el('input', {type: 'checkbox'});
    multi.checked = state.multiOnly;
    multi.onchange = () => {
      state.multiOnly = multi.checked;
      renderRows(body);
    };
    tb.append(el('label', {}, multi, t('multiOnly')),
      el('div', {class: 'sp'}),
      el('div', {class: 'ac'},
        el('a', {class: 'a-blue', text: t('selectAllAddable'),
          onclick: () => selectAllVisible(body)}),
        el('a', {class: 'a-mut', text: t('clearSelection'),
          onclick: () => clearSelection(body)})));
    body.append(tb);

    const unreadable = state.cards.filter((c) => c.readFailed);
    if (unreadable.length) {
      body.append(el('div', {class: 'info'},
        el('b', {text: t('cardsReadFailed', {n: unreadable.length})}),
        ' ',
        t('cardsReadFailedNote',
          {names: unreadable.map((c) => c.shortName).join(' / ')})));
    }

    if (state.lastRun) body.append(renderLastRunStrip());

    const list = el('div', {class: 'list', id: 'list'});
    body.append(list);
    shell.append(body);
    renderRows(body);

    shell.append(renderFooter());
  }

  /** @return {!Element} The 可加 | 已加 sub-tab row under the search box. */
  function renderOffersSubTabs() {
    const addableN =
        state.offers.filter((g) => addableCards(g).length > 0).length;
    const addedN =
        buildAddedIndex(state.cards, state.redeemed.byToken).length;
    const mk = (key, label, n) => {
      const tab = el('div',
        {class: state.offersSub === key ? 'tab on' : 'tab'}, label);
      tab.append(el('span', {class: 'n', text: ` ${n}`}));
      tab.onclick = () => {
        if (state.offersSub === key) return;
        state.offersSub = key;
        render();
      };
      return tab;
    };
    const row = el('div', {class: 'tabs'});
    row.append(mk('addable', t('subAddable'), addableN));
    row.append(mk('added', t('subAdded'), addedN));
    row.append(el('div', {style: 'flex:1'}));
    if (state.offersSub === 'added') {
      row.append(el('div', {class: 'sortmini',
        text: `${t('sortShortExpiry')} ▾`}));
    }
    return row;
  }

  /**
   * Renders the added (redeem-tracking) sub-view into the list body: stats
   * strip + always-expanded per-card rows, or a loading / error block while
   * the savings records aren't available.
   * @param {!Element} body Panel body.
   */
  function renderAddedView(body) {
    const r = state.redeemed;
    if (r.error) {
      body.append(el('div', {class: 'info'},
        el('b', {text: t('addedError')}), ' ',
        el('a', {class: 'lnk', text: t('retry'),
          onclick: () => loadRedeemed(true)})));
      return;
    }
    if (!r.loaded) {
      if (!r.loading) loadRedeemed();
      body.append(el('div', {class: 'msg', style: 'padding:28px 24px'},
        el('div', {style: 'display:flex;gap:10px;justify-content:center;' +
            'align-items:center'},
        el('span', {class: 'spin'}),
        el('span', {class: 'note', text: t('addedLoading')}))));
      return;
    }
    const groups = buildAddedIndex(state.cards, r.byToken);
    const s = addedStats(groups);
    body.append(counters([
      {n: fmtMoney(s.redeemedAmount), l: t('statRedeemed'), c: 'g'},
      {n: s.pending, l: t('statPending'), c: 'navy'},
      {n: s.expiring, l: t('statExpiring'), c: 'r'}]));
    body.append(el('div', {id: 'addedlist'}));
    renderAddedRows(body);
  }

  /**
   * (Re)draws just the added-offer rows (so typing in search keeps focus).
   * @param {!Element} body Panel body (contains #addedlist).
   */
  function renderAddedRows(body) {
    const list = body.querySelector('#addedlist');
    if (!list) return;
    list.textContent = '';
    const q = state.query;
    const groups = buildAddedIndex(state.cards, state.redeemed.byToken)
      .filter((g) => !q || g.name.toLowerCase().includes(q));
    if (groups.length === 0) {
      list.append(el('div', {class: 'msg', style: 'padding:24px'},
        el('div', {class: 'note',
          text: q ? t('noMatchingOffers') : t('noAddedOffers')})));
      return;
    }
    for (const g of groups) list.append(renderAddedRow(g));
  }

  /**
   * One added-offer group: merchant row + always-open per-card redemption
   * lines (a posting shows ✓ + amount + date; otherwise "no cashback seen").
   * Fully-redeemed groups render dimmed.
   * @param {!Object} g An added-offer group.
   * @return {!Element} The group element.
   */
  function renderAddedRow(g) {
    const logo = el('div', {class: 'logo'});
    if (g.image) {
      logo.append(el('img', {src: g.image, alt: '', loading: 'lazy'}));
    } else {
      const [bg, fg] = logoColors(g.name);
      logo.style.background = bg;
      logo.style.color = fg;
      logo.textContent = merchantInitials(g.name);
    }
    const main = el('div', {class: 'mn'},
      el('div', {class: 'nm', text: g.name}),
      g.description ? el('div', {class: 'ds', text: g.description}) : null);

    const rt = el('div', {class: 'rt'});
    const ratio = t('redeemedOfCards',
      {x: g.redeemedCount, y: g.cards.length});
    if (g.fullyRedeemed) {
      rt.append(el('div', {class: 'aday ok', text: `✓ ${ratio}`}));
      const parts = [];
      if (g.totalRedeemedUsd > 0) parts.push(fmtMoney(g.totalRedeemedUsd));
      if (g.totalRedeemedPoints > 0) {
        parts.push(fmtPoints(g.totalRedeemedPoints));
      }
      if (parts.length) {
        rt.append(el('div', {class: 'asub',
          text: t('totalRedeemed', {amt: parts.join(' + ')})}));
      }
    } else {
      if (Number.isFinite(g.daysLeft)) {
        rt.append(el('div', {
          class: g.daysLeft <= 7 ? 'aday urgent' : 'aday',
          text: daysLabel(g.daysLeft)}));
      } else if (g.expiry) {
        rt.append(el('div', {class: 'aday', text: expiryLabel(g)}));
      }
      rt.append(el('div',
        {class: g.redeemedCount > 0 ? 'asub ok' : 'asub', text: ratio}));
    }

    const cardsBox = el('div', {class: 'acards'});
    for (const c of g.cards) {
      const sw = el('span', {class: 'sw'});
      const cardData = cardOf(c.token);
      if (cardData?.art) sw.append(el('img', {src: cardData.art, alt: ''}));
      else sw.style.background = swatchStyle(c.token);
      const digits = cardData ?
        `…${cardData.digits}` : `…${String(c.token).slice(-4)}`;
      let status;
      if (c.redeemed) {
        let text = t('cashbackPosted');
        if (c.redeemed.amount > 0) {
          text += ` ${c.redeemed.unit === 'points' ?
            fmtPoints(c.redeemed.amount) : fmtMoney(c.redeemed.amount)}`;
        }
        if (c.redeemed.date) text += ` · ${c.redeemed.date}`;
        status = el('span', {class: 'acst ok', text});
      } else {
        status = el('span', {class: 'acst', text: t('noCashbackSeen')});
      }
      cardsBox.append(el('div', {class: 'acrow'}, sw,
        el('span', {class: 'acdig', text: digits}),
        el('div', {style: 'flex:1'}), status));
    }

    return el('div', {class: g.fullyRedeemed ? 'agrp dim' : 'agrp'},
      el('div', {class: 'arow'}, logo, main, rt), cardsBox);
  }

  /**
   * Reads each card's redeemed (savings) list for the added sub-view.
   * Cached until the next panel refresh; `force` re-reads.
   * @param {boolean=} force Re-read even if already loaded.
   * @return {!Promise<void>} Resolves when rendered.
   */
  async function loadRedeemed(force = false) {
    const r = state.redeemed;
    if (r.loading || (r.loaded && !force)) return;
    state.redeemed = {...r, loading: true, error: ''};
    try {
      const byToken = new Map();
      await mapLimit(state.cards, MAX_CONCURRENT_READS, async (card) => {
        byToken.set(card.token, await fetchRedeemedOffers(card.token));
      });
      state.redeemed = {byToken, loaded: true, loading: false, error: '',
        readAt: Date.now()};
    } catch (error) {
      state.redeemed = {...state.redeemed, loading: false,
        error: error.message || t('addedError')};
    }
    render();
  }

  /** @return {!Element} The "上次执行" summary strip. */
  function renderLastRunStrip() {
    const r = state.lastRun;
    const strip = el('div', {class: 'lastrun'});
    strip.append(document.createTextNode(t('lastRunPrefix')));
    strip.append(el('b',
      {class: 'g', text: t('lastRunConfirmed', {n: r.confirmed})}));
    strip.append(document.createTextNode(' · '));
    strip.append(el('b',
      {class: 'r', text: t('lastRunFailed', {n: r.failed})}));
    strip.append(document.createTextNode(' · '));
    strip.append(el('b',
      {class: 'am', text: t('lastRunDedupe', {n: r.dedupe})}));
    strip.append(el('span', {class: 'when', text: ` · ${whenLabel(r.at)}`}));
    strip.append(el('div', {class: 'sp'}));
    strip.append(el('span', {class: 'lnk', text: t('view'), onclick: () => {
      state.view = 'result';
      render();
    }}));
    return strip;
  }

  /**
   * A short clock label: `今天 14:32` / `昨天 14:32` / `7/4 14:32`.
   * @param {number} ts Epoch ms.
   * @return {string} Label.
   */
  function whenLabel(ts) {
    const d = new Date(ts);
    const hm = `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
    const today = new Date();
    const sameDay = (a, b) => a.toDateString() === b.toDateString();
    const yest = new Date(today.getTime() - 86400000);
    if (sameDay(d, today)) return t('today', {time: hm});
    if (sameDay(d, yest)) return t('yesterday', {time: hm});
    return `${d.getMonth() + 1}/${d.getDate()} ${hm}`;
  }

  /** @param {!Element} body Panel body (contains #list). */
  function renderRows(body) {
    const list = body.querySelector('#list');
    list.textContent = '';
    const shown = visibleOffers();
    for (const group of shown) list.append(renderOfferRow(group));
    if (shown.length === 0) {
      const empty = el('div', {class: 'note', text: t('noMatchingOffers')});
      list.append(el('div', {class: 'msg', style: 'padding:24px'}, empty));
    }
    refreshFooter();
  }

  /** @return {!Element} The list-view footer. */
  function renderFooter() {
    const sm = el('div', {class: 'sm', id: 'sm'});
    const go = el('button', {class: 'go', id: 'go', text: t('addToSelected'),
      onclick: () => runSelected()});
    return el('div', {class: 'ft'}, sm, go);
  }

  /** Updates the footer summary + button enabled state. */
  function refreshFooter() {
    if (!panelRoot) return;
    const sm = panelRoot.getElementById('sm');
    const go = panelRoot.getElementById('go');
    if (!sm || !go) return;
    const offers = state.selected.size;
    const pairs = selectedCount();
    sm.textContent = '';
    sm.append(document.createTextNode(t('footerSelPrefix')),
      el('b', {text: String(offers)}),
      document.createTextNode(t('footerSelMid')),
      el('b', {text: String(pairs)}),
      document.createTextNode(t('footerSelSuffix')));
    go.disabled = pairs === 0;
  }

  // ---- Benefits tab (3a) ---------------------------------------------------

  /**
   * Formats a currency amount, dropping `.00` on whole dollars.
   * @param {number} n Amount.
   * @param {string=} symbol Currency symbol.
   * @return {string} e.g. `$412` or `$28.56`.
   */
  function fmtMoney(n, symbol = '$') {
    const v = Number(n) || 0;
    return v % 1 === 0 ? `${symbol}${v}` : `${symbol}${v.toFixed(2)}`;
  }

  /**
   * Formats a points amount, e.g. `5,000 pts` / `5,000 点`. Points are a
   * separate ledger from dollars and never go through {@link fmtMoney}.
   * @param {number} n Points.
   * @return {string} Localized points label.
   */
  function fmtPoints(n) {
    return t('pointsAmount',
      {n: Math.round(Number(n) || 0).toLocaleString('en-US')});
  }

  /** @param {number} d Days left. @return {string} e.g. `还剩 5 天`. */
  function daysLabel(d) {
    if (!Number.isFinite(d)) return '';
    if (d < 0) return t('expired');
    return t('daysLeft', {n: d});
  }

  /**
   * Whether a benefit is available but not yet activated (e.g. CLEAR Plus),
   * so it shows a "去激活" prompt instead of a progress bar. Best-effort on the
   * tracker `status`; unknown/active statuses fall through to a normal row.
   * @param {!Object} group A benefit group.
   * @return {boolean} True when the benefit needs activation.
   */
  function isInactiveBenefit(group) {
    return /AVAILABLE|INACTIVE|NOT[_ ]?ENROLLED|ELIGIBLE[_ ]?TO/i
      .test(group.status || '');
  }

  /** @param {!Object} extra Header overrides. @return {!Object} Header opts. */
  function benefitsHeaderOpts(extra) {
    const owned = state.cards.filter(
      (c) => (c.relationship || 'BASIC') === 'BASIC').length;
    const parts = [t('primaryCardsN', {n: owned})];
    const ago = agoLabel(state.benefitsReadAt);
    if (ago) parts.push(ago);
    return {glyph: '+', title: t('panelTitle'), subtitle: parts.join(' · '),
      lang: true, close: true, tabs: true, ...extra};
  }

  /** @param {!Element} shell Panel content root. */
  function renderBenefitsTab(shell) {
    if (state.benefitsError) return renderBenefitsError(shell);
    if (!state.benefitsLoaded) return renderBenefitsLoading(shell);
    renderBenefitsList(shell);
  }

  /** @param {!Element} shell Panel content root. */
  function renderBenefitsLoading(shell) {
    shell.append(renderHeader(benefitsHeaderOpts({})));
    const pr = state.benefitsRun || {done: 0, total: 0};
    const known = pr.total > 0;
    const pct = known ? Math.round(pr.done / pr.total * 100) : 8;
    const body = el('div', {class: 'body', style: 'padding:20px 18px'});
    const rowStyle = 'display:flex;justify-content:space-between;' +
        'align-items:baseline;margin-bottom:8px';
    body.append(el('div', {style: rowStyle},
      el('div', {style: 'font-size:13px;font-weight:700',
        text: t('loadingBenefits')}),
      el('div', {class: 'note',
        text: known ? t('loadingCardN', {done: pr.done, total: pr.total}) :
          t('loadingCardList')})));
    body.append(el('div', {class: 'bar', style: 'margin-bottom:6px'},
      el('div', {style: `width:${pct}%`})));
    body.append(el('div', {class: 'note', text: t('benefitsReadOnly')}));
    shell.append(body);
  }

  /** @param {!Element} shell Panel content root. */
  function renderBenefitsError(shell) {
    shell.append(renderHeader(benefitsHeaderOpts({})));
    const body = el('div', {class: 'body'});
    const msg = el('div', {class: 'msg'});
    msg.append(el('div', {class: 'cir bad', text: '!'}));
    msg.append(el('div', {class: 'h', text: t('benefitsReadFailed')}));
    msg.append(el('div', {class: 'txt', text: t('errorSessionHint')}));
    msg.append(el('div', {class: 'btn pri', text: t('retry'),
      onclick: () => loadBenefits(true)}));
    body.append(msg);
    shell.append(body);
  }

  /**
   * @param {!Object} group A benefit group.
   * @param {string} q Lowercased search query.
   * @return {boolean} Whether the benefit matches by name or card.
   */
  function matchesBenefitQuery(group, q) {
    if (!q) return true;
    if (group.name.toLowerCase().includes(q)) return true;
    return group.entries.some(
      (e) => `${e.family} …${e.digits}`.toLowerCase().includes(q));
  }

  /** @param {!Element} shell Panel content root. */
  function renderBenefitsList(shell) {
    shell.append(renderHeader(benefitsHeaderOpts({
      refresh: true, onRefresh: () => loadBenefits(true)})));
    const body = el('div', {class: 'body'});

    const search = el('input', {type: 'search', value: state.benefitQuery,
      placeholder: t('searchBenefits')});
    search.oninput = (e) => {
      state.benefitQuery = e.target.value;
      renderBenefitBody(body);
    };
    body.append(el('div', {class: 'sr'}, search));

    body.append(renderBenefitStats());

    const tb = el('div', {class: 'btb'});
    tb.append(el('div', {class: 'sortlbl'}, t('sortByExpiry'),
      el('span', {class: 'caret', text: '▾'})));
    tb.append(el('div', {class: 'sp'}));
    const un = el('input', {type: 'checkbox'});
    un.checked = state.benefitUnusedOnly;
    un.onchange = () => {
      state.benefitUnusedOnly = un.checked;
      renderBenefitBody(body);
    };
    tb.append(el('label', {class: 'unused'}, un, t('unusedOnly')));
    body.append(tb);

    body.append(el('div', {id: 'bbody'}));
    shell.append(body);
    renderBenefitBody(body);

    shell.append(el('div', {class: 'bfoot'},
      el('div', {class: 'note', text: t('benefitsFootnote')})));
  }

  /**
   * Renders the filtered benefit list into `#bbody` (without touching the
   * search box / stats / toolbar), so typing in search keeps focus.
   * @param {!Element} body The benefits body element.
   */
  function renderBenefitBody(body) {
    const bbody = body.querySelector('#bbody');
    if (!bbody) return;
    bbody.textContent = '';
    const q = state.benefitQuery.trim().toLowerCase();
    const match = (g) => matchesBenefitQuery(g, q);
    const active = state.benefits.filter((g) => !g.fullyUsed && match(g));
    const used = state.benefits.filter((g) => g.fullyUsed && match(g));
    const shown = state.benefitUnusedOnly ? active :
      state.benefits.filter(match);
    const list = el('div', {class: 'blist'});
    for (const g of shown) list.append(renderBenefitRow(g));
    if (shown.length === 0) {
      const text = q ?
        t('noBenefitsMatch', {q: state.benefitQuery.trim()}) :
        t('noBenefits');
      list.append(el('div', {class: 'msg', style: 'padding:24px'},
        el('div', {class: 'note', text})));
    }
    bbody.append(list);

    if (state.benefitUnusedOnly && used.length) {
      bbody.append(renderBenefitDoneSection(used));
    }
  }

  /** @return {!Element} The three-stat header bar. */
  function renderBenefitStats() {
    const s = state.benefitStats ||
        {thisMonthUnused: 0, redeemedYtd: 0, annualFee: 0, paybackPct: 0};
    const col = (cls, value, valCls, label, sub) => {
      const c = el('div', {class: `bcol ${cls}`});
      c.append(el('div', {class: `bval ${valCls}`, text: value}));
      c.append(el('div', {class: 'blbl', text: label}));
      if (sub) c.append(el('div', {class: 'bsub2', text: sub}));
      return c;
    };
    const feeCol = s.annualFee > 0 ?
      col('r', `${s.paybackPct}%`, 'ink',
        t('feeOffset', {spent: fmtMoney(s.redeemedYtd),
          fee: fmtMoney(s.annualFee)}),
        t('trackedOnly')) :
      col('r', fmtMoney(s.redeemedYtd), 'ink', t('redeemedYtd'),
        t('trackedOnly2'));
    return el('div', {class: 'bstats'},
      col('l', fmtMoney(s.thisMonthUnused), 'navy', t('leftThisMonth')),
      el('div', {class: 'vsep'}),
      col('m', fmtMoney(s.redeemedYtd), 'green', t('redeemedYtd')),
      el('div', {class: 'vsep'}),
      feeCol);
  }

  /**
   * @param {!Array<!Object>} used Fully-used benefit groups.
   * @return {!Element} The collapsible "已用完" section.
   */
  function renderBenefitDoneSection(used) {
    const wrap = el('div', {class: 'bdone'});
    const head = el('div', {class: 'bsec'});
    head.append(el('span', {class: 'bsec-t', text: t('usedUpSection')}));
    head.append(el('span', {class: 'bsec-n', text: String(used.length)}));
    head.append(el('div', {class: 'sp'}));
    head.append(el('span', {class: 'caret',
      text: state.benefitDoneOpen ? '▴' : '▾'}));
    head.onclick = () => {
      state.benefitDoneOpen = !state.benefitDoneOpen;
      render();
    };
    wrap.append(head);
    if (state.benefitDoneOpen) {
      for (const g of used) wrap.append(renderBenefitRow(g));
    }
    return wrap;
  }

  /**
   * @param {!Object} group A benefit group.
   * @return {!Element} One benefit row (expandable when multi-card).
   */
  function renderBenefitRow(group) {
    const [bg, fg] = logoColors(group.name);
    const logo = el('div', {class: 'blogo',
      style: `background:${bg};color:${fg}`,
      text: benefitInitials(group.name)});
    const title = el('div', {class: 'btitle'},
      el('span', {class: 'bname', text: group.name}));
    if (group.period) {
      title.append(el('span', {class: 'bp', text: periodText(group.period)}));
    }
    if (group.multiCard) {
      title.append(el('span', {class: 'bx',
        text: t('xCards', {n: group.entries.length})}));
    }
    const cardText = group.entries
      .map((e) => `${e.family} …${e.digits}`).join(' · ');
    const mn = el('div', {class: 'bmn'}, title,
      el('div', {class: 'bcard', text: cardText}));

    // Not-yet-activated benefit (e.g. CLEAR Plus): no progress, a "去激活" CTA.
    if (isInactiveBenefit(group)) {
      const rt = el('div', {class: 'brt'},
        el('div', {class: 'binact', text: t('notActivated')}),
        el('div', {class: 'bwhen',
          text: `${fmtMoney(group.target, group.symbol)} / ` +
            `${periodText(group.period || 'year')}`}));
      const btn = el('div', {class: 'bactivate', text: t('activate'),
        onclick: () => window.open(
          'https://global.americanexpress.com/card-benefits/view-all',
          '_blank')});
      return el('div', {class: 'brow'}, logo, mn, rt, btn);
    }

    // Inline aggregate progress bar on every credit row (single and multi).
    const pct = group.target > 0 ?
      Math.min(100, Math.round(group.spent / group.target * 100)) : 0;
    mn.append(el('div', {class: 'bbar'}, el('div', {style: `width:${pct}%`})));

    const urgent = Number.isFinite(group.daysLeft) && group.daysLeft <= 7;
    const rt = el('div', {class: 'brt'},
      el('div', {class: 'bamt'},
        `${fmtMoney(group.spent, group.symbol)} `,
        el('span', {class: 'of',
          text: `/ ${fmtMoney(group.target, group.symbol)}`})),
      el('div', {class: urgent ? 'bdays urgent' : 'bdays',
        text: daysLabel(group.daysLeft)}));

    // Every credit row is expandable (consistency) — reveals the per-card
    // breakdown with card-art thumbnails.
    const expanded = state.benefitsExpanded.has(group.key);
    const row = el('div', {class: 'brow'}, logo, mn, rt,
      el('span', {class: 'bcaret', text: expanded ? '▴' : '▾'}));
    const wrap = el('div', {class: expanded ? 'bgrp exp' : 'bgrp'});
    row.onclick = () => {
      if (expanded) state.benefitsExpanded.delete(group.key);
      else state.benefitsExpanded.add(group.key);
      render();
    };
    wrap.append(row);
    if (expanded) wrap.append(renderBenefitBreakdown(group));
    return wrap;
  }

  /**
   * @param {!Object} group A benefit group.
   * @return {!Element} Per-card breakdown rows (card-art thumbnail + progress).
   */
  function renderBenefitBreakdown(group) {
    const box = el('div', {class: 'bsub'});
    for (const e of group.entries) {
      const pct = e.target > 0 ?
        Math.min(100, Math.round(e.spent / e.target * 100)) : 0;
      const sw = el('span', {class: 'sw'});
      if (e.art) sw.append(el('img', {src: e.art, alt: ''}));
      else sw.style.background = swatchStyle(e.token);
      box.append(el('div', {class: 'bsubrow'}, sw,
        el('span', {class: 'bsubcard', text: `…${e.digits}`}),
        el('div', {class: 'bbar grow'}, el('div', {style: `width:${pct}%`})),
        el('span', {class: 'bsubamt',
          text: `${fmtMoney(e.spent, e.symbol)} / ` +
            `${fmtMoney(e.target, e.symbol)}`})));
    }
    return box;
  }

  /** @param {!Element} shell Panel content root. */
  function renderLoadingView(shell) {
    shell.append(renderHeader({glyph: '+', title: t('panelTitle'),
      subtitle: t('loadingSubtitle'), close: true}));
    const pr = state.run || {done: 0, total: 0};
    const known = pr.total > 0;
    // Before the account list returns the total is unknown; show a small sliver
    // so the bar reads as "working" rather than empty.
    const pct = known ? Math.round(pr.done / pr.total * 100) : 8;
    const body = el('div', {class: 'body', style: 'padding:20px 18px'});
    const rowStyle = 'display:flex;justify-content:space-between;' +
        'align-items:baseline;margin-bottom:8px';
    body.append(el('div', {style: rowStyle},
      el('div', {style: 'font-size:13px;font-weight:700',
        text: t('loadingOffers')}),
      el('div', {class: 'note',
        text: known ? t('loadingCardN', {done: pr.done, total: pr.total}) :
          t('loadingCardList')})));
    body.append(el('div', {class: 'bar', style: 'margin-bottom:6px'},
      el('div', {style: `width:${pct}%`})));
    body.append(el('div', {class: 'note', text: t('loadingReadOnly')}));
    shell.append(body);
  }

  /** @param {!Element} shell Panel content root. */
  function renderRunningView(shell) {
    const run = state.run;
    const seen = run.results.length;
    const ok = run.results.filter((r) => r.reportedOk).length;
    const skipped = run.results.filter((r) => r.skipped).length;
    const fail = seen - ok - skipped;
    const pending = run.total - seen;
    const pct = run.total ? Math.round(seen / run.total * 100) : 0;
    shell.append(renderHeader({glyph: el('span', {class: 'spin'}),
      title: t('runningTitle'),
      subtitle: t('runningSubtitle', {n: run.total}),
      right: el('div', {class: 'b', style: 'font-size:12px;font-weight:700',
        text: t('processedOf',
          {done: run.results.length, total: run.total})})}));
    const body = el('div', {class: 'body'});
    body.append(el('div', {style: 'padding:16px 18px 0'},
      el('div', {class: 'bar'}, el('div', {style: `width:${pct}%`}))));
    const cols = [
      {n: ok, l: t('submitOk'), c: 'g'}, {n: fail, l: t('submitFail'), c: 'r'},
      {n: pending, l: t('submitting'), c: 'b'}];
    if (skipped) cols.push({n: skipped, l: t('notSubmitted'), c: 'am'});
    body.append(counters(cols));
    const rl = el('div', {style: 'padding:4px 18px 8px'});
    const settledIds = new Set(run.results.map((r) => `${r.key}|${r.token}`));
    for (const task of run.tasks) {
      const done = run.results.find(
        (r) => r.key === task.key && r.token === task.token);
      const txt = `${task.name} → ${cardLabel(task.token)}`;
      let icon; let stTxt; let stCls;
      if (!done) {
        icon = el('span', {class: 'spin'});
        stTxt = t('submitting');
        stCls = 'st b';
      } else if (done.skipped) {
        icon = el('span', {class: 'am', text: '⊘'});
        stTxt = t('notSubmitted');
        stCls = 'st am';
      } else if (done.reportedOk) {
        icon = el('span', {class: 'g', text: '✓'});
        stTxt = t('submitOk');
        stCls = 'st g';
      } else {
        icon = el('span', {class: 'r', text: '✗'});
        stTxt = t('submitFail');
        stCls = 'st r';
      }
      rl.append(el('div', {class: 'ri'}, icon,
        el('div', {class: 'txt', text: txt}),
        el('div', {class: stCls, text: stTxt})));
    }
    body.append(rl);
    const footStyle = 'border-top:1px solid #E7E8EA;padding:11px 18px';
    body.append(el('div', {style: footStyle},
      el('div', {class: 'note', text: t('runningNote')})));
    shell.append(body);
    void settledIds;
  }

  /** @param {!Element} shell Panel content root. */
  function renderResultView(shell) {
    const results = [...state.lastResults.values()];
    const n = (s) => results.filter((r) => r.state === s).length;
    const throttled = results.some(
      (r) => r.blocked || r.state === ResultState.SKIPPED);
    shell.append(renderHeader({glyph: throttled ? '!' : '✓',
      title: throttled ? t('resultTitleStopped') : t('resultTitleOk'),
      subtitle: t('resultSubtitle', {n: results.length}), close: true,
      err: throttled}));
    const body = el('div', {class: 'body'});
    const cols = [
      {n: n(ResultState.VERIFIED), l: t('confirmedAdded'), c: 'g'},
      {n: n(ResultState.FAILED), l: t('addFailed'), c: 'r'},
      {n: n(ResultState.GHOST) + n(ResultState.UNVERIFIED),
        l: t('dedupeOrUnknown'), c: 'am'}];
    if (n(ResultState.SKIPPED)) {
      cols.push({n: n(ResultState.SKIPPED), l: t('notSubmitted'), c: 'am'});
    }
    body.append(counters(cols));
    if (throttled) {
      body.append(el('div', {class: 'info'},
        el('b', {text: t('throttledTitle')}), t('throttledBody')));
    }
    body.append(el('div', {class: 'info'},
      el('b', {text: t('dedupeHelpTitle')}), t('dedupeHelpBody')));

    const section = (title, filter, glyph, cls) => {
      const items = results.filter(filter);
      if (!items.length) return;
      body.append(el('div', {style: 'padding:0 18px'},
        el('div', {class: 'sh', text: title})));
      const wrap = el('div', {style: 'padding:0 18px 4px'});
      for (const r of items) {
        const label = `${r.name} → ${cardLabel(r.token)}`;
        if (r.state === ResultState.FAILED && r.message) {
          // Long server message: stack it under the card, left-aligned.
          wrap.append(el('div', {class: 'si col'},
            el('span', {text: label}),
            el('span', {class: `${cls} si-msg`, text: `“${r.message}”`})));
        } else {
          wrap.append(el('div', {class: 'si'},
            el('span', {text: label}),
            el('span', {class: cls, text: glyph})));
        }
      }
      body.append(wrap);
    };
    section(t('confirmedAdded'),
      (r) => r.state === ResultState.VERIFIED, '✓', 'g');
    section(t('addFailed'), (r) => r.state === ResultState.FAILED, '✗', 'r');
    section(t('secSkipped'),
      (r) => r.state === ResultState.SKIPPED, '⊘', 'am');
    section(t('secGhost'),
      (r) => r.state === ResultState.GHOST, '⚠', 'am');
    section(t('secUnverified'),
      (r) => r.state === ResultState.UNVERIFIED, '?', 'note');

    const retryable = (r) => (r.state === ResultState.FAILED ||
        r.state === ResultState.SKIPPED) && !r.gone;
    const retry = el('div', {class: 'lnk rerun', text: t('retryUnfinished'),
      onclick: () => retryFailed()});
    if (!results.some(retryable)) {
      retry.style.display = 'none';
    }
    shell.append(body);
    shell.append(el('div', {class: 'ft'},
      el('div', {class: 'lnk', text: t('exportCsv'),
        onclick: () => exportCsv()}),
      el('div', {class: 'sp', style: 'flex:1'}), retry,
      el('button', {class: 'go', text: t('backToList'),
        onclick: () => backToList()})));
  }

  /**
   * @param {!Array<{n: number, l: string, c: string}>} cols Counter columns.
   * @return {!Element} The 3-up counter strip.
   */
  function counters(cols) {
    const row = el('div', {class: 'cnt'});
    cols.forEach((col, i) => {
      if (i) row.append(el('div', {class: 'sep'}));
      row.append(el('div', {class: 'c'},
        el('div', {class: `n ${col.c}`, text: String(col.n)}),
        el('div', {class: 'l', text: col.l})));
    });
    return row;
  }

  /** @return {!Element} A gray placeholder row (loading / dimmed backdrop). */
  function skeletonRow() {
    return el('div', {class: 'skrow'},
      el('div', {class: 'sklogo'}),
      el('div', {class: 'skmn'},
        el('div', {class: 'skl a'}), el('div', {class: 'skl b'})));
  }

  /** @param {!Element} shell Panel content root (pre-submit confirm dialog). */
  function renderConfirmView(shell) {
    const tasks = state.pendingTasks || [];
    const byOffer = new Map();
    for (const t of tasks) byOffer.set(t.name, (byOffer.get(t.name) || 0) + 1);

    const wrap = el('div', {class: 'cfwrap'});
    const dim = el('div', {class: 'cfdim'});
    dim.append(renderHeader({glyph: '+', title: t('panelTitle')}));
    const sk = el('div', {class: 'cfsk'});
    for (let i = 0; i < 4; i++) sk.append(skeletonRow());
    dim.append(sk);
    wrap.append(dim);

    const dlg = el('div', {class: 'cfdlg'});
    dlg.append(el('div', {class: 'cf-t',
      text: t('confirmTitle', {n: tasks.length})}));
    dlg.append(el('div', {class: 'cf-d'},
      t('confirmBody1', {offers: byOffer.size, cards: tasks.length}),
      el('b', {text: t('confirmBodyBold')}),
      t('confirmBody2')));
    const sum = el('div', {class: 'cf-sum'});
    for (const [name, n] of byOffer) {
      sum.append(el('div', {text: t('confirmRowCards', {name, n})}));
    }
    dlg.append(sum);
    dlg.append(el('div', {class: 'cf-btns'},
      el('div', {class: 'cf-cancel', text: t('cancel'),
        onclick: () => cancelConfirm()}),
      el('div', {class: 'cf-ok', text: t('confirmSubmit'),
        onclick: () => confirmRun()})));
    wrap.append(el('div', {class: 'cfov'}, dlg));
    shell.append(wrap);
  }

  /** @param {!Element} shell Panel content root. */
  function renderEmptyView(shell) {
    shell.append(renderHeader({glyph: '+', title: t('panelTitle'),
      close: true, lang: true, refresh: true, tabs: true}));
    shell.append(el('div', {class: 'body'}, el('div', {class: 'msg'},
      el('div', {class: 'cir ok', text: '✓'}),
      el('div', {class: 'h', text: t('emptyTitle')}),
      el('div', {class: 'txt', text: t('emptyBody')}),
      el('div', {class: 'btn', onclick: () => refresh()}, t('reload')))));
  }

  /** @param {!Element} shell Panel content root. */
  function renderErrorView(shell) {
    shell.append(renderHeader({glyph: '+', title: t('panelTitle'),
      close: true, lang: true, err: true}));
    shell.append(el('div', {class: 'body'}, el('div', {class: 'msg'},
      el('div', {class: 'cir bad', text: '!'}),
      el('div', {class: 'h', text: t('errorTitle')}),
      el('div', {class: 'txt',
        text: state.errorMessage || t('errorSessionHint')}),
      el('div', {class: 'btn pri', onclick: () => refresh()}, t('retry')))));
  }

  /** @param {!Element} shell Panel content root (first-run language pick). */
  function renderLanguageView(shell) {
    // Deliberately bilingual: no language has been chosen yet, so both must
    // be readable. The detected language is pre-highlighted as the default.
    const detected = detectLanguage();
    shell.append(renderHeader(
      {glyph: '+', title: 'Amex 助手 · Amex Assistant', close: true}));
    const pick = (lang, label) => el('div', {
      class: detected === lang ? 'btn pri' : 'btn',
      text: label,
      style: 'min-width:112px;justify-content:center',
      onclick: () => chooseLanguage(lang),
    });
    shell.append(el('div', {class: 'body'}, el('div', {class: 'msg'},
      el('div', {class: 'cir ok', text: '文',
        style: 'font-size:16px;font-weight:700'}),
      el('div', {class: 'h', text: '选择语言 · Choose your language'}),
      el('div', {class: 'txt',
        text: '之后可以在面板右上角随时切换 · ' +
          'You can switch anytime from the panel header.'}),
      el('div', {style: 'display:flex;gap:10px;justify-content:center'},
        pick('zh', '中文'), pick('en', 'English')))));
  }

  // ---- helpers for card family / raw account -------------------------------

  /**
   * @param {!CardSnapshot} card Card. @return {string} Product family (short).
   */
  function familyOf(card) {
    return (card.shortName || '').replace(/\s*···.*$/, '') ||
        `…${String(card.token).slice(-4)}`;
  }

  /**
   * cardDisplayDigits works on a raw account; snapshot only kept `shortName`,
   * which already embeds the digits, so re-derive from the label.
   * @param {!CardSnapshot} card Card.
   * @return {!Object} A shim account for cardDisplayDigits.
   */
  function cardRaw(card) {
    const m = /···(\d+)/.exec(card.shortName || '');
    return {display_account_number: m ? m[1] : ''};
  }

  // ---- selection helpers ---------------------------------------------------

  /** @param {!Element} body Panel body. */
  function selectAllVisible(body) {
    for (const group of visibleOffers()) {
      const addable = addableCards(group);
      if (addable.length) {
        state.selected.set(group.key, new Set(addable.map((c) => c.token)));
      }
    }
    renderRows(body);
  }

  /** @param {!Element} body Panel body. */
  function clearSelection(body) {
    state.selected.clear();
    renderRows(body);
  }

  // ---- run / retry / export ------------------------------------------------

  /**
   * Runs the current selection (or a given task list), driving the running and
   * result views. Always returns to a usable view.
   * @param {!Array<!Task>=} presetTasks Optional explicit tasks (retry).
   * @return {!Promise<void>} Resolves when the run finishes.
   */
  async function runSelected(presetTasks) {
    const tasks = presetTasks || buildTasks();
    if (tasks.length === 0) return;
    if (presetTasks) return doRun(tasks, true);
    // A fresh submit goes through the confirm dialog first.
    state.pendingTasks = tasks;
    state.view = 'confirm';
    render();
  }

  /** Confirms the pending submit and runs it. */
  function confirmRun() {
    const tasks = state.pendingTasks;
    state.pendingTasks = null;
    if (tasks && tasks.length) doRun(tasks, false);
  }

  /** Cancels the confirm dialog and returns to the list. */
  function cancelConfirm() {
    state.pendingTasks = null;
    state.view = 'list';
    render();
  }

  /**
   * Runs a task list, driving the running → result views.
   * @param {!Array<!Task>} tasks Tasks to submit.
   * @param {boolean} isRetry Whether these are re-sent failed pairs.
   * @return {!Promise<void>} Resolves when the result view is shown.
   */
  async function doRun(tasks, isRetry) {
    state.view = 'running';
    state.run = {tasks, total: tasks.length, results: []};
    render();
    try {
      // A snapshot that has sat around long enough may carry rotated
      // offerIds; re-read the involved cards and re-resolve before sending.
      if (state.snapshotAt &&
          Date.now() - state.snapshotAt > SNAPSHOT_MAX_AGE_MS) {
        tasks = await freshenTasks(tasks);
        if (tasks.length === 0) {
          // Everything already landed or is no longer available; the list
          // (rebuilt from the fresh reads) tells that story.
          state.selected.clear();
          state.view = 'list';
          render();
          return;
        }
        state.run = {tasks, total: tasks.length, results: []};
        render();
      }
      const results = await executeSelected(tasks, {
        onSettle: (attempt) => {
          state.run.results.push(attempt);
          render();
        },
      });
      // A retry merges over the previous report so pairs settled earlier
      // (verified, landed, gone) stay visible; a fresh run starts clean.
      const merged = isRetry ? state.lastResults : new Map();
      for (const r of results) merged.set(`${r.key}|${r.token}`, r);
      state.lastResults = merged;
      state.selected.clear();
      const throttled = results.some(
        (r) => r.blocked || r.state === ResultState.SKIPPED);
      if (!throttled) {
        // Refresh the offer list; skipped when throttled so the tool goes
        // fully quiet instead of firing another full read sweep.
        try {
          state.cards = await snapshot();
          state.offers = buildOfferIndex(state.cards);
          state.snapshotAt = Date.now();
        } catch { /* keep previous list; result view still shows outcomes */ }
      }
      setLastRunSummary();
      state.view = 'result';
    } catch (error) {
      state.errorMessage = t('runInterrupted', {msg: error.message});
      state.view = 'error';
    }
    render();
  }

  /**
   * Re-reads the cards involved in `tasks` and re-resolves each task against
   * the fresh data (see {@link resolveTasks}). Used when the snapshot backing
   * the selection is old enough that offerIds may have rotated. If the
   * re-read fails, the original tasks are returned unchanged — better to
   * attempt with what we have than to block the run.
   * @param {!Array<!Task>} tasks Tasks about to run.
   * @return {!Promise<!Array<!Task>>} Tasks carrying fresh offerIds; may be
   *     smaller when pairs turn out to be already added or gone.
   */
  async function freshenTasks(tasks) {
    const tokens = [...new Set(tasks.map((t) => t.token))];
    try {
      await mapLimit(tokens, MAX_CONCURRENT_READS, async (token) => {
        const card = cardOf(token);
        if (!card) return;
        const eligible = await fetchEligibleOffers(token);
        const enrolled = await fetchEnrolledOffers(token);
        card.eligible = eligible;
        card.enrolled = enrolled;
        card.enrolledKeys =
            new Set(enrolled.map(offerGroupKey).filter(Boolean));
        card.readFailed = false;
      });
      state.offers = buildOfferIndex(state.cards);
    } catch {
      return tasks;
    }
    return resolveTasks(tasks, state.offers).tasks;
  }

  /** Records a compact summary of the last run for the list-view strip. */
  function setLastRunSummary() {
    const vals = [...state.lastResults.values()];
    const count = (s) => vals.filter((r) => r.state === s).length;
    state.lastRun = {
      confirmed: count(ResultState.VERIFIED),
      failed: count(ResultState.FAILED),
      dedupe: count(ResultState.GHOST),
      at: Date.now(),
    };
  }

  /**
   * Re-runs the failed and never-submitted pairs from the last result.
   * Pairs are re-planned against the current snapshot first (fresh offerIds;
   * see {@link planRetry}); pairs that turn out to be already on the card or
   * no longer available are settled in place without a resend.
   */
  function retryFailed() {
    const {tasks, landed, gone} =
        planRetry([...state.lastResults.values()], state.offers);
    for (const r of [...landed, ...gone]) {
      state.lastResults.set(`${r.key}|${r.token}`, r);
    }
    if (tasks.length) runSelected(tasks);
    else render(); // nothing left to resend; show the settled states
  }

  /** Downloads the last run's results as a CSV file. */
  function exportCsv() {
    const rows = [['offer', 'card', 'state', 'http_status', 'message']];
    for (const r of state.lastResults.values()) {
      rows.push([r.name, cardLabel(r.token), r.state, r.httpStatus || '',
        r.message || '']);
    }
    const csv = rows.map((row) => row
      .map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','),
    ).join('\n');
    const url = URL.createObjectURL(
      new Blob([csv], {type: 'text/csv;charset=utf-8'}));
    const link = el('a', {href: url, download: 'amex-assistant-results.csv'});
    link.click();
    URL.revokeObjectURL(url);
  }

  /** Returns from the result view to the offer list. */
  function backToList() {
    state.view = 'list';
    render();
  }

  // ---- panel shell / lifecycle ---------------------------------------------

  /**
   * Makes a fixed-positioned host draggable. Clamps to the viewport; the host's
   * own inline style remembers where it was left. A press that doesn't move
   * counts as a click (for the launcher).
   * @param {!Element} host The fixed element to move.
   * @param {!EventTarget} listenOn Where to listen (element or shadow root).
   * @param {{only: (string|undefined), ignore: (string|undefined),
   *          onClick: (function(!Event)|undefined),
   *          onMove: (function()|undefined)}=} opts Behavior.
   */
  function makeDraggable(host, listenOn, opts = {}) {
    listenOn.addEventListener('mousedown', (e) => {
      if (e.button !== 0) return;
      if (opts.only && !e.target.closest(opts.only)) return;
      if (opts.ignore && e.target.closest(opts.ignore)) return;
      const rect = host.getBoundingClientRect();
      const offX = e.clientX - rect.left;
      const offY = e.clientY - rect.top;
      const startX = e.clientX;
      const startY = e.clientY;
      let moved = false;
      const move = (ev) => {
        if (Math.abs(ev.clientX - startX) +
            Math.abs(ev.clientY - startY) > 4) {
          moved = true;
          if (opts.onMove) opts.onMove();
        }
        const maxX = window.innerWidth - host.offsetWidth - 4;
        const maxY = window.innerHeight - host.offsetHeight - 4;
        const x = Math.max(4, Math.min(ev.clientX - offX, maxX));
        const y = Math.max(4, Math.min(ev.clientY - offY, maxY));
        host.style.left = `${x}px`;
        host.style.top = `${y}px`;
        host.style.right = 'auto';
      };
      const up = (ev) => {
        document.removeEventListener('mousemove', move);
        document.removeEventListener('mouseup', up);
        if (!moved && opts.onClick) opts.onClick(ev);
      };
      document.addEventListener('mousemove', move);
      document.addEventListener('mouseup', up);
      e.preventDefault();
    });
  }

  /** @return {?Element} The panel shell (`.p`), if created. */
  function panelShell() {
    return panelRoot ? panelRoot.getElementById('shell') : null;
  }

  /** Grows the panel in from its collapsed state. */
  function animatePanelIn() {
    const p = panelShell();
    if (!p) return;
    p.classList.add('closing');
    void p.offsetWidth; // reflow so removing the class transitions
    p.classList.remove('closing');
  }

  /**
   * Shrinks the panel out, then runs `done` once.
   * @param {function()} done Called after the shrink finishes.
   */
  function animatePanelOut(done) {
    const p = panelShell();
    if (!p) {
      done();
      return;
    }
    let finished = false;
    const finish = () => {
      if (finished) return;
      finished = true;
      done();
    };
    p.addEventListener('transitionend', finish, {once: true});
    setTimeout(finish, 260);
    requestAnimationFrame(() => p.classList.add('closing'));
  }

  /**
   * Fades the launcher pill in or out.
   * @param {boolean} show Fade in (true) or out then hide (false).
   */
  function fadeLauncher(show) {
    if (!launcherButton) return;
    const pill = launcherButton.shadowRoot.querySelector('.l');
    if (!pill) return;
    if (show) {
      launcherButton.style.display = '';
      pill.classList.add('closed');
      void pill.offsetWidth;
      pill.classList.remove('closed');
    } else {
      pill.classList.add('closed');
      setTimeout(() => {
        if (launcherButton) launcherButton.style.display = 'none';
      }, 150);
    }
  }

  /** @return {!ShadowRoot} Creates the panel host + shadow root. */
  function createPanel() {
    const host = el('div', {style:
      'position:fixed;top:16px;right:16px;z-index:2147483647'});
    document.body.appendChild(host);
    panelHost = host;
    const root = host.attachShadow({mode: 'open'});
    panelRoot = root;
    root.append(el('style', {text: PANEL_STYLE}));
    root.append(el('div', {class: 'p', id: 'shell'}));
    // Drag the panel by its title row; buttons/tabs keep their own clicks.
    makeDraggable(host, root, {only: '.hrow', ignore: 'button, .rf, .cl'});
    return root;
  }

  /**
   * (Re)loads the snapshot and shows the list (or empty/error). Cached so
   * reopening is instant until the next refresh.
   * @return {!Promise<void>} Resolves when rendered.
   */
  async function refresh() {
    state.lastResults = new Map();
    state.run = {done: 0, total: state.cards.length};
    // Redeemed records are re-read lazily after a refresh.
    state.redeemed = {byToken: new Map(), loaded: false, loading: false,
      error: '', readAt: 0};
    state.view = 'loading';
    render();
    try {
      state.cards = await snapshot((done, total) => {
        state.run = {done, total};
        if (state.view === 'loading') render();
      });
      state.offers = buildOfferIndex(state.cards);
      state.snapshotAt = Date.now();
      loaded = true;
      if (!state.offers.length && state.cards.some((c) => c.readFailed)) {
        // Nothing usable came back — surface the failure rather than an
        // (untrue) "no offers" empty state.
        state.errorMessage = t('cardsReadAllFailed');
        state.view = 'error';
      } else {
        state.view = state.offers.length ? 'list' : 'empty';
      }
    } catch (error) {
      state.errorMessage = `${error.message}`;
      state.view = 'error';
    }
    render();
  }

  /** Shows the panel, creating and loading it only on first use. */
  function showPanel() {
    if (!panelHost) createPanel();
    fadeLauncher(false);
    panelHost.style.display = '';
    if (!langChosen) {
      // First run: ask for a language before anything loads. Nothing hits
      // the account until the user picks (or reopens with a saved choice).
      state.view = 'language';
      render();
    } else if (!loaded) {
      refresh();
    } else {
      render();
    }
    animatePanelIn();
  }

  /** Hides the panel (keeps cached state) and restores the launcher. */
  function hidePanel() {
    animatePanelOut(() => {
      if (panelHost) panelHost.style.display = 'none';
      fadeLauncher(true);
    });
  }

  /** Installs the launch pill. Nothing hits the account until opened. */
  function installLauncher() {
    const host = el('div', {style:
      'position:fixed;top:96px;right:0;z-index:2147483647'});
    const root = host.attachShadow({mode: 'open'});
    root.append(el('style', {text: `
      .l { display:flex; align-items:center; gap:9px; background:#fff;
        border:1px solid #E3E5E8; border-right:none;
        border-radius:6px 0 0 6px; padding:10px 16px 10px 12px;
        box-shadow:0 3px 12px rgba(0,23,90,.14); cursor:pointer;
        font:12.5px 'Helvetica Neue',Helvetica,system-ui,sans-serif;
        user-select:none;
        transition:box-shadow .15s ease, opacity .14s ease, transform .14s ease }
      .l:hover { box-shadow:0 5px 18px rgba(0,23,90,.22) }
      .l.closed { opacity:0; transform:scale(.85) translateX(10px);
        pointer-events:none }
      /* Once dragged off the edge it becomes a normal free-floating pill. */
      .l.float { border-right:1px solid #E3E5E8; border-radius:6px }
      .i { width:24px; height:24px; border-radius:4px; background:#006FCF;
        color:#fff; display:flex; align-items:center; justify-content:center;
        font-size:15px; font-weight:600; line-height:1 }
      .t { font-weight:800; color:#00175A; letter-spacing:.1px }
    `}));
    const pill = el('div', {class: 'l'},
      el('div', {class: 'i', text: '+'}),
      el('div', {},
        el('div', {class: 't', text: t('launcherTitle')})));
    root.append(pill);
    document.body.appendChild(host);
    launcherButton = host;
    // Draggable; a press that doesn't move opens the panel.
    makeDraggable(host, pill, {
      onClick: () => showPanel(),
      onMove: () => pill.classList.add('float'),
    });
  }

  window.AmexAssistant = {...api, showPanel, openPanel: showPanel};
  initLanguage();
  installLauncher();
})();