PageSourceSearch

https://cardcompass.app/recommend-engine.js?v=4

js cardcompass.app collected 2026-10-02 01:12:56 UTC 29,443 bytes, 596 lines download raw bytes

1// recommend-engine.js — shared card-recommendation engine.
2//
3// This is the single source of truth for "which cards match this user" logic,
4// extracted from quiz.html (the only page that used to score against the full
5// window.ALL_CARDS database with a rich multi-factor model). index.html's
6// homepage banner, account.html's Matches tab, get-started.html's signup
7// flow, and quiz.html itself all call into window.CCEngine instead of each
8// keeping their own hardcoded 5-card copy.
9//
10// Load after cards-data.js: <script src="recommend-engine.js?v=4"></script>
11
12(function () {
13  'use strict';
14
15  // Legacy inline array kept only as a fallback if cards-data.js fails to load.
16  var CARDS_FALLBACK = [
17    {
18      id: 'csp', name: 'Chase Sapphire Preferred®', issuer: 'Chase',
19      cardImage: 'https://cdn.prodstatic.com/shared/images/cards/219x139/ae837480-ed19-11ee-b6aa-83dad90afdcd.png',
20      badgeClass: 'ci-chase', badgeText: 'CHASE', annualFee: 95, minScore: 'good',
21      bonus: '60,000 Points', bonusValue: 750,
22      applyUrl: 'https://creditcards.chase.com/rewards-credit-cards/sapphire/preferred',
23      rateLabel: '5× on travel via Chase',
24      rates: { travel: 5, dining: 3, grocery: 3, gas: 2, other: 1, online: 1 },
25      categories: ['travel', 'dining'],
26      pros: ['5× points on Chase Travel, 3× on dining & groceries', '60,000-point sign-up bonus worth ~$750 in travel',
27             'Transfer to United, Hyatt, Southwest, and more', 'No foreign transaction fees']
28    },
29    {
30      id: 'amex_gold', name: 'American Express® Gold Card', issuer: 'Amex',
31      cardImage: 'https://cdn.prodstatic.com/shared/images/cards/219x139/d2bef540-4974-11f1-84f5-41ce5a3f4820.png',
32      badgeClass: 'ci-amex', badgeText: 'AMEX', annualFee: 250, minScore: 'good',
33      bonus: '60,000 Points', bonusValue: 1200,
34      applyUrl: 'https://www.americanexpress.com/us/credit-cards/card/gold-card/',
35      rateLabel: '4× at restaurants worldwide',
36      rates: { travel: 3, dining: 4, grocery: 4, gas: 1, other: 1, online: 1 },
37      categories: ['travel', 'dining', 'hotel'],
38      pros: ['4× at restaurants worldwide & U.S. supermarkets', '3× on flights booked directly or via Amex Travel',
39             '$120 dining credit + $120 Uber Cash annually', 'No foreign transaction fees']
40    },
41    {
42      id: 'venture_x', name: 'Capital One Venture X', issuer: 'Capital One',
43      cardImage: 'https://cdn.prodstatic.com/shared/images/cards/219x139/a5c974a0-eb11-11f0-8b83-5b931725a212.png',
44      badgeClass: 'ci-cap', badgeText: 'CAP1', annualFee: 395, minScore: 'excellent',
45      bonus: '75,000 Miles', bonusValue: 1312,
46      applyUrl: 'https://www.capitalone.com/credit-cards/venture-x/',
47      rateLabel: '10× miles on hotels via Capital One',
48      rates: { travel: 10, dining: 2, grocery: 2, gas: 2, other: 2, online: 2 },
49      categories: ['travel'],
50      pros: ['$300 annual travel credit', '75,000-mile welcome bonus worth ~$1,312',
51             'Unlimited Priority Pass lounge access', '10× on hotels & car rentals via Cap One']
52    },
53    {
54      id: 'discover_it', name: 'Discover it® Cash Back', issuer: 'Discover',
55      badgeClass: 'ci-disc', badgeText: 'DISC', annualFee: 0, minScore: 'fair',
56      bonus: 'Cashback Match', bonusValue: 0,
57      applyUrl: 'https://www.discover.com/credit-cards/cash-back/it-card.html',
58      rateLabel: '5% rotating categories',
59      rates: { travel: 1, dining: 1, grocery: 5, gas: 5, other: 1, online: 1 },
60      categories: ['cashback', 'nofee'],
61      pros: ['5% rotating categories (up to quarterly max)', 'No annual fee',
62             'Unlimited dollar-for-dollar cashback match first year']
63    },
64    {
65      id: 'citi_dc', name: 'Citi Double Cash® Card', issuer: 'Citi',
66      badgeClass: 'ci-citi', badgeText: 'CITI', annualFee: 0, minScore: 'good',
67      bonus: '$200 Cash', bonusValue: 200,
68      applyUrl: 'https://www.citi.com/credit-cards/citi-double-cash-credit-card',
69      rateLabel: '2% on everything',
70      rates: { travel: 2, dining: 2, grocery: 2, gas: 2, other: 2, online: 2 },
71      categories: ['cashback', 'nofee'],
72      pros: ['Flat 2% cash back on every purchase', 'No annual fee', 'No categories to track']
73    }
74  ];
75
76  function getCardPool() {
77    return window.ALL_CARDS || CARDS_FALLBACK;
78  }
79
80  // Bank/investment "relationship" balance-tier reward-boost programs.
81  // One table per issuer program; tiers sorted ascending by minBalance.
82  // NOTE: verify against the issuer's current published tiers before
83  // changing -- Bank of America itself replaced this exact table (Preferred
84  // Rewards -> BofA Rewards) in May 2026; treat this as a "known to drift" fact.
85  var RELATIONSHIP_PROGRAMS = {
86    'Bank of America': {
87      label: 'BofA Rewards',
88      tiers: [
89        { name: 'Member',           minBalance: 0,        bonus: 0.10 },
90        { name: 'Preferred Plus',   minBalance: 30000,    bonus: 0.25 },
91        { name: 'Preferred Honors', minBalance: 100000,   bonus: 0.50 },
92        { name: 'Premier',          minBalance: 1000000,  bonus: 0.75 }
93      ]
94    },
95    // U.S. Bank doesn't name these tiers (no "Gold"/"Platinum"-style labels
96    // like BofA) -- thresholds/percentages per usbank.com's own Smartly
97    // program rules page. Applies only to the U.S. Bank Smartly Visa
98    // Signature Card, not U.S. Bank's other cards.
99    'U.S. Bank': {
100      label: 'Smartly Rewards',
101      tiers: [
102        { name: '$10K+ Tier',  minBalance: 10000,  bonus: 0.25 },
103        { name: '$50K+ Tier',  minBalance: 50000,  bonus: 0.50 },
104        { name: '$100K+ Tier', minBalance: 100000, bonus: 1.00 }
105      ]
106    }
107  };
108
109  function getRelationshipTier(issuer, balance) {
110    var program = RELATIONSHIP_PROGRAMS[issuer];
111    if (!program || !(balance > 0)) return { program: program || null, tier: null };
112    var tier = null;
113    program.tiers.forEach(function (t) { if (balance >= t.minBalance) tier = t; });
114    return { program: program, tier: tier };
115  }
116
117  // Resolves a card's relationship-bonus multiplier against a profile's
118  // declared bank relationships. Always safe to call -- missing
119  // bankRelationships, no matching issuer, or a zero balance all resolve
120  // to multiplier 1 (no-op).
121  function getRelationshipMultiplier(card, a) {
122    if (!card.relationshipBonus) return { multiplier: 1, tier: null, program: null };
123    var rel = (a.bankRelationships || []).find(function (r) { return r.issuer === card.relationshipBonus.issuer; });
124    var info = getRelationshipTier(card.relationshipBonus.issuer, rel ? rel.balance : 0);
125    return { multiplier: 1 + (info.tier ? info.tier.bonus : 0), tier: info.tier, program: info.program };
126  }
127
128  function scoreCard(card, a) {
129    var s = 0;
130
131    // SPEND REWARDS VALUE (monthly spend × earn rate, scaled)
132    var spVal =
133      (a.spend.travel  || 0) * (card.rates.travel  || 1) +
134      (a.spend.dining  || 0) * (card.rates.dining  || 1) +
135      (a.spend.grocery || 0) * (card.rates.grocery || 1) +
136      (a.spend.gas     || 0) * (card.rates.gas     || 1) +
137      (a.spend.other   || 0) * (card.rates.other   || 1) +
138      (a.spend.online  || 0) * (card.rates.online  || 1);
139    var rel = getRelationshipMultiplier(card, a);
140    s += Math.min(Math.round(spVal * rel.multiplier * 0.05), 40);
141
142    // GOAL ALIGNMENT
143    var goalCats = {
144      travel:   ['travel'],
145      cashback: ['cashback'],
146      airline:  ['airline'],
147      hotel:    ['hotel'],
148      credit:   ['secured', 'student'],
149      business: ['business']
150    };
151    (goalCats[a.goal] || []).forEach(function (cat) { if ((card.categories || []).includes(cat)) s += 22; });
152    if (a.goal === 'travel' && (card.categories || []).includes('airline')) s += 7;
153    if (a.goal === 'travel' && (card.categories || []).includes('hotel'))   s += 5;
154    if (a.goal === 'cashback' && (card.categories || []).includes('nofee')) s += 5;
155
156    // TRAVEL FREQUENCY
157    var isTravel = ['travel', 'airline', 'hotel'].some(function (c) { return (card.categories || []).includes(c); });
158    if (isTravel) {
159      s += ({ frequent: 18, monthly: 12, occasional: 6, rarely: -8 })[a.travelFreq] || 0;
160    }
161    if (a.travelFreq === 'rarely') {
162      if ((card.categories || []).includes('cashback')) s += 10;
163      if ((card.categories || []).includes('nofee'))    s += 5;
164    }
165
166    // AIRLINE PREFERENCE
167    var airlineIds = {
168      united:        ['united-explorer'],
169      delta:         ['delta-gold-amex', 'delta-platinum-amex', 'delta-reserve-amex'],
170      american:      ['citi-aadvantage-platinum', 'aadvantage-mileup'],
171      southwest:     ['southwest-rapid-rewards-plus', 'southwest-rapid-rewards-priority'],
172      international: ['chase-sapphire-preferred', 'chase-sapphire-reserve', 'amex-gold', 'amex-platinum',
173                      'capital-one-venture-x', 'capital-one-venture', 'citi-premier']
174    };
175    (a.airlines || []).forEach(function (al) {
176      if (al === 'none') {
177        if ((card.categories || []).includes('travel') && !(card.categories || []).includes('airline')) s += 8;
178      } else {
179        var matched = airlineIds[al] || [];
180        if (matched.includes(card.id)) s += 20;
181        if ((card.categories || []).includes('airline') && !matched.includes(card.id)) s -= 12;
182      }
183    });
184
185    // HOTEL PREFERENCE
186    var hotelIds = {
187      marriott:     ['marriott-bonvoy-boundless', 'marriott-bonvoy-brilliant'],
188      hilton:       ['hilton-honors-surpass', 'hilton-honors-amex', 'hilton-honors-aspire'],
189      hyatt:        ['world-of-hyatt'],
190      ihg:          ['ihg-one-rewards-premier'],
191      flexible:     ['chase-sapphire-preferred', 'chase-sapphire-reserve', 'amex-gold',
192                     'capital-one-venture-x', 'capital-one-venture', 'citi-premier', 'ink-business-preferred'],
193      notimportant: []
194    };
195    if (a.hotel) {
196      var matchedHotel = hotelIds[a.hotel] || [];
197      if (matchedHotel.includes(card.id)) s += 20;
198      if (a.hotel !== 'flexible' && a.hotel !== 'notimportant' &&
199          (card.categories || []).includes('hotel') && !matchedHotel.includes(card.id)) s -= 10;
200      if (a.hotel === 'notimportant' &&
201          (card.categories || []).includes('hotel') && !(card.categories || []).includes('travel')) s 
201-= 8;
202    }
203
204    // INTERNATIONAL TRAVEL
205    var noFTF = card.foreignFee === 'None';
206    if (a.intl === 'often') s += noFTF ? 14 : -14;
207    else if (a.intl === 'sometimes') s += noFTF ? 7 : -4;
208
209    // ANNUAL FEE
210    var feeMax = { nofee: 0, low: 100, mid: 350, high: 1000 };
211    var maxFee = feeMax[a.fee] != null ? feeMax[a.fee] : 1000;
212    if (card.annualFee === 0 && a.fee === 'nofee') s += 15;
213    else if (card.annualFee <= maxFee && a.fee !== 'nofee') {
214      if (a.fee === 'high' && card.annualFee >= 300) s += 8;
215      else if (a.fee === 'mid' && card.annualFee > 0 && card.annualFee <= 350) s += 6;
216      else if (a.fee === 'low' && card.annualFee > 0 && card.annualFee <= 100) s += 5;
217    } else if (card.annualFee > maxFee) s -= 35;
218
219    // CREDIT SCORE GATE
220    var scoreOrd = ['poor', 'fair', 'good', 'verygood', 'excellent'];
221    var minIdx  = scoreOrd.indexOf(card.minScore || 'good');
222    var userIdx = a.score === 'unknown' ? 2 : scoreOrd.indexOf(a.score);
223    if (userIdx < minIdx) s -= 60;
224    else if (userIdx >= minIdx + 2) s += 5;
225
226    // BUSINESS FILTER
227    var isBiz = (card.categories || []).includes('business');
228    if (isBiz && a.goal !== 'business') s -= 35;
229    if (!isBiz && a.goal === 'business') s -= 3;
230
231    // SECURED / STUDENT FILTER
232    var isSecured = (card.categories || []).includes('secured');
233    var isStudent = (card.categories || []).includes('student');
234    if (isSecured && a.goal !== 'credit') s -= 25;
235    if (isStudent && a.goal !== 'credit' && !['poor', 'fair'].includes(a.score)) s -= 15;
236    if (['poor', 'fair'].includes(a.score) && isSecured) s += 30;
237    if (['poor', 'fair'].includes(a.score) && isStudent) s += 15;
238
239    return Math.max(s, 0);
240  }
241
242  function getTopSpend(a) {
243    var cats = ['travel', 'dining', 'grocery', 'gas', 'online', 'other'];
244    return cats.reduce(function (top, c) { return (a.spend[c] || 0) > (a.spend[top] || 0) ? c : top; }, 'other');
245  }
246
247  function buildReason(card, a) {
248    var parts = [];
249    var rel = getRelationshipMultiplier(card, a);
250    if (rel.tier) {
251      parts.push(rel.program.label + ' ' + rel.tier.name + ' status boosts your rewards by ' +
252        Math.round(rel.tier.bonus * 100) + '% on this card');
253    }
254    var catLabels = {
255      travel: 'travel', dining: 'dining', grocery: 'groceries',
256      gas: 'gas', online: 'online shopping', other: 'everyday purchases'
257    };
258    var top = getTopSpend(a);
259    if ((a.spend[top] || 0) > 0 && (card.rates[top] || 1) >= 3 && parts.length < 2) {
260      parts.push(card.rates[top] + '× on ' + catLabels[top] + ' matches your highest spend category');
261    }
262    var goalMsgs = {
263      travel:   (card.categories || []).includes('travel')   ? 'Matches your goal of earning flexible travel rewards' : null,
264      cashback: (card.categories || []).includes('cashback') ? 'Maximizes cash back on your spending profile' : null,
265      airline:  (card.categories || []).includes('airline')  ? 'Earns airline miles as your primary goal' : null,
266      hotel:    (card.categories || []).includes('hotel')    ? 'Earns hotel points and elite status benefits' : null,
267      credit:   'Designed to build or establish credit history',
268      business: (card.categories || []).includes('business') ? 'Built for business spending and expense management' : null
269    };
270    if (goalMsgs[a.goal] && parts.length < 2) parts.push(goalMsgs[a.goal]);
271    var airlineNameMap = { delta: 'Delta SkyMiles', united: 'United MileagePlus', american: 'American AAdvantage', southwest: 'Southwest Rapid Rewards' };
272    var airlineCardMap2 = {
273      delta:     ['delta-gold-amex', 'delta-platinum-amex', 'delta-reserve-amex'],
274      united:    ['united-explorer'],
275      american:  ['citi-aadvantage-platinum', 'aadvantage-mileup'],
276      southwest: ['southwest-rapid-rewards-plus', 'southwest-rapid-rewards-priority']
277    };
278    for (var i = 0; i < (a.airlines || []).length; i++) {
279      var al = a.airlines[i];
280      if (airlineCardMap2[al] && airlineCardMap2[al].includes(card.id) && parts.length < 2) {
281        parts.push('Earns ' + airlineNameMap[al] + ' miles directly at your preferred airline');
282        break;
283      }
284    }
285    var hotelMsgMap = {
286      marriott: { ids: ['marriott-bonvoy-boundless', 'marriott-bonvoy-brilliant'], msg: 'Earns Marriott Bonvoy points at your preferred hotel chain' },
287      hilton:   { ids: ['hilton-honors-surpass', 'hilton-honors-amex', 'hilton-honors-aspire'], msg: 'Earns Hilton Honors points and status at your preferred chain' },
288      hyatt:    { ids: ['world-of-hyatt'], msg: 'Best Hyatt card — exceptional point value at World of Hyatt properties' },
289      ihg:      { ids: ['ihg-one-rewards-premier'], msg: 'Earns IHG One Rewards points at your preferred chain' }
290    };
291    Object.keys(hotelMsgMap).forEach(function (chain) {
292      var info = hotelMsgMap[chain];
293      if (a.hotel === chain && info.ids.includes(card.id) && parts.length < 2) parts.push(info.msg);
294    });
295    if (a.intl === 'often' && card.foreignFee === 'None' && parts.length < 2)
296      parts.push('No foreign transaction fees for your frequent international travel');
297    if (a.fee === 'nofee' && card.annualFee === 0 && parts.length < 2)
298      parts.push('$0 annual fee matches your preference');
299    var total = Object.values(a.spend).reduce(function (s, v) { return s + v; }, 0);
300    if (total > 500 && card.annualFee > 0 && parts.length < 2) {
301      var estAnnual = Math.round(total * 12 * 0.02);
302      parts.push('Your $' + total.toLocaleString() + '/mo spend can generate ~$' + estAnnual.toLocaleString() + '/yr in rewards');
303    }
304    if (!parts.length) parts.push('Strong overall match for your spending and travel profile');
305    return parts.slice(0, 2).join(' · ');
306  }
307
308  // Ranks the full card pool (or a supplied pool) against a profile.
309  // opts: { limit, excludeIds: [], minScore: 0, pool }
310  function rankCards(profile, opts) {
311    opts = opts || {};
312    var pool = opts.pool || getCardPool();
313    var exclude = opts.excludeIds || [];
314    var minScore = opts.minScore != null ? opts.minScore : 0;
315    var scored = pool
316      .filter(function (c) { return !exclude.includes(c.id); })
317      .map(function (c) { return Object.assign({}, c, { quizScore: scoreCard(c, profile) }); })
318      .filter(function (c) { return c.quizScore > minScore; })
319      .sort(function (a, b) { return b.quizScore - a.quizScore; });
320    return opts.limit ? scored.slice(0, opts.limit) : scored;
321  }
322
323  // One-time spend-allocation weights used only to spread a lump-sum monthly
324  // spend figure (from the 4-field quick forms) across categories — matches
325  // the SPEND_ALLOC table used historically on index.html/account.html and
326  // in functions/api/auth/migrate.js's legacy-account migration.
327  var SPEND_ALLOC = {
328    travel:   { travel: .40, dining: .25, grocery: .15, gas: .08, other: .12 },
329    cashback: { travel: .10, dining: .20, grocery: .25, gas: .15, other: .30 },
330    category: { travel: .08, dining: .15, grocery: .30, gas: .22, other: .25 },
331    bonus:    { travel: .20, dining: .25, grocery: .20, gas: .10, other: .25 },
332    credit:   { travel: .10, dining: .20, grocery: .25, gas: .15, other: .30 }
333  };
334
335  function feeDollarsToTolerance(maxFee) {
336    var n = parseFloat(maxFee);
337    if (!n || n <= 0) return 'nofee';
338    if (n <= 100) return 'low';
339    if (n <= 250) return 'mid';
340    return 'high';
341  }
342
343  // D1 `preferences` row -> engine profile shape. Lossless once Milestone 5's
344  // expanded Preferences UI is live; degrades gracefully for legacy rows.
345  function normalizeProfile(prefsRow) {
346    if (!prefsRow) return null;
347    var airlines = [];
348    try { airlines = JSON.parse(prefsRow.airlines || '[]'); } catch (e) {}
349    var bankRelationships = [];
350    try { bankRelationships = JSON.parse(prefsRow.bank_relationships || '[]'); } catch (e) {}
351    return {
352      goal: prefsRow.goal || null,
353      travelFreq: prefsRow.travel_freq || null,
354      airlines: airlines,
355      hotel: prefsRow.hotel || null,
356      intl: prefsRow.intl_travel || null,
357      fee: prefsRow.fee_tolerance || null,
358      score: prefsRow.credit_score || null,
359      bankRelationships: bankRelationships,
360      spend: {
361        travel: prefsRow.spend_travel || 0,
362        dining: prefsRow.spend_dining || 0,
363        grocery: prefsRow.spend_grocery || 0,
364        gas: prefsRow.spend_gas || 0,
365        online: prefsRow.spend_online || 0,
366        other: prefsRow.spend_other || 0
367      }
368    };
369  }
370
371  // Adapts the minimal 4-field quick-quiz shape (index.html banner,
372  // get-started.html signup) into a full profile, filling missing richer
373  // fields (travel frequency, airline/hotel preference, international
374  // travel) with neutral defaults.
375  function profileFromQuickForm(goal, spend, creditTier, maxFeeDollars) {
376    var alloc = SPEND_ALLOC[goal] || SPEND_ALLOC.cashback;
377    var lump = parseFloat(spend) || 0;
378    var creditMap = { excellent: 'excellent', good: 'good', fair: 'fair', building: 'poor' };
379    return {
380      goal: goal || 'cashback',
381      travelFreq: 'occasional',
382      airlines: [],
383      hotel: 'flexible',
384      intl: 'rarely',
385      fee: feeDollarsToTolerance(maxFeeDollars),
386      score: creditMap[creditTier] || 'good',
387      spend: {
388        travel: lump * alloc.travel, dining: lump * alloc.dining, grocery: lump * alloc.grocery,
389        gas: lump * alloc.gas, online: 0, other: lump * alloc.other
390      }
391    };
392  }
393
394  // Estimated dollar value of a single transaction on a given card, used by
395  // the expense-tracking feature (Milestone 6) for both per-expense verdicts
396  // and the "apply for a better card" aggregate rollup — one formula, reused
397  // everywhere, so results stay internally consistent.
398  //
399  // card.rates[category] is a per-dollar multiplier (points/miles cards) or
400  // a literal cashback percentage (flat-rate cards) — cards-data.js uses the
401  // same numeric convention for both (e.g. Citi Double Cash rates.other=2
402  // means 2% cash back; Chase Sapphire Preferred rates.travel=5 means 5
403  // points per $1). Valuing each point/mile at ~1¢ (POINT_VALUE_CENTS) makes
404  // amount * rate * (POINT_VALUE_CENTS/100) correct for flat-cashback cards
405  // and a defensible conservative estimate for points cards.
406  var POINT_VALUE_CENTS = 1.0;
407  // Hotel points are worth well under a cent, so at 1 cent a Hilton card (14x)
408  // outranked everything on every page. These are the site's own published
409  // Free Night values from rewards-calculator.html; scripts/check-spend-rank.js
410  // fails if the two drift apart. Programs the site values at 1 cent or more
411  // (airlines, Hyatt, flexible points) stay at the conservative 1 cent above.
412  var HOTEL_POINT_CENTS = [
413    [/Hilton/i, 0.5],
414    [/IHG/i, 0.55],
415    [/Marriott|Bonvoy/i, 0.7],
416    [/Wyndham/i, 0.9]
417  ];
418  function pointValueCents(card) {
419    for (var i = 0; i < HOTEL_POINT_CENTS.length; i++) {
420      if (HOTEL_POINT_CENTS[i][0].test(card.name || '')) return HOTEL_POINT_CENTS[i][1];
421    }
422    return POINT_VALUE_CENTS;
423  }
424  function estimateEarn(card, category, amount, profile) {
425    var rate = (card.rates && card.rates[category]) || 0;
426    var rel = profile ? getRelationshipMultiplier(card, profile) : { multiplier: 1 };
427    return amount * rate * rel.multiplier * (pointValueCents(card) / 100);
428  }
429
430  // Homepage spend calculator. Ranks cards on entered monthly spend alone:
431  // year one = rewards + welcome offer - annual fee; after = rewards - fee.
432  // The welcome offer is counted at the conservative CARD_FRESHNESS floor, and
433  // only when this spending would meet the offer's own requirement.
434  // Hotel and airline co-brands stay out even with per-program point values: their
435  // points only pay off if you stay with that chain or fly that airline, which this
436  // calculator cannot know and the quiz asks outright. With them in, the Wyndham
437  // card wins half of all "every year after" states.
438  var SPEND_POOL_EXCLUDE = ['business', 'secured', 'student', 'hotel', 'airline'];
439
440  // "75,000 miles after $4,000 spend in 3 months" -> { amount: 4000, months: 3 }
441  function parseBonusRequirement(card) {
442    var m = /\$([\d,]+)[^$]*?\bin\s+(?:the\s+)?(?:first\s+)?(\d+)\s+(month|day)/i.exec(card.bonusDesc || '');
443    if (!m) return null;
444    var n = parseInt(m[2], 10);
445    return { amount: parseInt(m[1].replace(/,/g, ''), 10), months: m[3].toLowerCase() === 'day' ? Math.round(n / 30) : n };
446  }
447
448  // "Pick your category" cards (Venmo: 3% on your top category, 2% on the second,
449  // 1% on the rest) cannot be a fixed rates table: card.rates holds the bonus rate
450  // on every eligible category at once, which overstates anyone who spends in more
451  // than one. card.rateTiers = { among, tiers, base } hands tiers[0] to the
452  // visitor's biggest category in `among`, tiers[1] to the next, and `base` to the
453  // rest of `among`. Categories outside `among` keep card.rates.
454  function effectiveRates(card, spend) {
455    var out = {};
456    Object.keys(spend).forEach(function (k) { out[k] = (card.rates && card.rates[k]) || 0; });
457    var t = card.rateTiers;
458    if (!t) return out;
459    t.among.filter(function (k) { return k in spend; })
460      .sort(function (a, b) { return (spend[b] || 0) - (spend[a] || 0) || t.among.indexOf(a) - t.among.indexOf(b); })
461      .forEach(function (k, i) { out[k] = i < t.tiers.length ? t.tiers[i] : t.base; });
462    return out;
463  }
464
465  function rankBySpend(spend, opts) {
466    opts = opts || {};
467    var fresh = window.CARD_FRESHNESS || {};
468    var cats = Object.keys(spend);
469    var monthly = cats.reduce(function (sum, k) { return sum + (spend[k] || 0); }, 0);
470    return getCardPool().filter(function (c) {
471      return !(c.categories || []).some(function (k) { return SPEND_POOL_EXCLUDE.indexOf(k) !== -1; });
472    }).map(function (c) {
473      var perCat = {};
474      var rewards = 0;
475      var rates = effectiveRates(c, spend);
476      cats.forEach(function (k) {
477        perCat[k] = Math.round((spend[k] || 0) * rates[k] * (pointValueCents(c) / 100) * 12);
478        rewards += perCat[k];
479      });
480      var f = fresh[c.id] || {};
481      var offer = f.bonusFloor != null ? f.bonusFloor : Math.round((c.bonusValue || 0) / 2);
482      var req = parseBonusRequirement(c);
483      var met = !req || monthly * req.months >= req.amount;
484      var welcome = met ? offer : 0;
485      var fee = c.annualFee || 0;
486      return {
487        card: c, rates: rates, perCat: perCat, rewards: rewards, welcome: welcome, offer: offer, met: met, req: req, fee: fee,
488        yearOne: rewards + welcome - fee, after: rewards - fee, checked: f.bonusUpdated || null
489      };
490    }).sort(function (a, b) {
491      var k = opts.mode === 'after' ? 'after' : 'yearOne';
492      return b[k] - a[k] || a.fee - b.fee || (a.card.id < b.card.id ? -1 : 1);
493    });
494  }
495
496  var CATEGORIES = ['travel', 'dining', 'grocery', 'gas', 'online', 'other'];
497
498  // For each category, which of the user's owned cards earns the most —
499  // used both by the Expenses tab's per-transaction verdicts and by
500  // rewards-optimizer.html's signed-in "your best card" column (Milestone 7),
501  // neither of which needs expense history, just the wallet.
502  function bestOwnedPerCategory(walletCards) {
503    var result = {};
504    CATEGORIES.forEach(function (cat) {
505      var best = null;
506      walletCards.forEach(function (c) {
507        if (!best || (c.rates[cat] || 0) > (best.rates[cat] || 0)) best = c;
508      });
509      result[cat] = best;
510    });
511    return result;
512  }
513
514  // Full expense analysis: a verdict per logged expense (did the card used
515  // earn the most among owned cards for that category?) plus a category-
516  // level rollup comparing the best owned card against the best card in the
517  // ENTIRE pool, surfacing an "apply for X" recommendation when the gap is
518  // large enough to matter. Pure computation over already-fetched data — no
519  // dedicated /api/analyze endpoint needed.
520  //
521  // Thresholds are named constants, not magic numbers, so they're easy to
522  // retune: ignore verdict deltas under 50 cents (avoids flagging trivial
523  // rounding-level differences on small purchases), and only recommend an
524  // unowned card when the projected annual delta clears $50/year.
525  var VERDICT_DELTA_THRESHOLD = 0.50;
526  var ANNUAL_DELTA_THRESHOLD = 50;
527
528  function analyzeExpenses(expenses, walletCards, allCards, profile) {
529    var bestOwned = bestOwnedPerCategory(walletCards);
530
531    var perExpenseVerdicts = expenses.map(function (exp) {
532      var usedCard = walletCards.find(function (c) { return c.id === exp.cardId; });
533      if (!usedCard) return Object.assign({}, exp, { optimal: null, usedEarn: 0, bestEarn: 0, bestOwned: null, delta: 0 });
534      var best = bestOwned[exp.category] || usedCard;
535      var usedEarn = estimateEarn(usedCard, exp.category, exp.amount, profile);
536      var bestEarn = estimateEarn(best, exp.category, exp.amount, profile);
537      var delta = bestEarn - usedEarn;
538      return Object.assign({}, exp, {
539        optimal: best.id === usedCard.id || delta <= VERDICT_DELTA_THRESHOLD,
540        usedEarn: usedEarn, bestEarn: bestEarn, bestOwned: best, delta: delta
541      });
542    });
543
544    // Category-level rollup: trailing spend by category -> best owned vs.
545    // best in the entire pool -> recommend applying only if the annualized
546    // gap clears the threshold.
547    var categoryRollups = {};
548    CATEGORIES.forEach(function (cat) {
549      var spent = expenses.filter(function (e) { return e.category === cat; })
550        .reduce(function (sum, e) { return sum + e.amount; }, 0);
551      if (spent <= 0) return;
552
553      var bestOverall = null;
554      allCards.forEach(function (c) {
555        if (!bestOverall || (c.rates[cat] || 0) > (bestOverall.rates[cat] || 0)) bestOverall = c;
556      });
557      var ownedBest = bestOwned[cat];
558      if (!bestOverall) return;
559
560      // Annualize the trailing sample (expenses are assumed to cover ~90
561      // days of history — see Expenses tab caller) for a yearly-delta estimate.
562      var annualizedSpend = spent * (365 / 90);
563      var ownedAnnualEarn = ownedBest ? estimateEarn(ownedBest, cat, annualizedSpend, profile) : 0;
564      var overallAnnualEarn = estimateEarn(bestOverall, cat, annualizedSpend, profile);
565      var annualDelta = overallAnnualEarn - ownedAnnualEarn;
566      var ownedHasIt = ownedBest && ownedBest.id === bestOverall.id;
567
568      categoryRollups[cat] = {
569        spent: spent, bestOwned: ownedBest, bestOverall: bestOverall,
570        annualDelta: annualDelta,
571        recommendApply: !ownedHasIt && annualDelta >= ANNUAL_DELTA_THRESHOLD
572      };
573    });
574
575    return { perExpenseVerdicts: perExpenseVerdicts, categoryRollups: categoryRollups };
576  }
577
578  window.CCEngine = {
579    getCardPool: getCardPool,
580    scoreCard: scoreCard,
581    buildReason: buildReason,
582    getTopSpend: getTopSpend,
583    rankCards: rankCards,
584    normalizeProfile: normalizeProfile,
585    profileFromQuickForm: profileFromQuickForm,
586    estimateEarn: estimateEarn,
587    rankBySpend: rankBySpend,
588    pointValueCents: pointValueCents,
589    parseBonusRequirement: parseBonusRequirement,
590    bestOwnedPerCategory: bestOwnedPerCategory,
591    analyzeExpenses: analyzeExpenses,
592    getRelationshipTier: getRelationshipTier,
593    getRelationshipMultiplier: getRelationshipMultiplier,
594    _fallbackPool: CARDS_FALLBACK
595  };
596})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.