/* * budgets.js * Copyright (c) 2023 james@firefly-iii.org * * This file is part of Firefly III (https://github.com/firefly-iii). * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import {getVariable} from "../../store/get-variable.js"; import Get from "../../api/v2/model/transaction/get.js"; import {getDefaultChartSettings} from "../../support/default-chart-settings.js"; import {Chart} from 'chart.js'; import {Flow, SankeyController} from 'chartjs-chart-sankey'; import {loadTranslations} from "../../support/load-translations.js"; import {I18n} from "i18n-js"; Chart.register({SankeyController, Flow}); const CACHE_KEY = 'dashboard-sankey-data'; let i18n; let currencies = []; let afterPromises = false; let chart = null; let transactions = []; let autoConversion = false; let translations = { category: null, unknown_category: null, in: null, out: null, // TODO unknown_source: null, unknown_dest: null, unknown_account: null, expense_account: null, revenue_account: null, budget: null, unknown_budget: null, all_money: null, }; const colors = { a: 'red', b: 'green', c: 'blue', d: 'gray' }; const getColor = function (key) { if (key.includes(translations.revenue_account)) { return 'forestgreen'; } if (key.includes('(' + translations.in + ',')) { return 'green'; } if (key.includes(translations.budget) || key.includes(translations.unknown_budget)) { return 'Orchid'; } if (key.includes('(' + translations.out + ',')) { return 'MediumOrchid'; } if (key.includes(translations.all_money)) { return 'blue'; } return 'red'; } // little helper function getObjectName(type, name, direction, code) { // category 4x if ('category' === type && null !== name && 'in' === direction) { return translations.category + ' "' + name + '" (' + translations.in + (autoConversion ? ', ' + code + ')' : ')'); } if ('category' === type && null === name && 'in' === direction) { return translations.unknown_category + ' (' + translations.in + (autoConversion ? ', ' + code + ')' : ')'); } if ('category' === type && null !== name && 'out' === direction) { return translations.category + ' "' + name + '" (' + translations.out + (autoConversion ? ', ' + code + ')' : ')'); } if ('category' === type && null === name && 'out' === direction) { return translations.unknown_category + ' (' + translations.out + (autoConversion ? ', ' + code + ')' : ')'); } // account 4x if ('account' === type && null === name && 'in' === direction) { return translations.unknown_source + (autoConversion ? ' (' + code + ')' : ''); } if ('account' === type && null !== name && 'in' === direction) { return translations.revenue_account + '"' + name + '"' + (autoConversion ? ' (' + code + ')' : ''); } if ('account' === type && null === name && 'out' === direction) { return translations.unknown_dest + (autoConversion ? ' (' + code + ')' : ''); } if ('account' === type && null !== name && 'out' === direction) { return translations.expense_account + ' "' + name + '"' + (autoConversion ? ' (' + code + ')' : ''); } // budget 2x if ('budget' === type && null !== name) { return translations.budget + ' "' + name + '"' + (autoConversion ? ' (' + code + ')' : ''); } if ('budget' === type && null === name) { return translations.unknown_budget + (autoConversion ? ' (' + code + ')' : ''); } console.error('Cannot handle: type:"' + type + '", dir: "' + direction + '"'); } function getLabelName(type, name, code) { // category if ('category' === type && null !== name) { return translations.category + ' "' + name + '"' + (autoConversion ? ' (' + code + ')' : ''); } if ('category' === type && null === name) { return translations.unknown_category + (autoConversion ? ' (' + code + ')' : ''); } // account if ('account' === type && null === name) { return translations.unknown_account + (autoConversion ? ' (' + code + ')' : ''); } if ('account' === type && null !== name) { return name + (autoConversion ? ' (' + code + ')' : ''); } // budget 2x if ('budget' === type && null !== name) { return translations.budget + ' "' + name + '"' + (autoConversion ? ' (' + code + ')' : ''); } if ('budget' === type && null === name) { return translations.unknown_budget + (autoConversion ? ' (' + code + ')' : ''); } console.error('Cannot handle: type:"' + type + '"'); } export default () => ({ loading: false, autoConversion: false, generateOptions() { let options = getDefaultChartSettings('sankey'); // reset currencies currencies = []; // variables collected for the sankey chart: let amounts = {}; let labels = {}; for (let i in transactions) { if (transactions.hasOwnProperty(i)) { let group = transactions[i]; for (let ii in group.attributes.transactions) { if (group.attributes.transactions.hasOwnProperty(ii)) { // properties of the transaction, used in the generation of the chart: let transaction = group.attributes.transactions[ii]; let currencyCode = this.autoConversion ? transaction.native_code : transaction.currency_code; let amount = this.autoConversion ? parseFloat(transaction.native_amount) : parseFloat(transaction.amount); let flowKey; /* Two entries in the sankey diagram for deposits: 1. From the revenue account (source) to a category (in). 2. From the category (in) to the big inbox. */ if ('deposit' === transaction.type) { // nr 1 let category = getObjectName('category', transaction.category_name, 'in', currencyCode); let revenueAccount = getObjectName('account', transaction.source_name, 'in', currencyCode); labels[category] = getLabelName('category', transaction.category_name, currencyCode); labels[revenueAccount] = getLabelName('account', transaction.source_name, currencyCode); flowKey = revenueAccount + '-' + category + '-' + currencyCode; if (!amounts.hasOwnProperty(flowKey)) { amounts[flowKey] = { from: revenueAccount, to: category, amount: 0 }; } amounts[flowKey].amount += amount; // nr 2 flowKey = category + '-' + translations.all_money + '-' + currencyCode; if (!amounts.hasOwnProperty(flowKey)) { amounts[flowKey] = { from: category, to: translations.all_money + (this.autoConversion ? ' (' + currencyCode + ')' : ''), amount: 0 }; } amounts[flowKey].amount += amount; } /* Three entries in the sankey diagram for withdrawals: 1. From the big box to a budget. 2. From a budget to a category. 3. From a category to an expense account. */ if ('withdrawal' === transaction.type) { // 1. let budget = getObjectName('budget', transaction.budget_name, 'out', currencyCode); labels[budget] = getLabelName('budget', transaction.budget_name, currencyCode); flowKey = translations.all_money + '-' + budget + '-' + currencyCode; if (!amounts.hasOwnProperty(flowKey)) { amounts[flowKey] = { from: translations.all_money + ' (' + currencyCode + ')', to: budget, amount: 0 }; } amounts[flowKey].amount += amount; // 2. let category = getObjectName('category', transaction.category_name, 'out', currencyCode); labels[category] = getLabelName('category', transaction.category_name, currencyCode); flowKey = budget + '-' + category + '-' + currencyCode; if (!amounts.hasOwnProperty(flowKey)) { amounts[flowKey] = { from: budget, to: category, amount: 0 }; } amounts[flowKey].amount += amount; // 3. let expenseAccount = getObjectName('account', transaction.destination_name, 'out', currencyCode); labels[expenseAccount] = getLabelName('account', transaction.destination_name, currencyCode); flowKey = category + '-' + expenseAccount + '-' + currencyCode; if (!amounts.hasOwnProperty(flowKey)) { amounts[flowKey] = { from: category, to: expenseAccount, amount: 0 }; } amounts[flowKey].amount += amount; } } } } } let dataSet = // sankey chart has one data set. { label: 'Firefly III dashboard sankey chart', data: [], colorFrom: (c) => getColor(c.dataset.data[c.dataIndex].from), colorTo: (c) => getColor(c.dataset.data[c.dataIndex].to), colorMode: 'gradient', // or 'from' or 'to' labels: labels, size: 'min', // or 'min' if flow overlap is preferred }; for (let i in amounts) { if (amounts.hasOwnProperty(i)) { let amount = amounts[i]; dataSet.data.push({from: amount.from, to: amount.to, flow: amount.amount}); } } options.data.datasets.push(dataSet); return options; }, drawChart(options) { if (null !== chart) { chart.data.datasets = options.data.datasets; chart.update(); return; } chart = new Chart(document.querySelector("#sankey-chart"), options); }, getFreshData() { const cacheValid = window.store.get('cacheValid'); let cachedData = window.store.get(CACHE_KEY); if (cacheValid && typeof cachedData !== 'undefined') { transactions = cachedData; this.drawChart(this.generateOptions()); this.loading = false; return; } let params = { start: window.store.get('start').slice(0, 10), end: window.store.get('end').slice(0, 10), type: 'withdrawal,deposit', page: 1 }; this.downloadTransactions(params); }, downloadTransactions(params) { //console.log('Downloading page ' + params.page + '...'); const getter = new Get(); getter.get(params).then((response) => { transactions = [...transactions, ...response.data.data]; //this.drawChart(this.generateOptions(response.data)); //this.loading = false; if (parseInt(response.data.meta.pagination.total_pages) > params.page) { // continue to next page. params.page++; this.downloadTransactions(params); return; } window.store.set(CACHE_KEY, transactions); this.drawChart(this.generateOptions()); this.loading = false; }); }, loadChart() { if (true === this.loading) { return; } this.loading = true; if (0 !== transactions.length) { this.drawChart(this.generateOptions()); this.loading = false; return; } this.getFreshData(); }, init() { // console.log('sankey init'); transactions = []; Promise.all([getVariable('autoConversion', false), getVariable('language', 'en-US')]).then((values) => { this.autoConversion = values[0]; autoConversion = values[0]; i18n = new I18n(); i18n.locale = values[1]; loadTranslations(i18n, values[1]).then(() => { // some translations: translations.all_money = i18n.t('firefly.all_money'); translations.category = i18n.t('firefly.category'); translations.in = i18n.t('firefly.money_flowing_in'); translations.out = i18n.t('firefly.money_flowing_out'); translations.unknown_category = i18n.t('firefly.unknown_category_plain'); translations.unknown_source = i18n.t('firefly.unknown_source_plain'); translations.unknown_dest = i18n.t('firefly.unknown_dest_plain'); translations.unknown_account = i18n.t('firefly.unknown_any_plain'); translations.unknown_budget = i18n.t('firefly.unknown_budget_plain'); translations.expense_account = i18n.t('firefly.expense_account'); translations.revenue_account = i18n.t('firefly.revenue_account'); translations.budget = i18n.t('firefly.budget'); // console.log('sankey after promises'); afterPromises = true; this.loadChart(); }); }); window.store.observe('end', () => { if (!afterPromises) { return; } // console.log('sankey observe end'); this.transactions = []; this.loadChart(); }); window.store.observe('autoConversion', (newValue) => { if (!afterPromises) { return; } // console.log('sankey observe autoConversion'); this.autoConversion = newValue; this.loadChart(); }); }, });