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.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

Advertisement:

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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();
})();