From 7283b724b124b1dcb6a6cfe14d90ee4609a7b660 Mon Sep 17 00:00:00 2001 From: MaysWind Date: Tue, 5 Aug 2025 23:29:49 +0800 Subject: [PATCH] add candlestick chart for account balance trends --- pkg/api/transactions.go | 9 +- pkg/models/transaction.go | 6 +- pkg/services/transactions.go | 8 +- .../base/AccountBalanceTrendsChartBase.ts | 89 +++++++++++---- .../desktop/AccountBalanceTrendsChart.vue | 102 +++++++++++++++--- .../mobile/AccountBalanceTrendsBarChart.vue | 19 ++-- src/core/statistics.ts | 24 +++++ src/desktop-main.ts | 3 +- src/lib/numeral.ts | 12 ++- src/locales/de.json | 5 + src/locales/en.json | 5 + src/locales/es.json | 5 + src/locales/helpers.ts | 2 + src/locales/it.json | 5 + src/locales/ja.json | 5 + src/locales/pt_BR.json | 5 + src/locales/ru.json | 5 + src/locales/uk.json | 5 + src/locales/vi.json | 5 + src/locales/zh_Hans.json | 5 + src/locales/zh_Hant.json | 5 + src/models/transaction.ts | 3 +- .../ReconciliationStatementPageBase.ts | 12 +-- .../dialogs/ReconciliationStatementDialog.vue | 12 ++- 24 files changed, 292 insertions(+), 64 deletions(-) diff --git a/pkg/api/transactions.go b/pkg/api/transactions.go index 8581abec..025871a9 100644 --- a/pkg/api/transactions.go +++ b/pkg/api/transactions.go @@ -368,17 +368,20 @@ func (a *TransactionsApi) TransactionReconciliationStatementHandler(c *core.WebC for i := 0; i < len(transactionResult); i++ { transactionResult := transactionResult[i] - accountBalance := int64(0) + accountOpeningBalance := int64(0) + accountClosingBalance := int64(0) if transactionWithBalance, exists := transactionAccountBalanceMap[transactionResult.Id]; exists { - accountBalance = transactionWithBalance.AccountBalance + accountOpeningBalance = transactionWithBalance.AccountOpeningBalance + accountClosingBalance = transactionWithBalance.AccountClosingBalance } else { log.Warnf(c, "[transactions.TransactionReconciliationStatementHandler] missing account balance for transaction \"id:%d\" of user \"uid:%d\"", transactionResult.Id, uid) } responseItems[i] = &models.TransactionReconciliationStatementResponseItem{ TransactionInfoResponse: transactionResult, - AccountBalance: accountBalance, + AccountOpeningBalance: accountOpeningBalance, + AccountClosingBalance: accountClosingBalance, } } diff --git a/pkg/models/transaction.go b/pkg/models/transaction.go index 46271e6b..7f427535 100644 --- a/pkg/models/transaction.go +++ b/pkg/models/transaction.go @@ -123,7 +123,8 @@ type Transaction struct { // TransactionWithAccountBalance represents a transaction item with account balance type TransactionWithAccountBalance struct { *Transaction - AccountBalance int64 + AccountOpeningBalance int64 + AccountClosingBalance int64 } // TransactionGeoLocationRequest represents all parameters of transaction geographic location info update request @@ -338,7 +339,8 @@ type TransactionInfoPageWrapperResponse2 struct { // TransactionReconciliationStatementResponseItem represents a transaction reconciliation statement response type TransactionReconciliationStatementResponseItem struct { *TransactionInfoResponse - AccountBalance int64 `json:"accountBalance"` + AccountOpeningBalance int64 `json:"accountOpeningBalance"` + AccountClosingBalance int64 `json:"accountClosingBalance"` } // TransactionReconciliationStatementResponse represents the response of all transaction reconciliation statement response diff --git a/pkg/services/transactions.go b/pkg/services/transactions.go index 2d452606..ad7a6488 100644 --- a/pkg/services/transactions.go +++ b/pkg/services/transactions.go @@ -142,6 +142,7 @@ func (s *TransactionService) GetAllTransactionsWithAccountBalanceByMaxTime(c cor totalOutflows := int64(0) openingBalance := int64(0) accumulatedBalance := int64(0) + lastAccumulatedBalance := int64(0) for i := len(allTransactions) - 1; i >= 0; i-- { transaction := allTransactions[i] @@ -163,6 +164,7 @@ func (s *TransactionService) GetAllTransactionsWithAccountBalanceByMaxTime(c cor if transaction.TransactionTime < minTransactionTime { openingBalance = accumulatedBalance + lastAccumulatedBalance = accumulatedBalance continue } @@ -183,10 +185,12 @@ func (s *TransactionService) GetAllTransactionsWithAccountBalanceByMaxTime(c cor } transactionsAndAccountBalance := &models.TransactionWithAccountBalance{ - Transaction: transaction, - AccountBalance: accumulatedBalance, + Transaction: transaction, + AccountOpeningBalance: lastAccumulatedBalance, + AccountClosingBalance: accumulatedBalance, } + lastAccumulatedBalance = accumulatedBalance allTransactionsAndAccountBalance = append(allTransactionsAndAccountBalance, transactionsAndAccountBalance) } diff --git a/src/components/base/AccountBalanceTrendsChartBase.ts b/src/components/base/AccountBalanceTrendsChartBase.ts index 52c7ca41..d0f67dde 100644 --- a/src/components/base/AccountBalanceTrendsChartBase.ts +++ b/src/components/base/AccountBalanceTrendsChartBase.ts @@ -15,6 +15,7 @@ import type { AccountInfoResponse } from '@/models/account.ts'; import type { TransactionReconciliationStatementResponseItem } from '@/models/transaction.ts'; import { isDefined, isArray } from '@/lib/common.ts'; +import { sumAmounts } from '@/lib/numeral.ts'; import { getYearAndMonthFromUnixTime, getYearFirstUnixTimeBySpecifiedUnixTime, @@ -27,13 +28,19 @@ import { import { getAllDateRangesByYearMonthRange } from '@/lib/statistics.ts'; export interface AccountBalanceUnixTimeAndBalanceRange extends UnixTimeRange { - minUnixTimeBalance: number; - maxUnixTimeBalance: number; + minUnixTimeOpeningBalance: number; + minUnixTimeClosingBalance: number; + maxUnixTimeClosingBalance: number; } export interface AccountBalanceTrendsChartItem { displayDate: string; - amount: number; + openingBalance: number; + closingBalance: number; + minimumBalance: number; + maximumBalance: number; + medianBalance: number; + averageBalance: number; } export interface CommonAccountBalanceTrendsChartProps { @@ -52,19 +59,22 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren } let minUnixTime = Number.MAX_SAFE_INTEGER, maxUnixTime = 0; - let minUnixTimeBalance = 0, maxUnixTimeBalance = 0; + let minUnixTimeOpeningBalance = 0; + let minUnixTimeClosingBalance = 0; + let maxUnixTimeClosingBalance = 0; for (let i = 0; i < props.items.length; i++) { const item = props.items[i]; if (item.time < minUnixTime) { minUnixTime = item.time; - minUnixTimeBalance = item.accountBalance; + minUnixTimeOpeningBalance = item.accountOpeningBalance; + minUnixTimeClosingBalance = item.accountClosingBalance; } if (item.time > maxUnixTime) { maxUnixTime = item.time; - maxUnixTimeBalance = item.accountBalance; + maxUnixTimeClosingBalance = item.accountClosingBalance; } } @@ -75,8 +85,9 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren return { minUnixTime: minUnixTime, maxUnixTime: maxUnixTime, - minUnixTimeBalance: minUnixTimeBalance, - maxUnixTimeBalance: maxUnixTimeBalance + minUnixTimeOpeningBalance: minUnixTimeOpeningBalance, + minUnixTimeClosingBalance: minUnixTimeClosingBalance, + maxUnixTimeClosingBalance: maxUnixTimeClosingBalance }; }); @@ -125,7 +136,12 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren dayDataItemsMap[dateRangeMinUnixTime] = dataItems; } - let lastAmount = dataDateRange.value.minUnixTimeBalance; + let lastOpeningBalance = dataDateRange.value.minUnixTimeOpeningBalance; + let lastClosingBalance = dataDateRange.value.minUnixTimeClosingBalance; + let lastMinimumBalance = lastClosingBalance; + let lastMaximumBalance = lastClosingBalance; + let lastMedianBalance = lastClosingBalance; + let lastAverageBalance = lastClosingBalance; for (let i = 0; i < allDateRanges.value.length; i++) { const dateRange = allDateRanges.value[i]; @@ -146,29 +162,56 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren } if (isArray(dataItems)) { - let lastUnixTime = 0; + if (dataItems.length < 1) { + continue; + } - for (let i = 0; i < dataItems.length; i++) { - const dataItem = dataItems[i]; + dataItems.sort(function (data1: TransactionReconciliationStatementResponseItem, data2: TransactionReconciliationStatementResponseItem) { + return data1.time - data2.time; + }); - if (dataItem.time >= lastUnixTime) { - lastUnixTime = dataItem.time; + const openingBalance = dataItems[0].accountOpeningBalance; + const closingBalance = dataItems[dataItems.length - 1].accountClosingBalance; + const minimumBalance = Math.min(...dataItems.map(item => item.accountClosingBalance)); + const maximumBalance = Math.max(...dataItems.map(item => item.accountClosingBalance)); + const medianBalance = dataItems[Math.floor(dataItems.length / 2)].accountClosingBalance; + const averageBalance = Math.floor(sumAmounts(dataItems.map(item => item.accountClosingBalance)) / dataItems.length); - if (props.account.isAsset) { - lastAmount = dataItem.accountBalance; - } else if (props.account.isLiability) { - lastAmount = -dataItem.accountBalance; - } else { - lastAmount = dataItem.accountBalance; - } - } + if (props.account.isAsset) { + lastOpeningBalance = openingBalance; + lastClosingBalance = closingBalance; + lastMinimumBalance = minimumBalance; + lastMaximumBalance = maximumBalance; + lastMedianBalance = medianBalance; + lastAverageBalance = averageBalance; + } else if (props.account.isLiability) { + lastOpeningBalance = -openingBalance; + lastClosingBalance = -closingBalance; + lastMinimumBalance = -minimumBalance; + lastMaximumBalance = -maximumBalance; + lastMedianBalance = -medianBalance; + lastAverageBalance = -averageBalance; + } else { + lastOpeningBalance = openingBalance; + lastClosingBalance = closingBalance; + lastMinimumBalance = minimumBalance; + lastMaximumBalance = maximumBalance; + lastMedianBalance = medianBalance; + lastAverageBalance = averageBalance; } } ret.push({ displayDate: displayDate, - amount: lastAmount + openingBalance: lastOpeningBalance, + closingBalance: lastClosingBalance, + minimumBalance: lastMinimumBalance, + maximumBalance: lastMaximumBalance, + medianBalance: lastMedianBalance, + averageBalance: lastAverageBalance }); + + lastOpeningBalance = lastClosingBalance; } return ret; diff --git a/src/components/desktop/AccountBalanceTrendsChart.vue b/src/components/desktop/AccountBalanceTrendsChart.vue index 266cc059..da5f755e 100644 --- a/src/components/desktop/AccountBalanceTrendsChart.vue +++ b/src/components/desktop/AccountBalanceTrendsChart.vue @@ -10,11 +10,17 @@ import type { CallbackDataParams } from 'echarts/types/dist/shared'; import { useI18n } from '@/locales/helpers.ts'; import { type CommonAccountBalanceTrendsChartProps, useAccountBalanceTrendsChartBase } from '@/components/base/AccountBalanceTrendsChartBase.ts' +import { useUserStore } from '@/stores/user.ts'; + +import type { NameValue } from '@/core/base.ts'; import type { ColorValue } from '@/core/color.ts'; import { ThemeType } from '@/core/theme.ts'; -import { TrendChartType } from '@/core/statistics.ts'; +import { AccountBalanceTrendChartType } from '@/core/statistics.ts'; import { DEFAULT_CHART_COLORS } from '@/consts/color.ts'; +import { isArray } from '@/lib/common.ts'; +import { getExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts'; + interface DesktopAccountBalanceTrendsChartProps extends CommonAccountBalanceTrendsChartProps { legendName: string; skeleton?: boolean; @@ -26,21 +32,26 @@ interface AccountBalanceTrendsChartDataItem { name: string; itemStyle: { color: ColorValue; + color0?: string; + borderColor?: string; + borderColor0?: string; }; selected: boolean; type: string; areaStyle?: object; stack: string; animation: boolean; - data: number[]; + data: (number | number[])[]; } const props = defineProps(); const theme = useTheme(); -const { formatAmountWithCurrency } = useI18n(); +const { tt, formatAmountWithCurrency } = useI18n(); const { allDataItems, allDisplayDateRanges } = useAccountBalanceTrendsChartBase(props); +const userStore = useUserStore(); + const isDarkMode = computed(() => theme.global.name.value === ThemeType.Dark); const allSeries = computed(() => { @@ -57,15 +68,32 @@ const allSeries = computed(() => { data: [] }; - if (props.type === TrendChartType.Area.type) { + if (props.type === AccountBalanceTrendChartType.Area.type) { series.areaStyle = {}; - } else if (props.type === TrendChartType.Column.type) { + } else if (props.type === AccountBalanceTrendChartType.Column.type) { series.type = 'bar'; + } else if (props.type === AccountBalanceTrendChartType.Candlestick.type) { + const expenseIncomeAmountColor = getExpenseAndIncomeAmountColor(userStore.currentUserExpenseAmountColor, userStore.currentUserIncomeAmountColor, isDarkMode.value); + series.type = 'candlestick'; + series.itemStyle.color = expenseIncomeAmountColor.incomeAmountColor; + series.itemStyle.color0 = expenseIncomeAmountColor.expenseAmountColor; + series.itemStyle.borderColor = expenseIncomeAmountColor.incomeAmountColor; + series.itemStyle.borderColor0 = expenseIncomeAmountColor.expenseAmountColor; } for (let i = 0; i < allDataItems.value.length; i++) { const item = allDataItems.value[i]; - series.data.push(item.amount); + + if (props.type === AccountBalanceTrendChartType.Candlestick.type) { + series.data.push([ + item.openingBalance, + item.closingBalance, + item.minimumBalance, + item.maximumBalance + ]); + } else { + series.data.push(item.closingBalance); + } } return [series]; @@ -82,7 +110,14 @@ const yAxisWidth = computed(() => { for (let i = 0; i < allSeries.value.length; i++) { for (let j = 0; j < allSeries.value[i].data.length; j++) { - const value = allSeries.value[i].data[j]; + const data = allSeries.value[i].data[j]; + let value: number; + + if (isArray(data)) { + value = data[1]; // for candlestick, use closing balance + } else { + value = data as number; // for line or bar chart + } if (value > maxValue) { maxValue = value; @@ -134,13 +169,54 @@ const chartOptions = computed(() => { color: isDarkMode.value ? '#eee' : '#333' }, formatter: (params: CallbackDataParams[]) => { - const amount = params[0].data as number; - const value = formatAmountWithCurrency(amount, props.account.currency); + if (props.type === AccountBalanceTrendChartType.Candlestick.type) { + const dataIndex = params[0].dataIndex; + const dataItem = allDataItems.value[dataIndex]; + const displayItems: NameValue[] = [ + { + name: tt('Opening Balance'), + value: formatAmountWithCurrency(dataItem.openingBalance, props.account.currency) + }, + { + name: tt('Closing Balance'), + value: formatAmountWithCurrency(dataItem.closingBalance, props.account.currency) + }, + { + name: tt('Minimum Balance'), + value: formatAmountWithCurrency(dataItem.minimumBalance, props.account.currency) + }, + { + name: tt('Maximum Balance'), + value: formatAmountWithCurrency(dataItem.maximumBalance, props.account.currency) + }, + { + name: tt('Median Balance'), + value: formatAmountWithCurrency(dataItem.medianBalance, props.account.currency) + }, + { + name: tt('Average Balance'), + value: formatAmountWithCurrency(dataItem.averageBalance, props.account.currency) + } + ]; - return `${params[0].name}
` - + '
' - + `${props.legendName}${value}
` - + '
'; + let tooltip = `${params[0].name} ${props.legendName}
`; + + for (let i = 0; i < displayItems.length; i++) { + tooltip += `
` + + `${displayItems[i].name}${displayItems[i].value}
` + + `
`; + } + + return tooltip; + } else { + const amount = params[0].data as number; + const value = formatAmountWithCurrency(amount, props.account.currency); + + return `${params[0].name}
` + + '
' + + `${props.legendName}${value}
` + + '
'; + } } }, grid: { diff --git a/src/components/mobile/AccountBalanceTrendsBarChart.vue b/src/components/mobile/AccountBalanceTrendsBarChart.vue index 966935f7..e2597b8a 100644 --- a/src/components/mobile/AccountBalanceTrendsBarChart.vue +++ b/src/components/mobile/AccountBalanceTrendsBarChart.vue @@ -28,7 +28,7 @@ :style="`top: ${virtualDataItems.topPosition}px`" :virtual-list-index="item.index" :title="item.displayDate" - :after="formatAmountWithCurrency(item.amount, account.currency)" + :after="formatAmountWithCurrency(item.closingBalance, account.currency)" v-for="item in virtualDataItems.items" >