Skip to content
This repository was archived by the owner on Feb 11, 2025. It is now read-only.

Commit d34b107

Browse files
authored
Optimize fetch performance, particularly for old zeroed accounts (#21)
1 parent add3512 commit d34b107

File tree

3 files changed

+186
-42
lines changed

3 files changed

+186
-42
lines changed

src/shared/lib/__tests__/accounts.test.ts

+114
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { TEST_MINT_API_KEY } from '@root/src/shared/lib/constants';
22
import {
3+
calculateIntervalForAccountHistory,
34
fetchAccounts,
45
fetchDailyBalancesForAllAccounts,
56
fetchMonthlyBalancesForAccount,
67
fetchNetWorthBalances,
78
formatBalancesAsCSV,
89
} from '../accounts';
10+
import { DateTime } from 'luxon';
911

1012
describe('fetchMonthlyBalancesForAccount', () => {
1113
it('fetches balances by date for asset account', async () => {
@@ -77,6 +79,58 @@ describe('formatBalancesAsCSV', () => {
7779
]);
7880
expect(result).toEqual(`"Date","Amount"
7981
"2020-01-01",""
82+
`);
83+
});
84+
85+
it('trims trailing zero balances', () => {
86+
const result = formatBalancesAsCSV([
87+
{
88+
amount: 123.45,
89+
date: '2020-01-01',
90+
type: '',
91+
},
92+
{
93+
amount: 234.56,
94+
date: '2020-01-02',
95+
type: '',
96+
},
97+
{
98+
amount: 0,
99+
date: '2020-01-03',
100+
type: '',
101+
},
102+
{
103+
amount: 0,
104+
date: '2020-01-04',
105+
type: '',
106+
},
107+
]);
108+
expect(result).toEqual(`"Date","Amount"
109+
"2020-01-01","123.45"
110+
"2020-01-02","234.56"
111+
`);
112+
});
113+
114+
it('leaves one row if all balances are zero', () => {
115+
const result = formatBalancesAsCSV([
116+
{
117+
amount: 0,
118+
date: '2020-01-01',
119+
type: '',
120+
},
121+
{
122+
amount: 0,
123+
date: '2020-01-02',
124+
type: '',
125+
},
126+
{
127+
amount: 0,
128+
date: '2020-01-03',
129+
type: '',
130+
},
131+
]);
132+
expect(result).toEqual(`"Date","Amount"
133+
"2020-01-01","0"
80134
`);
81135
});
82136
});
@@ -90,3 +144,63 @@ describe('fetchDailyBalancesForAllAccounts', () => {
90144
expect(response.length).toBeGreaterThan(0);
91145
}, 60000);
92146
});
147+
148+
describe('calculateIntervalForAccountHistory', () => {
149+
it('starts at the first day of the first month with history', () => {
150+
const result = calculateIntervalForAccountHistory([
151+
{ date: '2023-01-31', amount: 5, type: '' },
152+
{ date: '2023-02-28', amount: 10, type: '' },
153+
]);
154+
expect(result.start.toISODate()).toBe('2023-01-01');
155+
});
156+
157+
it('ends today for nonzero balances', () => {
158+
const result = calculateIntervalForAccountHistory([
159+
{ date: '2023-01-31', amount: 5, type: '' },
160+
{ date: '2023-02-28', amount: 10, type: '' },
161+
]);
162+
expect(result.end.toISODate()).toBe(DateTime.now().toISODate());
163+
});
164+
165+
it('ends today even if the data goes beyond today', () => {
166+
const nextMonth = DateTime.now().plus({ month: 1 }).endOf('month').toISODate();
167+
const result = calculateIntervalForAccountHistory([
168+
{ date: '2023-01-31', amount: 5, type: '' },
169+
{ date: nextMonth, amount: 10, type: '' },
170+
]);
171+
expect(result.end.toISODate()).toBe(DateTime.now().toISODate());
172+
});
173+
174+
it('ends 1 month after the last historic nonzero monthly balance', () => {
175+
const result = calculateIntervalForAccountHistory([
176+
{ date: '2023-01-31', amount: 5, type: '' },
177+
{ date: '2023-02-28', amount: 10, type: '' },
178+
{ date: '2023-03-31', amount: 0, type: '' },
179+
]);
180+
expect(result.end.toISODate()).toBe('2023-03-31');
181+
});
182+
183+
it('ends 1 month after the last historic nonzero monthly balance', () => {
184+
const result = calculateIntervalForAccountHistory([
185+
{ date: '2023-01-31', amount: 5, type: '' },
186+
{ date: '2023-02-28', amount: 10, type: '' },
187+
{ date: '2023-03-31', amount: 0, type: '' },
188+
{ date: '2023-04-30', amount: 0, type: '' },
189+
{ date: '2023-05-31', amount: 0, type: '' },
190+
]);
191+
expect(result.end.toISODate()).toBe('2023-03-31');
192+
});
193+
194+
it('includes two full months for zero balances', () => {
195+
// No need for a special case here, the interval is 2 months because we always add 1 month for
196+
// safety to the last month worth including in the report.
197+
const result = calculateIntervalForAccountHistory([
198+
{ date: '2023-01-31', amount: 0, type: '' },
199+
{ date: '2023-02-28', amount: 0, type: '' },
200+
{ date: '2023-03-31', amount: 0, type: '' },
201+
{ date: '2023-04-30', amount: 0, type: '' },
202+
]);
203+
expect(result.start.toISODate()).toBe('2023-01-01');
204+
expect(result.end.toISODate()).toBe('2023-02-28');
205+
});
206+
});

src/shared/lib/accounts.ts

+69-42
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { DateTime, Interval } from 'luxon';
33
import { makeMintApiRequest } from '@root/src/shared/lib/auth';
44
import {
55
DATE_FILTER_ALL_TIME,
6+
MINT_DAILY_TRENDS_MAX_DAYS,
67
MINT_HEADERS,
78
MINT_RATE_LIMIT_DELAY_MS,
89
} from '@root/src/shared/lib/constants';
@@ -75,10 +76,40 @@ export const fetchMonthlyBalancesForAccount = async ({
7576
}
7677
};
7778

79+
export const calculateIntervalForAccountHistory = (monthlyBalances: TrendEntry[]) => {
80+
const startDate = monthlyBalances[0]?.date;
81+
82+
if (!startDate) {
83+
throw new Error('Unable to determine start date for account history.');
84+
}
85+
86+
// find the last month with a non-zero balance
87+
let endDate: string;
88+
let monthIndex = monthlyBalances.length - 1;
89+
while (monthIndex > 0 && monthlyBalances[monthIndex].amount === 0) {
90+
monthIndex -= 1;
91+
endDate = monthlyBalances[monthIndex].date;
92+
}
93+
94+
const now = DateTime.now();
95+
const approximateRangeEnd = endDate
96+
? // Mint trend months are strange and daily balances may be present after the end of the reported
97+
// month (anecodotally observed daily balances 10 days into the first month that showed a zero
98+
// monthly balance).
99+
DateTime.fromISO(endDate).plus({ month: 1 }).endOf('month')
100+
: now;
101+
102+
// then fetch balances for each period in the range
103+
return Interval.fromDateTimes(
104+
DateTime.fromISO(startDate).startOf('month'),
105+
(approximateRangeEnd < now ? approximateRangeEnd : now).endOf('day'),
106+
);
107+
};
108+
78109
/**
79-
* Determine earliest date for which account has balance history, and return monthly intervals from then to now.
110+
* Determine earliest date for which account has balance history, and return 43 day intervals from then to now.
80111
*/
81-
const fetchMonthlyIntervalsForAccountHistory = async ({
112+
const fetchIntervalsForAccountHistory = async ({
82113
accountId,
83114
overrideApiKey,
84115
}: {
@@ -95,35 +126,25 @@ const fetchMonthlyIntervalsForAccountHistory = async ({
95126
}
96127

97128
const { balancesByDate: monthlyBalances, reportType } = balanceInfo;
98-
99-
const startDate = monthlyBalances[0]?.date;
100-
101-
if (!startDate) {
102-
throw new Error('Unable to determine start date for account history.');
103-
}
104-
105-
// then fetch balances for each month in range, since that's the only timeframe that the API will return a balance for each day
106-
const months = Interval.fromDateTimes(
107-
DateTime.fromISO(startDate).startOf('month'),
108-
DateTime.local().endOf('month'),
109-
).splitBy({
110-
months: 1,
129+
const interval = calculateIntervalForAccountHistory(monthlyBalances);
130+
const periods = interval.splitBy({
131+
days: MINT_DAILY_TRENDS_MAX_DAYS,
111132
}) as Interval[];
112133

113-
return { months, reportType };
134+
return { periods, reportType };
114135
};
115136

116137
/**
117138
* Fetch balance history for each month for an account.
118139
*/
119-
const fetchDailyBalancesForMonthIntervals = async ({
120-
months,
140+
const fetchDailyBalancesForAccount = async ({
141+
periods,
121142
accountId,
122143
reportType,
123144
overrideApiKey,
124145
onProgress,
125146
}: {
126-
months: Interval[];
147+
periods: Interval[];
127148
accountId: string;
128149
reportType: string;
129150
overrideApiKey?: string;
@@ -137,8 +158,8 @@ const fetchDailyBalancesForMonthIntervals = async ({
137158
count: 0,
138159
};
139160

140-
const dailyBalancesByMonth = await withRateLimit({ delayMs: MINT_RATE_LIMIT_DELAY_MS })(
141-
months.map(
161+
const dailyBalancesByPeriod = await withRateLimit({ delayMs: MINT_RATE_LIMIT_DELAY_MS })(
162+
periods.map(
142163
({ start, end }) =>
143164
() =>
144165
withRetry(() =>
@@ -167,13 +188,13 @@ const fetchDailyBalancesForMonthIntervals = async ({
167188
)
168189
.finally(() => {
169190
counter.count += 1;
170-
onProgress?.({ complete: counter.count, total: months.length });
191+
onProgress?.({ complete: counter.count, total: periods.length });
171192
}),
172193
),
173194
),
174195
);
175196

176-
const balancesByDate = dailyBalancesByMonth.reduce((acc, balances) => acc.concat(balances), []);
197+
const balancesByDate = dailyBalancesByPeriod.reduce((acc, balances) => acc.concat(balances), []);
177198

178199
return balancesByDate;
179200
};
@@ -187,43 +208,43 @@ export const fetchDailyBalancesForAllAccounts = async ({
187208
}) => {
188209
const accounts = await withRetry(() => fetchAccounts({ overrideApiKey }));
189210

190-
// first, fetch the range of months we need to fetch for each account
191-
const accountsWithMonthsToFetch = await Promise.all(
211+
// first, fetch the range of dates we need to fetch for each account
212+
const accountsWithPeriodsToFetch = await Promise.all(
192213
accounts.map(async ({ id: accountId, name: accountName }) => {
193-
const { months, reportType } = await withDefaultOnError({ months: [], reportType: '' })(
194-
fetchMonthlyIntervalsForAccountHistory({
214+
const { periods, reportType } = await withDefaultOnError({ periods: [], reportType: '' })(
215+
fetchIntervalsForAccountHistory({
195216
accountId,
196217
overrideApiKey,
197218
}),
198219
);
199-
return { months, reportType, accountId, accountName };
220+
return { periods, reportType, accountId, accountName };
200221
}),
201222
);
202223

203-
// one per account per month
204-
const totalRequestsToFetch = accountsWithMonthsToFetch.reduce(
205-
(acc, { months }) => acc + months.length,
224+
// one per account per 43 day period
225+
const totalRequestsToFetch = accountsWithPeriodsToFetch.reduce(
226+
(acc, { periods }) => acc + periods.length,
206227
0,
207228
);
208229

209230
// fetch one account at a time so we don't hit the rate limit
210231
const balancesByAccount = await resolveSequential(
211-
accountsWithMonthsToFetch.map(
212-
({ accountId, accountName, months, reportType }, accountIndex) =>
232+
accountsWithPeriodsToFetch.map(
233+
({ accountId, accountName, periods, reportType }, accountIndex) =>
213234
async () => {
214235
const balances = await withDefaultOnError<TrendEntry[]>([])(
215-
fetchDailyBalancesForMonthIntervals({
236+
fetchDailyBalancesForAccount({
216237
accountId,
217-
months,
238+
periods,
218239
reportType,
219240
overrideApiKey,
220241
onProgress: ({ complete }) => {
221242
// this is the progress handler for *each* account, so we need to sum up the results before calling onProgress
222243

223-
const previousAccounts = accountsWithMonthsToFetch.slice(0, accountIndex);
244+
const previousAccounts = accountsWithPeriodsToFetch.slice(0, accountIndex);
224245
// since accounts are fetched sequentially, we can assume that all previous accounts have completed all their requests
225246
const previousCompletedRequestCount = previousAccounts.reduce(
226-
(acc, { months }) => acc + months.length,
247+
(acc, { periods }) => acc + periods.length,
227248
0,
228249
);
229250
const completedRequests = previousCompletedRequestCount + complete;
@@ -344,11 +365,17 @@ export const fetchAccounts = async ({
344365

345366
export const formatBalancesAsCSV = (balances: TrendEntry[], accountName?: string) => {
346367
const header = ['Date', 'Amount', accountName && 'Account Name'].filter(Boolean);
347-
const rows = balances.map(({ date, amount }) => [
348-
date,
349-
amount,
350-
...(accountName ? [accountName] : []),
351-
]);
368+
const maybeAccountColumn: [string?] = accountName ? [accountName] : [];
369+
// remove zero balances from the end of the report leaving just the first row if all are zero
370+
const rows = balances.reduceRight(
371+
(acc, { date, amount }, index) => {
372+
if (acc.length || amount !== 0 || index === 0) {
373+
acc.unshift([date, amount, ...maybeAccountColumn]);
374+
}
375+
return acc;
376+
},
377+
[] as [string, number, string?][],
378+
);
352379

353380
return formatCSV([header, ...rows]);
354381
};

src/shared/lib/constants.ts

+3
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ export const UTM_URL_PARAMETERS = {
1414

1515
// we may need to increase this, need to test more
1616
export const MINT_RATE_LIMIT_DELAY_MS = 50;
17+
18+
// The Mint API returns daily activity when the date range is 43 days or fewer.
19+
export const MINT_DAILY_TRENDS_MAX_DAYS = 43;

0 commit comments

Comments
 (0)