diff --git a/src/components/popup/DownloadTrend.tsx b/src/components/popup/DownloadTrend.tsx
new file mode 100644
index 0000000..8d9ddd3
--- /dev/null
+++ b/src/components/popup/DownloadTrend.tsx
@@ -0,0 +1,68 @@
+import ErrorBoundary from '@root/src/components/ErrorBoundary';
+import Progress from '@root/src/components/Progress';
+import SpinnerWithText from '@root/src/components/SpinnerWithText';
+import Text from '@root/src/components/Text';
+import DefaultButton from '@root/src/components/button/DefaultButton';
+
+import useStorage from '@root/src/shared/hooks/useStorage';
+import { useMemo } from 'react';
+import trendStorage, { TrendDownloadStatus } from '../../shared/storages/trendStorage';
+
+const DownloadTrend = () => {
+ const trendStateValue = useStorage(trendStorage);
+ const isSuccess = trendStateValue.status === TrendDownloadStatus.Success;
+
+ const content = useMemo(() => {
+ const { progress } = trendStateValue ?? {};
+ const { completePercentage = 0 } = progress ?? {};
+
+ if (isSuccess) {
+ return (
+
+ Download complete!
+
+ Balance history for the trend downloaded to your computer.
+
+
+ Import into Monarch
+
+
+ );
+ } else if (completePercentage > 0) {
+ return (
+
+
+ Downloading balance history for the trend. This may take a minute.
+
+ {completePercentage > 0 && (
+
+ )}
+
+ );
+ } else {
+ return Getting your balance information...;
+ }
+ }, [isSuccess, trendStateValue]);
+
+ if (trendStateValue.status === TrendDownloadStatus.Error) {
+ return (
+
+ Sorry, there was an error downloading the trend balances. Note that daily trend data is less
+ likely to be available for trends that include very old transactions across a large number
+ of accounts. Please try again later or refine the trend.
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default DownloadTrend;
diff --git a/src/components/popup/PopupContainer.tsx b/src/components/popup/PopupContainer.tsx
index 498247f..fd5f9be 100644
--- a/src/components/popup/PopupContainer.tsx
+++ b/src/components/popup/PopupContainer.tsx
@@ -14,6 +14,9 @@ import OtherResources from '@root/src/components/popup/OtherResources';
import { fetchAccounts } from '@root/src/shared/lib/accounts';
import DownloadBalances from '@root/src/components/popup/DownloadBalances';
import accountStorage, { AccountsDownloadStatus } from '@root/src/shared/storages/accountStorage';
+import DownloadTrend from './DownloadTrend';
+import { isSupportedTrendReport } from '../../shared/lib/trends';
+import trendStorage, { TrendDownloadStatus } from '../../shared/storages/trendStorage';
interface Page {
title: string;
@@ -29,12 +32,26 @@ const PAGE_TO_COMPONENT: Record = {
title: 'Mint Account Balance History',
component: DownloadBalances,
},
+ downloadTrend: {
+ title: 'Current Trend Balance History',
+ component: DownloadTrend,
+ },
};
const PopupContainer = ({ children }: React.PropsWithChildren) => {
const { currentPage, downloadTransactionsStatus } = useStorage(stateStorage);
+ const { trend, status: trendStatus } = useStorage(trendStorage);
const { status, userData } = usePopupContext();
const sendMessage = useMessageSender();
+ let showPage = currentPage;
+
+ // Trend download is likely to be completed multiple times in a row; do not require the user to
+ // click the back arrow after every download if the popup was closed.
+ if (currentPage === 'downloadTrend' && trendStatus === TrendDownloadStatus.Success) {
+ showPage = undefined;
+ stateStorage.patch({ currentPage: undefined });
+ trendStorage.patch({ status: TrendDownloadStatus.Idle });
+ }
const onDownloadTransactions = useCallback(async () => {
const { downloadTransactionsStatus } = await stateStorage.get();
@@ -98,6 +115,15 @@ const PopupContainer = ({ children }: React.PropsWithChildren) => {
await sendMessage({ action: Action.DownloadAllAccountBalances });
}, [sendMessage]);
+ const onDownloadTrend = useCallback(async () => {
+ await stateStorage.patch({
+ currentPage: 'downloadTrend',
+ downloadTransactionsStatus: undefined,
+ totalTransactionsCount: undefined,
+ });
+ await sendMessage({ action: Action.DownloadTrendBalances });
+ }, [sendMessage]);
+
const content = useMemo(() => {
switch (status) {
case ResponseStatus.Loading:
@@ -129,6 +155,11 @@ const PopupContainer = ({ children }: React.PropsWithChildren) => {
Download Mint account balance history
+
+ Download current trend daily balances
+
);
default:
@@ -138,17 +169,23 @@ const PopupContainer = ({ children }: React.PropsWithChildren) => {
);
}
- }, [status, userData?.userName, onDownloadTransactions, onDownloadAccountBalanceHistory]);
+ }, [
+ status,
+ userData?.userName,
+ trend?.reportType,
+ onDownloadTransactions,
+ onDownloadAccountBalanceHistory,
+ ]);
- const { component: PageComponent, title: pageTitle } = PAGE_TO_COMPONENT[currentPage] ?? {};
+ const { component: PageComponent, title: pageTitle } = PAGE_TO_COMPONENT[showPage] ?? {};
// 💀
const showBackArrow =
- currentPage === 'downloadTransactions'
+ showPage === 'downloadTransactions'
? downloadTransactionsStatus !== ResponseStatus.Loading
- : currentPage === 'downloadBalances'
+ : showPage === 'downloadBalances'
? downloadTransactionsStatus !== ResponseStatus.Loading
- : !!currentPage; // there's a page that's not index (index is undefined)
+ : !!showPage; // there's a page that's not index (index is undefined)
// Make sure it's actually running
if (currentPage === 'downloadBalances') {
@@ -158,8 +195,11 @@ const PopupContainer = ({ children }: React.PropsWithChildren) => {
} = accountStorage.getSnapshot();
if (status === AccountsDownloadStatus.Loading) {
setTimeout(async () => {
- const { progress } = await accountStorage.get();
- if (completePercentage === progress.completePercentage) {
+ const { status, progress } = await accountStorage.get();
+ if (
+ status === AccountsDownloadStatus.Loading &&
+ completePercentage === progress.completePercentage
+ ) {
await accountStorage.patch({
status: AccountsDownloadStatus.Error,
});
@@ -175,6 +215,24 @@ const PopupContainer = ({ children }: React.PropsWithChildren) => {
});
}
}, 30_000);
+ } else if (currentPage === 'downloadTrend') {
+ const {
+ status,
+ progress: { completePercentage },
+ } = trendStorage.getSnapshot();
+ if (status === TrendDownloadStatus.Loading) {
+ setTimeout(async () => {
+ const { status, progress } = await trendStorage.get();
+ if (
+ status === TrendDownloadStatus.Loading &&
+ completePercentage === progress.completePercentage
+ ) {
+ await trendStorage.patch({
+ status: TrendDownloadStatus.Error,
+ });
+ }
+ }, 5_000); // 5 seconds is enough since there is no networking before progress begins
+ }
}
return (
diff --git a/src/pages/background/index.ts b/src/pages/background/index.ts
index bab34fe..75a9bf0 100644
--- a/src/pages/background/index.ts
+++ b/src/pages/background/index.ts
@@ -1,10 +1,13 @@
import { ResponseStatus } from '@root/src/pages/popup/Popup';
import { ErrorCode } from '@root/src/shared/constants/error';
-import { Action } from '@root/src/shared/hooks/useMessage';
+import { Action, Message } from '@root/src/shared/hooks/useMessage';
import {
fetchDailyBalancesForAllAccounts,
formatBalancesAsCSV,
BalanceHistoryCallbackProgress,
+ fetchDailyBalancesForTrend,
+ TrendBalanceHistoryCallbackProgress,
+ TrendEntry,
} from '@root/src/shared/lib/accounts';
import { throttle } from '@root/src/shared/lib/events';
import stateStorage from '@root/src/shared/storages/stateStorage';
@@ -20,6 +23,8 @@ import reloadOnUpdate from 'virtual:reload-on-update-in-background-script';
import 'webextension-polyfill';
import * as Sentry from '@sentry/browser';
+import trendStorage, { TrendDownloadStatus } from '../../shared/storages/trendStorage';
+import { getCurrentTrendState } from '../../shared/lib/trends';
// @ts-ignore - https://github.com/getsentry/sentry-javascript/issues/5289#issuecomment-1368705821
Sentry.WINDOW.document = {
@@ -49,7 +54,7 @@ reloadOnUpdate('pages/background');
const THROTTLE_INTERVAL_MS = 200;
-chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
if (sender.tab?.url.startsWith('chrome://')) {
return true;
}
@@ -65,10 +70,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
handlePopupOpened(sendResponse);
} else if (message.action === Action.GetMintApiKey) {
handleMintAuthentication(sendResponse);
+ } else if (message.action === Action.GetTrendState) {
+ handleGetTrendState(sendResponse);
} else if (message.action === Action.DownloadTransactions) {
handleTransactionsDownload(sendResponse);
} else if (message.action === Action.DownloadAllAccountBalances) {
handleDownloadAllAccountBalances(sendResponse);
+ } else if (message.action === Action.DownloadTrendBalances) {
+ handleDownloadTrendBalances(sendResponse);
} else if (message.action === Action.DebugThrowError) {
throw new Error('Debug error');
} else {
@@ -125,6 +134,35 @@ function getMintApiKey() {
return window.__shellInternal?.appExperience?.appApiKey;
}
+const handleGetTrendState = async (sendResponse: (args: unknown) => void) => {
+ const [activeMintTab] = await chrome.tabs.query({
+ active: true,
+ url: 'https://mint.intuit.com/*',
+ });
+
+ // No active Mint tab, return early
+ if (!activeMintTab) {
+ sendResponse({ success: false, error: ErrorCode.MintTabNotFound });
+ return;
+ }
+
+ // Get the trend state from the page
+ const response = await chrome.scripting.executeScript({
+ target: { tabId: activeMintTab.id },
+ world: 'MAIN',
+ func: getCurrentTrendState,
+ });
+
+ const [{ result: trend }] = response;
+
+ await trendStorage.patch({ trend });
+ if (trend) {
+ sendResponse({ success: true, trend });
+ } else {
+ sendResponse({ success: false, error: ErrorCode.MintTrendStateNotFound });
+ }
+};
+
const handleTransactionsDownload = async (sendResponse: (args: unknown) => void) => {
const totalTransactionCount = await fetchTransactionsTotalCount();
@@ -170,7 +208,10 @@ const handleDownloadAllAccountBalances = async (sendResponse: () => void) => {
const seenCount = (seenAccountNames[accountName] = (seenAccountNames[accountName] || 0) + 1);
// If there are multiple accounts with the same name, export both with distinct filenames
const disambiguation = seenCount > 1 ? ` (${seenCount - 1})` : '';
- zip.file(`${accountName}${disambiguation}-${fiName}.csv`, formatBalancesAsCSV(balances, accountName));
+ zip.file(
+ `${accountName}${disambiguation}-${fiName}.csv`,
+ formatBalancesAsCSV({ balances, accountName }),
+ );
});
const zipFile = await zip.generateAsync({ type: 'base64' });
@@ -192,6 +233,48 @@ const handleDownloadAllAccountBalances = async (sendResponse: () => void) => {
}
};
+let pendingTrendBalances: Promise;
+
+/** Download daily balances for the specified trend. */
+const handleDownloadTrendBalances = async (sendResponse: () => void) => {
+ try {
+ if (pendingTrendBalances) {
+ // already downloading
+ await pendingTrendBalances;
+ } else {
+ const throttledSendDownloadTrendBalancesProgress = throttle(
+ sendDownloadTrendBalancesProgress,
+ THROTTLE_INTERVAL_MS,
+ );
+ const { trend } = await trendStorage.get();
+ await trendStorage.set({
+ trend,
+ status: TrendDownloadStatus.Loading,
+ progress: { completePercentage: 0 },
+ });
+ pendingTrendBalances = fetchDailyBalancesForTrend({
+ trend,
+ onProgress: throttledSendDownloadTrendBalancesProgress,
+ });
+ const balances = await pendingTrendBalances;
+ const { reportType } = trend;
+ const csv = formatBalancesAsCSV({ balances, reportType });
+
+ chrome.downloads.download({
+ url: `data:text/csv,${csv}`,
+ filename: 'mint-trend-daily-balances.csv',
+ });
+
+ await trendStorage.patch({ status: TrendDownloadStatus.Success });
+ }
+ } catch (e) {
+ await trendStorage.patch({ status: TrendDownloadStatus.Error });
+ } finally {
+ pendingTrendBalances = null;
+ sendResponse();
+ }
+};
+
/**
* Updates both the state storage and sends a message with the current progress,
* so the popup can update the UI and we have a state to restore from if the
@@ -200,3 +283,7 @@ const handleDownloadAllAccountBalances = async (sendResponse: () => void) => {
const sendDownloadBalancesProgress = async (payload: BalanceHistoryCallbackProgress) => {
await accountStorage.patch({ progress: payload });
};
+
+const sendDownloadTrendBalancesProgress = async (payload: TrendBalanceHistoryCallbackProgress) => {
+ await trendStorage.patch({ progress: payload });
+};
diff --git a/src/pages/popup/Popup.tsx b/src/pages/popup/Popup.tsx
index 6761125..1a1fe8b 100644
--- a/src/pages/popup/Popup.tsx
+++ b/src/pages/popup/Popup.tsx
@@ -90,6 +90,10 @@ const Popup = () => {
} else {
await authenticateUser(apiKey);
}
+
+ await sendMessage({
+ action: Action.GetTrendState,
+ });
};
const authenticateOnDashboard = async () => {
diff --git a/src/shared/constants/error.ts b/src/shared/constants/error.ts
index 2d0b3d4..108a29d 100644
--- a/src/shared/constants/error.ts
+++ b/src/shared/constants/error.ts
@@ -1,4 +1,5 @@
export enum ErrorCode {
MintTabNotFound = 'ERR_MINT_NOT_FOUND',
MintApiKeyNotFound = 'ERR_MINT_API_KEY_NOT_FOUND',
+ MintTrendStateNotFound = 'ERR_MINT_TREND_STATE_NOT_FOUND',
}
diff --git a/src/shared/hooks/useMessage.ts b/src/shared/hooks/useMessage.ts
index 9392067..19ac3eb 100644
--- a/src/shared/hooks/useMessage.ts
+++ b/src/shared/hooks/useMessage.ts
@@ -3,23 +3,30 @@ import { useRef, useEffect, useCallback } from 'react';
export enum Action {
PopupOpened = 'POPUP_OPENED',
GetMintApiKey = 'GET_MINT_API_KEY',
+ GetTrendState = 'GET_TREND_STATE',
// Sent by the button in the popup to start downloading transactions
RequestTransactionsDownload = 'REQUEST_TRANSACTIONS_DOWNLOAD',
DownloadTransactions = 'DOWNLOAD_TRANSACTIONS',
DownloadAllAccountBalances = 'DOWNLOAD_ALL_ACCOUNT_BALANCES',
DownloadBalancesProgress = 'DOWNLOAD_BALANCES_PROGRESS',
DownloadBalancesComplete = 'DOWNLOAD_BALANCES_COMPLETE',
+ DownloadTrendBalances = 'DOWNLOAD_TREND_BALANCES',
+ DownloadTrendBalancesProgress = 'DOWNLOAD_TREND_BALANCES_PROGRESS',
// Debug actions
DebugThrowError = 'DEBUG_THROW_ERROR',
}
-type Message = { action: Action; payload?: Record };
+export type Message> = {
+ action: Action;
+ payload?: TPayload;
+};
export const useMessageListener = >(
action: Action,
callback: (payload: TPayload) => void | Promise,
) => {
- const listenerRef = useRef<(message: Message, sender: unknown, sendResponse: unknown) => void>();
+ const listenerRef =
+ useRef<(message: Message, sender: unknown, sendResponse: unknown) => void>();
useEffect(() => {
if (listenerRef.current) {
@@ -31,9 +38,9 @@ export const useMessageListener = >(
if (message.action === action) {
// eslint-disable-next-line no-prototype-builtins
if (callback.hasOwnProperty('then')) {
- await callback(message.payload as TPayload);
+ await callback(message.payload);
} else {
- callback(message.payload as TPayload);
+ callback(message.payload);
}
}
diff --git a/src/shared/lib/__tests__/accounts.test.ts b/src/shared/lib/__tests__/accounts.test.ts
index a96868f..4c44108 100644
--- a/src/shared/lib/__tests__/accounts.test.ts
+++ b/src/shared/lib/__tests__/accounts.test.ts
@@ -1,26 +1,30 @@
import { TEST_MINT_API_KEY } from '@root/src/shared/lib/constants';
import {
+ ReportType,
+ TrendState,
calculateIntervalForAccountHistory,
fetchAccounts,
fetchDailyBalancesForAllAccounts,
- fetchMonthlyBalancesForAccount,
+ fetchMonthlyBalances,
fetchNetWorthBalances,
formatBalancesAsCSV,
+ getAccountTypeFilterForTrend,
+ makeAccountIdFilter,
} from '../accounts';
import { DateTime } from 'luxon';
-describe('fetchMonthlyBalancesForAccount', () => {
+describe('fetchMonthlyBalances', () => {
it('fetches balances by date for asset account', async () => {
- const { balancesByDate } = await fetchMonthlyBalancesForAccount({
- accountId: '43237333_1544498',
+ const { balancesByDate } = await fetchMonthlyBalances({
+ matchAllFilters: [makeAccountIdFilter('43237333_1544498')],
overrideApiKey: TEST_MINT_API_KEY,
});
expect(balancesByDate.length).toEqual(141);
});
it('fetches balances for debt account', async () => {
- const { balancesByDate } = await fetchMonthlyBalancesForAccount({
- accountId: '43237333_2630847',
+ const { balancesByDate } = await fetchMonthlyBalances({
+ matchAllFilters: [makeAccountIdFilter('43237333_2630847')],
overrideApiKey: TEST_MINT_API_KEY,
});
expect(balancesByDate.length).toEqual(141);
@@ -45,13 +49,13 @@ describe('fetchAccounts', () => {
describe('formatBalancesAsCSV', () => {
it('includes account name if provied', () => {
- const result = formatBalancesAsCSV(
- [
- { amount: 123.45, date: '2021-01-01', type: '' },
- { amount: 234.56, date: '2021-02-01', type: '' },
+ const result = formatBalancesAsCSV({
+ balances: [
+ { amount: 123.45, date: '2021-01-01', type: 'ASSET' },
+ { amount: 234.56, date: '2021-02-01', type: 'ASSET' },
],
- `Mason's Account`,
- );
+ accountName: `Mason's Account`,
+ });
expect(result).toEqual(`"Date","Amount","Account Name"
"2021-01-01","123.45","Mason's Account"
"2021-02-01","234.56","Mason's Account"
@@ -59,10 +63,12 @@ describe('formatBalancesAsCSV', () => {
});
it('does not include account name if not provided', () => {
- const result = formatBalancesAsCSV([
- { amount: 123.45, date: '2021-01-01', type: '' },
- { amount: 234.56, date: '2021-02-01', type: '' },
- ]);
+ const result = formatBalancesAsCSV({
+ balances: [
+ { amount: 123.45, date: '2021-01-01', type: 'ASSET' },
+ { amount: 234.56, date: '2021-02-01', type: 'ASSET' },
+ ],
+ });
expect(result).toEqual(`"Date","Amount"
"2021-01-01","123.45"
"2021-02-01","234.56"
@@ -70,41 +76,45 @@ describe('formatBalancesAsCSV', () => {
});
it('converts undefined balances to empty string', () => {
- const result = formatBalancesAsCSV([
- {
- amount: undefined,
- date: '2020-01-01',
- type: '',
- },
- ]);
+ const result = formatBalancesAsCSV({
+ balances: [
+ {
+ amount: undefined,
+ date: '2020-01-01',
+ type: 'ASSET',
+ },
+ ],
+ });
expect(result).toEqual(`"Date","Amount"
"2020-01-01",""
`);
});
it('trims trailing zero balances', () => {
- const result = formatBalancesAsCSV([
- {
- amount: 123.45,
- date: '2020-01-01',
- type: '',
- },
- {
- amount: 234.56,
- date: '2020-01-02',
- type: '',
- },
- {
- amount: 0,
- date: '2020-01-03',
- type: '',
- },
- {
- amount: 0,
- date: '2020-01-04',
- type: '',
- },
- ]);
+ const result = formatBalancesAsCSV({
+ balances: [
+ {
+ amount: 123.45,
+ date: '2020-01-01',
+ type: 'ASSET',
+ },
+ {
+ amount: 234.56,
+ date: '2020-01-02',
+ type: 'ASSET',
+ },
+ {
+ amount: 0,
+ date: '2020-01-03',
+ type: 'ASSET',
+ },
+ {
+ amount: 0,
+ date: '2020-01-04',
+ type: 'ASSET',
+ },
+ ],
+ });
expect(result).toEqual(`"Date","Amount"
"2020-01-01","123.45"
"2020-01-02","234.56"
@@ -112,25 +122,131 @@ describe('formatBalancesAsCSV', () => {
});
it('leaves one row if all balances are zero', () => {
- const result = formatBalancesAsCSV([
- {
- amount: 0,
- date: '2020-01-01',
- type: '',
- },
- {
- amount: 0,
- date: '2020-01-02',
- type: '',
- },
- {
- amount: 0,
- date: '2020-01-03',
- type: '',
- },
- ]);
+ const result = formatBalancesAsCSV({
+ balances: [
+ {
+ amount: 0,
+ date: '2020-01-01',
+ type: 'ASSET',
+ },
+ {
+ amount: 0,
+ date: '2020-01-02',
+ type: 'ASSET',
+ },
+ {
+ amount: 0,
+ date: '2020-01-03',
+ type: 'ASSET',
+ },
+ ],
+ });
expect(result).toEqual(`"Date","Amount"
"2020-01-01","0"
+`);
+ });
+
+ it('handles net income balances', () => {
+ const result = formatBalancesAsCSV({
+ reportType: 'NET_INCOME',
+ balances: [
+ {
+ amount: 0,
+ date: '2020-01-01',
+ type: 'INCOME',
+ },
+ {
+ amount: 123.45,
+ date: '2020-01-01',
+ type: 'EXPENSE',
+ },
+ {
+ amount: 43.21,
+ date: '2020-01-02',
+ type: 'INCOME',
+ },
+ {
+ amount: 234.56,
+ date: '2020-01-03',
+ type: 'INCOME',
+ },
+ ],
+ });
+ expect(result).toEqual(`"Date","Income","Expenses","Net"
+"2020-01-01","0","123.45","-123.45"
+"2020-01-02","43.21","0","43.21"
+"2020-01-03","234.56","0","234.56"
+`);
+ });
+
+ it('trims trailing zero net income balances', () => {
+ const result = formatBalancesAsCSV({
+ reportType: 'NET_INCOME',
+ balances: [
+ {
+ amount: 0,
+ date: '2020-01-01',
+ type: 'INCOME',
+ },
+ {
+ amount: 123.45,
+ date: '2020-01-01',
+ type: 'EXPENSE',
+ },
+ {
+ amount: 0,
+ date: '2020-01-02',
+ type: 'INCOME',
+ },
+ {
+ amount: 0,
+ date: '2020-01-03',
+ type: 'INCOME',
+ },
+ ],
+ });
+ expect(result).toEqual(`"Date","Income","Expenses","Net"
+"2020-01-01","0","123.45","-123.45"
+`);
+ });
+
+ it('fixes floating point subtraction in net income balances', () => {
+ const result = formatBalancesAsCSV({
+ reportType: 'NET_INCOME',
+ balances: [
+ {
+ amount: 123.45,
+ date: '2020-01-01',
+ type: 'INCOME',
+ },
+ {
+ amount: 12.35,
+ date: '2020-01-01',
+ type: 'EXPENSE',
+ },
+ ],
+ });
+ // Not 111.10000000000001
+ expect(result).toEqual(`"Date","Income","Expenses","Net"
+"2020-01-01","123.45","12.35","111.10"
+`);
+ });
+
+ it('represents zero in the Net column as 0.00', () => {
+ // as a consequence of toFixed(2)
+ const result = formatBalancesAsCSV({
+ reportType: 'NET_INCOME',
+ balances: [
+ {
+ amount: 0,
+ date: '2020-01-01',
+ type: 'INCOME',
+ },
+ ],
+ });
+ // Not 111.10000000000001
+ expect(result).toEqual(`"Date","Income","Expenses","Net"
+"2020-01-01","0","0","0.00"
`);
});
});
@@ -148,16 +264,16 @@ describe('fetchDailyBalancesForAllAccounts', () => {
describe('calculateIntervalForAccountHistory', () => {
it('starts at the first day of the first month with history', () => {
const result = calculateIntervalForAccountHistory([
- { date: '2023-01-31', amount: 5, type: '' },
- { date: '2023-02-28', amount: 10, type: '' },
+ { date: '2023-01-31', amount: 5, type: 'ASSET' },
+ { date: '2023-02-28', amount: 10, type: 'ASSET' },
]);
expect(result.start.toISODate()).toBe('2023-01-01');
});
it('ends today for nonzero balances', () => {
const result = calculateIntervalForAccountHistory([
- { date: '2023-01-31', amount: 5, type: '' },
- { date: '2023-02-28', amount: 10, type: '' },
+ { date: '2023-01-31', amount: 5, type: 'ASSET' },
+ { date: '2023-02-28', amount: 10, type: 'ASSET' },
]);
expect(result.end.toISODate()).toBe(DateTime.now().toISODate());
});
@@ -165,28 +281,28 @@ describe('calculateIntervalForAccountHistory', () => {
it('ends today even if the data goes beyond today', () => {
const nextMonth = DateTime.now().plus({ month: 1 }).endOf('month').toISODate();
const result = calculateIntervalForAccountHistory([
- { date: '2023-01-31', amount: 5, type: '' },
- { date: nextMonth, amount: 10, type: '' },
+ { date: '2023-01-31', amount: 5, type: 'ASSET' },
+ { date: nextMonth, amount: 10, type: 'ASSET' },
]);
expect(result.end.toISODate()).toBe(DateTime.now().toISODate());
});
it('ends 1 month after the last historic nonzero monthly balance', () => {
const result = calculateIntervalForAccountHistory([
- { date: '2023-01-31', amount: 5, type: '' },
- { date: '2023-02-28', amount: 10, type: '' },
- { date: '2023-03-31', amount: 0, type: '' },
+ { date: '2023-01-31', amount: 5, type: 'ASSET' },
+ { date: '2023-02-28', amount: 10, type: 'ASSET' },
+ { date: '2023-03-31', amount: 0, type: 'ASSET' },
]);
expect(result.end.toISODate()).toBe('2023-03-31');
});
it('ends 1 month after the last historic nonzero monthly balance', () => {
const result = calculateIntervalForAccountHistory([
- { date: '2023-01-31', amount: 5, type: '' },
- { date: '2023-02-28', amount: 10, type: '' },
- { date: '2023-03-31', amount: 0, type: '' },
- { date: '2023-04-30', amount: 0, type: '' },
- { date: '2023-05-31', amount: 0, type: '' },
+ { date: '2023-01-31', amount: 5, type: 'ASSET' },
+ { date: '2023-02-28', amount: 10, type: 'ASSET' },
+ { date: '2023-03-31', amount: 0, type: 'ASSET' },
+ { date: '2023-04-30', amount: 0, type: 'ASSET' },
+ { date: '2023-05-31', amount: 0, type: 'ASSET' },
]);
expect(result.end.toISODate()).toBe('2023-03-31');
});
@@ -195,12 +311,85 @@ describe('calculateIntervalForAccountHistory', () => {
// No need for a special case here, the interval is 2 months because we always add 1 month for
// safety to the last month worth including in the report.
const result = calculateIntervalForAccountHistory([
- { date: '2023-01-31', amount: 0, type: '' },
- { date: '2023-02-28', amount: 0, type: '' },
- { date: '2023-03-31', amount: 0, type: '' },
- { date: '2023-04-30', amount: 0, type: '' },
+ { date: '2023-01-31', amount: 0, type: 'ASSET' },
+ { date: '2023-02-28', amount: 0, type: 'ASSET' },
+ { date: '2023-03-31', amount: 0, type: 'ASSET' },
+ { date: '2023-04-30', amount: 0, type: 'ASSET' },
]);
expect(result.start.toISODate()).toBe('2023-01-01');
expect(result.end.toISODate()).toBe('2023-02-28');
});
});
+
+describe('getAccountTypeFilterForTrend', () => {
+ const baseTrend: TrendState = {
+ reportType: 'ASSETS_TIME',
+ fixedFilter: 'CUSTOM',
+ fromDate: '2020-01-01',
+ toDate: '2020-01-01',
+ };
+
+ it('should exclude certain debt accounts for ASSETS_TIME', () => {
+ const filter = getAccountTypeFilterForTrend({ ...baseTrend, reportType: 'ASSETS_TIME' });
+ expect(filter('BankAccount')).toBe(true);
+ expect(filter('VehicleAccount')).toBe(true);
+ expect(filter('CreditAccount')).toBe(false);
+ expect(filter('LoanAccount')).toBe(false);
+ });
+
+ it('should exclude certain asset accounts for DEBTS_TIME', () => {
+ const filter = getAccountTypeFilterForTrend({ ...baseTrend, reportType: 'DEBTS_TIME' });
+ expect(filter('CreditAccount')).toBe(true);
+ expect(filter('LoanAccount')).toBe(true);
+ expect(filter('BankAccount')).toBe(false);
+ expect(filter('InvestmentAccount')).toBe(false);
+ });
+
+ it('should exclude property accounts for SPENDING_TIME', () => {
+ const filter = getAccountTypeFilterForTrend({ ...baseTrend, reportType: 'SPENDING_TIME' });
+ expect(filter('BankAccount')).toBe(true);
+ expect(filter('CreditAccount')).toBe(true);
+ expect(filter('RealEstateAccount')).toBe(false);
+ expect(filter('VehicleAccount')).toBe(false);
+ });
+
+ it('should exclude property accounts for INCOME_TIME', () => {
+ const filter = getAccountTypeFilterForTrend({ ...baseTrend, reportType: 'INCOME_TIME' });
+ expect(filter('BankAccount')).toBe(true);
+ expect(filter('CreditAccount')).toBe(true);
+ expect(filter('RealEstateAccount')).toBe(false);
+ expect(filter('VehicleAccount')).toBe(false);
+ });
+
+ it('should include all accounts except insurance and cash for NET_WORTH', () => {
+ const filter = getAccountTypeFilterForTrend({ ...baseTrend, reportType: 'NET_WORTH' });
+ expect(filter('BankAccount')).toBe(true);
+ expect(filter('CreditAccount')).toBe(true);
+ expect(filter('RealEstateAccount')).toBe(true);
+ expect(filter('VehicleAccount')).toBe(true);
+ });
+
+ it('should exclude property accounts for NET_INCOME', () => {
+ const filter = getAccountTypeFilterForTrend({ ...baseTrend, reportType: 'NET_INCOME' });
+ expect(filter('BankAccount')).toBe(true);
+ expect(filter('CreditAccount')).toBe(true);
+ expect(filter('RealEstateAccount')).toBe(false);
+ expect(filter('VehicleAccount')).toBe(false);
+ });
+
+ it('should exclude cash and insurance accounts for all types', () => {
+ const reportTypes: ReportType[] = [
+ 'ASSETS_TIME',
+ 'DEBTS_TIME',
+ 'SPENDING_TIME',
+ 'INCOME_TIME',
+ 'NET_WORTH',
+ 'NET_INCOME',
+ ];
+ for (const reportType of reportTypes) {
+ const filter = getAccountTypeFilterForTrend({ ...baseTrend, reportType });
+ expect(filter('CashAccount')).toBe(false);
+ expect(filter('InsuranceAccount')).toBe(false);
+ }
+ });
+});
diff --git a/src/shared/lib/accounts.ts b/src/shared/lib/accounts.ts
index d5fe6a1..bf95f00 100644
--- a/src/shared/lib/accounts.ts
+++ b/src/shared/lib/accounts.ts
@@ -14,17 +14,87 @@ import {
withRateLimit,
} from '@root/src/shared/lib/promises';
-type TrendEntry = {
+export type AccountCategory = 'DEBT' | 'ASSET';
+
+export type TrendType = 'DEBT' | 'ASSET' | 'INCOME' | 'EXPENSE';
+
+export type ReportType =
+ | 'ASSETS_TIME'
+ | 'DEBTS_TIME'
+ | 'SPENDING_TIME'
+ | 'INCOME_TIME'
+ | 'NET_INCOME'
+ | 'NET_WORTH';
+
+export type FixedDateFilter =
+ | 'LAST_7_DAYS'
+ | 'LAST_14_DAYS'
+ | 'THIS_MONTH'
+ | 'LAST_MONTH'
+ | 'LAST_3_MONTHS'
+ | 'LAST_6_MONTHS'
+ | 'LAST_12_MONTHS'
+ | 'THIS_YEAR'
+ | 'LAST_YEAR'
+ | 'ALL_TIME'
+ | 'CUSTOM';
+
+export type TrendEntry = {
amount: number;
date: string;
// this is determined by the type of report we fetch (DEBTS_TIME/ASSETS_TIME)
// it will return different values if we decide to fetch more types of reports (e.g., SPENDING_TIME)
- type: 'DEBT' | 'ASSET' | string;
+ type: TrendType;
+ /** Represents the negative amount in net income/worth trends. Calculated, not from Mint */
+ inverseAmount?: number;
};
type TrendsResponse = {
Trend: TrendEntry[];
- // there's more here...
+ metaData: unknown;
+};
+
+type CategoryFilterData = {
+ type: 'CATEGORY';
+ includeChildCategories: boolean;
+ categoryId: string;
+ categoryName: string;
+};
+
+type DescriptionFilterData = {
+ type: 'DESCRIPTION';
+ description: string;
+};
+
+type TagFilterData = {
+ type: 'TAG';
+ tagId: string;
+ tagName: string;
+};
+
+export type FilterData = CategoryFilterData | DescriptionFilterData | TagFilterData;
+
+export type MatchType = 'all' | 'any';
+
+/** State of user selections on the Mint Trends page */
+export type TrendState = {
+ /** Selected accounts */
+ accountIds?: string[];
+ reportType: ReportType;
+ /** Spending and income filters, transform with {@link apiFilterForFilterData} for the API */
+ otherFilters?: FilterData[];
+ /**
+ * Whether transactions must match any or all {@link otherFilters}.
+ *
+ * Account filters always match all.
+ */
+ matchType?: MatchType;
+ /** Semantic representation of the {@link fromDate} {@link toDate} range */
+ fixedFilter: FixedDateFilter;
+ /** ISO start date */
+ fromDate: string;
+ /** ISO end date */
+ toDate: string;
};
export type BalanceHistoryProgressCallback = (progress: {
@@ -35,8 +105,94 @@ export type BalanceHistoryProgressCallback = (progress: {
export type BalanceHistoryCallbackProgress = Parameters[0];
+export type TrendBalanceHistoryProgressCallback = (progress: {
+ completePercentage: number;
+}) => void | Promise;
+
+export type TrendBalanceHistoryCallbackProgress =
+ Parameters[0];
+
type ProgressCallback = (progress: { complete: number; total: number }) => void | Promise;
+type AccountIdFilter = {
+ type: 'AccountIdFilter';
+ accountId: string;
+};
+
+type CategoryIdFilter = {
+ type: 'CategoryIdFilter';
+ categoryId: string;
+ includeChildCategories: boolean;
+};
+
+type DescriptionNameFilter = {
+ type: 'DescriptionNameFilter';
+ description: string;
+};
+
+type TagIdFilter = {
+ type: 'TagIdFilter';
+ tagId: string;
+};
+
+type ApiFilter = AccountIdFilter | CategoryIdFilter | DescriptionNameFilter | TagIdFilter;
+
+export type AccountType =
+ | 'BankAccount'
+ | 'CashAccount'
+ | 'CreditAccount'
+ | 'InsuranceAccount'
+ | 'InvestmentAccount'
+ | 'LoanAccount'
+ | 'RealEstateAccount'
+ | 'VehicleAccount'
+ | 'OtherPropertyAccount';
+
+type AccountTypeFilter = (accountType: AccountType) => boolean;
+
+type AccountsResponse = {
+ Account: {
+ type: AccountType;
+ id: string;
+ name: string;
+ fiName: string;
+ }[];
+};
+
+type FetchAccountsOptions = {
+ offset?: number;
+ limit?: number;
+ overrideApiKey?: string;
+};
+
+/**
+ * Allows filtering accounts since the API does not seem to allow negated account ID queries, yet
+ * also does not expose the selected accounts (see {@link deselectedAccountIds}).
+ *
+ * Logic from `accountsToFilter` data in Mint trends ui module.
+ */
+export const getAccountTypeFilterForTrend = (trend: TrendState): AccountTypeFilter => {
+ const defaultFilter = (type) => type !== 'CashAccount' && type !== 'InsuranceAccount';
+ switch (trend.reportType) {
+ case 'INCOME_TIME':
+ case 'SPENDING_TIME':
+ return (type) =>
+ type !== 'RealEstateAccount' && type !== 'VehicleAccount' && defaultFilter(type);
+ case 'ASSETS_TIME':
+ return (type) => type !== 'LoanAccount' && type !== 'CreditAccount' && defaultFilter(type);
+ case 'DEBTS_TIME':
+ return (type) =>
+ type !== 'BankAccount' && type !== 'InvestmentAccount' && defaultFilter(type);
+ case 'NET_INCOME':
+ return (type) =>
+ type !== 'RealEstateAccount' && type !== 'VehicleAccount' && defaultFilter(type);
+ case 'NET_WORTH':
+ return defaultFilter;
+ default:
+ throw new Error(`Unsupported report type: ${trend.reportType}`);
+ }
+};
+
/**
* Use internal Mint "Trends" API to fetch account balance by month
* for all time.
@@ -44,25 +200,30 @@ type ProgressCallback = (progress: { complete: number; total: number }) => void
* This is technically a paginated API, but since the limit is 1000 (> 83 years)
* we probably don't need to worry about pagination.
*/
-export const fetchMonthlyBalancesForAccount = async ({
- accountId,
+export const fetchMonthlyBalances = async ({
+ matchAllFilters,
+ matchAnyFilters,
+ reportType,
offset,
limit,
overrideApiKey,
}: {
- accountId: string;
+ matchAllFilters?: ApiFilter[];
+ matchAnyFilters?: ApiFilter[];
+ reportType?: ReportType;
offset?: number;
limit?: number;
overrideApiKey?: string;
-}): Promise<{ balancesByDate: TrendEntry[]; reportType: string } | undefined> => {
+}): Promise<{ balancesByDate: TrendEntry[]; reportType: ReportType } | undefined> => {
// we don't have a good way to know if an account is "asset" or "debt", so we just try both reports
// the Mint API returns undefined if the report type doesn't match the account type
- const tryReportTypes = ['ASSETS_TIME', 'DEBTS_TIME'];
+ const tryReportTypes: ReportType[] = reportType ? [reportType] : ['ASSETS_TIME', 'DEBTS_TIME'];
for (const reportType of tryReportTypes) {
const response = await fetchTrends({
reportType,
- filters: [makeAccountIdFilter(accountId)],
+ matchAllFilters,
+ matchAnyFilters,
offset,
limit,
overrideApiKey,
@@ -117,7 +278,10 @@ const fetchIntervalsForAccountHistory = async ({
}) => {
// fetch monthly balances so we can get start date
const balanceInfo = await withRetry(() =>
- fetchMonthlyBalancesForAccount({ accountId, overrideApiKey }),
+ fetchMonthlyBalances({
+ matchAllFilters: [makeAccountIdFilter(accountId)],
+ overrideApiKey,
+ }),
);
if (!balanceInfo) {
@@ -134,26 +298,27 @@ const fetchIntervalsForAccountHistory = async ({
};
/**
- * Fetch balance history for each month for an account.
+ * Fetch balance history for each month for one or more accounts.
*/
-const fetchDailyBalancesForAccount = async ({
+const fetchDailyBalances = async ({
periods,
- accountId,
reportType,
+ matchAllFilters,
+ matchAnyFilters,
overrideApiKey,
onProgress,
}: {
periods: Interval[];
- accountId: string;
- reportType: string;
- fiName: string;
+ reportType: ReportType;
+ matchAllFilters?: ApiFilter[];
+ matchAnyFilters?: ApiFilter[];
+ fiName?: string;
overrideApiKey?: string;
onProgress?: ProgressCallback;
}) => {
if (!reportType) {
throw new Error('Invalid report type.');
}
-
const counter = {
count: 0,
};
@@ -165,7 +330,8 @@ const fetchDailyBalancesForAccount = async ({
withRetry(() =>
fetchTrends({
reportType,
- filters: [makeAccountIdFilter(accountId)],
+ matchAllFilters,
+ matchAnyFilters,
dateFilter: {
type: 'CUSTOM',
startDate: start.toISODate(),
@@ -178,13 +344,25 @@ const fetchDailyBalancesForAccount = async ({
overrideApiKey,
})
.then((response) => response.json())
- .then(({ Trend }) =>
- Trend.map(({ amount, type, ...rest }) => ({
+ .then(({ Trend, metaData }) => {
+ if (!Trend && !metaData) {
+ throw new Error('Unexpected response');
+ }
+ if (!Trend) {
+ // Trend is omitted when all balances are zero in this period, so build the rows
+ const dates = Interval.fromDateTimes(start, end).splitBy({ day: 1 });
+ return dates.slice(1).map((date: Interval) => ({
+ date: date.start.toISODate(),
+ amount: 0,
+ type: null,
+ }));
+ }
+ return Trend.map(({ amount, type, ...rest }) => ({
...rest,
type,
amount: type === 'DEBT' ? -amount : amount,
- })),
- ),
+ }));
+ }),
).finally(() => {
counter.count += 1;
onProgress?.({ complete: counter.count, total: periods.length });
@@ -208,8 +386,11 @@ export const fetchDailyBalancesForAllAccounts = async ({
// first, fetch the range of dates we need to fetch for each account
const accountsWithPeriodsToFetch = await withRateLimit()(
- accounts.map(({ id: accountId, name: accountName, fiName: fiName }) => async () => {
- const { periods, reportType } = await withDefaultOnError({ periods: [], reportType: '' })(
+ accounts.map(({ id: accountId, name: accountName, fiName }) => async () => {
+ const { periods, reportType } = await withDefaultOnError({
+ periods: [] as Interval[],
+ reportType: null as ReportType,
+ })(
fetchIntervalsForAccountHistory({
accountId,
overrideApiKey,
@@ -231,8 +412,8 @@ export const fetchDailyBalancesForAllAccounts = async ({
({ accountId, accountName, periods, reportType, fiName }, accountIndex) =>
async () => {
const balances = await withDefaultOnError([])(
- fetchDailyBalancesForAccount({
- accountId,
+ fetchDailyBalances({
+ matchAllFilters: [makeAccountIdFilter(accountId)],
periods,
reportType,
overrideApiKey,
@@ -269,6 +450,61 @@ export const fetchDailyBalancesForAllAccounts = async ({
return balancesByAccount;
};
+export const fetchDailyBalancesForTrend = async ({
+ trend,
+ onProgress,
+ overrideApiKey,
+}: {
+ trend: TrendState;
+ onProgress?: TrendBalanceHistoryProgressCallback;
+ overrideApiKey?: string;
+}) => {
+ const { accountIds, matchType, otherFilters, reportType, fromDate, toDate, fixedFilter } = trend;
+ const matchAllFilters: ApiFilter[] = accountIds.map(makeAccountIdFilter);
+ const matchAnyFilters: ApiFilter[] = [];
+
+ if (otherFilters?.length) {
+ if (matchType === 'any') {
+ matchAnyFilters.push(...otherFilters.map(apiFilterForFilterData));
+ } else {
+ matchAllFilters.push(...otherFilters.map(apiFilterForFilterData));
+ }
+ }
+
+ let interval: Interval;
+ // ALL_TIME may report a fromDate that is inaccurate by several years (e.g. 2007 when the trend
+ // data begins in 2015) so use the monthly trend to find an accurate start date
+ if (fixedFilter === 'ALL_TIME') {
+ const { balancesByDate } = await fetchMonthlyBalances({
+ matchAllFilters,
+ matchAnyFilters,
+ reportType,
+ overrideApiKey,
+ });
+ interval = calculateIntervalForAccountHistory(balancesByDate);
+ } else {
+ interval = Interval.fromDateTimes(DateTime.fromISO(fromDate), DateTime.fromISO(toDate));
+ }
+ const periods = interval.splitBy({
+ days: MINT_DAILY_TRENDS_MAX_DAYS,
+ }) as Interval[];
+
+ const balances = await fetchDailyBalances({
+ matchAllFilters,
+ matchAnyFilters,
+ periods,
+ reportType,
+ overrideApiKey,
+ onProgress: ({ complete }) => {
+ onProgress?.({
+ completePercentage: complete / periods.length,
+ });
+ },
+ });
+
+ return balances;
+};
+
/**
* Use internal Mint API to fetch net worth history. Return list of
* balances for type: "ASSET" and type: "DEBT" for each month.
@@ -295,14 +531,16 @@ export const fetchNetWorthBalances = async ({
const fetchTrends = ({
reportType,
- filters = [],
+ matchAllFilters = [],
+ matchAnyFilters = [],
dateFilter = DATE_FILTER_ALL_TIME,
offset = 0,
limit = 1000, // mint default
overrideApiKey,
}: {
reportType: string;
- filters?: Record[];
+ matchAllFilters?: (AccountIdFilter | ApiFilter)[];
+ matchAnyFilters?: ApiFilter[];
dateFilter?: Record;
offset?: number;
limit?: number;
@@ -321,7 +559,11 @@ const fetchTrends = ({
searchFilters: [
{
matchAll: true,
- filters,
+ filters: matchAllFilters,
+ },
+ {
+ matchAll: false,
+ filters: matchAnyFilters,
},
],
offset,
@@ -331,15 +573,6 @@ const fetchTrends = ({
overrideApiKey,
);
-type AccountsResponse = {
- Account: {
- type: string;
- id: string;
- name: string;
- fiName: string;
- }[];
-};
-
/**
* Use internal Mint API to fetch all of user's accounts.
*/
@@ -347,11 +580,7 @@ export const fetchAccounts = async ({
offset = 0,
limit = 1000, // mint default
overrideApiKey,
-}: {
- offset?: number;
- limit?: number;
- overrideApiKey?: string;
-}) => {
+}: FetchAccountsOptions) => {
const response = await makeMintApiRequest(
`/pfm/v1/accounts?offset=${offset}&limit=${limit}`,
{
@@ -364,24 +593,107 @@ export const fetchAccounts = async ({
return accounts;
};
-export const formatBalancesAsCSV = (balances: TrendEntry[], accountName?: string) => {
- const header = ['Date', 'Amount', accountName && 'Account Name'].filter(Boolean);
- const maybeAccountColumn: [string?] = accountName ? [accountName] : [];
+/**
+ * Merges paired API response into a single amount/inverseAmount entry.
+ *
+ * The API response does not include an entry for zero inverse amounts, but there is always a
+ * positive amount for each date in the trend.
+ */
+const zipTrendEntries = (trendEntries: TrendEntry[]) => {
+ const mergedTrendEntries: TrendEntry[] = [];
+ for (let i = 0; i < trendEntries.length; i += 1) {
+ const trendEntry = trendEntries[i];
+ const nextTrendEntry = trendEntries[i + 1];
+ let inverseAmount = 0;
+ // If the next entry is the inverse of this one, consume it
+ if (nextTrendEntry && nextTrendEntry.type !== trendEntry.type) {
+ i += 1;
+ inverseAmount = nextTrendEntry.amount;
+ }
+ mergedTrendEntries.push({
+ ...trendEntry,
+ inverseAmount,
+ });
+ }
+ return mergedTrendEntries;
+};
+
+export const formatBalancesAsCSV = ({
+ balances,
+ accountName,
+ reportType,
+}: {
+ balances: TrendEntry[];
+ accountName?: string;
+ reportType?: ReportType;
+}) => {
+ const header = ['Date'];
+ const columns: (keyof TrendEntry | ((trendEntry: TrendEntry) => string | number))[] = ['date'];
+ let trendEntries = balances;
+ // net income/worth reports have two rows per date, CSV needs one row with two columns
+ if (reportType?.startsWith('NET_')) {
+ // merge the positive and negative balances into one row
+ trendEntries = zipTrendEntries(balances);
+ if (reportType === 'NET_INCOME') {
+ header.push('Income', 'Expenses');
+ } else {
+ header.push('Assts', 'Debts');
+ }
+ header.push('Net');
+ columns.push('amount', 'inverseAmount', (trendEntry) =>
+ (trendEntry.amount - trendEntry.inverseAmount).toFixed(2),
+ );
+ } else {
+ header.push('Amount');
+ columns.push('amount');
+ }
+ const maybeAccountColumn: [string?] = [];
+ if (accountName) {
+ header.push('Account Name');
+ maybeAccountColumn.push(accountName);
+ }
// remove zero balances from the end of the report leaving just the first row if all are zero
- const rows = balances.reduceRight(
- (acc, { date, amount }, index) => {
- if (acc.length || amount !== 0 || index === 0) {
- acc.unshift([date, amount, ...maybeAccountColumn]);
+ const rows = trendEntries.reduceRight(
+ (acc, trendEntry, index) => {
+ if (acc.length || trendEntry.amount !== 0 || trendEntry.inverseAmount || index === 0) {
+ acc.unshift([
+ ...columns.map((col) => (typeof col === 'function' ? col(trendEntry) : trendEntry[col])),
+ ...maybeAccountColumn,
+ ]);
}
return acc;
},
- [] as [string, number, string?][],
+ [] as (string | number)[][],
);
return formatCSV([header, ...rows]);
};
-const makeAccountIdFilter = (accountId: string) => ({
+export const makeAccountIdFilter = (accountId: string): AccountIdFilter => ({
type: 'AccountIdFilter',
accountId,
});
+
+/** Convert trend state filter data to API request filters */
+const apiFilterForFilterData = (data: FilterData): ApiFilter => {
+ switch (data.type) {
+ case 'CATEGORY':
+ return {
+ type: 'CategoryIdFilter',
+ categoryId: data.categoryId,
+ includeChildCategories: data.includeChildCategories,
+ };
+ case 'DESCRIPTION':
+ return {
+ type: 'DescriptionNameFilter',
+ description: data.description,
+ };
+ case 'TAG':
+ return {
+ type: 'TagIdFilter',
+ tagId: data.tagId,
+ };
+ default:
+ throw new Error('Unsupported filter type');
+ }
+};
diff --git a/src/shared/lib/constants.ts b/src/shared/lib/constants.ts
index 1a613cc..0f3fd35 100644
--- a/src/shared/lib/constants.ts
+++ b/src/shared/lib/constants.ts
@@ -12,6 +12,7 @@ export const UTM_URL_PARAMETERS = {
utm_source: 'mint_export_extension',
};
+
/** Default number of API requests that can start in each 1 second window */
export const MINT_RATE_LIMIT_REQUESTS_PER_SECOND = 20;
diff --git a/src/shared/lib/trends.ts b/src/shared/lib/trends.ts
new file mode 100644
index 0000000..5bd69b0
--- /dev/null
+++ b/src/shared/lib/trends.ts
@@ -0,0 +1,100 @@
+import {
+ TrendState,
+ ReportType,
+ FixedDateFilter,
+ TrendType,
+ FilterData,
+ MatchType,
+} from '../../shared/lib/accounts';
+
+type TrendsUiState = {
+ reportCategory: TrendType;
+ reportType: ReportType;
+ fromDate: string;
+ toDate: string;
+ fixedFilter: FixedDateFilter;
+ accounts: {
+ id: string;
+ isSelected: boolean;
+ }[];
+ filterCategories: FilterData[];
+ matchType: MatchType;
+};
+
+type TrendsUiReactProps = {
+ children: {
+ props: {
+ children: {
+ props: {
+ children: [
+ unknown,
+ {
+ props: {
+ store: {
+ getState: () => { Trends: TrendsUiState };
+ };
+ };
+ },
+ ];
+ };
+ };
+ };
+ };
+};
+
+/**
+ * State for the current visible trend.
+ *
+ * This function must be executed in the context of the Mint tab and therefore must be
+ * self-contained.
+ */
+export const getCurrentTrendState = () => {
+ if (
+ // disable when not viewing the Trends page
+ window.location.pathname.startsWith('/trends')
+ ) {
+ try {
+ // Return React data backing HTML elements
+ const getReactProps = (selector: string) => {
+ const el = document.querySelector(selector);
+ return el?.[Object.keys(el).find((key) => key.startsWith('__reactProps'))] as Props;
+ };
+ const trendsUiState =
+ getReactProps(
+ '.cg-pfm-trends-ui',
+ ).children.props.children.props.children[1].props.store.getState();
+ const { reportType, fromDate, toDate, fixedFilter, accounts, filterCategories, matchType } =
+ trendsUiState.Trends;
+ const accountIds = accounts.flatMap((a) => (a.isSelected ? a.id : []));
+ const trendState: TrendState = {
+ accountIds,
+ reportType,
+ otherFilters: filterCategories,
+ matchType,
+ fixedFilter,
+ fromDate,
+ toDate,
+ };
+ return trendState;
+ } catch (e) {
+ // ignore
+ }
+ }
+ return null;
+};
+
+/** Whether the extension can generate daily balances for the active trend. */
+export const isSupportedTrendReport = (type: ReportType) => {
+ switch (type) {
+ case 'ASSETS_TIME':
+ case 'DEBTS_TIME':
+ case 'INCOME_TIME':
+ case 'SPENDING_TIME':
+ case 'NET_INCOME':
+ case 'NET_WORTH':
+ return true;
+ default:
+ // by tag, by category, by type, etc.
+ return false;
+ }
+};
diff --git a/src/shared/storages/stateStorage.ts b/src/shared/storages/stateStorage.ts
index 41b2dba..f26b118 100644
--- a/src/shared/storages/stateStorage.ts
+++ b/src/shared/storages/stateStorage.ts
@@ -1,6 +1,6 @@
import { createStorage, StorageType } from '@src/shared/storages/base';
-export type PageKey = 'downloadTransactions' | 'downloadBalances';
+export type PageKey = 'downloadTransactions' | 'downloadBalances' | 'downloadTrend';
type State = {
currentPage: PageKey | undefined;
diff --git a/src/shared/storages/trendStorage.ts b/src/shared/storages/trendStorage.ts
new file mode 100644
index 0000000..df03b56
--- /dev/null
+++ b/src/shared/storages/trendStorage.ts
@@ -0,0 +1,31 @@
+import { TrendBalanceHistoryCallbackProgress, TrendState } from '@root/src/shared/lib/accounts';
+import { createStorage, StorageType } from '@src/shared/storages/base';
+
+export enum TrendDownloadStatus {
+ Idle = 'idle',
+ Loading = 'loading',
+ Success = 'success',
+ Error = 'error',
+}
+
+type State = {
+ status: TrendDownloadStatus;
+ trend: TrendState;
+ progress?: TrendBalanceHistoryCallbackProgress;
+};
+
+const trendStorage = createStorage(
+ 'trend',
+ {
+ status: TrendDownloadStatus.Idle,
+ trend: null,
+ progress: {
+ completePercentage: 0,
+ },
+ },
+ {
+ storageType: StorageType.Local,
+ },
+);
+
+export default trendStorage;