Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Button in the popup to export any time-based trend #30

Open
wants to merge 32 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
1080624
Fix last day missing in each interval
idpaterson Nov 17, 2023
e5165b5
Use a generic for Message payload
idpaterson Nov 17, 2023
ad597ca
Improved typing for Mint API
idpaterson Nov 17, 2023
40debad
Add fetchTrendAccounts to make sense of Mint trend state
idpaterson Nov 17, 2023
a342b1a
Added fetchDailyBalancesForTrend
idpaterson Nov 17, 2023
5cf7bfd
Support CSV export of net worth and income
idpaterson Nov 17, 2023
44d1ceb
Add trendStorage
idpaterson Nov 22, 2023
ab37de4
Update unit tests for new call signatures
idpaterson Nov 22, 2023
276a4ca
Add GetTrendState action to expose current trend
idpaterson Nov 22, 2023
627778b
Add DownloadTrendBalances action
idpaterson Nov 22, 2023
4953eb1
Disable export of filtered trends
idpaterson Nov 22, 2023
5212d7f
formatBalancesAsCSV unit tests for net income
idpaterson Nov 18, 2023
595fcd3
Added tests for getAccountTypeFilterForTrend()
idpaterson Nov 18, 2023
2c3488b
No account filter if all accounts selected
idpaterson Nov 18, 2023
97742cb
Match account filtering to Mint logic
idpaterson Nov 18, 2023
c836d9c
Add popup button to download current trend
idpaterson Nov 22, 2023
e192a53
Count retried requests toward progress only once
idpaterson Nov 22, 2023
297fd02
Use p-ratelimit to limit concurrent requests
idpaterson Nov 23, 2023
ccec2f4
Fix empty trend CSV on error
idpaterson Nov 23, 2023
778326e
Acknowledge causes of daily trend balance failure
idpaterson Nov 23, 2023
4d5d46f
Limit to one trend export at a time
idpaterson Nov 23, 2023
d8a3659
Clean up pending trend when finished
idpaterson Nov 23, 2023
c71f4f9
Rate limit the account history interval lookup
idpaterson Nov 23, 2023
759a09f
Do not trust fromDate for ALL_TIME trends
idpaterson Dec 14, 2023
9045e5c
Get trend state from React not localStorage
idpaterson Dec 14, 2023
29c7e63
Easier trend state retrieval from Redux store
idpaterson Dec 14, 2023
db1b734
Export trends filtered by tag, merchant, category
idpaterson Dec 14, 2023
f544ecc
Zero fill responses with no Trend data
idpaterson Dec 14, 2023
93706a5
Open popup to main screen after trend download
idpaterson Dec 15, 2023
7c5c43b
Merge branch 'main' into pull-requests/export-any-trend-from-popup
idpaterson Dec 23, 2023
d41ca8b
Error out if trend export is not actually running
idpaterson Dec 23, 2023
7af7089
Merge branch 'main' into pull-requests/export-any-trend-from-popup
idpaterson Jan 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/components/popup/DownloadTrend.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-3">
<Text type="header">Download complete!</Text>
<Text className="font-normal">
Balance history for the trend downloaded to your computer.
</Text>
<DefaultButton href="https://help.monarchmoney.com/hc/en-us/articles/14882425704212-Upload-account-balance-history">
Import into Monarch
</DefaultButton>
</div>
);
} else if (completePercentage > 0) {
return (
<div className="flex flex-col gap-3">
<Text className="text-current text-textLight">
Downloading balance history for the trend. This may take a minute.
</Text>
{completePercentage > 0 && (
<div className="flex flex-col gap-3">
<Progress percentage={completePercentage} />
</div>
)}
</div>
);
} else {
return <Text>Getting your balance information...</Text>;
}
}, [isSuccess, trendStateValue]);

if (trendStateValue.status === TrendDownloadStatus.Error) {
return (
<ErrorBoundary>
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.
</ErrorBoundary>
);
}

return (
<div className="mt-2 p-large py-xlarge">
<SpinnerWithText complete={isSuccess}>
<div className="text-center">{content}</div>
</SpinnerWithText>
</div>
);
};

export default DownloadTrend;
72 changes: 65 additions & 7 deletions src/components/popup/PopupContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,12 +32,26 @@ const PAGE_TO_COMPONENT: Record<PageKey, Page> = {
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();
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -129,6 +155,11 @@ const PopupContainer = ({ children }: React.PropsWithChildren) => {
<DefaultButton onClick={onDownloadAccountBalanceHistory}>
Download Mint account balance history
</DefaultButton>
<DefaultButton
onClick={onDownloadTrend}
disabled={!isSupportedTrendReport(trend?.reportType)}>
Download current trend daily balances
</DefaultButton>
</div>
);
default:
Expand All @@ -138,17 +169,23 @@ const PopupContainer = ({ children }: React.PropsWithChildren) => {
</div>
);
}
}, [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') {
Expand All @@ -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,
});
Expand All @@ -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 (
Expand Down
93 changes: 90 additions & 3 deletions src/pages/background/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 = {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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' });
Expand All @@ -192,6 +233,48 @@ const handleDownloadAllAccountBalances = async (sendResponse: () => void) => {
}
};

let pendingTrendBalances: Promise<TrendEntry[]>;

/** 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
Expand All @@ -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 });
};
4 changes: 4 additions & 0 deletions src/pages/popup/Popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ const Popup = () => {
} else {
await authenticateUser(apiKey);
}

await sendMessage({
action: Action.GetTrendState,
});
};

const authenticateOnDashboard = async () => {
Expand Down
1 change: 1 addition & 0 deletions src/shared/constants/error.ts
Original file line number Diff line number Diff line change
@@ -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',
}
Loading