display year-over-year and period-over-period growth rates in account balance trends chart in account reconciliation statements on desktop version

This commit is contained in:
MaysWind
2026-04-09 01:04:15 +08:00
parent cd59c4e6a5
commit fcedb3147d
5 changed files with 228 additions and 109 deletions
@@ -1,4 +1,4 @@
import { computed } from 'vue'; import { ref, computed } from 'vue';
import { useI18n } from '@/locales/helpers.ts'; import { useI18n } from '@/locales/helpers.ts';
@@ -28,7 +28,10 @@ import {
getFiscalYearStartDateTime getFiscalYearStartDateTime
} from '@/lib/datetime.ts'; } from '@/lib/datetime.ts';
import { TimezoneTypeForStatistics } from '@/core/timezone.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 { export interface AccountBalanceUnixTimeAndBalanceRange extends UnixTimeRange {
minUnixTimeOpeningBalance: number; minUnixTimeOpeningBalance: number;
@@ -37,6 +40,8 @@ export interface AccountBalanceUnixTimeAndBalanceRange extends UnixTimeRange {
} }
export interface AccountBalanceTrendsChartItem { export interface AccountBalanceTrendsChartItem {
dateRangeKey: string;
lastYearDateRangeKey: string;
displayDate: string; displayDate: string;
openingBalance: number; openingBalance: number;
closingBalance: number; closingBalance: number;
@@ -65,6 +70,9 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
formatDateTimeToGregorianLikeFiscalYear formatDateTimeToGregorianLikeFiscalYear
} = useI18n(); } = useI18n();
const showYearOverYearOnTooltip = ref<boolean>(true);
const showPeriodOverPeriodOnTooltip = computed<boolean>(() => props.dateAggregationType === ChartDateAggregationType.Day.type || props.dateAggregationType === ChartDateAggregationType.Month.type || props.dateAggregationType === ChartDateAggregationType.Quarter.type);
const dataDateRange = computed<AccountBalanceUnixTimeAndBalanceRange | null>(() => { const dataDateRange = computed<AccountBalanceUnixTimeAndBalanceRange | null>(() => {
if (!props.items || props.items.length < 1) { if (!props.items || props.items.length < 1) {
return null; return null;
@@ -187,6 +195,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
} }
const dataItems = dayDataItemsMap[displayDate]; const dataItems = dayDataItemsMap[displayDate];
const dateRangeKey = getDateRangeKeyWithYearOffset(dateRange, props.dateAggregationType) ?? '';
const lastYearDateRangeKey = getDateRangeKeyWithYearOffset(dateRange, props.dateAggregationType, -1) ?? '';
if (isArray(dataItems)) { if (isArray(dataItems)) {
if (dataItems.length < 1) { if (dataItems.length < 1) {
@@ -243,6 +253,8 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
} }
ret.push({ ret.push({
dateRangeKey: dateRangeKey,
lastYearDateRangeKey: lastYearDateRangeKey,
displayDate: displayDate, displayDate: displayDate,
openingBalance: lastOpeningBalance, openingBalance: lastOpeningBalance,
closingBalance: lastClosingBalance, closingBalance: lastClosingBalance,
@@ -260,6 +272,18 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
return ret; return ret;
}); });
const allDataItemsMap = computed<Record<string, AccountBalanceTrendsChartItem>>(() => {
const ret: Record<string, AccountBalanceTrendsChartItem> = {};
for (const item of allDataItems.value) {
if (item.dateRangeKey) {
ret[item.dateRangeKey] = item;
}
}
return ret;
});
const allDisplayDateRanges = computed<string[]>(() => { const allDisplayDateRanges = computed<string[]>(() => {
if (!allDataItems.value || allDataItems.value.length < 1) { if (!allDataItems.value || allDataItems.value.length < 1) {
return []; return [];
@@ -269,9 +293,13 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
}); });
return { return {
// states
showYearOverYearOnTooltip,
showPeriodOverPeriodOnTooltip,
// computed states // computed states
allDateRanges, allDateRanges,
allDataItems, allDataItems,
allDataItemsMap,
allDisplayDateRanges allDisplayDateRanges
}; };
} }
@@ -16,7 +16,7 @@ import {
import { useUserStore } from '@/stores/user.ts'; 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 { TextDirection } from '@/core/text.ts';
import type { ColorStyleValue } from '@/core/color.ts'; import type { ColorStyleValue } from '@/core/color.ts';
import { ThemeType } from '@/core/theme.ts'; import { ThemeType } from '@/core/theme.ts';
@@ -52,8 +52,19 @@ interface AccountBalanceTrendsChartDataItem {
const props = defineProps<DesktopAccountBalanceTrendsChartProps>(); const props = defineProps<DesktopAccountBalanceTrendsChartProps>();
const theme = useTheme(); const theme = useTheme();
const { tt, getCurrentLanguageTextDirection, formatAmountToLocalizedNumeralsWithCurrency } = useI18n(); const {
const { allDataItems, allDisplayDateRanges } = useAccountBalanceTrendsChartBase(props); tt,
getCurrentLanguageTextDirection,
formatAmountToLocalizedNumeralsWithCurrency,
formatPercentToLocalizedNumerals
} = useI18n();
const {
showYearOverYearOnTooltip,
showPeriodOverPeriodOnTooltip,
allDataItems,
allDataItemsMap,
allDisplayDateRanges
} = useAccountBalanceTrendsChartBase(props);
const userStore = useUserStore(); const userStore = useUserStore();
@@ -189,105 +200,74 @@ const chartOptions = computed<object>(() => {
color: isDarkMode.value ? '#eee' : '#333' color: isDarkMode.value ? '#eee' : '#333'
}, },
formatter: (params: CallbackDataParams[]) => { 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) { if (props.type === AccountBalanceTrendChartType.Boxplot.type) {
const dataIndex = params[0]!.dataIndex; header += ` ${props.legendName}`;
const dataItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem; displayItems = getBoxplotChartTooltip(dataItem);
const displayItems: NameValue[] = [ yearOverYearDataItemDisplayItems = yearOverYearDataItem ? getBoxplotChartTooltip(yearOverYearDataItem) : undefined;
{ periodOverPeriodDataItemDisplayItems = periodOverPeriodDataItem ? getBoxplotChartTooltip(periodOverPeriodDataItem) : undefined;
name: tt('Minimum Balance'), separatorLineIndex = 5;
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}<br/>`;
for (const [displayItem, index] of itemAndIndex(displayItems)) {
if (index === 5) {
tooltip += '<div style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px"></div>';
}
tooltip += `<div><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>`
+ `<span>${displayItem.name}</span><span class="ms-5" style="float: inline-end">${displayItem.value}</span>`
+ `</div>`;
}
return tooltip;
} else if (props.type === AccountBalanceTrendChartType.Candlestick.type) { } else if (props.type === AccountBalanceTrendChartType.Candlestick.type) {
const dataIndex = params[0]!.dataIndex; header += ` ${props.legendName}`;
const dataItem = allDataItems.value[dataIndex] as AccountBalanceTrendsChartItem; displayItems = getCandlestickChartTooltip(dataItem);
const displayItems: NameValue[] = [ yearOverYearDataItemDisplayItems = yearOverYearDataItem ? getCandlestickChartTooltip(yearOverYearDataItem) : undefined;
{ periodOverPeriodDataItemDisplayItems = periodOverPeriodDataItem ? getCandlestickChartTooltip(periodOverPeriodDataItem) : undefined;
name: tt('Opening Balance'), separatorLineIndex = 4;
value: formatAmountToLocalizedNumeralsWithCurrency(dataItem.openingBalance, props.account.currency) } else {
}, displayItems = getDefaultChartTooltip(dataItem);
{ yearOverYearDataItemDisplayItems = yearOverYearDataItem ? getDefaultChartTooltip(yearOverYearDataItem) : undefined;
name: tt('Closing Balance'), periodOverPeriodDataItemDisplayItems = periodOverPeriodDataItem ? getDefaultChartTooltip(periodOverPeriodDataItem) : undefined;
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)
}
];
let tooltip = `${params[0]!.name} ${props.legendName}<br/>`; const totalColumnCount = 2 + (yearOverYearDataItemDisplayItems && yearOverYearDataItemDisplayItems.length ? 1 : 0) + (periodOverPeriodDataItemDisplayItems && periodOverPeriodDataItemDisplayItems.length ? 1 : 0);
let tooltip = `<table class="chart-tooltip-table"><tbody><tr><td colspan="2">${header}</td>`;
for (const [displayItem, index] of itemAndIndex(displayItems)) { if (yearOverYearDataItemDisplayItems && yearOverYearDataItemDisplayItems.length) {
if (index === 4) { tooltip += `<td><span class="ms-5" style="float: inline-end">${tt('Year-over-Year')}</span></td>`;
tooltip += '<div style="border-bottom: ' + (isDarkMode.value ? '#eee' : '#333') + ' dashed 1px"></div>'; }
}
tooltip += `<div><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>` if (periodOverPeriodDataItemDisplayItems && periodOverPeriodDataItemDisplayItems.length) {
+ `<span>${displayItem.name}</span><span class="ms-5" style="float: inline-end">${displayItem.value}</span>` tooltip += `<td><span class="ms-5" style="float: inline-end">${tt('Period-over-Period')}</span></td>`;
+ `</div>`; }
tooltip += '</tr>';
for (const [displayItem, index] of itemAndIndex(displayItems)) {
const displayValue = formatAmountToLocalizedNumeralsWithCurrency(displayItem.value, props.account.currency);
tooltip += `<tr><td><span class="chart-pointer" style="background-color: #${DEFAULT_CHART_COLORS[index]}"></span>`
+ `<span>${displayItem.name}</span></td><td><span class="ms-5" style="float: inline-end">${displayValue}</span></td>`;
if (yearOverYearDataItemDisplayItems && yearOverYearDataItemDisplayItems.length && yearOverYearDataItemDisplayItems[index]) {
const yearOverYearDisplayItem = yearOverYearDataItemDisplayItems[index];
const displayGrowthRate = formatDisplayChangeRate(displayItem.value, yearOverYearDisplayItem.value);
tooltip += `<td><span class="ms-5" style="float: inline-end">${displayGrowthRate}</span></td>`;
} }
return tooltip; if (periodOverPeriodDataItemDisplayItems && periodOverPeriodDataItemDisplayItems.length && periodOverPeriodDataItemDisplayItems[index]) {
} else { const periodOverPeriodDisplayItem = periodOverPeriodDataItemDisplayItems[index];
const amount = params[0]!.data as number; const displayGrowthRate = formatDisplayChangeRate(displayItem.value, periodOverPeriodDisplayItem.value);
const value = formatAmountToLocalizedNumeralsWithCurrency(amount, props.account.currency); tooltip += `<td><span class="ms-5" style="float: inline-end">${displayGrowthRate}</span></td>`;
}
return `${params[0]!.name}<br/>` tooltip += '</tr>';
+ '<div><span class="chart-pointer" style="background-color: #' + DEFAULT_CHART_COLORS[0] + '"></span>'
+ `<span>${props.legendName}</span><span class="ms-5" style="float: inline-end">${value}</span>` if (separatorLineIndex !== undefined && index === separatorLineIndex - 1) {
+ '</div>'; tooltip += `<tr><td colspan="${totalColumnCount}" style="border-bottom: ${(isDarkMode.value ? '#eee' : '#333')} dashed 1px"></td></tr>`;
}
} }
tooltip += `</tbody></table>`;
return tooltip;
} }
}, },
grid: { grid: {
@@ -332,6 +312,91 @@ const chartOptions = computed<object>(() => {
series: allSeries.value 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');
}
</script> </script>
<style scoped> <style scoped>
+6 -11
View File
@@ -64,6 +64,9 @@ import {
getDateTypeByDateRange, getDateTypeByDateRange,
getFiscalYearFromUnixTime getFiscalYearFromUnixTime
} from '@/lib/datetime.ts'; } from '@/lib/datetime.ts';
import {
getDateRangeKeyWithYearOffset
} from '@/lib/statistics.ts';
type AxisChartType = InstanceType<typeof AxisChart>; type AxisChartType = InstanceType<typeof AxisChart>;
@@ -297,19 +300,11 @@ function getSeriesId(item: Record<string, unknown>): string {
} }
function getDateRangeKey(dateRange: YearUnixTime | FiscalYearUnixTime | YearQuarterUnixTime | YearMonthUnixTime | YearMonthDayUnixTime, yearOffset?: number): string | undefined { function getDateRangeKey(dateRange: YearUnixTime | FiscalYearUnixTime | YearQuarterUnixTime | YearMonthUnixTime | YearMonthDayUnixTime, yearOffset?: number): string | undefined {
if (props.dateAggregationType === ChartDateAggregationType.Year.type) { if (props.dateAggregationType === ChartDateAggregationType.Day.type && props.chartMode !== 'daily') {
return (dateRange.year + (yearOffset ?? 0)).toString();
} else if (props.dateAggregationType === ChartDateAggregationType.FiscalYear.type && 'year' in dateRange) {
return (dateRange.year + (yearOffset ?? 0)).toString();
} else if (props.dateAggregationType === ChartDateAggregationType.Quarter.type && 'quarter' in dateRange) {
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.quarter}`;
} else if (props.dateAggregationType === ChartDateAggregationType.Month.type && 'month0base' in dateRange) {
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.month0base + 1}`;
} else if (props.dateAggregationType === ChartDateAggregationType.Day.type && 'day' in dateRange && props.chartMode === 'daily') {
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.month}-${dateRange.day}`;
} else {
return undefined; return undefined;
} }
return getDateRangeKeyWithYearOffset(dateRange, props.dateAggregationType, yearOffset);
} }
function formatDisplayChangeRate(current: number, reference: number): string { function formatDisplayChangeRate(current: number, reference: number): string {
@@ -96,6 +96,8 @@ const allVirtualListItems = computed<MobileAccountBalanceTrendsChartItem[]>(() =
const finalDataItem: MobileAccountBalanceTrendsChartItem = { const finalDataItem: MobileAccountBalanceTrendsChartItem = {
index: index, index: index,
dateRangeKey: dataItem.dateRangeKey,
lastYearDateRangeKey: dataItem.lastYearDateRangeKey,
displayDate: dataItem.displayDate, displayDate: dataItem.displayDate,
openingBalance: dataItem.openingBalance, openingBalance: dataItem.openingBalance,
closingBalance: dataItem.closingBalance, closingBalance: dataItem.closingBalance,
+32 -3
View File
@@ -1,6 +1,19 @@
import type { TextualYearMonth, Year1BasedMonth, YearUnixTime, YearQuarterUnixTime, YearMonthUnixTime } from '@/core/datetime.ts'; import {
import type { FiscalYearUnixTime } from '@/core/fiscalyear.ts'; type TextualYearMonth,
import { ChartSortingType, ChartDateAggregationType } from '@/core/statistics.ts'; type Year1BasedMonth,
type YearUnixTime,
type YearQuarterUnixTime,
type YearMonthUnixTime,
YearMonthDayUnixTime
} from '@/core/datetime.ts';
import {
type FiscalYearUnixTime
} from '@/core/fiscalyear.ts';
import {
ChartSortingType,
ChartDateAggregationType
} from '@/core/statistics.ts';
import type { import type {
YearMonthItems, YearMonthItems,
SortableTransactionStatisticDataItem SortableTransactionStatisticDataItem
@@ -88,3 +101,19 @@ export function getAllDateRangesByYearMonthRange(startYearMonth: Year1BasedMonth
return getAllMonthsStartAndEndUnixTimes(startYearMonth, endYearMonth); return getAllMonthsStartAndEndUnixTimes(startYearMonth, endYearMonth);
} }
} }
export function getDateRangeKeyWithYearOffset(dateRange: YearUnixTime | FiscalYearUnixTime | YearQuarterUnixTime | YearMonthUnixTime | YearMonthDayUnixTime, dateAggregationType: number, yearOffset?: number): string | undefined {
if (dateAggregationType === ChartDateAggregationType.Year.type) {
return (dateRange.year + (yearOffset ?? 0)).toString();
} else if (dateAggregationType === ChartDateAggregationType.FiscalYear.type && 'year' in dateRange) {
return (dateRange.year + (yearOffset ?? 0)).toString();
} else if (dateAggregationType === ChartDateAggregationType.Quarter.type && 'quarter' in dateRange) {
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.quarter}`;
} else if (dateAggregationType === ChartDateAggregationType.Month.type && 'month0base' in dateRange) {
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.month0base + 1}`;
} else if (dateAggregationType === ChartDateAggregationType.Day.type && 'day' in dateRange) {
return `${dateRange.year + (yearOffset ?? 0)}-${dateRange.month}-${dateRange.day}`;
} else {
return undefined;
}
}