earnrate 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -18
- package/data/CHANGELOG.md +56 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +14 -6
- package/data/cards/amex-blue-cash-everyday.yaml +12 -3
- package/data/cards/amex-blue-cash-preferred.yaml +17 -3
- package/data/cards/amex-gold.yaml +19 -3
- package/data/cards/amex-platinum.yaml +19 -3
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +10 -2
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +14 -3
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +4 -1
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -5
- package/data/cards/bilt-obsidian.yaml +4 -3
- package/data/cards/capital-one-kohls-card.yaml +11 -2
- package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
- package/data/cards/capital-one-savor.yaml +32 -10
- package/data/cards/capital-one-venture-x.yaml +19 -4
- package/data/cards/capital-one-venture.yaml +19 -5
- package/data/cards/chase-aeroplan.yaml +15 -3
- package/data/cards/chase-freedom-flex.yaml +49 -10
- package/data/cards/chase-freedom-unlimited.yaml +6 -2
- package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
- package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -2
- package/data/cards/chase-ihg-one-rewards.yaml +8 -2
- package/data/cards/chase-ink-business-cash.yaml +7 -3
- package/data/cards/chase-ink-business-preferred.yaml +9 -2
- package/data/cards/chase-prime-visa.yaml +24 -5
- package/data/cards/chase-sapphire-preferred.yaml +59 -6
- package/data/cards/chase-sapphire-reserve.yaml +12 -2
- package/data/cards/chase-southwest-performance-business.yaml +22 -4
- package/data/cards/chase-southwest-plus.yaml +13 -3
- package/data/cards/chase-southwest-premier-business.yaml +22 -5
- package/data/cards/chase-southwest-premier.yaml +13 -3
- package/data/cards/chase-southwest-priority.yaml +18 -2
- package/data/cards/chase-united-business.yaml +5 -2
- package/data/cards/chase-united-club-business.yaml +2 -2
- package/data/cards/chase-united-club.yaml +16 -3
- package/data/cards/chase-united-explorer.yaml +5 -2
- package/data/cards/chase-united-gateway.yaml +2 -2
- package/data/cards/chase-united-quest.yaml +12 -3
- package/data/cards/citi-aadvantage-business.yaml +10 -4
- package/data/cards/citi-aadvantage-executive.yaml +8 -8
- package/data/cards/citi-aadvantage-mileup.yaml +7 -4
- package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
- package/data/cards/citi-costco-anywhere-visa-business.yaml +5 -2
- package/data/cards/citi-costco-anywhere-visa.yaml +6 -3
- package/data/cards/citi-custom-cash.yaml +43 -12
- package/data/cards/citi-double-cash.yaml +9 -3
- package/data/cards/citi-my-best-buy-visa.yaml +6 -3
- package/data/cards/citi-strata-elite.yaml +12 -2
- package/data/cards/citi-strata-premier.yaml +24 -2
- package/data/cards/sofi-unlimited-2.yaml +17 -3
- package/data/cards/td-bank-nordstrom-credit-card.yaml +12 -3
- package/data/cards/td-bank-nordstrom-visa.yaml +19 -3
- package/data/cards/td-bank-target-circle-card.yaml +13 -4
- package/data/cards/usbank-cash-plus.yaml +29 -7
- package/data/cards/wells-fargo-active-cash.yaml +9 -2
- package/data/cards/wells-fargo-autograph.yaml +31 -7
- package/data/issuers.yaml +32 -0
- package/data/merchants/seed.yaml +7 -0
- package/dist/brand.js +1 -0
- package/dist/cli/bin.js +10 -1
- package/dist/cli/cheatsheet.js +10 -5
- package/dist/cli/explore.js +6 -13
- package/dist/cli/import.js +2 -2
- package/dist/cli/missed.js +65 -0
- package/dist/cli/offers.js +17 -7
- package/dist/cli/run.js +29 -17
- package/dist/cli/todo.js +62 -0
- package/dist/cli/wallet.js +9 -6
- package/dist/data/file-store.js +6 -7
- package/dist/data/integrity.js +4 -0
- package/dist/data/load.js +4 -1
- package/dist/data/store.js +6 -22
- package/dist/data/wallet-edit.js +38 -13
- package/dist/engine/cheat-sheet.js +14 -6
- package/dist/engine/checklist.js +152 -0
- package/dist/engine/compare.js +2 -0
- package/dist/engine/evaluate.js +16 -11
- package/dist/engine/explore.js +6 -38
- package/dist/engine/memberships.js +1 -1
- package/dist/engine/offers.js +2 -1
- package/dist/engine/periods.js +18 -0
- package/dist/engine/recommend.js +21 -20
- package/dist/engine/rule-text.js +47 -0
- package/dist/engine/setup.js +14 -24
- package/dist/engine/simulate.js +7 -6
- package/dist/engine/text.js +13 -1
- package/dist/engine/wallet-change.js +10 -12
- package/dist/engine/wallet-summary.js +80 -65
- package/dist/format/card-text.js +7 -20
- package/dist/format/explore.js +2 -2
- package/dist/format/offers.js +2 -2
- package/dist/format/recommendation.js +2 -2
- package/dist/format/wallet.js +7 -1
- package/dist/import/analyze.js +2 -1
- package/dist/mcp/context.js +38 -13
- package/dist/mcp/inputs.js +1 -1
- package/dist/mcp/schemas.js +9 -0
- package/dist/mcp/serve.js +4 -0
- package/dist/mcp/server.js +8 -2
- package/dist/mcp/tools/account.js +44 -16
- package/dist/mcp/tools/answers.js +58 -19
- package/dist/mcp/tools/wallet.js +64 -25
- package/dist/merchants/cache.js +0 -10
- package/dist/merchants/missed.js +78 -0
- package/dist/merchants/resolve.js +81 -4
- package/dist/schema/card.js +4 -1
- package/dist/schema/issuer.js +8 -0
- package/dist/schema/wallet.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { daysBetween, periodEnd, periodKey } from "./periods.js";
|
|
2
|
+
import { CAP_PERIODS, choiceName, listed, money, shortDate, wholeDollars, } from "./text.js";
|
|
3
|
+
import { optionName } from "./rule-text.js";
|
|
4
|
+
import { activationsDue, walletSummary, } from "./wallet-summary.js";
|
|
5
|
+
// What the wallet's cards need this period, soonest first: bonuses to
|
|
6
|
+
// activate, categories to pick, caps running low, welcome bonuses to finish,
|
|
7
|
+
// and rotating categories the issuer hasn't posted yet.
|
|
8
|
+
export function checklist(catalog, wallet, today, summary = walletSummary(catalog, wallet, today),
|
|
9
|
+
// A shorter card name than the issuer's full one, where the caller has it.
|
|
10
|
+
shortName) {
|
|
11
|
+
const items = [];
|
|
12
|
+
for (const held of wallet.cards) {
|
|
13
|
+
const card = catalog.cards.find((c) => c.id === held.id);
|
|
14
|
+
const s = summary.cards.find((c) => c.cardId === held.id);
|
|
15
|
+
if (!card || !s)
|
|
16
|
+
continue;
|
|
17
|
+
const name = s.checkoutLabel ?? shortName?.(card.id) ?? s.cardName;
|
|
18
|
+
for (const a of activationsDue(catalog, card, held, today)) {
|
|
19
|
+
const starts = a.from !== null && a.from > today
|
|
20
|
+
? `, starting ${shortDate(a.from)}`
|
|
21
|
+
: '';
|
|
22
|
+
items.push({
|
|
23
|
+
kind: 'activate',
|
|
24
|
+
cardId: card.id,
|
|
25
|
+
text: `Activate ${name}'s bonus on ${a.what}${starts}${a.activateBy === null ? '' : `, by ${shortDate(a.activateBy)}`}.`,
|
|
26
|
+
due: a.activateBy ?? (a.from !== null && a.from > today ? a.from : a.to),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const picked = (id) => s.chosen?.picks[id] !== undefined;
|
|
30
|
+
const current = s.chosen?.current === true;
|
|
31
|
+
const unpicked = card.choices.filter((c) => !c.default &&
|
|
32
|
+
!(current && picked(c.id)) &&
|
|
33
|
+
c.period !== 'until_changed');
|
|
34
|
+
const [first] = unpicked;
|
|
35
|
+
if (first) {
|
|
36
|
+
const period = first.period === 'until_changed'
|
|
37
|
+
? null
|
|
38
|
+
: periodKey(first.period, today, null);
|
|
39
|
+
items.push({
|
|
40
|
+
kind: 'pick',
|
|
41
|
+
cardId: card.id,
|
|
42
|
+
text: `Pick ${name}'s ${listed(unpicked.map((c) => choiceName(c.id)))} categories for this ${first.period.replace('_', ' ')}; until then they earn the base rate.`,
|
|
43
|
+
due: period === null ? null : periodEnd(period),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// Picks made for this period: near its end, a reminder to make the
|
|
48
|
+
// next period's (Cash+ asks each quarter).
|
|
49
|
+
const ending = card.choices.find((c) => c.period !== 'until_changed' && !c.default && current);
|
|
50
|
+
const period = ending === undefined || ending.period === 'until_changed'
|
|
51
|
+
? null
|
|
52
|
+
: periodKey(ending.period, today, null);
|
|
53
|
+
const end = period === null ? null : periodEnd(period);
|
|
54
|
+
if (ending && end !== null && daysBetween(today, end) <= 14)
|
|
55
|
+
items.push({
|
|
56
|
+
kind: 'pick',
|
|
57
|
+
cardId: card.id,
|
|
58
|
+
text: `Pick ${name}'s categories for next ${ending.period.replace('_', ' ')}; this one ends ${shortDate(end)}.`,
|
|
59
|
+
due: end,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// A pick earnrate assumes because none was saved: worth confirming.
|
|
63
|
+
const assumed = card.choices.filter((c) => c.default && !picked(c.id));
|
|
64
|
+
if (assumed.length > 0)
|
|
65
|
+
items.push({
|
|
66
|
+
kind: 'pick',
|
|
67
|
+
cardId: card.id,
|
|
68
|
+
text: `${name}'s ${listed(assumed.map((c) => choiceName(c.id)))} pick is assumed to be ${listed(assumed.flatMap((c) => (c.default ?? []).map((o) => optionName(catalog, card, c.id, o))))}; set it if you picked something else.`,
|
|
69
|
+
due: null,
|
|
70
|
+
});
|
|
71
|
+
for (const cap of s.caps) {
|
|
72
|
+
// Only caps nearly used up: the rest aren't something to do.
|
|
73
|
+
if (cap.remaining === null || cap.remaining > cap.amount * 0.25)
|
|
74
|
+
continue;
|
|
75
|
+
const resets = cap.currentPeriod === null ? null : periodEnd(cap.currentPeriod);
|
|
76
|
+
items.push({
|
|
77
|
+
kind: 'cap',
|
|
78
|
+
cardId: card.id,
|
|
79
|
+
text: cap.remaining === 0
|
|
80
|
+
? `${name}'s ${cap.label} cap is used up${resets === null ? '' : ` until ${shortDate(resets)}`}; another card may earn more there.`
|
|
81
|
+
: `${name}: ${money(cap.remaining)} left of ${money(cap.amount)} ${CAP_PERIODS[cap.period]} at the bonus rate on ${cap.label}.`,
|
|
82
|
+
due: resets,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const b = s.bonus;
|
|
86
|
+
if (b?.state === 'open' && b.remaining !== null && b.deadline !== null) {
|
|
87
|
+
const weeks = Math.max(1, (b.daysLeft ?? 7) / 7);
|
|
88
|
+
items.push({
|
|
89
|
+
kind: 'bonus',
|
|
90
|
+
cardId: card.id,
|
|
91
|
+
text: `${name}'s welcome bonus: ${money(b.remaining)} to go by ${shortDate(b.deadline)}, about ${wholeDollars(b.remaining / weeks)} a week.`,
|
|
92
|
+
due: b.deadline,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (s.rotationGap !== null) {
|
|
96
|
+
items.push({
|
|
97
|
+
kind: 'rotation',
|
|
98
|
+
cardId: card.id,
|
|
99
|
+
text: `${name}'s ${s.rotationGap} categories aren't out yet; earnrate adds them once the issuer posts them.`,
|
|
100
|
+
due: null,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return items.sort((a, b) => (a.due ?? '9999').localeCompare(b.due ?? '9999') ||
|
|
105
|
+
a.cardId.localeCompare(b.cardId));
|
|
106
|
+
}
|
|
107
|
+
// An all-day calendar event for each dated action (activations, picks,
|
|
108
|
+
// welcome-bonus deadlines), with a reminder two days before. Built on the
|
|
109
|
+
// device: nothing is sent anywhere.
|
|
110
|
+
export function checklistCalendar(items, stamp) {
|
|
111
|
+
const escape = (t) => t.replace(/\\/g, '\\\\').replace(/[,;]/g, (c) => `\\${c}`);
|
|
112
|
+
const fold = (line) => {
|
|
113
|
+
const parts = [];
|
|
114
|
+
for (let i = 0; i < line.length; i += 73)
|
|
115
|
+
parts.push(line.slice(i, i + 73));
|
|
116
|
+
return parts.join('\r\n ');
|
|
117
|
+
};
|
|
118
|
+
const day = (d) => d.replace(/-/g, '');
|
|
119
|
+
const next = (d) => {
|
|
120
|
+
const t = new Date(`${d}T00:00:00Z`);
|
|
121
|
+
t.setUTCDate(t.getUTCDate() + 1);
|
|
122
|
+
return t.toISOString().slice(0, 10);
|
|
123
|
+
};
|
|
124
|
+
const events = items
|
|
125
|
+
.filter((i) => i.due !== null && i.kind !== 'cap' && i.kind !== 'rotation')
|
|
126
|
+
.flatMap((i) => {
|
|
127
|
+
const due = i.due ?? '';
|
|
128
|
+
return [
|
|
129
|
+
'BEGIN:VEVENT',
|
|
130
|
+
`UID:${i.kind}-${i.cardId}-${due}@earnrate.dev`,
|
|
131
|
+
`DTSTAMP:${stamp}`,
|
|
132
|
+
`DTSTART;VALUE=DATE:${day(due)}`,
|
|
133
|
+
`DTEND;VALUE=DATE:${day(next(due))}`,
|
|
134
|
+
fold(`SUMMARY:${escape(i.text)}`),
|
|
135
|
+
'BEGIN:VALARM',
|
|
136
|
+
'TRIGGER:-P2D',
|
|
137
|
+
'ACTION:DISPLAY',
|
|
138
|
+
fold(`DESCRIPTION:${escape(i.text)}`),
|
|
139
|
+
'END:VALARM',
|
|
140
|
+
'END:VEVENT',
|
|
141
|
+
];
|
|
142
|
+
});
|
|
143
|
+
return [
|
|
144
|
+
'BEGIN:VCALENDAR',
|
|
145
|
+
'VERSION:2.0',
|
|
146
|
+
'PRODID:-//earnrate//checklist//EN',
|
|
147
|
+
'CALSCALE:GREGORIAN',
|
|
148
|
+
...events,
|
|
149
|
+
'END:VCALENDAR',
|
|
150
|
+
'',
|
|
151
|
+
].join('\r\n');
|
|
152
|
+
}
|
package/dist/engine/compare.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Returns closer than this are equal: floating-point noise, not a difference.
|
|
2
2
|
export const EPSILON = 1e-9;
|
|
3
|
+
// Dollar amounts closer than half a cent print the same.
|
|
4
|
+
export const HALF_CENT = 0.005;
|
|
3
5
|
// A card close to the best one: within a percentage point, and at least
|
|
4
6
|
// three quarters of its rate. Answers and cheat sheets mention a welcome
|
|
5
7
|
// bonus card when it's this close.
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { periodKey } from "./periods.js";
|
|
1
|
+
import { inEffect, periodKey } from "./periods.js";
|
|
2
2
|
export function purchaseContext(catalog, wallet, merchant, category, purchase) {
|
|
3
3
|
return {
|
|
4
4
|
category,
|
|
@@ -110,9 +110,7 @@ function applicability(card, rule, ctx, held, picked) {
|
|
|
110
110
|
return 'no';
|
|
111
111
|
if (rule.geography === 'us_only' && ctx.country !== 'US')
|
|
112
112
|
return 'no';
|
|
113
|
-
if (rule
|
|
114
|
-
return 'no';
|
|
115
|
-
if (rule.valid_to !== undefined && ctx.date > rule.valid_to)
|
|
113
|
+
if (!inEffect(rule, ctx.date))
|
|
116
114
|
return 'no';
|
|
117
115
|
const membership = rule.requires?.membership;
|
|
118
116
|
if (membership !== undefined && !ctx.memberships.has(membership))
|
|
@@ -149,14 +147,14 @@ function capState(rule, card, held, date) {
|
|
|
149
147
|
const { period, spent } = capUsed(cap, key, held, date);
|
|
150
148
|
return { key, cap, period, remaining: Math.max(0, cap.amount - spent) };
|
|
151
149
|
}
|
|
152
|
-
function rateAfterCap(rate, cap, amount) {
|
|
150
|
+
function rateAfterCap(rate, cap, afterCap, amount) {
|
|
153
151
|
if (!cap)
|
|
154
152
|
return rate;
|
|
155
153
|
if (amount !== null && amount > 0) {
|
|
156
154
|
const inCap = Math.min(amount, cap.remaining);
|
|
157
|
-
return (inCap * rate + (amount - inCap) *
|
|
155
|
+
return (inCap * rate + (amount - inCap) * afterCap) / amount;
|
|
158
156
|
}
|
|
159
|
-
return cap.remaining > 0 ? rate :
|
|
157
|
+
return cap.remaining > 0 ? rate : afterCap;
|
|
160
158
|
}
|
|
161
159
|
// On equal rates an uncapped rule wins, so spend that can earn the same
|
|
162
160
|
// without a cap doesn't use one up. Otherwise the earlier rule stays.
|
|
@@ -172,24 +170,31 @@ export function evaluateCard(card, held, ctx) {
|
|
|
172
170
|
nominalRate: card.base_rate,
|
|
173
171
|
rate: card.base_rate,
|
|
174
172
|
cap: null,
|
|
173
|
+
afterCap: null,
|
|
175
174
|
};
|
|
176
175
|
let best = base;
|
|
177
176
|
let nominal = base;
|
|
178
177
|
const blocked = [];
|
|
178
|
+
const applying = [];
|
|
179
179
|
for (const rule of card.rules) {
|
|
180
180
|
const status = applicability(card, rule, ctx, held, picked);
|
|
181
181
|
if (status === 'no')
|
|
182
182
|
continue;
|
|
183
|
-
if (status === 'needs-activation')
|
|
183
|
+
if (status === 'needs-activation')
|
|
184
184
|
blocked.push(rule);
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
else
|
|
186
|
+
applying.push(rule);
|
|
187
|
+
}
|
|
188
|
+
const uncapped = Math.max(card.base_rate, ...applying.filter((r) => r.cap === undefined).map((r) => r.rate));
|
|
189
|
+
for (const rule of applying) {
|
|
187
190
|
const cap = capState(rule, card, held, ctx.date);
|
|
191
|
+
const afterCap = cap ? Math.max(cap.cap.then_rate, uncapped) : null;
|
|
188
192
|
const earning = {
|
|
189
193
|
rule,
|
|
190
194
|
nominalRate: rule.rate,
|
|
191
|
-
rate: rateAfterCap(rule.rate, cap, ctx.amount),
|
|
195
|
+
rate: rateAfterCap(rule.rate, cap, afterCap ?? rule.rate, ctx.amount),
|
|
192
196
|
cap,
|
|
197
|
+
afterCap,
|
|
193
198
|
};
|
|
194
199
|
if (beats(earning, earning.rate, best, best.rate))
|
|
195
200
|
best = earning;
|
package/dist/engine/explore.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
3
|
import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
|
|
4
4
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
5
|
import { recommend } from "./recommend.js";
|
|
6
6
|
import { rotationGap } from "./rotation.js";
|
|
7
7
|
import { membershipsUsed } from "./setup.js";
|
|
8
|
-
import { optionName } from "./
|
|
9
|
-
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, money, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
8
|
+
import { optionName } from "./rule-text.js";
|
|
9
|
+
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, listed, money, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
10
10
|
import { cardValuation } from "./valuation.js";
|
|
11
11
|
import { EPSILON } from "./compare.js";
|
|
12
12
|
export const ALL_PERSONAL_CARDS = {
|
|
@@ -49,38 +49,6 @@ export function idealHolding(card, date, picks = {}) {
|
|
|
49
49
|
: {}),
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
-
const plainWords = (s) => s
|
|
53
|
-
.toLowerCase()
|
|
54
|
-
.replace(/[^a-z0-9]+/g, ' ')
|
|
55
|
-
.trim();
|
|
56
|
-
// A category from everyday words: an alias ("groceries") first, then the
|
|
57
|
-
// name, then the id, so "grocery" means supermarkets rather than the parent.
|
|
58
|
-
export function findCategory(catalog, text) {
|
|
59
|
-
const wanted = plainWords(text);
|
|
60
|
-
if (wanted === '')
|
|
61
|
-
return null;
|
|
62
|
-
const by = (key) => catalog.categories.find((c) => key(c).map(plainWords).includes(wanted))?.id;
|
|
63
|
-
return (by((c) => c.aliases) ?? by((c) => [c.name]) ?? by((c) => [c.id]) ?? null);
|
|
64
|
-
}
|
|
65
|
-
export function categoryResolution(catalog, category, channel) {
|
|
66
|
-
const found = catalog.categories.find((c) => c.id === category);
|
|
67
|
-
if (!found)
|
|
68
|
-
throw new Error(`unknown category '${category}'`);
|
|
69
|
-
return {
|
|
70
|
-
input: found.name,
|
|
71
|
-
merchantId: null,
|
|
72
|
-
resolvedName: found.name,
|
|
73
|
-
category,
|
|
74
|
-
likelyMcc: null,
|
|
75
|
-
accepts: null,
|
|
76
|
-
ambiguity: null,
|
|
77
|
-
channel,
|
|
78
|
-
confidence: 'high',
|
|
79
|
-
source: 'category',
|
|
80
|
-
bookedVia: [],
|
|
81
|
-
inference: null,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
52
|
function walletOf(held, options) {
|
|
85
53
|
return {
|
|
86
54
|
valuations: options.valuations,
|
|
@@ -131,7 +99,7 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
|
|
|
131
99
|
? {
|
|
132
100
|
amount: cap.amount,
|
|
133
101
|
period: cap.period,
|
|
134
|
-
thenRate: cap.then_rate,
|
|
102
|
+
thenRate: best.afterCap ?? cap.then_rate,
|
|
135
103
|
currency: cardValuation(catalog, wallet, card).currency,
|
|
136
104
|
}
|
|
137
105
|
: null,
|
|
@@ -185,7 +153,7 @@ function compareExplored(a, b) {
|
|
|
185
153
|
export function exploreCards(catalog, query, options, resolved) {
|
|
186
154
|
const merchant = resolved ??
|
|
187
155
|
(query.merchant !== null
|
|
188
|
-
?
|
|
156
|
+
? resolvePlace(catalog, query.merchant, query.channel)
|
|
189
157
|
: categoryResolution(catalog, query.category ?? 'other', query.channel));
|
|
190
158
|
const purchase = {
|
|
191
159
|
merchant: merchant.input,
|
|
@@ -259,7 +227,7 @@ function exploreAssumptions(catalog, merchant, ranked, options) {
|
|
|
259
227
|
assumptions.push('Assumes each card is set up for this purchase: the right category pick, any activation done, and none of its cap used yet. A card you hold may differ.');
|
|
260
228
|
assumptions.push(options.memberships.length === 0
|
|
261
229
|
? 'Assumes no memberships (like Amazon Prime). Rates that need one are listed separately.'
|
|
262
|
-
: `Assumes these memberships: ${options.memberships.
|
|
230
|
+
: `Assumes these memberships: ${listed(options.memberships.map((m) => membershipName(catalog, m)))}.`);
|
|
263
231
|
const values = pointValuesText(catalog, ranked.slice(0, 10));
|
|
264
232
|
if (values !== null)
|
|
265
233
|
assumptions.push(values);
|
|
@@ -6,7 +6,7 @@ export function membershipName(catalog, id) {
|
|
|
6
6
|
return membership(catalog, id)?.name ?? words(id);
|
|
7
7
|
}
|
|
8
8
|
// "a Costco membership", for a sentence.
|
|
9
|
-
|
|
9
|
+
function withArticle(name) {
|
|
10
10
|
return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`;
|
|
11
11
|
}
|
|
12
12
|
// Tiers of one membership: holding one, the others in its group aren't
|
package/dist/engine/offers.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { bonusDollars } from "./bonus.js";
|
|
2
2
|
import { matchesFilters } from "./explore.js";
|
|
3
3
|
import { membershipCosts, paidMemberships } from "./memberships.js";
|
|
4
|
+
import { inEffect } from "./periods.js";
|
|
4
5
|
import { money, roundCents } from "./text.js";
|
|
5
6
|
import { cardValuation } from "./valuation.js";
|
|
6
7
|
function ineligibleText(catalog, wallet, notWith) {
|
|
@@ -41,7 +42,7 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
41
42
|
if (!matchesFilters(card, { ...filters, issuer: null, includeClosed: true }))
|
|
42
43
|
continue;
|
|
43
44
|
const offer = card.welcome_offer;
|
|
44
|
-
if (!offer || (offer
|
|
45
|
+
if (!offer || !inEffect(offer, today)) {
|
|
45
46
|
withoutOffer.push(card.id);
|
|
46
47
|
continue;
|
|
47
48
|
}
|
package/dist/engine/periods.js
CHANGED
|
@@ -38,3 +38,21 @@ export function choicePeriod(card) {
|
|
|
38
38
|
];
|
|
39
39
|
return periods.length > 1 ? null : (periods[0] ?? 'calendar_year');
|
|
40
40
|
}
|
|
41
|
+
// Whether a dated rule or offer is in effect on `date`; an open end has no
|
|
42
|
+
// limit on that side.
|
|
43
|
+
export const inEffect = (window, date) => (window.valid_from === undefined || window.valid_from <= date) &&
|
|
44
|
+
(window.valid_to === undefined || window.valid_to >= date);
|
|
45
|
+
// The last day of a period key ("2026-Q4", "2026-10", "2026").
|
|
46
|
+
export function periodEnd(period) {
|
|
47
|
+
const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
|
|
48
|
+
if (quarter) {
|
|
49
|
+
const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
|
|
50
|
+
return `${quarter[1] ?? ''}-${end ?? ''}`;
|
|
51
|
+
}
|
|
52
|
+
const month = /^(\d{4})-(\d{2})$/.exec(period);
|
|
53
|
+
if (month) {
|
|
54
|
+
const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
|
|
55
|
+
return last.toISOString().slice(0, 10);
|
|
56
|
+
}
|
|
57
|
+
return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
|
|
58
|
+
}
|
package/dist/engine/recommend.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { resolveMerchant, resolveMerchantById } from "../merchants/resolve.js";
|
|
1
|
+
import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus } from "./bonus.js";
|
|
3
3
|
import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
|
+
import { membershipName } from "./memberships.js";
|
|
4
5
|
import { daysBetween } from "./periods.js";
|
|
6
|
+
import { ruleWhat } from "./rule-text.js";
|
|
5
7
|
import { rotationGap } from "./rotation.js";
|
|
6
8
|
import { EPSILON, isClose } from "./compare.js";
|
|
7
|
-
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
9
|
+
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
8
10
|
import { cardValuation } from "./valuation.js";
|
|
9
11
|
export const STALE_AFTER_DAYS = 180;
|
|
10
12
|
// The warning when no card works; the CLI shows it as the answer instead.
|
|
@@ -84,7 +86,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
84
86
|
const currency = valuation.currency;
|
|
85
87
|
if (best.rule) {
|
|
86
88
|
const note = best.rule.note ? ` ${best.rule.note}` : '';
|
|
87
|
-
lines.push(`${earnText(catalog, best.nominalRate, currency)}
|
|
89
|
+
lines.push(`${earnText(catalog, best.nominalRate, currency)} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
|
|
88
90
|
}
|
|
89
91
|
else {
|
|
90
92
|
lines.push(`${earnText(catalog, card.base_rate, currency)}, its base rate. No bonus category applies here.`);
|
|
@@ -92,7 +94,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
92
94
|
const cap = best.cap;
|
|
93
95
|
if (cap && amount !== null && amount > cap.remaining) {
|
|
94
96
|
const over = amount - cap.remaining;
|
|
95
|
-
lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
|
|
97
|
+
lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, best.afterCap ?? cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
|
|
96
98
|
}
|
|
97
99
|
if (valuation.pooledFrom !== null) {
|
|
98
100
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
@@ -104,7 +106,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
104
106
|
lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
|
|
105
107
|
}
|
|
106
108
|
if (currency !== 'cash') {
|
|
107
|
-
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${
|
|
109
|
+
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.effectiveReturnPct)} back.`);
|
|
108
110
|
}
|
|
109
111
|
return lines;
|
|
110
112
|
}
|
|
@@ -121,17 +123,17 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
121
123
|
warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
|
|
122
124
|
}
|
|
123
125
|
else if (purchase.amount !== null && purchase.amount > remaining) {
|
|
124
|
-
warnings.push(`This purchase crosses the ${capLabel(best)} on
|
|
126
|
+
warnings.push(`This purchase crosses the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)}: only ${money(remaining)} is left for ${period}.`);
|
|
125
127
|
}
|
|
126
128
|
else if (remaining < cap.amount * NEAR_CAP_SHARE) {
|
|
127
|
-
warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on
|
|
129
|
+
warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)} for ${period}.`);
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
132
|
if (nominal.rule &&
|
|
131
133
|
nominal.cap &&
|
|
132
134
|
nominal !== best &&
|
|
133
135
|
nominal.cap.remaining === 0) {
|
|
134
|
-
warnings.push(`The ${capLabel(nominal)} on
|
|
136
|
+
warnings.push(`The ${capLabel(nominal)} on ${ruleWhat(catalog, nominal.rule)} is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.afterCap ?? nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
|
|
135
137
|
}
|
|
136
138
|
if (best.rule?.unverified !== undefined) {
|
|
137
139
|
warnings.push(`This rate isn't confirmed against the issuer's terms yet: ${best.rule.unverified}`);
|
|
@@ -144,7 +146,7 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
144
146
|
warnings.push(`Your chosen categories are set for ${evaluation.staleChoice}, not the current period, so chosen-category bonuses don't apply until you update them.`);
|
|
145
147
|
}
|
|
146
148
|
for (const rule of evaluation.needsActivation) {
|
|
147
|
-
warnings.push(`Activate this bonus with the issuer (it's free) to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
|
|
149
|
+
warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
|
|
148
150
|
}
|
|
149
151
|
return warnings;
|
|
150
152
|
}
|
|
@@ -183,7 +185,7 @@ function tieNote(entries) {
|
|
|
183
185
|
tied.some((e) => e.evaluation.best.cap !== null)
|
|
184
186
|
? 'because its rate has no cap'
|
|
185
187
|
: 'by a fixed order; either works';
|
|
186
|
-
return `${names} also ${verb} ${
|
|
188
|
+
return `${names} also ${verb} ${pct(winner.effectiveReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
|
|
187
189
|
}
|
|
188
190
|
// Finds the closest point inside a currency's value range where the winner
|
|
189
191
|
// stops winning, varying one currency at a time. Returns are linear in the
|
|
@@ -278,19 +280,18 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
278
280
|
const twin = alts.find((e) => e.card.id !== winner.card.id &&
|
|
279
281
|
alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
|
|
280
282
|
if (alt.card.id !== winner.card.id)
|
|
281
|
-
warnings.push(`${as}, ${displayName(alt)} wins (${
|
|
283
|
+
warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.effectiveReturnPct)}).`);
|
|
282
284
|
else if (twin)
|
|
283
|
-
warnings.push(`${as}, ${displayName(twin)} earns as much (${
|
|
285
|
+
warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.effectiveReturnPct)}).`);
|
|
284
286
|
}
|
|
285
287
|
}
|
|
286
288
|
if (!winner) {
|
|
287
289
|
warnings.push(noCardText(merchant.resolvedName));
|
|
288
290
|
// Only where the merchant's data says what its website takes: without
|
|
289
291
|
// that, every network is assumed, and a gas pump has no website.
|
|
290
|
-
const record =
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
].find((m) => m.id === merchant.merchantId);
|
|
292
|
+
const record = merchant.merchantId === null
|
|
293
|
+
? undefined
|
|
294
|
+
: findMerchant(catalog, merchant.merchantId);
|
|
294
295
|
const online = merchant.channel === 'in_store' &&
|
|
295
296
|
merchant.merchantId !== null &&
|
|
296
297
|
record?.online?.accepts !== undefined
|
|
@@ -300,7 +301,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
300
301
|
? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
|
|
301
302
|
: undefined;
|
|
302
303
|
if (alt)
|
|
303
|
-
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${
|
|
304
|
+
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.effectiveReturnPct)}).`);
|
|
304
305
|
}
|
|
305
306
|
else if (merchant.accepts === null &&
|
|
306
307
|
(winner.card.network === 'amex' || winner.card.network === 'discover')) {
|
|
@@ -325,7 +326,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
325
326
|
const alt = rankEntries(catalog, wallet, purchase, via, via.category)
|
|
326
327
|
.entries[0];
|
|
327
328
|
if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
|
|
328
|
-
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${
|
|
329
|
+
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.effectiveReturnPct)}.`);
|
|
329
330
|
}
|
|
330
331
|
}
|
|
331
332
|
const open = bonusPick(catalog, wallet, entries, purchase.date);
|
|
@@ -341,7 +342,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
341
342
|
const bonus = open && (open.behind || ties) ? open : null;
|
|
342
343
|
const best = winner?.effectiveReturnPct ?? 0;
|
|
343
344
|
if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
|
|
344
|
-
notes.push(`${displayName(open.entry)} earns ${
|
|
345
|
+
notes.push(`${displayName(open.entry)} earns ${pct(open.entry.effectiveReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(best)}, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
|
|
345
346
|
}
|
|
346
347
|
const chosen = bonus?.entry ?? winner;
|
|
347
348
|
// A tie among the top cards doesn't matter when a welcome bonus decides.
|
|
@@ -377,7 +378,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
377
378
|
: `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
|
|
378
379
|
? ''
|
|
379
380
|
: costHere === null
|
|
380
|
-
? ` It earns ${
|
|
381
|
+
? ` It earns ${pct(top.effectiveReturnPct)} here against ${pct(first.effectiveReturnPct)} on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
|
|
381
382
|
: ` Using it here earns ${money(costHere)} less than ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`}`);
|
|
382
383
|
}
|
|
383
384
|
let confidence = merchant.confidence;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { findMerchant } from "../merchants/resolve.js";
|
|
2
|
+
import { categoryPhrase, choiceName, listed, words } from "./text.js";
|
|
3
|
+
// What a rule or a pick covers, in words, for answers and wallet lists.
|
|
4
|
+
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
5
|
+
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
6
|
+
const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
7
|
+
function merchantName(catalog, id) {
|
|
8
|
+
const merchant = findMerchant(catalog, id);
|
|
9
|
+
const name = merchant?.name ?? words(id);
|
|
10
|
+
return merchant?.kind === 'booking'
|
|
11
|
+
? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
|
|
12
|
+
: name;
|
|
13
|
+
}
|
|
14
|
+
export function optionName(catalog, card, choiceId, option) {
|
|
15
|
+
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
16
|
+
if (!o)
|
|
17
|
+
return words(option);
|
|
18
|
+
const parts = [
|
|
19
|
+
...o.categories.map((c) => categoryPhrase(catalog, c)),
|
|
20
|
+
...o.merchants.map((m) => merchantName(catalog, m)),
|
|
21
|
+
];
|
|
22
|
+
if (o.online)
|
|
23
|
+
parts.push('online shopping');
|
|
24
|
+
return parts.length > 0 ? listed(parts) : words(option);
|
|
25
|
+
}
|
|
26
|
+
// What a rule covers, as a list of phrases.
|
|
27
|
+
export function ruleParts(catalog, rule) {
|
|
28
|
+
if (rule.match.any)
|
|
29
|
+
return ['everything else'];
|
|
30
|
+
if (rule.match.choice !== undefined)
|
|
31
|
+
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
32
|
+
const merchants = rule.match.merchants ?? [];
|
|
33
|
+
// A long list of one kind of store reads as "select streaming services".
|
|
34
|
+
const kinds = new Set(merchants.map((m) => findMerchant(catalog, m)?.category));
|
|
35
|
+
const [kind] = kinds;
|
|
36
|
+
const merchantParts = merchants.length > 4 && kinds.size === 1 && kind !== undefined
|
|
37
|
+
? [`select ${categoryPhrase(catalog, kind)}`]
|
|
38
|
+
: merchants.map((m) => merchantName(catalog, m));
|
|
39
|
+
const parts = [
|
|
40
|
+
...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
41
|
+
...merchantParts,
|
|
42
|
+
];
|
|
43
|
+
return parts.length > 0 ? parts : [words(rule.id)];
|
|
44
|
+
}
|
|
45
|
+
export function ruleWhat(catalog, rule) {
|
|
46
|
+
return listed(ruleParts(catalog, rule));
|
|
47
|
+
}
|
package/dist/engine/setup.js
CHANGED
|
@@ -2,19 +2,10 @@ import { isActivated, pickedCategories } from "./evaluate.js";
|
|
|
2
2
|
import { membership, membershipName, sameGroup } from "./memberships.js";
|
|
3
3
|
import { daysBetween } from "./periods.js";
|
|
4
4
|
import { rotationGap } from "./rotation.js";
|
|
5
|
+
import { idList, listed } from "./text.js";
|
|
5
6
|
// Rules that start within this many days are worth activating now.
|
|
6
7
|
const ACTIVATION_LOOKAHEAD_DAYS = 45;
|
|
7
|
-
const
|
|
8
|
-
amex: 'american express',
|
|
9
|
-
'bank-of-america': 'bofa boa bankofamerica',
|
|
10
|
-
'capital-one': 'capitalone',
|
|
11
|
-
citi: 'citibank',
|
|
12
|
-
usbank: 'us bank',
|
|
13
|
-
'wells-fargo': 'wf wellsfargo',
|
|
14
|
-
'td-bank': 'td',
|
|
15
|
-
column: 'bilt',
|
|
16
|
-
};
|
|
17
|
-
const words = (s) => s
|
|
8
|
+
const searchWords = (s) => s
|
|
18
9
|
.toLowerCase()
|
|
19
10
|
.replace(/\+/g, ' plus ')
|
|
20
11
|
.replace(/&/g, ' and ')
|
|
@@ -33,12 +24,12 @@ function editDistance(a, b) {
|
|
|
33
24
|
}
|
|
34
25
|
return row[b.length] ?? 0;
|
|
35
26
|
}
|
|
36
|
-
function matcher(card) {
|
|
27
|
+
function matcher(catalog, card) {
|
|
37
28
|
const haystack = [
|
|
38
|
-
...
|
|
39
|
-
...
|
|
40
|
-
...
|
|
41
|
-
...
|
|
29
|
+
...searchWords(card.id),
|
|
30
|
+
...searchWords(card.name),
|
|
31
|
+
...searchWords(card.issuer),
|
|
32
|
+
...(catalog.issuers.find((i) => i.id === card.issuer)?.search ?? []).flatMap(searchWords),
|
|
42
33
|
];
|
|
43
34
|
const compact = haystack.join('');
|
|
44
35
|
return (token) => haystack.some((w) => w.startsWith(token)) ||
|
|
@@ -108,12 +99,12 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
108
99
|
// issuer (as a prefix, or with a typo or two in a long word). With no full
|
|
109
100
|
// match, the cards matching the most words come back instead.
|
|
110
101
|
export function searchCards(catalog, query, wallet, today) {
|
|
111
|
-
const tokens =
|
|
102
|
+
const tokens = searchWords(query);
|
|
112
103
|
const scored = catalog.cards.map((card) => {
|
|
113
|
-
const matches = matcher(card);
|
|
104
|
+
const matches = matcher(catalog, card);
|
|
114
105
|
return { card, score: tokens.filter((t) => matches(t)).length };
|
|
115
106
|
});
|
|
116
|
-
const byShortestId = (a, b) =>
|
|
107
|
+
const byShortestId = (a, b) => searchWords(a.card.id).length - searchWords(b.card.id).length ||
|
|
117
108
|
a.card.id.localeCompare(b.card.id);
|
|
118
109
|
const full = scored.filter((s) => s.score === tokens.length);
|
|
119
110
|
const best = Math.max(0, ...scored.map((s) => s.score));
|
|
@@ -131,7 +122,6 @@ export function searchCards(catalog, query, wallet, today) {
|
|
|
131
122
|
exact,
|
|
132
123
|
};
|
|
133
124
|
}
|
|
134
|
-
const list = (ids) => ids.join(', ');
|
|
135
125
|
// What a card in the wallet still needs before its answers are right.
|
|
136
126
|
export function setupNotes(catalog, wallet, held, today) {
|
|
137
127
|
const card = catalog.cards.find((c) => c.id === held.id);
|
|
@@ -145,11 +135,11 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
145
135
|
(staleChoice === null || choice.period === 'until_changed');
|
|
146
136
|
if (current)
|
|
147
137
|
continue;
|
|
148
|
-
const options =
|
|
138
|
+
const options = idList(Object.keys(choice.options));
|
|
149
139
|
notes.push({
|
|
150
140
|
kind: 'pick',
|
|
151
141
|
text: choice.default && picked.has(choice.id)
|
|
152
|
-
? `${name}'s ${choice.id} pick is assumed to be ${
|
|
142
|
+
? `${name}'s ${choice.id} pick is assumed to be ${idList(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
|
|
153
143
|
: `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
|
|
154
144
|
});
|
|
155
145
|
}
|
|
@@ -158,14 +148,14 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
158
148
|
if (missing.length > 0) {
|
|
159
149
|
notes.push({
|
|
160
150
|
kind: 'membership',
|
|
161
|
-
text: `Some of ${name}'s rates need
|
|
151
|
+
text: `Some of ${name}'s rates need ${listed(missing.map((m) => `${membershipName(catalog, m)} (${m})`), 'or')}; they're counted without it.`,
|
|
162
152
|
});
|
|
163
153
|
}
|
|
164
154
|
const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
|
|
165
155
|
if (toActivate.length > 0) {
|
|
166
156
|
notes.push({
|
|
167
157
|
kind: 'activation',
|
|
168
|
-
text: `${name} has rates that apply only once activated with the issuer: ${
|
|
158
|
+
text: `${name} has rates that apply only once activated with the issuer: ${idList(toActivate)}. Mark the ones already activated.`,
|
|
169
159
|
});
|
|
170
160
|
}
|
|
171
161
|
if (needsAnniversary(card) && held.anniversary === undefined) {
|