// ==UserScript==
// @name Monarch Money (Charts)
// @namespace http://tampermonkey.net/
// @version 0.18.0
// @description Additional trend charts added to Monarch Money's dashboard page.
// @author William T. Wissemann
// @match https://app.monarchmoney.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=monarchmoney.com
// @grant none
// @run-at document-idle
// @require https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js
// @require https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js
// @require https://cdn.jsdelivr.net/npm/hammerjs@2.0.8
// @require https://cdn.jsdelivr.net/npm/chartjs-plugin-crosshair@2.0.0/dist/chartjs-plugin-crosshair.min.js
// @require https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js
// ==/UserScript==
const graphql = 'https://api.monarchmoney.com/graphql';
const START_DATE = '2010-01-01';
function accountTypeToColor(accountType, alpha) {
const lookup = {
'Net Worth': `rgba(55, 162, 235, ${alpha})`, // blue
'Credit Cards': `rgba(255, 159, 65, ${alpha})`, // orange
Loans: `rgba(255, 99, 132, ${alpha})`, // red
Investments: `rgba(255, 205, 87, ${alpha})`, // yellow
Cash: `rgba(76, 192, 192, ${alpha})`, // green
'Real Estate': `rgba(153, 102, 255, ${alpha})`, // purple
Valuables: `rgba(201, 203, 207, ${alpha})`, // gray
Vehicles: `rgba(55, 162, 235, ${alpha})`, // blue
};
return lookup[accountType];
}
function getPersistReports() {
return JSON.parse(JSON.parse(localStorage.getItem('persist:reports')).filters);
}
function getStyle() {
const cssObj = window.getComputedStyle(document.querySelectorAll('[class*=Page__Root]')[0], null);
const bgColor = cssObj.getPropertyValue('background-color');
if (bgColor === 'rgb(8, 32, 67)') {
return 'dark';
}
return 'light';
}
function getGraphqlToken() {
return JSON.parse(JSON.parse(localStorage.getItem('persist:root')).user).token;
}
function createGraphOption(data) {
return {
mode: 'cors',
method: 'POST',
headers: {
accept: '*/*',
authorization: `Token ${getGraphqlToken()}`,
'content-type': 'application/json',
origin: 'https://app.monarchmoney.com',
},
body: JSON.stringify(data),
};
}
async function getAccountHistory(id) {
const options = createGraphOption({
operationName: 'AccountDetails_getAccount',
variables: {
id,
},
query: "query AccountDetails_getAccount($id: UUID!, $filters: TransactionFilterInput) {\n account(id: $id) {\n id\n ...AccountFields\n ...EditAccountFormFields\n credential {\n id\n institution {\n id\n ...InstitutionStatusFields\n __typename\n }\n __typename\n }\n institution {\n id\n ...InstitutionStatusFields\n __typename\n }\n __typename\n }\n transactions: allTransactions(filters: $filters) {\n totalCount\n results(limit: 1) {\n id\n ...TransactionsListFields\n __typename\n }\n __typename\n }\n snapshots: snapshotsForAccount(accountId: $id) {\n date\n signedBalance\n __typename\n }\n}\n\nfragment AccountFields on Account {\n id\n displayName\n syncDisabled\n deactivatedAt\n isHidden\n isAsset\n mask\n createdAt\n updatedAt\n displayLastUpdatedAt\n currentBalance\n displayBalance\n includeInNetWorth\n hideFromList\n hideTransactionsFromReports\n includeBalanceInNetWorth\n includeInGoalBalance\n dataProvider\n dataProviderAccountId\n isManual\n transactionsCount\n holdingsCount\n manualInvestmentsTrackingMethod\n order\n icon\n logoUrl\n type {\n name\n display\n group\n __typename\n }\n subtype {\n name\n display\n __typename\n }\n credential {\n id\n updateRequired\n disconnectedFromDataProviderAt\n dataProvider\n institution {\n id\n plaidInstitutionId\n name\n status\n logo\n __typename\n }\n __typename\n }\n institution {\n id\n name\n logo\n primaryColor\n url\n __typename\n }\n __typename\n}\n\nfragment EditAccountFormFields on Account {\n id\n displayName\n deactivatedAt\n displayBalance\n includeInNetWorth\n hideFromList\n hideTransactionsFromReports\n dataProvider\n dataProviderAccountId\n isManual\n manualInvestmentsTrackingMethod\n isAsset\n invertSyncedBalance\n canInvertBalance\n useAvailableBalance\n canUseAvailableBalance\n type {\n name\n display\n __typename\n }\n subtype {\n name\n display\n __typename\n }\n __typename\n}\n\nfragment InstitutionStatusFields on Institution {\n id\n hasIssuesReported\n hasIssuesReportedMessage\n plaidStatus\n status\n balanceStatus\n transactionsStatus\n __typename\n}\n\nfragment TransactionsListFields on Transaction {\n id\n ...TransactionOverviewFields\n __typename\n}\n\nfragment TransactionOverviewFields on Transaction {\n id\n amount\n pending\n date\n hideFromReports\n plaidName\n notes\n isRecurring\n reviewStatus\n needsReview\n isSplitTransaction\n dataProviderDescription\n attachments {\n id\n __typename\n }\n category {\n id\n name\n icon\n group {\n id\n type\n __typename\n }\n __typename\n }\n merchant {\n name\n id\n transactionsCount\n __typename\n }\n tags {\n id\n name\n color\n order\n __typename\n }\n account {\n id\n displayName\n icon\n logoUrl\n __typename\n }\n __typename\n}",
});
return fetch(graphql, options)
.then((response) => response.json())
.then((data) => {
const today = new Date().toISOString().slice(0, 10);
const differenceInDays = (new Date(today).getTime() - new Date(START_DATE).getTime()) / (1000 * 3600 * 24) + 1;
// Create the array filled with nulls
const recentBalances = new Array(differenceInDays).fill(null);
for (let i = 0; i < data.data.snapshots.length; i += 1) {
let index = getDateIndex(data.data.snapshots[i].date);
if (index > -1) {
recentBalances[index] = data.data.snapshots[i].signedBalance;
}
}
return recentBalances;
}).catch((error) => {
console.error(error);
});
}
function getDateIndex(dateString) {
// Calculate the difference in days from the start date
const differenceInDays = (new Date(dateString).getTime() - new Date(START_DATE).getTime()) / (1000 * 3600 * 24) + 1;
// Check for invalid dates (before start date)
if (differenceInDays <= 0) {
return -1; // Return -1 for dates before the start date
}
// Return the index within the array (0-based)
return differenceInDays - 1;
}
async function getAccountDetails() {
const options = createGraphOption({
operationName: 'Web_GetAccountsPage',
variables: {},
query: 'query Web_GetAccountsPage {\n hasAccounts\n accountTypeSummaries {\n type {\n name\n display\n group\n __typename\n }\n accounts {\n id\n ...AccountsListFields\n __typename\n }\n totalDisplayBalance\n __typename\n }\n householdPreferences {\n id\n accountGroupOrder\n __typename\n }\n}\n\nfragment AccountsListFields on Account {\n id\n syncDisabled\n isHidden\n isAsset\n includeInNetWorth\n order\n type {\n name\n display\n __typename\n }\n ...AccountListItemFields\n __typename\n}\n\nfragment AccountListItemFields on Account {\n id\n displayName\n displayBalance\n signedBalance\n updatedAt\n syncDisabled\n icon\n logoUrl\n isHidden\n isAsset\n includeInNetWorth\n includeBalanceInNetWorth\n displayLastUpdatedAt\n ...AccountMaskFields\n credential {\n id\n updateRequired\n dataProvider\n disconnectedFromDataProviderAt\n __typename\n }\n institution {\n id\n ...InstitutionStatusTooltipFields\n __typename\n }\n __typename\n}\n\nfragment AccountMaskFields on Account {\n id\n mask\n subtype {\n display\n __typename\n }\n __typename\n}\n\nfragment InstitutionStatusTooltipFields on Institution {\n id\n logo\n name\n status\n plaidStatus\n hasIssuesReported\n url\n hasIssuesReportedMessage\n transactionsStatus\n balanceStatus\n __typename\n}',
});
return fetch(graphql, options)
.then((response) => response.json())
.then((data) => {
const accountDetails = data.data.accountTypeSummaries;
const accountLookupById = {};
for (let i = 0; i < accountDetails.length; i += 1) {
const { display } = accountDetails[i].type;
for (let j = 0; j < accountDetails[i].accounts.length; j += 1) {
accountLookupById[accountDetails[i].accounts[j].id] = {
type: display,
displayName: accountDetails[i].accounts[j].displayName,
accountData: accountDetails[i].accounts[j],
};
}
}
data.accountLookupById = accountLookupById;
return data;
}).catch((error) => {
console.error(error);
});
}
async function getAccountPageRecentBalanceByDate(date) {
const data = await fetch(graphql, createGraphOption({
operationName: 'Web_GetAccountsPageRecentBalance',
variables: {
startDate: date,
},
query: `query Web_GetAccountsPageRecentBalance($startDate: Date!) {
accounts {
id
recentBalances(startDate: $startDate)
__typename
}
}`,
}))
.then((response) => response.json())
.then((data) => data).catch((error) => {
console.error(error);
});
for (let i = 0; i < data.data.accounts.length; i += 1) {
const recentBalances = await getAccountHistory(data.data.accounts[i].id).then((data) => data)
data.data.accounts[i].recentBalances = recentBalances;
}
return data;
}
async function getAccountPageRecentBalance() {
const today = new Date().toISOString().slice(0, 10);
try {
const cachedData = JSON.parse(localStorage?.getItem('tm:AccountPageRecentBalance') ?? '{}');
if (
!cachedData.cacheDate ||
cachedData.cacheDate !== today ||
!cachedData.data ||
!cachedData.data.data ||
!cachedData.data.data.accounts ||
!Array.isArray(cachedData.data.data.accounts[0]?.recentBalances) ||
cachedData.data.data.accounts[0].recentBalances.length !==
(new Date(today).getTime() - new Date(START_DATE).getTime()) / (1000 * 3600 * 24) + 1
) {
const freshData = await getAccountPageRecentBalanceByDate(START_DATE);
localStorage.setItem('tm:AccountPageRecentBalance', JSON.stringify({ cacheDate: today, data: freshData }));
return freshData;
}
return cachedData.data;
} catch (error) {
console.error('Error handling cached data:', error);
const freshData = await getAccountPageRecentBalanceByDate(START_DATE);
localStorage.setItem('tm:AccountPageRecentBalance', JSON.stringify({ cacheDate: today, data: freshData }));
return freshData;
}
}
function recentBalancesMerge(data, label) {
let offset = 0;
const date = new Date(START_DATE);
const dataset = {
label,
data: data[0].recentBalances.map((item) => {
offset += 1;
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
return { x: d.toISOString().slice(0, 10), y: item };
}),
borderColor: accountTypeToColor(label, '255'),
backgroundColor: accountTypeToColor(label, '0.2'),
borderWidth: 1,
fill: true,
pointRadius: 0,
hidden: !['Net Worth', 'Investments', 'Cash'].includes(label),
};
for (let i = 1; i < data.length; i += 1) {
for (let j = 0; j < data[i].recentBalances.length; j += 1) {
const val = data[i].recentBalances[j];
if (dataset.data[j].y === undefined) {
dataset.data[j].y = val;
} else if (val !== null) {
dataset.data[j].y += val;
}
}
}
return dataset;
}
function chartStyleOption(title) {
let labels = {
fontColor: 'rgba(256, 256, 256)',
};
if (getStyle() === 'dark') {
Chart.defaults.color = 'rgba(255, 255, 255, 0.7)';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.3)';
labels = {};
} else {
Chart.defaults.color = 'rgba(0, 0, 0, 0.7)';
Chart.defaults.borderColor = 'rgba(0, 0, 0, 0.2)';
labels = {};
}
return {
maintainAspectRatio: false,
responsive: true,
resizeDelay: 1000,
legend: {
labels,
},
interaction: {
axis: 'x',
mode: 'nearest',
intersect: false,
},
plugins: {
crosshair: {
line: {
color: 'rgba(55, 162, 235, .5)', // crosshair line color
width: 1, // crosshair line width
},
},
tooltip: {
position: 'nearest',
backgroundColor: 'rgba(0, 0, 0, .65)',
callbacks: {
label(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(context.parsed.y);
}
return label;
},
title(context) {
const label = context[0].label || '';
return label.match(/^\w+ \d+, \d+/)[0];
},
},
},
title: {
display: true,
text: title,
},
zoom: {
limits: {
x: { min: 'original', max: 'original' },
},
zoom: {
wheel: {
enabled: true,
modifierKey: 'shift',
},
drag: {
enabled: true,
},
pinch: {
enabled: true,
},
mode: 'x',
},
},
},
scales: {
x: {
type: 'time',
time: {
unit: 'month',
},
border: {
dash: [5, 8],
},
ticks: {
major: {
enabled: true,
},
callback: function(value, index, values) {
const d = new Date(value);
const year = d.getFullYear();
const month = d.getMonth();
const day = d.getDay();
const mShort = d.toLocaleString('en-US', { month: 'short' });
if(values[index] !== undefined){
if (month == 0){
values[index].major = true;
return `${year} ${mShort}`;
} else {
return `${mShort}`;
}
}
}
},
},
y: {
type: localStorage['tm:YChartType'] || "linear",
ticks: {
beginAtZero: true,
},
border: {
dash: [5, 8],
},
},
},
};
}
function drawSnapshotsByAccountType(chart) {
fetch(graphql, createGraphOption({
operationName: 'Common_GetSnapshotsByAccountType',
variables: {
startDate: START_DATE,
timeframe: 'month',
},
query: `query Common_GetSnapshotsByAccountType($startDate: Date!, $timeframe: Timeframe!) {
snapshotsByAccountType(startDate: $startDate, timeframe: $timeframe) {
accountType
month
balance
__typename
}
accountTypes {
name
display
group
__typename
}
}`,
})).then((response) => response.json())
.then((d) => {
// Process the data received from the API
const { data } = d;
const datasets = [{
label: 'Net Worth',
borderColor: accountTypeToColor('Net Worth', '255'),
backgroundColor: accountTypeToColor('Net Worth', '0.2'),
data: [],
fill: true,
borderWidth: 2,
pointRadius: 0,
hidden: false,
}];
const netWorth = {};
for (let i = 0; i < data.accountTypes.length; i += 1) {
// User selects an account type
const selectedAccountType = data.accountTypes[i].name;
const selectedAccountDisplay = data.accountTypes[i].display;
if (selectedAccountDisplay === 'Other') {
continue;
}
// Filter snapshots by selected account type
const filteredSnapshots = data.snapshotsByAccountType.filter(
(snapshot) => snapshot.accountType === selectedAccountType,
);
// Extract relevant data from filtered snapshots
// const balances = filteredSnapshots.map(snapshot => snapshot.balance);
const balances = filteredSnapshots.map((snapshot) => {
const date = snapshot.month;
if (netWorth[date] !== undefined) {
netWorth[date].y += snapshot.balance;
} else {
netWorth[date] = { x: date, y: snapshot.balance };
}
return { x: date, y: snapshot.balance };
});
const set = {
label: selectedAccountDisplay,
data: balances,
borderColor: accountTypeToColor(selectedAccountDisplay, '255'),
backgroundColor: accountTypeToColor(selectedAccountDisplay, '0.2'),
fill: true,
borderWidth: 2,
pointRadius: 0,
hidden: !['brokerage', 'depository'].includes(selectedAccountType),
};
if (set.data.length > 0) {
datasets.push(set);
}
}
Object.values(netWorth).forEach((value) => {
datasets[0].data.push(value);
});
datasets[0].data.sort((a, b) => a.x.localeCompare(b.x));
// Create a new Chart.js instance
const ctx = chart.getContext('2d');
// eslint-disable-next-line no-new
new Chart(ctx, {
type: 'line',
data: {
datasets,
},
options: chartStyleOption('ACCOUNT TRENDS: WITHOUT FILTERS'),
});
})
.catch((error) => {
console.error(error);
});
}
function drawNetworthChart(chart) {
const persistFilters = getPersistReports();
getAccountDetails().then((accountDetails) => {
getAccountPageRecentBalance()
.then((d) => {
// Process the data received from the API
let data = null;
if (persistFilters.accounts !== undefined) {
data = d.data.accounts.filter((object) => persistFilters.accounts.includes(object.id));
} else {
data = d.data.accounts;
}
const accountTypes = [];
for (let i = 0; i < data.length; i += 1) {
if (accountDetails.accountLookupById[data[i].id] !== undefined) {
const { displayName, type, accountData } = accountDetails.accountLookupById[data[i].id];
data[i].displayName = displayName;
data[i].type = type;
data[i].includeInNetWorth = accountData.includeInNetWorth;
if (!accountTypes.includes(type)) {
accountTypes.push(type);
}
}
}
data = data.filter((object) => object.includeInNetWorth === true);
const datasets = [];
datasets.push(recentBalancesMerge(data, 'Net Worth'));
for (let i = 0; i < accountTypes.length; i += 1) {
datasets.push(recentBalancesMerge(data.filter((object) => [accountTypes[i]].includes(object.type)), accountTypes[i]));
}
for (let i = 0; i < datasets.length; i += 1) {
datasets[i].data = datasets[i].data.filter((dataset) => dataset.y !== 0 && dataset.y !== null);
}
// Create a new Chart.js instance
const ctx = chart.getContext('2d');
// eslint-disable-next-line no-new
new Chart(ctx, {
type: 'line',
data: {
datasets,
},
options: chartStyleOption('ACCOUNT TRENDS: REPORT FILTERS'),
});
}).catch((error) => {
console.error(error);
});
});
}
function createChartDiv(claseName) {
const chartDiv = document.createElement('div');
chartDiv.className = 'TM_CHARTS';
chartDiv.width = '100%';
chartDiv.height = '400px';
chartDiv.margin = 'auto';
chartDiv.style.width = chartDiv.width;
chartDiv.style.height = chartDiv.height;
chartDiv.style.padding = '22px 22px 0px 20px';
chartDiv.boxShadow = 'rgba(0, 0, 0, 0.2) 0px 4px 8px';
const canvas = document.createElement('canvas');
canvas.style.borderRadius = '25px';
canvas.width = '100%';
canvas.height = '400px';
canvas.style.padding = '0px 10px 0px 0px';
if (getStyle() === 'dark') {
canvas.style.backgroundColor = 'rgb(13, 44, 92)';
} else {
canvas.style.backgroundColor = 'rgb(255, 255, 255)';
}
canvas.style.width = canvas.width;
canvas.style.height = canvas.height;
canvas.style.display = 'block';
canvas.className = claseName;
chartDiv.appendChild(canvas);
return [canvas, chartDiv];
}
function unloadCharts() {
const tmCharts = document.querySelectorAll('[class*=TM_CHARTS]');
if(tmCharts.length > 0) {
for (let i = 0; i < tmCharts.length; i += 1) {
tmCharts[i].remove();
}
}
}
document.addEventListener('keydown', (event) => {
console.log(event);
if (event.ctrlKey === true && event.key === 'l') {
// ctrl + l: toogle between log and liner
localStorage['tm:YChartType'] = localStorage['tm:YChartType'] === "logarithmic" ? "linear" : "logarithmic";
console.log(localStorage['tm:YChartType']);
unloadCharts();
}
});
(function () {
setInterval(() => {
if (window.location.pathname === '/dashboard' && (document.querySelectorAll('[class*=TM_CHARTS]').length === 0 || localStorage['tm:DarkLightMode'] !== getStyle())) {
const injectionInterval = setInterval(() => {
if (localStorage['tm:DarkLightMode'] !== getStyle()) {
unloadCharts();
}
// only run the injectionInterval once
clearInterval(injectionInterval);
// inject a div at the top of MM's scroll
const scrollRoot = document.querySelectorAll('[class*=Scroll__Root]')[0];
const [snapshotsByAccountCanvas, snapshotsByAccountDiv] = createChartDiv('TM_snapshotsByAccountType');
scrollRoot.insertBefore(snapshotsByAccountDiv, scrollRoot.children[0]);
drawSnapshotsByAccountType(snapshotsByAccountCanvas);
const [networthCanvas, networthAccountDiv] = createChartDiv('TM_networthChart');
scrollRoot.insertBefore(networthAccountDiv, scrollRoot.children[1]);
drawNetworthChart(networthCanvas);
localStorage['tm:DarkLightMode'] = getStyle();
}, 1000);
}
}, 5000);
}());