diff --git a/src/lib/datetime.ts b/src/lib/datetime.ts index c35924de..c0ccaf5f 100644 --- a/src/lib/datetime.ts +++ b/src/lib/datetime.ts @@ -317,6 +317,15 @@ export function getThisMonthLastUnixTime(): number { return moment.unix(getThisMonthFirstUnixTime()).add(1, 'months').subtract(1, 'seconds').unix(); } +export function getMonthFirstUnixTimeBySpecifiedUnixTime(unixTime: number): number { + const date = moment.unix(unixTime).set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); + return date.subtract(date.date() - 1, 'days').unix(); +} + +export function getMonthLastUnixTimeBySpecifiedUnixTime(unixTime: number): number { + return moment.unix(getMonthFirstUnixTimeBySpecifiedUnixTime(unixTime)).add(1, 'months').subtract(1, 'seconds').unix(); +} + export function getThisMonthSpecifiedDayFirstUnixTime(date: number): number { return moment().set({ date: date, hour: 0, minute: 0, second: 0, millisecond: 0 }).unix(); } @@ -792,6 +801,28 @@ export function getRecentDateRangeType(allRecentMonthDateRanges: LocalizedRecent return getRecentDateRangeTypeByDateType(allRecentMonthDateRanges, DateRange.Custom.type); } +export function getFullMonthDateRange(minTime: number, maxTime: number, firstDayOfWeek: number): TimeRangeAndDateType | null { + if (isDateRangeMatchOneMonth(minTime, maxTime)) { + return null; + } + + if (!minTime) { + return getDateRangeByDateType(DateRange.ThisMonth.type, firstDayOfWeek); + } + + const monthFirstUnixTime = getMonthFirstUnixTimeBySpecifiedUnixTime(minTime); + const monthLastUnixTime = getMonthLastUnixTimeBySpecifiedUnixTime(minTime); + const dateType = getDateTypeByDateRange(monthFirstUnixTime, monthLastUnixTime, firstDayOfWeek, DateRangeScene.Normal); + + const dateRange: TimeRangeAndDateType = { + dateType: dateType, + maxTime: monthLastUnixTime, + minTime: monthFirstUnixTime + }; + + return dateRange; +} + export function getTimeValues(date: Date, is24Hour: boolean, isMeridiemIndicatorFirst: boolean): string[] { const hourMinuteSeconds = [ getTwoDigitsString(is24Hour ? date.getHours() : getHourIn12HourFormat(date.getHours())), @@ -849,6 +880,20 @@ export function getCombinedDateAndTimeValues(date: Date, timeValues: string[], i return newDateTime; } +export function getMonthFirstDayOrCurrentDayShortDate(unixTime: number): string { + const currentTime = moment(); + let dateTime = moment.unix(unixTime); + + if (dateTime.year() === currentTime.year() && dateTime.month() === currentTime.month()) { + return getShortDate(currentTime); + } + + dateTime = dateTime.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }); + dateTime = dateTime.subtract(dateTime.date() - 1, 'days'); + + return getShortDate(dateTime); +} + export function isDateRangeMatchFullYears(minTime: number, maxTime: number): boolean { const minDateTime = parseDateFromUnixTime(minTime).set({ millisecond: 0 }); const maxDateTime = parseDateFromUnixTime(maxTime).set({ millisecond: 999 }); diff --git a/src/locales/de.json b/src/locales/de.json index 2508b837..a879d9d6 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1508,6 +1508,7 @@ "Income and Expense Trends": "Einkommens- und Ausgabentrends", "View Details": "Details anzeigen", "Transaction List": "Transaktionsliste", + "Transaction Calendar": "Transaction Calendar", "Transaction Details": "Transaktionsdetails", "Statistics & Analysis": "Statistiken & Analysen", "Account List": "Kontoliste", diff --git a/src/locales/en.json b/src/locales/en.json index 1eaeb0b9..d38a6f56 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1508,6 +1508,7 @@ "Income and Expense Trends": "Income and Expense Trends", "View Details": "View Details", "Transaction List": "Transaction List", + "Transaction Calendar": "Transaction Calendar", "Transaction Details": "Transaction Details", "Statistics & Analysis": "Statistics & Analysis", "Account List": "Account List", diff --git a/src/locales/es.json b/src/locales/es.json index 47d608c6..0d177060 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1507,6 +1507,7 @@ "Income and Expense Trends": "Tendencias de ingresos y gastos", "View Details": "Ver detalles", "Transaction List": "Lista de transacciones", + "Transaction Calendar": "Transaction Calendar", "Transaction Details": "Detalles de la transacción", "Statistics & Analysis": "Estadísticas y análisis", "Account List": "Lista de cuentas", diff --git a/src/locales/it.json b/src/locales/it.json index 52c0c7f6..7b269b2e 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -1508,6 +1508,7 @@ "Income and Expense Trends": "Andamento entrate e uscite", "View Details": "Visualizza dettagli", "Transaction List": "Elenco transazioni", + "Transaction Calendar": "Transaction Calendar", "Transaction Details": "Dettagli transazione", "Statistics & Analysis": "Statistiche e analisi", "Account List": "Elenco account", diff --git a/src/locales/ja.json b/src/locales/ja.json index 2a3b3d0b..59fb2c01 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -1508,6 +1508,7 @@ "Income and Expense Trends": "収入と支出の傾向", "View Details": "詳細を表示", "Transaction List": "取引リスト", + "Transaction Calendar": "Transaction Calendar", "Transaction Details": "取引の詳細", "Statistics & Analysis": "統計と分析", "Account List": "口座リスト", diff --git a/src/locales/ru.json b/src/locales/ru.json index c11635cb..282d8711 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1508,6 +1508,7 @@ "Income and Expense Trends": "Тенденции доходов и расходов", "View Details": "Просмотреть детали", "Transaction List": "Список транзакций", + "Transaction Calendar": "Transaction Calendar", "Transaction Details": "Детали транзакции", "Statistics & Analysis": "Статистика и анализ", "Account List": "Список счетов", diff --git a/src/locales/uk.json b/src/locales/uk.json index 14cc72f8..3f93e621 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -1508,6 +1508,7 @@ "Income and Expense Trends": "Тренди доходів і витрат", "View Details": "Переглянути деталі", "Transaction List": "Список транзакцій", + "Transaction Calendar": "Transaction Calendar", "Transaction Details": "Деталі по транзакціях", "Statistics & Analysis": "Статистика та аналіз", "Account List": "Список рахунків", diff --git a/src/locales/vi.json b/src/locales/vi.json index 4fa5b126..bc24abc2 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -1508,6 +1508,7 @@ "Income and Expense Trends": "Xu hướng thu nhập và chi tiêu", "View Details": "Xem chi tiết", "Transaction List": "Danh sách giao dịch", + "Transaction Calendar": "Transaction Calendar", "Transaction Details": "Chi tiết giao dịch", "Statistics & Analysis": "Thống kê & Phân tích", "Account List": "Danh sách tài khoản", diff --git a/src/locales/zh_Hans.json b/src/locales/zh_Hans.json index ec1e50cf..1b8159f5 100644 --- a/src/locales/zh_Hans.json +++ b/src/locales/zh_Hans.json @@ -1508,6 +1508,7 @@ "Income and Expense Trends": "收入与支出趋势", "View Details": "查看详情", "Transaction List": "交易列表", + "Transaction Calendar": "交易日历", "Transaction Details": "交易详情", "Statistics & Analysis": "统计分析", "Account List": "账户列表", diff --git a/src/locales/zh_Hant.json b/src/locales/zh_Hant.json index 065a61ef..16e9757a 100644 --- a/src/locales/zh_Hant.json +++ b/src/locales/zh_Hant.json @@ -1508,6 +1508,7 @@ "Income and Expense Trends": "收入與支出趨勢", "View Details": "查看詳情", "Transaction List": "交易清單", + "Transaction Calendar": "交易日曆", "Transaction Details": "交易詳情", "Statistics & Analysis": "統計分析", "Account List": "帳戶清單", diff --git a/src/router/desktop.ts b/src/router/desktop.ts index 97825550..bacd92c9 100644 --- a/src/router/desktop.ts +++ b/src/router/desktop.ts @@ -103,6 +103,7 @@ const router = createRouter({ component: TransactionListPage, beforeEnter: checkLogin, props: route => ({ + initPageType: route.query['pageType'], initDateType: route.query['dateType'], initMaxTime: route.query['maxTime'], initMinTime: route.query['minTime'], diff --git a/src/stores/transaction.ts b/src/stores/transaction.ts index fd481839..e7f0a692 100644 --- a/src/stores/transaction.ts +++ b/src/stores/transaction.ts @@ -87,18 +87,21 @@ export interface TransactionListFilter extends TransactionListPartialFilter { keyword: string; } +export interface TransactionTotalAmount { + expense: number; + incompleteExpense: boolean; + income: number; + incompleteIncome: boolean; +} + export interface TransactionMonthList { readonly year: number; readonly month: number; readonly yearMonth: string; opened: boolean; readonly items: Transaction[]; - readonly totalAmount: { - expense: number; - incompleteExpense: boolean; - income: number; - incompleteIncome: boolean; - }; + readonly totalAmount: TransactionTotalAmount; + readonly dailyTotalAmounts: Record; } export const useTransactionsStore = defineStore('transactions', () => { @@ -216,7 +219,8 @@ export const useTransactionsStore = defineStore('transactions', () => { incompleteExpense: true, income: 0, incompleteIncome: true - } + }, + dailyTotalAmounts: {} }; transactions.value.push(monthList); @@ -319,6 +323,7 @@ export const useTransactionsStore = defineStore('transactions', () => { let totalIncome = 0; let hasUnCalculatedTotalExpense = false; let hasUnCalculatedTotalIncome = false; + const dailyTotalAmounts: Record = {}; const allAccountIdsMap: Record = {}; let totalAccountIdsCount = 0; @@ -336,6 +341,18 @@ export const useTransactionsStore = defineStore('transactions', () => { for (let i = 0; i < transactionMonthList.items.length; i++) { const transaction = transactionMonthList.items[i]; + const transactionDay = isNumber(transaction.day) ? transaction.day.toString() : '0'; + let dailyTotalAmount = dailyTotalAmounts[transactionDay]; + + if (!dailyTotalAmount) { + dailyTotalAmount = { + expense: 0, + incompleteExpense: false, + income: 0, + incompleteIncome: false + }; + dailyTotalAmounts[transactionDay] = dailyTotalAmount; + } let amount = transaction.sourceAmount; let account = transaction.sourceAccount; @@ -357,8 +374,10 @@ export const useTransactionsStore = defineStore('transactions', () => { if (!isNumber(balance)) { if (transaction.type === TransactionType.Expense) { hasUnCalculatedTotalExpense = true; + dailyTotalAmount.incompleteExpense = true; } else if (transaction.type === TransactionType.Income) { hasUnCalculatedTotalIncome = true; + dailyTotalAmount.incompleteIncome = true; } continue; @@ -369,8 +388,10 @@ export const useTransactionsStore = defineStore('transactions', () => { if (transaction.type === TransactionType.Expense) { totalExpense += amount; + dailyTotalAmount.expense += amount; } else if (transaction.type === TransactionType.Income) { totalIncome += amount; + dailyTotalAmount.income += amount; } else if (transaction.type === TransactionType.Transfer && totalAccountIdsCount > 0) { if (allAccountIdsMap[transaction.sourceAccountId] && allAccountIdsMap[transaction.destinationAccountId]) { // Do Nothing @@ -382,8 +403,10 @@ export const useTransactionsStore = defineStore('transactions', () => { // Do Nothing } else if (allAccountIdsMap[transaction.sourceAccountId] || (transaction.sourceAccount && allAccountIdsMap[transaction.sourceAccount.parentId])) { totalExpense += amount; + dailyTotalAmount.expense += amount; } else if (allAccountIdsMap[transaction.destinationAccountId] || (transaction.destinationAccount && allAccountIdsMap[transaction.destinationAccount.parentId])) { totalIncome += amount; + dailyTotalAmount.income += amount; } } } @@ -392,6 +415,29 @@ export const useTransactionsStore = defineStore('transactions', () => { transactionMonthList.totalAmount.incompleteExpense = incomplete || hasUnCalculatedTotalExpense; transactionMonthList.totalAmount.income = Math.floor(totalIncome); transactionMonthList.totalAmount.incompleteIncome = incomplete || hasUnCalculatedTotalIncome; + + for (const day in transactionMonthList.dailyTotalAmounts) { + if (!Object.prototype.hasOwnProperty.call(transactionMonthList.dailyTotalAmounts, day)) { + continue; + } + + delete transactionMonthList.dailyTotalAmounts[day]; + } + + for (const day in dailyTotalAmounts) { + if (!Object.prototype.hasOwnProperty.call(dailyTotalAmounts, day)) { + continue; + } + + const dailyTotalAmount = dailyTotalAmounts[day]; + + transactionMonthList.dailyTotalAmounts[day] = { + expense: Math.floor(dailyTotalAmount.expense), + incompleteExpense: incomplete || dailyTotalAmount.incompleteExpense, + income: Math.floor(dailyTotalAmount.income), + incompleteIncome: incomplete || dailyTotalAmount.incompleteIncome + }; + } } function fillTransactionObject(transaction: Transaction, currentUtcOffset: number): void { @@ -691,9 +737,11 @@ export const useTransactionsStore = defineStore('transactions', () => { return changed; } - function getTransactionListPageParams(): string { + function getTransactionListPageParams(pageType: number): string { const querys: string[] = []; + querys.push('pageType=' + pageType); + if (transactionsFilter.value.type) { querys.push('type=' + transactionsFilter.value.type); } diff --git a/src/views/base/transactions/TransactionListPageBase.ts b/src/views/base/transactions/TransactionListPageBase.ts index c4c9eac6..30cd43b7 100644 --- a/src/views/base/transactions/TransactionListPageBase.ts +++ b/src/views/base/transactions/TransactionListPageBase.ts @@ -9,6 +9,7 @@ import { useTransactionCategoriesStore } from '@/stores/transactionCategory.ts'; import { useTransactionTagsStore } from '@/stores/transactionTag.ts'; import { type TransactionListFilter, type TransactionMonthList, useTransactionsStore } from '@/stores/transaction.ts'; +import type { TypeAndName } from '@/core/base.ts'; import { type LocalizedDateRange, DateRange, DateRangeScene } from '@/core/datetime.ts'; import { AccountType } from '@/core/account.ts'; import { TransactionType } from '@/core/transaction.ts'; @@ -18,6 +19,10 @@ import type { TransactionCategory } from '@/models/transaction_category.ts'; import type { TransactionTag } from '@/models/transaction_tag.ts'; import type { Transaction } from '@/models/transaction.ts'; +import { + arrangeArrayWithNewStartIndex +} from '@/lib/common.ts'; + import { getUtcOffsetByUtcOffsetMinutes, getTimezoneOffset, @@ -35,9 +40,39 @@ import { categoryTypeToTransactionType } from '@/lib/category.ts'; +export class TransactionListPageType implements TypeAndName { + private static readonly allInstances: TransactionListPageType[] = []; + private static readonly allInstancesByType: Record = {}; + + public static readonly List = new TransactionListPageType(0, 'Transaction List'); + public static readonly Calendar = new TransactionListPageType(1, 'Transaction Calendar'); + + public static readonly Default = TransactionListPageType.List; + + public readonly type: number; + public readonly name: string; + + private constructor(type: number, name: string) { + this.type = type; + this.name = name; + + TransactionListPageType.allInstances.push(this); + TransactionListPageType.allInstancesByType[type] = this; + } + + public static values(): TransactionListPageType[] { + return TransactionListPageType.allInstances; + } + + public static valueOf(type: number): TransactionListPageType | undefined { + return TransactionListPageType.allInstancesByType[type]; + } +} + export function useTransactionListPageBase() { const { tt, + getAllLongWeekdayNames, getAllDateRanges, formatUnixTimeToLongDateTime, formatUnixTimeToLongDate, @@ -54,12 +89,15 @@ export function useTransactionListPageBase() { const transactionTagsStore = useTransactionTagsStore(); const transactionsStore = useTransactionsStore(); + const pageType = ref(TransactionListPageType.List.type); const loading = ref(true); const customMinDatetime = ref(0); const customMaxDatetime = ref(0); + const currentCalendarDate = ref(''); const currentTimezoneOffsetMinutes = computed(() => getTimezoneOffsetMinutes(settingsStore.appSettings.timeZone)); const firstDayOfWeek = computed(() => userStore.currentUserFirstDayOfWeek); + const dayNames = computed(() => arrangeArrayWithNewStartIndex(getAllLongWeekdayNames(), firstDayOfWeek.value)); const defaultCurrency = computed(() => getUnifiedSelectedAccountsCurrencyOrDefaultCurrency(allAccountsMap.value, queryAllFilterAccountIds.value, userStore.currentUserDefaultCurrency)); const showTotalAmountInTransactionListPage = computed(() => settingsStore.appSettings.showTotalAmountInTransactionListPage); const showTagInTransactionListPage = computed(() => settingsStore.appSettings.showTagInTransactionListPage); @@ -110,6 +148,16 @@ export function useTransactionListPageBase() { const allTransactionTags = computed>(() => transactionTagsStore.allTransactionTagsMap); const allAvailableTagsCount = computed(() => transactionTagsStore.allAvailableTagsCount); + const displayPageTypeName = computed(() => { + const type = TransactionListPageType.valueOf(pageType.value); + + if (type) { + return tt(type.name); + } + + return tt(TransactionListPageType.List.name); + }); + const query = computed(() => transactionsStore.transactionsFilter); const queryDateRangeName = computed(() => { if (query.value.dateType === DateRange.All.type) { @@ -268,12 +316,15 @@ export function useTransactionListPageBase() { return { // states + pageType, loading, customMinDatetime, customMaxDatetime, + currentCalendarDate, // computed states currentTimezoneOffsetMinutes, firstDayOfWeek, + dayNames, defaultCurrency, showTotalAmountInTransactionListPage, showTagInTransactionListPage, @@ -286,6 +337,7 @@ export function useTransactionListPageBase() { allAvailableCategoriesCount, allTransactionTags, allAvailableTagsCount, + displayPageTypeName, query, queryDateRangeName, queryMinTime, diff --git a/src/views/desktop/MainLayout.vue b/src/views/desktop/MainLayout.vue index c3c61c51..fbc65534 100644 --- a/src/views/desktop/MainLayout.vue +++ b/src/views/desktop/MainLayout.vue @@ -27,7 +27,7 @@