From fcedb3147d54719576c7e034edc44f28e8d04301 Mon Sep 17 00:00:00 2001 From: MaysWind Date: Thu, 9 Apr 2026 01:04:15 +0800 Subject: [PATCH] display year-over-year and period-over-period growth rates in account balance trends chart in account reconciliation statements on desktop version --- .../base/AccountBalanceTrendsChartBase.ts | 32 ++- .../desktop/AccountBalanceTrendsChart.vue | 251 +++++++++++------- src/components/desktop/TrendsChart.vue | 17 +- .../mobile/AccountBalanceTrendsBarChart.vue | 2 + src/lib/statistics.ts | 35 ++- 5 files changed, 228 insertions(+), 109 deletions(-) diff --git a/src/components/base/AccountBalanceTrendsChartBase.ts b/src/components/base/AccountBalanceTrendsChartBase.ts index 4ebeca1f..341323a7 100644 --- a/src/components/base/AccountBalanceTrendsChartBase.ts +++ b/src/components/base/AccountBalanceTrendsChartBase.ts @@ -1,4 +1,4 @@ -import { computed } from 'vue'; +import { ref, computed } from 'vue'; import { useI18n } from '@/locales/helpers.ts'; @@ -28,7 +28,10 @@ import { getFiscalYearStartDateTime } from '@/lib/datetime.ts'; import { TimezoneTypeForStatistics } from '@/core/timezone.ts'; -import { getAllDateRangesByYearMonthRange } from '@/lib/statistics.ts'; +import { + getAllDateRangesByYearMonthRange, + getDateRangeKeyWithYearOffset +} from '@/lib/statistics.ts'; export interface AccountBalanceUnixTimeAndBalanceRange extends UnixTimeRange { minUnixTimeOpeningBalance: number; @@ -37,6 +40,8 @@ export interface AccountBalanceUnixTimeAndBalanceRange extends UnixTimeRange { } export interface AccountBalanceTrendsChartItem { + dateRangeKey: string; + lastYearDateRangeKey: string; displayDate: string; openingBalance: number; closingBalance: number; @@ -65,6 +70,9 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren formatDateTimeToGregorianLikeFiscalYear } = useI18n(); + const showYearOverYearOnTooltip = ref(true); + const showPeriodOverPeriodOnTooltip = computed(() => props.dateAggregationType === ChartDateAggregationType.Day.type || props.dateAggregationType === ChartDateAggregationType.Month.type || props.dateAggregationType === ChartDateAggregationType.Quarter.type); + const dataDateRange = computed(() => { if (!props.items || props.items.length < 1) { return null; @@ -187,6 +195,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren } const dataItems = dayDataItemsMap[displayDate]; + const dateRangeKey = getDateRangeKeyWithYearOffset(dateRange, props.dateAggregationType) ?? ''; + const lastYearDateRangeKey = getDateRangeKeyWithYearOffset(dateRange, props.dateAggregationType, -1) ?? ''; if (isArray(dataItems)) { if (dataItems.length < 1) { @@ -243,6 +253,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren } ret.push({ + dateRangeKey: dateRangeKey, + lastYearDateRangeKey: lastYearDateRangeKey, displayDate: displayDate, openingBalance: lastOpeningBalance, closingBalance: lastClosingBalance, @@ -260,6 +272,18 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren return ret; }); + const allDataItemsMap = computed>(() => { + const ret: Record = {}; + + for (const item of allDataItems.value) { + if (item.dateRangeKey) { + ret[item.dateRangeKey] = item; + } + } + + return ret; + }); + const allDisplayDateRanges = computed(() => { if (!allDataItems.value || allDataItems.value.length < 1) { return []; @@ -269,9 +293,13 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren }); return { + // states + showYearOverYearOnTooltip, + showPeriodOverPeriodOnTooltip, // computed states allDateRanges, allDataItems, + allDataItemsMap, allDisplayDateRanges }; } diff --git a/src/components/desktop/AccountBalanceTrendsChart.vue b/src/components/desktop/AccountBalanceTrendsChart.vue index e34ee778..339659e2 100644 --- a/src/components/desktop/AccountBalanceTrendsChart.vue +++ b/src/components/desktop/AccountBalanceTrendsChart.vue @@ -16,7 +16,7 @@ import { import { useUserStore } from '@/stores/user.ts'; -import { type NameValue, itemAndIndex } from '@/core/base.ts'; +import { type NameNumeralValue, itemAndIndex } from '@/core/base.ts'; import { TextDirection } from '@/core/text.ts'; import type { ColorStyleValue } from '@/core/color.ts'; import { ThemeType } from '@/core/theme.ts'; @@ -52,8 +52,19 @@ interface AccountBalanceTrendsChartDataItem { const props = defineProps(); const theme = useTheme(); -const { tt, getCurrentLanguageTextDirection, formatAmountToLocalizedNumeralsWithCurrency } = useI18n(); -const { allDataItems, allDisplayDateRanges } = useAccountBalanceTrendsChartBase(props); +const { + tt, + getCurrentLanguageTextDirection, + formatAmountToLocalizedNumeralsWithCurrency, + formatPercentToLocalizedNumerals +} = useI18n(); +const { + showYearOverYearOnTooltip, + showPeriodOverPeriodOnTooltip, + allDataItems, + allDataItemsMap, + allDisplayDateRanges +} = useAccountBalanceTrendsChartBase(props); const userStore = useUserStore(); @@ -189,105 +200,74 @@ const chartOptions = computed(() => { color: isDarkMode.value ? '#eee' : '#333' }, formatter: (params: CallbackDataParams[]) => { + const dataIndex = params[0]!.dataIndex; + const dataItem: AccountBalanceTrendsChartItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem; + const yearOverYearDataItem: AccountBalanceTrendsChartItem | undefined = showYearOverYearOnTooltip.value ? allDataItemsMap.value[dataItem.lastYearDateRangeKey] : undefined; + const periodOverPeriodDataItem: AccountBalanceTrendsChartItem | undefined = showPeriodOverPeriodOnTooltip.value ? allDataItems.value[dataIndex - 1] : undefined; + + let header: string = params[0]!.name; + let displayItems: NameNumeralValue[] = []; + let yearOverYearDataItemDisplayItems: NameNumeralValue[] | undefined = undefined; + let periodOverPeriodDataItemDisplayItems: NameNumeralValue[] | undefined = undefined; + let separatorLineIndex: number | undefined = undefined; + if (props.type === AccountBalanceTrendChartType.Boxplot.type) { - const dataIndex = params[0]!.dataIndex; - const dataItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem; - const displayItems: NameValue[] = [ - { - name: tt('Minimum Balance'), - value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.minimumBalance, props.account.currency) - }, - { - name: tt('Q1 Balance (First Quartile)'), - value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.q1Balance, props.account.currency) - }, - { - name: tt('Median Balance'), - value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.medianBalance, props.account.currency) - }, - { - name: tt('Q3 Balance (Third Quartile)'), - value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.q3Balance, props.account.currency) - }, - { - name: tt('Maximum Balance'), - value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.maximumBalance, props.account.currency) - }, - { - name: tt('Opening Balance'), - value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.openingBalance, props.account.currency) - }, - { - name: tt('Closing Balance'), - value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.closingBalance, props.account.currency) - } - ]; - - let tooltip = `${params[0]!.name} ${props.legendName}
`; - - for (const [displayItem, index] of itemAndIndex(displayItems)) { - if (index === 5) { - tooltip += '
'; - } - - tooltip += `
` - + `${displayItem.name}${displayItem.value}` - + `
`; - } - - return tooltip; + header += ` ${props.legendName}`; + displayItems = getBoxplotChartTooltip(dataItem); + yearOverYearDataItemDisplayItems = yearOverYearDataItem ? getBoxplotChartTooltip(yearOverYearDataItem) : undefined; + periodOverPeriodDataItemDisplayItems = periodOverPeriodDataItem ? getBoxplotChartTooltip(periodOverPeriodDataItem) : undefined; + separatorLineIndex = 5; } else if (props.type === AccountBalanceTrendChartType.Candlestick.type) { - const dataIndex = params[0]!.dataIndex; - const dataItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem; - const displayItems: NameValue[] = [ - { - name: tt('Opening Balance'), - value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.openingBalance, props.account.currency) - }, - { - name: tt('Closing Balance'), - value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.closingBalance, props.account.currency) - }, - { - name: tt('Minimum Balance'), - value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.minimumBalance, props.account.currency) - }, - { - name: tt('Maximum Balance'), - value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.maximumBalance, props.account.currency) - }, - { - name: tt('Median Balance'), - value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.medianBalance, props.account.currency) - }, - { - name: tt('Average Balance'), - value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.averageBalance, props.account.currency) - } - ]; + header += ` ${props.legendName}`; + displayItems = getCandlestickChartTooltip(dataItem); + yearOverYearDataItemDisplayItems = yearOverYearDataItem ? getCandlestickChartTooltip(yearOverYearDataItem) : undefined; + periodOverPeriodDataItemDisplayItems = periodOverPeriodDataItem ? getCandlestickChartTooltip(periodOverPeriodDataItem) : undefined; + separatorLineIndex = 4; + } else { + displayItems = getDefaultChartTooltip(dataItem); + yearOverYearDataItemDisplayItems = yearOverYearDataItem ? getDefaultChartTooltip(yearOverYearDataItem) : undefined; + periodOverPeriodDataItemDisplayItems = periodOverPeriodDataItem ? getDefaultChartTooltip(periodOverPeriodDataItem) : undefined; + } - let tooltip = `${params[0]!.name} ${props.legendName}
`; + const totalColumnCount = 2 + (yearOverYearDataItemDisplayItems && yearOverYearDataItemDisplayItems.length ? 1 : 0) + (periodOverPeriodDataItemDisplayItems && periodOverPeriodDataItemDisplayItems.length ? 1 : 0); + let tooltip = ``; - for (const [displayItem, index] of itemAndIndex(displayItems)) { - if (index === 4) { - tooltip += '
'; - } + if (yearOverYearDataItemDisplayItems && yearOverYearDataItemDisplayItems.length) { + tooltip += ``; + } - tooltip += `
` - + `${displayItem.name}${displayItem.value}` - + `
`; + if (periodOverPeriodDataItemDisplayItems && periodOverPeriodDataItemDisplayItems.length) { + tooltip += ``; + } + + tooltip += ''; + + for (const [displayItem, index] of itemAndIndex(displayItems)) { + const displayValue = formatAmountToLocalizedNumeralsWithCurrency(displayItem.value, props.account.currency); + tooltip += ``; + + if (yearOverYearDataItemDisplayItems && yearOverYearDataItemDisplayItems.length && yearOverYearDataItemDisplayItems[index]) { + const yearOverYearDisplayItem = yearOverYearDataItemDisplayItems[index]; + const displayGrowthRate = formatDisplayChangeRate(displayItem.value, yearOverYearDisplayItem.value); + tooltip += ``; } - return tooltip; - } else { - const amount = params[0]!.data as number; - const value = formatAmountToLocalizedNumeralsWithCurrency(amount, props.account.currency); + if (periodOverPeriodDataItemDisplayItems && periodOverPeriodDataItemDisplayItems.length && periodOverPeriodDataItemDisplayItems[index]) { + const periodOverPeriodDisplayItem = periodOverPeriodDataItemDisplayItems[index]; + const displayGrowthRate = formatDisplayChangeRate(displayItem.value, periodOverPeriodDisplayItem.value); + tooltip += ``; + } - return `${params[0]!.name}
` - + '
' - + `${props.legendName}${value}` - + '
'; + tooltip += ''; + + if (separatorLineIndex !== undefined && index === separatorLineIndex - 1) { + tooltip += ``; + } } + + tooltip += `
${header}${tt('Year-over-Year')}${tt('Period-over-Period')}
` + + `${displayItem.name}${displayValue}${displayGrowthRate}${displayGrowthRate}
`; + return tooltip; } }, grid: { @@ -332,6 +312,91 @@ const chartOptions = computed(() => { series: allSeries.value }; }); + +function getBoxplotChartTooltip(dataItem: AccountBalanceTrendsChartItem): NameNumeralValue[] { + return [ + { + name: tt('Minimum Balance'), + value: dataItem.minimumBalance + }, + { + name: tt('Q1 Balance (First Quartile)'), + value: dataItem.q1Balance + }, + { + name: tt('Median Balance'), + value: dataItem.medianBalance + }, + { + name: tt('Q3 Balance (Third Quartile)'), + value: dataItem.q3Balance + }, + { + name: tt('Maximum Balance'), + value: dataItem.maximumBalance + }, + { + name: tt('Opening Balance'), + value: dataItem.openingBalance + }, + { + name: tt('Closing Balance'), + value: dataItem.closingBalance + } + ]; +} + +function getCandlestickChartTooltip(dataItem: AccountBalanceTrendsChartItem): NameNumeralValue[] { + return [ + { + name: tt('Opening Balance'), + value: dataItem.openingBalance + }, + { + name: tt('Closing Balance'), + value: dataItem.closingBalance + }, + { + name: tt('Minimum Balance'), + value: dataItem.minimumBalance + }, + { + name: tt('Maximum Balance'), + value: dataItem.maximumBalance + }, + { + name: tt('Median Balance'), + value: dataItem.medianBalance + }, + { + name: tt('Average Balance'), + value: dataItem.averageBalance + } + ]; +} + +function getDefaultChartTooltip(dataItem: AccountBalanceTrendsChartItem): NameNumeralValue[] { + return [ + { + name: props.legendName, + value: dataItem.closingBalance + } + ]; +} + + +function formatDisplayChangeRate(current: number, reference: number): string { + if (reference === 0 && current === 0) { + return formatPercentToLocalizedNumerals(0, 2, '<0.01'); + } + + if (reference === 0) { + return '-'; + } + + const rate = (current - reference) / reference * 100; + return formatPercentToLocalizedNumerals(rate, 2, '<0.01'); +}