diff --git a/src/components/desktop/TrendsChart.vue b/src/components/desktop/TrendsChart.vue index 45473098..e5902f56 100644 --- a/src/components/desktop/TrendsChart.vue +++ b/src/components/desktop/TrendsChart.vue @@ -11,11 +11,17 @@ import { useSettingsStore } from '@/stores/setting.js'; import { useUserStore } from '@/stores/user.js'; import colorConstants from '@/consts/color.js'; +import datetimeConstants from '@/consts/datetime.js'; import statisticsConstants from '@/consts/statistics.js'; -import { isNumber } from '@/lib/common.js'; import { - getYearMonthStringFromObject, - getAllYearMonthUnixTimesBetweenStartYearMonthAndEndYearMonth + isArray, + isNumber +} from '@/lib/common.js'; +import { + getAllYearsStartAndEndUnixTimes, + getAllQuartersStartAndEndUnixTimes, + getAllMonthsStartAndEndUnixTimes, + getDateTypeByDateRange } from '@/lib/datetime.js'; import { sortStatisticsItems @@ -29,6 +35,7 @@ export default { 'startYearMonth', 'endYearMonth', 'sortingType', + 'dateAggregationType', 'idField', 'nameField', 'valueField', @@ -67,15 +74,21 @@ export default { id = this.getItemName(item[this.nameField]); } - map[id] = item; + map[id] = { + [this.idField || 'id']: id, + [this.nameField || 'name']: item[this.nameField], + [this.hiddenField || 'hidden']: item[this.hiddenField], + [this.displayOrdersField || 'displayOrders']: item[this.displayOrdersField] + }; } return map; }, - allYearMonthTimes: function () { - if (this.startYearMonth && this.endYearMonth) { - return getAllYearMonthUnixTimesBetweenStartYearMonthAndEndYearMonth(this.startYearMonth, this.endYearMonth); - } else if (this.items && this.items.length) { + allDateRanges: function () { + let startYearMonth = this.startYearMonth; + let endYearMonth = this.endYearMonth; + + if ((!this.startYearMonth || !this.endYearMonth) && this.items && this.items.length) { let minYear = Number.MAX_SAFE_INTEGER, minMonth = Number.MAX_SAFE_INTEGER, maxYear = 0, maxMonth = 0; for (let i = 0; i < this.items.length; i++) { @@ -96,20 +109,37 @@ export default { } } - return getAllYearMonthUnixTimesBetweenStartYearMonthAndEndYearMonth(`${minYear}-${minMonth}`, `${maxYear}-${maxMonth}`); + startYearMonth = `${minYear}-${minMonth}`; + endYearMonth = `${maxYear}-${maxMonth}`; } - return []; + if (!startYearMonth || !endYearMonth) { + return []; + } + if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Year.type) { + return getAllYearsStartAndEndUnixTimes(startYearMonth, endYearMonth); + } else if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Quarter.type) { + return getAllQuartersStartAndEndUnixTimes(startYearMonth, endYearMonth); + } else { // if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Month.type) { + return getAllMonthsStartAndEndUnixTimes(startYearMonth, endYearMonth); + } }, - allDisplayMonths: function () { - const allDisplayMonths = []; + allDisplayDateRanges: function () { + const allDisplayDateRanges = []; - for (let i = 0; i < this.allYearMonthTimes.length; i++) { - const yearMonthTime = this.allYearMonthTimes[i]; - allDisplayMonths.push(this.$locale.formatUnixTimeToShortYearMonth(this.userStore, yearMonthTime.minUnixTime)); + for (let i = 0; i < this.allDateRanges.length; i++) { + const dateRange = this.allDateRanges[i]; + + if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Year.type) { + allDisplayDateRanges.push(this.$locale.formatUnixTimeToShortYear(this.userStore, dateRange.minUnixTime)); + } else if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Quarter.type) { + allDisplayDateRanges.push(this.$locale.formatYearQuarter(dateRange.year, dateRange.quarter)); + } else { // if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Month.type) { + allDisplayDateRanges.push(this.$locale.formatUnixTimeToShortYearMonth(this.userStore, dateRange.minUnixTime)); + } } - return allDisplayMonths; + return allDisplayDateRanges; }, allSeries: function () { const allSeries = []; @@ -122,20 +152,49 @@ export default { } const allAmounts = []; - const yearMonthDataMap = {}; + const dateRangeAmountMap = {}; for (let j = 0; j < item.items.length; j++) { const dataItem = item.items[j]; - yearMonthDataMap[`${dataItem.year}-${dataItem.month}`] = dataItem; + let dateRangeKey = ''; + + if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Year.type) { + dateRangeKey = dataItem.year; + } else if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Quarter.type) { + dateRangeKey = `${dataItem.year}-${Math.floor((dataItem.month - 1) / 3) + 1}`; + } else { // if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Month.type) { + dateRangeKey = `${dataItem.year}-${dataItem.month}`; + } + + const dataItems = dateRangeAmountMap[dateRangeKey] || []; + dataItems.push(dataItem); + + dateRangeAmountMap[dateRangeKey] = dataItems; } - for (let j = 0; j < this.allYearMonthTimes.length; j++) { - const yearMonth = getYearMonthStringFromObject(this.allYearMonthTimes[j]); - const dataItem = yearMonthDataMap[yearMonth]; - let amount = 0; + for (let j = 0; j < this.allDateRanges.length; j++) { + const dateRange = this.allDateRanges[j]; + let dateRangeKey = ''; - if (dataItem && isNumber(dataItem[this.valueField])) { - amount = dataItem[this.valueField]; + if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Year.type) { + dateRangeKey = dateRange.year; + } else if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Quarter.type) { + dateRangeKey = `${dateRange.year}-${dateRange.quarter}`; + } else { // if (this.dateAggregationType === statisticsConstants.allDateAggregationTypes.Month.type) { + dateRangeKey = `${dateRange.year}-${dateRange.month + 1}`; + } + + let amount = 0; + const dataItems = dateRangeAmountMap[dateRangeKey]; + + if (isArray(dataItems)) { + for (let i = 0; i < dataItems.length; i++) { + const dataItem = dataItems[i]; + + if (isNumber(dataItem[this.valueField])) { + amount += dataItem[this.valueField]; + } + } } allAmounts.push(amount); @@ -293,7 +352,7 @@ export default { xAxis: [ { type: 'category', - data: self.allDisplayMonths + data: self.allDisplayDateRanges } ], yAxis: [ @@ -332,11 +391,19 @@ export default { const id = e.seriesId; const item = this.itemsMap[id]; - const yearMonthTime = this.allYearMonthTimes[e.dataIndex]; + const itemId = this.idField ? item[this.idField] : ''; + const dateRange = this.allDateRanges[e.dataIndex]; + const minUnixTime = dateRange.minUnixTime; + const maxUnixTime = dateRange.maxUnixTime; + const dateRangeType = getDateTypeByDateRange(minUnixTime, maxUnixTime, this.userStore.currentUserFirstDayOfWeek, datetimeConstants.allDateRangeScenes.Normal); this.$emit('click', { - yearMonth: getYearMonthStringFromObject(yearMonthTime), - item: item + itemId: itemId, + dateRange: { + minTime: minUnixTime, + maxTime: maxUnixTime, + type: dateRangeType + } }); }, getColor: function (color) { diff --git a/src/consts/statistics.js b/src/consts/statistics.js index 81be2a44..7329ed4d 100644 --- a/src/consts/statistics.js +++ b/src/consts/statistics.js @@ -169,6 +169,29 @@ const allSortingTypesArray = [ const defaultSortingType = allSortingTypes.Amount.type; +const allDateAggregationTypes = { + Month: { + type: 0, + name: 'Aggregate by Month' + }, + Quarter: { + type: 1, + name: 'Aggregate by Quarter' + }, + Year: { + type: 2, + name: 'Aggregate by Year' + } +}; + +const allDateAggregationTypesArray = [ + allDateAggregationTypes.Month, + allDateAggregationTypes.Quarter, + allDateAggregationTypes.Year +] + +const defaultDateAggregationType = allDateAggregationTypes.Month.type; + export default { allAnalysisTypes: allAnalysisTypes, allCategoricalChartTypes: allCategoricalChartTypes, @@ -185,4 +208,7 @@ export default { allSortingTypes: allSortingTypes, allSortingTypesArray: allSortingTypesArray, defaultSortingType: defaultSortingType, + allDateAggregationTypes: allDateAggregationTypes, + allDateAggregationTypesArray: allDateAggregationTypesArray, + defaultDateAggregationType: defaultDateAggregationType, }; diff --git a/src/lib/datetime.js b/src/lib/datetime.js index cc36825a..43e9d4fa 100644 --- a/src/lib/datetime.js +++ b/src/lib/datetime.js @@ -283,6 +283,22 @@ export function getSpecifiedDayFirstUnixTime(unixTime) { return moment.unix(unixTime).set({ hour: 0, minute: 0, second: 0, millisecond: 0 }).unix(); } +export function getYearFirstUnixTime(year) { + return moment().set({ year: year, month: 0, date: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }).unix(); +} + +export function getYearLastUnixTime(year) { + return moment.unix(getYearFirstUnixTime(year)).add(1, 'years').subtract(1, 'seconds').unix(); +} + +export function getQuarterFirstUnixTime(yearQuarter) { + return moment().set({ year: yearQuarter.year, month: (yearQuarter.quarter - 1) * 3, date: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }).unix(); +} + +export function getQuarterLastUnixTime(yearQuarter) { + return moment.unix(getQuarterFirstUnixTime(yearQuarter)).add(3, 'months').subtract(1, 'seconds').unix(); +} + export function getYearMonthFirstUnixTime(yearMonth) { if (isString(yearMonth)) { yearMonth = getYearMonthObjectFromString(yearMonth); @@ -301,7 +317,69 @@ export function getYearMonthLastUnixTime(yearMonth) { return moment.unix(getYearMonthFirstUnixTime(yearMonth)).add(1, 'months').subtract(1, 'seconds').unix(); } -export function getAllYearMonthUnixTimesBetweenStartYearMonthAndEndYearMonth(startYearMonth, endYearMonth) { +export function getAllYearsStartAndEndUnixTimes(startYearMonth, endYearMonth) { + if (isString(startYearMonth)) { + startYearMonth = getYearMonthObjectFromString(startYearMonth); + } + + if (isString(endYearMonth)) { + endYearMonth = getYearMonthObjectFromString(endYearMonth); + } + + const allYearTimes = []; + + for (let year = startYearMonth.year; year <= endYearMonth.year; year++) { + const yearTime = { + year: year + }; + + yearTime.minUnixTime = getYearFirstUnixTime(year); + yearTime.maxUnixTime = getYearLastUnixTime(year); + + allYearTimes.push(yearTime); + } + + return allYearTimes; +} + +export function getAllQuartersStartAndEndUnixTimes(startYearMonth, endYearMonth) { + if (isString(startYearMonth)) { + startYearMonth = getYearMonthObjectFromString(startYearMonth); + } + + if (isString(endYearMonth)) { + endYearMonth = getYearMonthObjectFromString(endYearMonth); + } + + const allYearQuarterTimes = []; + + for (let year = startYearMonth.year, month = startYearMonth.month; year < endYearMonth.year || (year === endYearMonth.year && ((month / 3) <= (endYearMonth.month / 3))); ) { + const yearQuarterTime = { + year: year, + quarter: Math.floor((month / 3)) + 1 + }; + + yearQuarterTime.minUnixTime = getQuarterFirstUnixTime(yearQuarterTime); + yearQuarterTime.maxUnixTime = getQuarterLastUnixTime(yearQuarterTime); + + allYearQuarterTimes.push(yearQuarterTime); + + if (year === endYearMonth.year && month >= endYearMonth.month) { + break; + } + + if (month >= 9) { + year++; + month = 0; + } else { + month += 3; + } + } + + return allYearQuarterTimes; +} + +export function getAllMonthsStartAndEndUnixTimes(startYearMonth, endYearMonth) { if (isString(startYearMonth)) { startYearMonth = getYearMonthObjectFromString(startYearMonth); } diff --git a/src/lib/i18n.js b/src/lib/i18n.js index 919ad49a..a39e265e 100644 --- a/src/lib/i18n.js +++ b/src/lib/i18n.js @@ -451,6 +451,17 @@ function getI18nShortTimeFormat(translateFn, formatTypeValue) { return getDateTimeFormat(translateFn, datetimeConstants.allShortTimeFormat, datetimeConstants.allShortTimeFormatArray, 'format.shortTime', defaultShortTimeFormatTypeName, datetimeConstants.defaultShortTimeFormat, formatTypeValue); } +function formatYearQuarter(translateFn, year, quarter) { + if (1 <= quarter && quarter <= 4) { + return translateFn('format.yearQuarter.q' + quarter, { + year: year, + quarter: quarter + }); + } else { + return ''; + } +} + function isLongTime24HourFormat(translateFn, formatTypeValue) { const defaultLongTimeFormatTypeName = translateFn('default.longTimeFormat'); const type = getDateTimeFormatType(datetimeConstants.allLongTimeFormat, datetimeConstants.allLongTimeFormatArray, defaultLongTimeFormatTypeName, datetimeConstants.defaultLongTimeFormat, formatTypeValue); @@ -1142,6 +1153,25 @@ function getAllStatisticsSortingTypes(translateFn) { return allSortingTypes; } +function getAllStatisticsDateAggregationTypes(translateFn) { + const aggregationTypes = []; + + for (const aggregationTypeField in statisticsConstants.allDateAggregationTypes) { + if (!Object.prototype.hasOwnProperty.call(statisticsConstants.allDateAggregationTypes, aggregationTypeField)) { + continue; + } + + const aggregationType = statisticsConstants.allDateAggregationTypes[aggregationTypeField]; + + aggregationTypes.push({ + type: aggregationType.type, + displayName: translateFn(aggregationType.name) + }); + } + + return aggregationTypes; +} + function getAllTransactionEditScopeTypes(translateFn) { const allEditScopeTypes = []; @@ -1632,6 +1662,7 @@ export function i18nFunctions(i18nGlobal) { formatUnixTimeToShortMonthDay: (userStore, unixTime, utcOffset, currentUtcOffset) => formatUnixTime(unixTime, getI18nShortMonthDayFormat(i18nGlobal.t, userStore.currentUserShortDateFormat), utcOffset, currentUtcOffset), formatUnixTimeToLongTime: (userStore, unixTime, utcOffset, currentUtcOffset) => formatUnixTime(unixTime, getI18nLongTimeFormat(i18nGlobal.t, userStore.currentUserLongTimeFormat), utcOffset, currentUtcOffset), formatUnixTimeToShortTime: (userStore, unixTime, utcOffset, currentUtcOffset) => formatUnixTime(unixTime, getI18nShortTimeFormat(i18nGlobal.t, userStore.currentUserShortTimeFormat), utcOffset, currentUtcOffset), + formatYearQuarter: (year, quarter) => formatYearQuarter(i18nGlobal.t, year, quarter), isLongDateMonthAfterYear: (userStore) => isLongDateMonthAfterYear(i18nGlobal.t, userStore.currentUserLongDateFormat), isShortDateMonthAfterYear: (userStore) => isShortDateMonthAfterYear(i18nGlobal.t, userStore.currentUserShortDateFormat), isLongTime24HourFormat: (userStore) => isLongTime24HourFormat(i18nGlobal.t, userStore.currentUserLongTimeFormat), @@ -1668,6 +1699,7 @@ export function i18nFunctions(i18nGlobal) { getAllTrendChartTypes: () => getAllTrendChartTypes(i18nGlobal.t), getAllStatisticsChartDataTypes: (analysisType) => getAllStatisticsChartDataTypes(i18nGlobal.t, analysisType), getAllStatisticsSortingTypes: () => getAllStatisticsSortingTypes(i18nGlobal.t), + getAllStatisticsDateAggregationTypes: () => getAllStatisticsDateAggregationTypes(i18nGlobal.t), getAllTransactionEditScopeTypes: () => getAllTransactionEditScopeTypes(i18nGlobal.t), getAllTransactionScheduledFrequencyTypes: () => getAllTransactionScheduledFrequencyTypes(i18nGlobal.t), getAllTransactionDefaultCategories: (categoryType, locale) => getAllTransactionDefaultCategories(categoryType, locale, i18nGlobal.t), diff --git a/src/locales/en.json b/src/locales/en.json index fa94557f..88bb02b4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -71,6 +71,12 @@ "a_hh_mm": "A hh:mm", "hh_mm_a": "hh:mm A" }, + "yearQuarter": { + "q1": "{year}Q1", + "q2": "{year}Q2", + "q3": "{year}Q3", + "q4": "{year}Q4" + }, "misc": { "multiTextJoinSeparator": ", ", "hoursBehindDefaultTimezone": "{hours} hour(s) behind default timezone", @@ -1632,6 +1638,9 @@ "Sort by Amount": "Sort by Amount", "Sort by Display Order": "Sort by Display Order", "Sort by Name": "Sort by Name", + "Aggregate by Month": "Aggregate by Month", + "Aggregate by Quarter": "Aggregate by Quarter", + "Aggregate by Year": "Aggregate by Year", "Filter Accounts": "Filter Accounts", "Filter Transaction Categories": "Filter Transaction Categories", "Filter Transaction Tags": "Filter Transaction Tags", diff --git a/src/locales/zh_Hans.json b/src/locales/zh_Hans.json index dd158d57..f0958bd0 100644 --- a/src/locales/zh_Hans.json +++ b/src/locales/zh_Hans.json @@ -71,6 +71,12 @@ "a_hh_mm": "A hh:mm", "hh_mm_a": "hh:mm A" }, + "yearQuarter": { + "q1": "{year}Q1", + "q2": "{year}Q2", + "q3": "{year}Q3", + "q4": "{year}Q4" + }, "misc": { "multiTextJoinSeparator": "、", "hoursBehindDefaultTimezone": "比默认时区晚{hours}小时", @@ -1632,6 +1638,9 @@ "Sort by Amount": "按金额排序", "Sort by Display Order": "按显示顺序排序", "Sort by Name": "按名称排序", + "Aggregate by Month": "按月聚合", + "Aggregate by Quarter": "按季度聚合", + "Aggregate by Year": "按年聚合", "Filter Accounts": "过滤账户", "Filter Transaction Categories": "过滤交易类型", "Filter Transaction Tags": "过滤交易标签", diff --git a/src/router/desktop.js b/src/router/desktop.js index 9fdf5780..eae74ba3 100644 --- a/src/router/desktop.js +++ b/src/router/desktop.js @@ -121,7 +121,8 @@ const router = createRouter({ initEndTime: route.query.endTime, initFilterAccountIds: route.query.filterAccountIds, initFilterCategoryIds: route.query.filterCategoryIds, - initSortingType: route.query.sortingType + initSortingType: route.query.sortingType, + initTrendDateAggregationType: route.query.trendDateAggregationType }) }, { diff --git a/src/stores/statistics.js b/src/stores/statistics.js index 39584532..200ac755 100644 --- a/src/stores/statistics.js +++ b/src/stores/statistics.js @@ -754,7 +754,7 @@ export const useStatisticsStore = defineStore('statistics', { return changed; }, - getTransactionStatisticsPageParams(analysisType) { + getTransactionStatisticsPageParams(analysisType, trendDateAggregationType) { const querys = []; querys.push('analysisType=' + analysisType); @@ -776,6 +776,10 @@ export const useStatisticsStore = defineStore('statistics', { querys.push('startTime=' + this.transactionStatisticsFilter.trendChartStartYearMonth); querys.push('endTime=' + this.transactionStatisticsFilter.trendChartEndYearMonth); } + + if (trendDateAggregationType !== statisticsConstants.allDateAggregationTypes.Month.type) { + querys.push('trendDateAggregationType=' + trendDateAggregationType); + } } if (this.transactionStatisticsFilter.filterAccountIds) { @@ -798,7 +802,7 @@ export const useStatisticsStore = defineStore('statistics', { return querys.join('&'); }, - getTransactionListPageParams(analysisType, item, dateRange) { + getTransactionListPageParams(analysisType, itemId, dateRange) { const accountsStore = useAccountsStore(); const transactionCategoriesStore = useTransactionCategoriesStore(); const querys = []; @@ -819,7 +823,7 @@ export const useStatisticsStore = defineStore('statistics', { || this.transactionStatisticsFilter.chartDataType === statisticsConstants.allChartDataTypes.ExpenseByAccount.type || this.transactionStatisticsFilter.chartDataType === statisticsConstants.allChartDataTypes.AccountTotalAssets.type || this.transactionStatisticsFilter.chartDataType === statisticsConstants.allChartDataTypes.AccountTotalLiabilities.type) { - querys.push('accountIds=' + item.id); + querys.push('accountIds=' + itemId); if (!isObjectEmpty(this.transactionStatisticsFilter.filterCategoryIds)) { querys.push('categoryIds=' + getFinalCategoryIdsByFilteredCategoryIds(transactionCategoriesStore.allTransactionCategoriesMap, this.transactionStatisticsFilter.filterCategoryIds)); @@ -828,7 +832,7 @@ export const useStatisticsStore = defineStore('statistics', { || this.transactionStatisticsFilter.chartDataType === statisticsConstants.allChartDataTypes.IncomeBySecondaryCategory.type || this.transactionStatisticsFilter.chartDataType === statisticsConstants.allChartDataTypes.ExpenseByPrimaryCategory.type || this.transactionStatisticsFilter.chartDataType === statisticsConstants.allChartDataTypes.ExpenseBySecondaryCategory.type) { - querys.push('categoryIds=' + item.id); + querys.push('categoryIds=' + itemId); if (!isObjectEmpty(this.transactionStatisticsFilter.filterAccountIds)) { querys.push('accountIds=' + getFinalAccountIdsByFilteredAccountIds(accountsStore.allAccountsMap, this.transactionStatisticsFilter.filterAccountIds)); diff --git a/src/views/desktop/statistics/TransactionPage.vue b/src/views/desktop/statistics/TransactionPage.vue index 1c126d01..42b9f184 100644 --- a/src/views/desktop/statistics/TransactionPage.vue +++ b/src/views/desktop/statistics/TransactionPage.vue @@ -87,6 +87,22 @@ @click="shiftDateRange(1)"/> + + + + + + + +