earnrate 0.1.0 → 0.1.2

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.
@@ -106,7 +106,10 @@ function cardProblems(card, catalog, merchantIds) {
106
106
  problems.push(`${where} choice ${choice.id}: default '${d}' is not an option`);
107
107
  }
108
108
  for (const [option, covered] of options) {
109
- for (const c of covered.categories)
109
+ for (const c of [
110
+ ...covered.categories,
111
+ ...(covered.online?.exclude ?? []),
112
+ ])
110
113
  checkCategory(c, `choice ${choice.id}.${option}`);
111
114
  for (const m of covered.merchants)
112
115
  checkMerchant(m, `choice ${choice.id}.${option}`);
@@ -133,6 +136,20 @@ function cardProblems(card, catalog, merchantIds) {
133
136
  problems.push(`${where} ${context}: unknown cap '${rule.cap}'`);
134
137
  }
135
138
  }
139
+ for (const cap of card.caps.filter((c) => c.rotating)) {
140
+ const rules = card.rules.filter((r) => r.cap === cap.id);
141
+ if (rules.length === 0) {
142
+ problems.push(`${where} cap ${cap.id}: rotating, but no rule uses it`);
143
+ }
144
+ for (const r of rules) {
145
+ if (r.valid_from === undefined || r.valid_to === undefined) {
146
+ problems.push(`${where} rule ${r.id}: rules on rotating cap '${cap.id}' need valid_from and valid_to`);
147
+ }
148
+ }
149
+ if (cap.period === 'cardmember_year') {
150
+ problems.push(`${where} cap ${cap.id}: a rotating cap can't use cardmember years`);
151
+ }
152
+ }
136
153
  return problems;
137
154
  }
138
155
  export function catalogProblems(catalog) {
@@ -0,0 +1,162 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+ import { isMap, isSeq, parseDocument, stringify } from 'yaml';
5
+ import { WalletSchema } from "../schema/wallet.js";
6
+ const NEW_WALLET = `# Your earnrate wallet: the cards you hold, by id. Never card numbers.
7
+ # Format: https://github.com/cwasley/earnrate#your-wallet
8
+
9
+ `;
10
+ const BY_HAND = 'edit the wallet by hand';
11
+ const lineStart = (text, at) => text.lastIndexOf('\n', at - 1) + 1;
12
+ function afterLine(text, at) {
13
+ const end = text.indexOf('\n', at);
14
+ return end === -1 ? text.length : end + 1;
15
+ }
16
+ function insertLines(text, at, lines) {
17
+ const head = text.slice(0, at);
18
+ const gap = head.length > 0 && !head.endsWith('\n') ? '\n' : '';
19
+ return `${head}${gap}${lines}${text.slice(at)}`;
20
+ }
21
+ function range(node) {
22
+ const r = node?.range;
23
+ if (!r)
24
+ throw new Error(`a wallet value has no source position; ${BY_HAND}`);
25
+ return r;
26
+ }
27
+ const keyOf = (pair) => pair.key?.value;
28
+ const scalar = (value) => stringify(value).trimEnd();
29
+ const flowList = (ids) => `[${ids.join(', ')}]`;
30
+ function fieldLines(field, f, indent) {
31
+ switch (field) {
32
+ case 'checkout_label':
33
+ case 'anniversary': {
34
+ const value = f[field];
35
+ return value === undefined ? '' : `${indent}${field}: ${scalar(value)}\n`;
36
+ }
37
+ case 'activated':
38
+ return f.activated === undefined
39
+ ? ''
40
+ : `${indent}activated: ${flowList(f.activated)}\n`;
41
+ case 'chosen': {
42
+ if (f.chosen === undefined)
43
+ return '';
44
+ const picks = Object.entries(f.chosen.picks)
45
+ .map(([choice, ids]) => `${indent} ${choice}: ${flowList(ids)}\n`)
46
+ .join('');
47
+ return `${indent}chosen:\n${indent} period: ${scalar(f.chosen.period)}\n${indent} picks:\n${picks}`;
48
+ }
49
+ }
50
+ }
51
+ const FIELDS = ['checkout_label', 'anniversary', 'chosen', 'activated'];
52
+ function cardsSeq(text) {
53
+ const doc = parseDocument(text);
54
+ if (doc.errors.length > 0)
55
+ throw new Error(doc.errors[0]?.message);
56
+ const cards = doc.get('cards', true);
57
+ if (!isSeq(cards) || cards.flow) {
58
+ throw new Error(`"cards" isn't a block list; ${BY_HAND}`);
59
+ }
60
+ return { doc, cards };
61
+ }
62
+ function findCard(text, cardId) {
63
+ const { cards } = cardsSeq(text);
64
+ const card = cards.items.find((item) => isMap(item) && item.get('id') === cardId);
65
+ if (!isMap(card))
66
+ throw new Error(`no card '${cardId}' in the wallet`);
67
+ if (card.flow)
68
+ throw new Error(`card '${cardId}' is written inline; ${BY_HAND}`);
69
+ return card;
70
+ }
71
+ const column = (text, at) => at - lineStart(text, at);
72
+ // Appends a card after the last one, matching the list's indentation.
73
+ export function addCardText(text, entry) {
74
+ if (text === null || text.trim() === '') {
75
+ return `${NEW_WALLET}cards:\n${cardLines(entry, ' ')}`;
76
+ }
77
+ const { cards } = cardsSeq(text);
78
+ const first = cards.items[0];
79
+ if (!isMap(first))
80
+ throw new Error(`"cards" is empty; ${BY_HAND}`);
81
+ const keyColumn = column(text, range(first)[0]);
82
+ if (keyColumn < 2 || text[range(first)[0] - 2] !== '-') {
83
+ throw new Error(`can't tell how the cards are indented; ${BY_HAND}`);
84
+ }
85
+ const at = afterLine(text, range(cards)[1] - 1);
86
+ return insertLines(text, at, cardLines(entry, ' '.repeat(keyColumn - 2)));
87
+ }
88
+ function cardLines(entry, dashIndent) {
89
+ const indent = `${dashIndent} `;
90
+ return `${dashIndent}- id: ${entry.id}\n${FIELDS.map((f) => fieldLines(f, entry, indent)).join('')}`;
91
+ }
92
+ export function removeCardText(text, cardId) {
93
+ const card = findCard(text, cardId);
94
+ const start = lineStart(text, range(card)[0]);
95
+ const end = afterLine(text, range(card)[1] - 1);
96
+ return text.slice(0, start) + text.slice(end);
97
+ }
98
+ // Replaces each given field in place, or adds it after the card's last line.
99
+ export function updateCardText(text, cardId, fields) {
100
+ let out = text;
101
+ for (const field of FIELDS) {
102
+ if (fields[field] === undefined)
103
+ continue;
104
+ const card = findCard(out, cardId);
105
+ const indent = ' '.repeat(column(out, range(card)[0]));
106
+ const lines = fieldLines(field, fields, indent);
107
+ const pair = card.items.find((p) => keyOf(p) === field);
108
+ if (pair) {
109
+ const start = range(pair.key)[0];
110
+ const end = afterLine(out, range(pair.value)[1] - 1);
111
+ out = out.slice(0, start) + lines.trimStart() + out.slice(end);
112
+ }
113
+ else {
114
+ out = insertLines(out, afterLine(out, range(card)[1] - 1), lines);
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+ export function setMembershipsText(text, memberships) {
120
+ const { doc } = cardsSeq(text);
121
+ const root = doc.contents;
122
+ if (!isMap(root))
123
+ throw new Error(`the wallet isn't a map; ${BY_HAND}`);
124
+ const line = `memberships: ${flowList(memberships)}\n`;
125
+ const pair = root.items.find((p) => keyOf(p) === 'memberships');
126
+ if (pair) {
127
+ const start = range(pair.key)[0];
128
+ const end = afterLine(text, (pair.value === null ? range(pair.key) : range(pair.value))[1] - 1);
129
+ return text.slice(0, start) + line + text.slice(end);
130
+ }
131
+ const cards = root.items.find((p) => keyOf(p) === 'cards');
132
+ return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
133
+ }
134
+ async function readText(file) {
135
+ try {
136
+ return await readFile(file, 'utf8');
137
+ }
138
+ catch (e) {
139
+ if (e instanceof Error && 'code' in e && e.code === 'ENOENT')
140
+ return null;
141
+ throw e;
142
+ }
143
+ }
144
+ export async function readWalletText(file) {
145
+ const text = await readText(file);
146
+ return {
147
+ text,
148
+ wallet: text === null ? null : WalletSchema.parse(parseDocument(text).toJS()),
149
+ };
150
+ }
151
+ // Writes `after` only if it parses to exactly `expected`, so a splice that
152
+ // went wrong can never change anything it wasn't meant to.
153
+ export async function commitWalletText(file, after, expected) {
154
+ const parsed = WalletSchema.safeParse(parseDocument(after).toJS());
155
+ if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
156
+ throw new Error(`couldn't update ${file} safely; ${BY_HAND}`);
157
+ }
158
+ await mkdir(path.dirname(file), { recursive: true });
159
+ const tmp = `${file}.${String(process.pid)}.tmp`;
160
+ await writeFile(tmp, after);
161
+ await rename(tmp, file);
162
+ }
@@ -0,0 +1,202 @@
1
+ import { periodKey } from "../engine/periods.js";
2
+ import { knownMemberships, searchCards, setupNotes, } from "../engine/setup.js";
3
+ import { WalletCardSchema, WalletSchema, } from "../schema/wallet.js";
4
+ import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, } from "./wallet-edit.js";
5
+ const failed = (error) => ({ ok: false, error });
6
+ const list = (ids) => ids.join(', ');
7
+ function findCatalogCard(catalog, id, today) {
8
+ const card = catalog.cards.find((c) => c.id === id);
9
+ if (card)
10
+ return card;
11
+ const near = searchCards(catalog, id, null, today).cards.map((c) => c.id);
12
+ return near.length > 0
13
+ ? `No card '${id}' in earnrate. Closest: ${list(near)}.`
14
+ : `No card '${id}' in earnrate. Search the catalog to find its id.`;
15
+ }
16
+ function isAnniversary(text) {
17
+ const m = /^(\d{2})-(\d{2})$/.exec(text);
18
+ if (!m)
19
+ return false;
20
+ const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
21
+ return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
22
+ }
23
+ // The period key picks are filed under: the card's choice period, or for
24
+ // picks that carry over, the current year (it's never compared).
25
+ function choicePeriod(card, today) {
26
+ const periods = [
27
+ ...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
28
+ ];
29
+ if (periods.length > 1)
30
+ return null;
31
+ return periodKey(periods[0] ?? 'calendar_year', today, null);
32
+ }
33
+ function fieldsFor(card, held, input, today) {
34
+ const problems = [];
35
+ const fields = {};
36
+ if (input.checkoutLabel !== undefined) {
37
+ if (input.checkoutLabel.trim() === '')
38
+ problems.push('The checkout label is empty.');
39
+ // earnrate never stores card numbers; a last four is fine.
40
+ if (/\d{8,}/.test(input.checkoutLabel.replace(/[\s-]/g, '')))
41
+ problems.push('The checkout label looks like it has a card number in it. earnrate never stores card numbers; use a name like "Amex Gold".');
42
+ fields.checkout_label = input.checkoutLabel.trim();
43
+ }
44
+ if (input.anniversary !== undefined) {
45
+ if (!isAnniversary(input.anniversary)) {
46
+ problems.push(`"${input.anniversary}" isn't an anniversary. Use MM-DD, like 03-15.`);
47
+ }
48
+ fields.anniversary = input.anniversary;
49
+ }
50
+ if (input.activated !== undefined) {
51
+ const needing = card.rules
52
+ .filter((r) => r.requires?.activation)
53
+ .map((r) => r.id);
54
+ for (const id of input.activated) {
55
+ if (!needing.includes(id)) {
56
+ problems.push(needing.length > 0
57
+ ? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${list(needing)}.`
58
+ : `${card.id} has no rules that need activating.`);
59
+ }
60
+ }
61
+ fields.activated = [...new Set(input.activated)];
62
+ }
63
+ if (input.picks !== undefined) {
64
+ for (const [choiceId, picks] of Object.entries(input.picks)) {
65
+ const choice = card.choices.find((c) => c.id === choiceId);
66
+ if (!choice) {
67
+ problems.push(card.choices.length > 0
68
+ ? `${card.id} has no '${choiceId}' pick. Its picks are: ${list(card.choices.map((c) => c.id))}.`
69
+ : `${card.id} has no categories to pick.`);
70
+ continue;
71
+ }
72
+ const options = Object.keys(choice.options);
73
+ for (const p of picks) {
74
+ if (!options.includes(p)) {
75
+ problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${list(options)}.`);
76
+ }
77
+ }
78
+ if (picks.length === 0 || picks.length > choice.pick) {
79
+ problems.push(`${choiceId} takes ${choice.pick === 1 ? '1 pick' : `1 to ${String(choice.pick)} picks`}, not ${String(picks.length)}.`);
80
+ }
81
+ }
82
+ const period = choicePeriod(card, today);
83
+ if (period === null) {
84
+ problems.push(`${card.id} has picks on different schedules; edit the wallet by hand.`);
85
+ }
86
+ else {
87
+ const kept = held?.chosen?.period === period ? held.chosen.picks : {};
88
+ fields.chosen = {
89
+ period,
90
+ picks: { ...kept, ...input.picks },
91
+ };
92
+ }
93
+ }
94
+ return problems.length > 0 ? problems.join('\n') : fields;
95
+ }
96
+ const applyFields = (held, f) => WalletCardSchema.parse({
97
+ ...held,
98
+ ...(f.checkout_label === undefined
99
+ ? {}
100
+ : { checkout_label: f.checkout_label }),
101
+ ...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
102
+ ...(f.chosen === undefined ? {} : { chosen: f.chosen }),
103
+ ...(f.activated === undefined ? {} : { activated: f.activated }),
104
+ });
105
+ async function attempt(task) {
106
+ try {
107
+ return await task();
108
+ }
109
+ catch (e) {
110
+ return failed(e instanceof Error ? e.message : String(e));
111
+ }
112
+ }
113
+ const noWallet = (file) => `No wallet at ${file} yet. Add a card first to create it.`;
114
+ export function addCard(file, catalog, input, today) {
115
+ return attempt(async () => {
116
+ const card = findCatalogCard(catalog, input.id, today);
117
+ if (typeof card === 'string')
118
+ return failed(card);
119
+ const { text, wallet } = await readWalletText(file);
120
+ if (wallet?.cards.some((c) => c.id === card.id)) {
121
+ return failed(`${card.id} is already in the wallet. Update it instead.`);
122
+ }
123
+ const fields = fieldsFor(card, null, input, today);
124
+ if (typeof fields === 'string')
125
+ return failed(fields);
126
+ const held = applyFields(WalletCardSchema.parse({ id: card.id }), fields);
127
+ const expected = wallet
128
+ ? { ...wallet, cards: [...wallet.cards, held] }
129
+ : WalletSchema.parse({ cards: [held] });
130
+ await commitWalletText(file, addCardText(text, { id: card.id, ...fields }), expected);
131
+ return {
132
+ ok: true,
133
+ wallet: expected,
134
+ created: text === null,
135
+ notes: setupNotes(catalog, expected, held, today),
136
+ };
137
+ });
138
+ }
139
+ export function updateCard(file, catalog, input, today) {
140
+ return attempt(async () => {
141
+ const { text, wallet } = await readWalletText(file);
142
+ if (text === null || wallet === null)
143
+ return failed(noWallet(file));
144
+ const held = wallet.cards.find((c) => c.id === input.id);
145
+ const card = catalog.cards.find((c) => c.id === input.id);
146
+ if (!held || !card) {
147
+ return failed(`${input.id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
148
+ }
149
+ const fields = fieldsFor(card, held, input, today);
150
+ if (typeof fields === 'string')
151
+ return failed(fields);
152
+ if (Object.keys(fields).length === 0)
153
+ return failed('Nothing to change.');
154
+ const updated = applyFields(held, fields);
155
+ const expected = {
156
+ ...wallet,
157
+ cards: wallet.cards.map((c) => (c.id === held.id ? updated : c)),
158
+ };
159
+ await commitWalletText(file, updateCardText(text, held.id, fields), expected);
160
+ return {
161
+ ok: true,
162
+ wallet: expected,
163
+ created: false,
164
+ notes: setupNotes(catalog, expected, updated, today),
165
+ };
166
+ });
167
+ }
168
+ export function removeCard(file, _catalog, id) {
169
+ return attempt(async () => {
170
+ const { text, wallet } = await readWalletText(file);
171
+ if (text === null || wallet === null)
172
+ return failed(noWallet(file));
173
+ if (!wallet.cards.some((c) => c.id === id)) {
174
+ return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
175
+ }
176
+ if (wallet.cards.length === 1) {
177
+ return failed(`${id} is the last card in the wallet, and a wallet needs one. Add the new card first, then remove this one.`);
178
+ }
179
+ const expected = {
180
+ ...wallet,
181
+ cards: wallet.cards.filter((c) => c.id !== id),
182
+ };
183
+ await commitWalletText(file, removeCardText(text, id), expected);
184
+ return { ok: true, wallet: expected, created: false, notes: [] };
185
+ });
186
+ }
187
+ export function setMemberships(file, catalog, memberships) {
188
+ return attempt(async () => {
189
+ const known = knownMemberships(catalog);
190
+ const unknown = memberships.filter((m) => !known.includes(m));
191
+ if (unknown.length > 0) {
192
+ return failed(`No card rule uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
193
+ }
194
+ const { text, wallet } = await readWalletText(file);
195
+ if (text === null || wallet === null)
196
+ return failed(noWallet(file));
197
+ const unique = [...new Set(memberships)];
198
+ const expected = { ...wallet, memberships: unique };
199
+ await commitWalletText(file, setMembershipsText(text, unique), expected);
200
+ return { ok: true, wallet: expected, created: false, notes: [] };
201
+ });
202
+ }
@@ -30,6 +30,7 @@ export function pickedCategories(card, held, date) {
30
30
  let picks = chosen?.picks[choice.id];
31
31
  if (picks &&
32
32
  chosen &&
33
+ choice.period !== 'until_changed' &&
33
34
  chosen.period !== periodKey(choice.period, date, null)) {
34
35
  staleChoice = chosen.period;
35
36
  picks = undefined;
@@ -41,6 +42,7 @@ export function pickedCategories(card, held, date) {
41
42
  picked.set(choice.id, {
42
43
  categories: options.flatMap((o) => o.categories),
43
44
  merchants: options.flatMap((o) => o.merchants),
45
+ online: options.flatMap((o) => (o.online ? [o.online] : [])),
44
46
  });
45
47
  }
46
48
  return { picked, staleChoice };
@@ -60,7 +62,11 @@ function applicability(rule, ctx, held, picked) {
60
62
  const covered = picked.get(match.choice);
61
63
  const merchantPicked = ctx.merchantId !== null &&
62
64
  (covered?.merchants.includes(ctx.merchantId) ?? false);
63
- if (!merchantPicked && !inCategory(covered?.categories ?? []))
65
+ const onlinePicked = ctx.channel === 'online' &&
66
+ (covered?.online.some((o) => !inCategory(o.exclude)) ?? false);
67
+ if (!merchantPicked &&
68
+ !onlinePicked &&
69
+ !inCategory(covered?.categories ?? []))
64
70
  return 'no';
65
71
  }
66
72
  if (exclude?.categories && inCategory(exclude.categories))
@@ -2,6 +2,7 @@ import { resolveMerchant, resolveMerchantById } from "../merchants/resolve.js";
2
2
  import { evaluateCard, purchaseContext, } from "./evaluate.js";
3
3
  import { bonusStatus } from "./bonus.js";
4
4
  import { daysBetween } from "./periods.js";
5
+ import { rotationGap } from "./rotation.js";
5
6
  import { cardValuation } from "./valuation.js";
6
7
  const EPSILON = 1e-9;
7
8
  export const STALE_AFTER_DAYS = 180;
@@ -327,6 +328,14 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
327
328
  : '';
328
329
  warnings.push(`Not every business takes ${NETWORK_NAMES[winner.card.network] ?? winner.card.network}.${fallback}`);
329
330
  }
331
+ for (const held of wallet.cards) {
332
+ const card = catalog.cards.find((c) => c.id === held.id);
333
+ const gap = card ? rotationGap(card, purchase.date) : null;
334
+ if (card && gap !== null) {
335
+ const name = held.checkout_label ?? card.name;
336
+ warnings.push(`${name}'s rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more.`);
337
+ }
338
+ }
330
339
  const tie = tieNote(entries);
331
340
  if (tie)
332
341
  notes.push(tie);
@@ -0,0 +1,16 @@
1
+ import { periodKey } from "./periods.js";
2
+ // The period of the card's rotating categories that `date` falls in, when no
3
+ // rule in the data covers it yet; null when it's covered or the card doesn't
4
+ // rotate.
5
+ export function rotationGap(card, date) {
6
+ for (const cap of card.caps) {
7
+ if (!cap.rotating || cap.period === 'cardmember_year')
8
+ continue;
9
+ const covered = card.rules.some((r) => r.cap === cap.id &&
10
+ (r.valid_from ?? '') <= date &&
11
+ date <= (r.valid_to ?? ''));
12
+ if (!covered)
13
+ return periodKey(cap.period, date, null);
14
+ }
15
+ return null;
16
+ }
@@ -0,0 +1,168 @@
1
+ import { pickedCategories } from "./evaluate.js";
2
+ import { daysBetween } from "./periods.js";
3
+ import { rotationGap } from "./rotation.js";
4
+ // Rules that start within this many days are worth activating now.
5
+ const ACTIVATION_LOOKAHEAD_DAYS = 45;
6
+ const ISSUER_NICKNAMES = {
7
+ amex: 'american express',
8
+ 'bank-of-america': 'bofa boa bankofamerica',
9
+ 'capital-one': 'capitalone',
10
+ citi: 'citibank',
11
+ usbank: 'us bank',
12
+ 'wells-fargo': 'wf wellsfargo',
13
+ 'td-bank': 'td',
14
+ column: 'bilt',
15
+ };
16
+ const words = (s) => s
17
+ .toLowerCase()
18
+ .replace(/\+/g, ' plus ')
19
+ .replace(/&/g, ' and ')
20
+ .split(/[^a-z0-9]+/)
21
+ .filter((w) => w.length > 0);
22
+ function editDistance(a, b) {
23
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
24
+ for (let i = 1; i <= a.length; i++) {
25
+ let diagonal = row[0] ?? 0;
26
+ row[0] = i;
27
+ for (let j = 1; j <= b.length; j++) {
28
+ const above = row[j] ?? 0;
29
+ row[j] = Math.min(above + 1, (row[j - 1] ?? 0) + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
30
+ diagonal = above;
31
+ }
32
+ }
33
+ return row[b.length] ?? 0;
34
+ }
35
+ function matcher(card) {
36
+ const haystack = [
37
+ ...words(card.id),
38
+ ...words(card.name),
39
+ ...words(card.issuer),
40
+ ...words(ISSUER_NICKNAMES[card.issuer] ?? ''),
41
+ ];
42
+ const compact = haystack.join('');
43
+ return (token) => haystack.some((w) => w.startsWith(token)) ||
44
+ (token.length >= 4 && compact.includes(token)) ||
45
+ (token.length >= 5 &&
46
+ haystack.some((w) => editDistance(w, token) <= (token.length >= 8 ? 2 : 1)));
47
+ }
48
+ function needsAnniversary(card) {
49
+ return (card.caps.some((c) => c.period === 'cardmember_year') ||
50
+ card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year'));
51
+ }
52
+ function activationRules(card, today) {
53
+ return card.rules
54
+ .filter((r) => r.requires?.activation === true &&
55
+ (r.valid_to === undefined || r.valid_to >= today) &&
56
+ (r.valid_from === undefined ||
57
+ daysBetween(today, r.valid_from) <= ACTIVATION_LOOKAHEAD_DAYS))
58
+ .map((r) => r.id);
59
+ }
60
+ export function membershipsUsed(card) {
61
+ return [
62
+ ...new Set(card.rules.flatMap((r) => r.requires?.membership === undefined ? [] : [r.requires.membership])),
63
+ ];
64
+ }
65
+ export function knownMemberships(catalog) {
66
+ return [...new Set(catalog.cards.flatMap(membershipsUsed))].sort();
67
+ }
68
+ export function cardInfo(card, wallet, today) {
69
+ return {
70
+ id: card.id,
71
+ name: card.name,
72
+ issuer: card.issuer,
73
+ network: card.network,
74
+ currency: card.currency,
75
+ annualFee: card.annual_fee ?? null,
76
+ business: card.business,
77
+ onlyAt: card.only_at ?? null,
78
+ held: wallet?.cards.some((c) => c.id === card.id) ?? false,
79
+ choices: card.choices.map((c) => ({
80
+ id: c.id,
81
+ pick: c.pick,
82
+ period: c.period,
83
+ options: Object.keys(c.options),
84
+ default: c.default ?? null,
85
+ })),
86
+ memberships: membershipsUsed(card),
87
+ activation: activationRules(card, today),
88
+ needsAnniversary: needsAnniversary(card),
89
+ };
90
+ }
91
+ // Every word of the query has to match a word of the card's id, name or
92
+ // issuer (as a prefix, or with a typo or two in a long word). With no full
93
+ // match, the cards matching the most words come back instead.
94
+ export function searchCards(catalog, query, wallet, today) {
95
+ const tokens = words(query);
96
+ const scored = catalog.cards.map((card) => {
97
+ const matches = matcher(card);
98
+ return { card, score: tokens.filter((t) => matches(t)).length };
99
+ });
100
+ const byShortestId = (a, b) => words(a.card.id).length - words(b.card.id).length ||
101
+ a.card.id.localeCompare(b.card.id);
102
+ const full = scored.filter((s) => s.score === tokens.length);
103
+ const best = Math.max(0, ...scored.map((s) => s.score));
104
+ const exact = full.length > 0;
105
+ const chosen = exact
106
+ ? full.sort(byShortestId)
107
+ : best === 0
108
+ ? []
109
+ : scored
110
+ .filter((s) => s.score === best)
111
+ .sort(byShortestId)
112
+ .slice(0, 5);
113
+ return {
114
+ cards: chosen.map((s) => cardInfo(s.card, wallet, today)),
115
+ exact,
116
+ };
117
+ }
118
+ const list = (ids) => ids.join(', ');
119
+ // What a card in the wallet still needs before its answers are right.
120
+ export function setupNotes(catalog, wallet, held, today) {
121
+ const card = catalog.cards.find((c) => c.id === held.id);
122
+ if (!card)
123
+ return [];
124
+ const notes = [];
125
+ const name = held.checkout_label ?? card.name;
126
+ const { picked, staleChoice } = pickedCategories(card, held, today);
127
+ for (const choice of card.choices) {
128
+ const current = held.chosen?.picks[choice.id] !== undefined &&
129
+ (staleChoice === null || choice.period === 'until_changed');
130
+ if (current)
131
+ continue;
132
+ const options = list(Object.keys(choice.options));
133
+ notes.push({
134
+ kind: 'pick',
135
+ text: choice.default && picked.has(choice.id)
136
+ ? `${name}'s ${choice.id} pick is assumed to be ${list(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
137
+ : `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
138
+ });
139
+ }
140
+ const missing = membershipsUsed(card).filter((m) => !wallet.memberships.includes(m));
141
+ if (missing.length > 0) {
142
+ notes.push({
143
+ kind: 'membership',
144
+ text: `Some of ${name}'s rates need a membership: ${list(missing)}. Add any that apply to the wallet's memberships.`,
145
+ });
146
+ }
147
+ const toActivate = activationRules(card, today).filter((id) => !held.activated.includes(id));
148
+ if (toActivate.length > 0) {
149
+ notes.push({
150
+ kind: 'activation',
151
+ text: `${name} has rates that apply only once activated with the issuer: ${list(toActivate)}. Mark the ones already activated.`,
152
+ });
153
+ }
154
+ if (needsAnniversary(card) && held.anniversary === undefined) {
155
+ notes.push({
156
+ kind: 'anniversary',
157
+ text: `${name} has a cap that resets on the card's anniversary. Add the anniversary (MM-DD) so it can be tracked.`,
158
+ });
159
+ }
160
+ const gap = rotationGap(card, today);
161
+ if (gap !== null) {
162
+ notes.push({
163
+ kind: 'rotation',
164
+ text: `${name}'s rotating categories for ${gap} aren't in earnrate's data yet.`,
165
+ });
166
+ }
167
+ return notes;
168
+ }
@@ -1,6 +1,7 @@
1
1
  import { bonusStatus } from "./bonus.js";
2
2
  import { pickedCategories } from "./evaluate.js";
3
3
  import { daysBetween, periodKey } from "./periods.js";
4
+ import { rotationGap } from "./rotation.js";
4
5
  import { STALE_AFTER_DAYS } from "./recommend.js";
5
6
  import { cardValuation } from "./valuation.js";
6
7
  function capSummaries(card, held, today) {
@@ -54,6 +55,7 @@ export function walletSummary(catalog, wallet, today) {
54
55
  lastVerified: card.last_verified,
55
56
  verifiedBy: card.verified_by,
56
57
  dataIsStale: daysBetween(card.last_verified, today) > STALE_AFTER_DAYS,
58
+ rotationGap: rotationGap(card, today),
57
59
  caps: capSummaries(card, held, today),
58
60
  chosen: held.chosen
59
61
  ? {