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 ( +
+ +
{content}
+
+
+ ); +}; + +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;