From b94dc8eb83af11e2b29c065cb5532b888f375351 Mon Sep 17 00:00:00 2001 From: Sebastian Reategui Date: Thu, 5 Jun 2025 12:36:46 +1000 Subject: [PATCH] Feature - Add support for a fiscal year period defined in user settings. * Add "This fiscal year", "Last fiscal year" as date range options in Transaction Details to filter transactions to those periods * Add fiscal year ranges to Statistics & Trend Analysis * Add "fiscal year start date" to user profile settings, allowing the user to select any date of the calendar year as the start of the fiscal year * Add "fiscal year format" to user profile settings, allowing the user to specify how financial year date labels should appear Implementation notes: * The default fiscal year start is January 1 and the default fiscal year display format is "FY 2025" * Fiscal year start date (month number & day number) are stored together in db as a uint16, high byte & low byte respectively * February 29 is disallowed as a fiscal year start date, since it is never used as a convention in any country * Jest is added to the project as a dev dependency, for unit tests in frontend Signed-off-by: Sebastian Reategui --- cmd/user_data.go | 2 + cmd/webserver.go | 1 + jest.config.ts | 37 + package.json | 8 +- pkg/api/users.go | 18 + pkg/core/fiscalyear.go | 109 ++ pkg/core/fiscalyear_test.go | 127 ++ pkg/models/user.go | 68 +- pkg/services/users.go | 8 + pkg/validators/fiscal_year_start_date.go | 26 + pkg/validators/fiscal_year_start_date_test.go | 68 + src/components/base/DateRangeSelectionBase.ts | 7 +- .../base/FiscalYearStartSelectionBase.ts | 88 + src/components/base/TrendsChartBase.ts | 4 +- .../desktop/FiscalYearStartSelect.vue | 96 + src/components/desktop/TrendsChart.vue | 17 +- .../mobile/FiscalYearStartSelectionSheet.vue | 112 ++ src/components/mobile/TrendsBarChart.vue | 17 +- src/core/datetime.ts | 2 + src/core/fiscalyear.ts | 255 +++ src/core/map.ts | 4 + src/core/statistics.ts | 1 + src/desktop-main.ts | 2 + src/lib/__tests__/fiscal_year.data.json | 1588 +++++++++++++++++ src/lib/__tests__/fiscal_year.ts | 291 +++ src/lib/datetime.ts | 222 ++- src/lib/statistics.ts | 8 +- src/locales/en.json | 14 + src/locales/helpers.ts | 149 +- src/mobile-main.ts | 2 + src/models/user.ts | 15 + src/stores/statistics.ts | 4 +- src/stores/user.ts | 12 + .../StatisticsTransactionPageBase.ts | 2 + .../transactions/TransactionListPageBase.ts | 2 + src/views/base/users/UserProfilePageBase.ts | 5 + .../desktop/statistics/TransactionPage.vue | 13 +- src/views/desktop/transactions/ListPage.vue | 17 +- .../settings/tabs/UserBasicSettingTab.vue | 24 + .../mobile/statistics/TransactionPage.vue | 12 +- src/views/mobile/transactions/ListPage.vue | 25 +- src/views/mobile/users/UserProfilePage.vue | 40 +- 42 files changed, 3417 insertions(+), 105 deletions(-) create mode 100644 jest.config.ts create mode 100644 pkg/core/fiscalyear.go create mode 100644 pkg/core/fiscalyear_test.go create mode 100644 pkg/validators/fiscal_year_start_date.go create mode 100644 pkg/validators/fiscal_year_start_date_test.go create mode 100644 src/components/base/FiscalYearStartSelectionBase.ts create mode 100644 src/components/desktop/FiscalYearStartSelect.vue create mode 100644 src/components/mobile/FiscalYearStartSelectionSheet.vue create mode 100644 src/core/fiscalyear.ts create mode 100644 src/core/map.ts create mode 100644 src/lib/__tests__/fiscal_year.data.json create mode 100644 src/lib/__tests__/fiscal_year.ts diff --git a/cmd/user_data.go b/cmd/user_data.go index e4ddd298..7cda40a0 100644 --- a/cmd/user_data.go +++ b/cmd/user_data.go @@ -895,10 +895,12 @@ func printUserInfo(user *models.User) { fmt.Printf("[Language] %s\n", user.Language) fmt.Printf("[DefaultCurrency] %s\n", user.DefaultCurrency) fmt.Printf("[FirstDayOfWeek] %s (%d)\n", user.FirstDayOfWeek, user.FirstDayOfWeek) + fmt.Printf("[FiscalYearStart] %s (%d)\n", user.FiscalYearStart, user.FiscalYearStart) fmt.Printf("[LongDateFormat] %s (%d)\n", user.LongDateFormat, user.LongDateFormat) fmt.Printf("[ShortDateFormat] %s (%d)\n", user.ShortDateFormat, user.ShortDateFormat) fmt.Printf("[LongTimeFormat] %s (%d)\n", user.LongTimeFormat, user.LongTimeFormat) fmt.Printf("[ShortTimeFormat] %s (%d)\n", user.ShortTimeFormat, user.ShortTimeFormat) + fmt.Printf("[FiscalYearFormat] %s (%d)\n", user.FiscalYearFormat, user.FiscalYearFormat) fmt.Printf("[DecimalSeparator] %s (%d)\n", user.DecimalSeparator, user.DecimalSeparator) fmt.Printf("[DigitGroupingSymbol] %s (%d)\n", user.DigitGroupingSymbol, user.DigitGroupingSymbol) fmt.Printf("[DigitGrouping] %s (%d)\n", user.DigitGrouping, user.DigitGrouping) diff --git a/cmd/webserver.go b/cmd/webserver.go index b3b524d3..3e17a7ec 100644 --- a/cmd/webserver.go +++ b/cmd/webserver.go @@ -98,6 +98,7 @@ func startWebServer(c *core.CliContext) error { _ = v.RegisterValidation("validCurrency", validators.ValidCurrency) _ = v.RegisterValidation("validHexRGBColor", validators.ValidHexRGBColor) _ = v.RegisterValidation("validAmountFilter", validators.ValidAmountFilter) + _ = v.RegisterValidation("validFiscalYearStart", validators.ValidateFiscalYearStart) } router.NoRoute(bindApi(api.Default.ApiNotFound)) diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 00000000..b1c97e05 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,37 @@ +/** + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +import type { Config } from 'jest'; + +const config: Config = { + + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: false, + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + moduleNameMapper: { + "^@/(.*)$": "/src/$1" + }, + + // The test environment that will be used for testing + testEnvironment: "node", + + // The glob patterns Jest uses to detect test files + testMatch: [ + "**/__tests__/**/*.[jt]s?(x)", + "!**/__tests__/*_gen.[jt]s?(x)" + ], + + // A map from regular expressions to paths to transformers + transform: { + '^.+\\.m?tsx?$': ['ts-jest', { useESM: true, isolatedModules: true }], + }, + +}; + +module.exports = config; diff --git a/package.json b/package.json index b60b9ea8..621fb295 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "serve": "cross-env NODE_ENV=development vite", "build": "cross-env NODE_ENV=production vite build", "serve:dist": "vite preview", - "lint": "tsc --noEmit && eslint . --fix" + "lint": "tsc --noEmit && eslint . --fix", + "test": "jest" }, "dependencies": { "@mdi/js": "^7.4.47", @@ -47,11 +48,13 @@ "vuetify": "^3.8.7" }, "devDependencies": { + "@jest/globals": "^29.7.0", "@tsconfig/node22": "^22.0.2", "@types/cbor-js": "^0.1.1", "@types/crypto-js": "^4.2.2", "@types/git-rev-sync": "^2.0.2", "@types/node": "^22.15.29", + "@types/jest": "^29.5.14", "@types/ua-parser-js": "^0.7.39", "@vitejs/plugin-vue": "^5.2.4", "@vue/eslint-config-typescript": "^14.5.0", @@ -60,8 +63,11 @@ "eslint": "^9.28.0", "eslint-plugin-vue": "^10.1.0", "git-rev-sync": "^3.0.2", + "jest": "^29.7.0", "postcss-preset-env": "^10.2.0", "sass": "^1.89.1", + "ts-jest": "^29.3.1", + "ts-node": "^10.9.2", "typescript": "^5.8.3", "vite": "^6.3.5", "vite-plugin-pwa": "^1.0.0", diff --git a/pkg/api/users.go b/pkg/api/users.go index 6418f458..adeca00e 100644 --- a/pkg/api/users.go +++ b/pkg/api/users.go @@ -349,6 +349,15 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro userNew.FirstDayOfWeek = core.WEEKDAY_INVALID } + if userUpdateReq.FiscalYearStart != nil && *userUpdateReq.FiscalYearStart != user.FiscalYearStart { + user.FiscalYearStart = *userUpdateReq.FiscalYearStart + userNew.FiscalYearStart = *userUpdateReq.FiscalYearStart + modifyProfileBasicInfo = true + anythingUpdate = true + } else { + userNew.FiscalYearStart = core.FISCAL_YEAR_START_INVALID + } + if userUpdateReq.LongDateFormat != nil && *userUpdateReq.LongDateFormat != user.LongDateFormat { user.LongDateFormat = *userUpdateReq.LongDateFormat userNew.LongDateFormat = *userUpdateReq.LongDateFormat @@ -385,6 +394,15 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.WebContext) (any, *errs.Erro userNew.ShortTimeFormat = core.SHORT_TIME_FORMAT_INVALID } + if userUpdateReq.FiscalYearFormat != nil && *userUpdateReq.FiscalYearFormat != user.FiscalYearFormat { + user.FiscalYearFormat = *userUpdateReq.FiscalYearFormat + userNew.FiscalYearFormat = *userUpdateReq.FiscalYearFormat + modifyProfileBasicInfo = true + anythingUpdate = true + } else { + userNew.FiscalYearFormat = core.FISCAL_YEAR_FORMAT_INVALID + } + if userUpdateReq.DecimalSeparator != nil && *userUpdateReq.DecimalSeparator != user.DecimalSeparator { user.DecimalSeparator = *userUpdateReq.DecimalSeparator userNew.DecimalSeparator = *userUpdateReq.DecimalSeparator diff --git a/pkg/core/fiscalyear.go b/pkg/core/fiscalyear.go new file mode 100644 index 00000000..bcbfa390 --- /dev/null +++ b/pkg/core/fiscalyear.go @@ -0,0 +1,109 @@ +package core + +import ( + "fmt" + + "github.com/mayswind/ezbookkeeping/pkg/errs" +) + +// FiscalYearStart represents the fiscal year start date as a uint16 (month: high byte, day: low byte) +type FiscalYearStart uint16 + +// Fiscal Year Start Date Type +const ( + FISCAL_YEAR_START_DEFAULT FiscalYearStart = 0x0101 // January 1 + FISCAL_YEAR_START_MIN FiscalYearStart = 0x0101 // January 1 + FISCAL_YEAR_START_MAX FiscalYearStart = 0x0C1F // December 31 + FISCAL_YEAR_START_INVALID FiscalYearStart = 0xFFFF // Invalid +) + +// NewFiscalYearStart creates a new FiscalYearStart from month and day values +func NewFiscalYearStart(month uint8, day uint8) (FiscalYearStart, error) { + month, day, err := validateMonthDay(month, day) + if err != nil { + return 0, err + } + + return FiscalYearStart(uint16(month)<<8 | uint16(day)), nil +} + +// GetMonthDay extracts the month and day from FiscalYearType +func (f FiscalYearStart) GetMonthDay() (uint8, uint8, error) { + if f < FISCAL_YEAR_START_MIN || f > FISCAL_YEAR_START_MAX { + return 0, 0, errs.ErrFormatInvalid + } + + // Extract month and day (month in high byte, day in low byte) + month := uint8(f >> 8) + day := uint8(f & 0xFF) + + return validateMonthDay(month, day) +} + +// String returns a string representation of FiscalYearStart in MM/DD format +func (f FiscalYearStart) String() string { + month, day, err := f.GetMonthDay() + if err != nil { + return "Invalid" + } + return fmt.Sprintf("%02d-%02d", month, day) +} + +// validateMonthDay validates a month and day and returns them if valid +func validateMonthDay(month uint8, day uint8) (uint8, uint8, error) { + if month < 1 || month > 12 || day < 1 { + return 0, 0, errs.ErrFormatInvalid + } + + maxDays := uint8(31) + switch month { + case 1, 3, 5, 7, 8, 10, 12: // January, March, May, July, August, October, December + maxDays = 31 + case 4, 6, 9, 11: // April, June, September, November + maxDays = 30 + case 2: // February + maxDays = 28 // Disallow fiscal year start on leap day + } + + if day > maxDays { + return 0, 0, errs.ErrFormatInvalid + } + + return month, day, nil +} + +// FiscalYearFormat represents the fiscal year format as a uint8 +type FiscalYearFormat uint8 + +// Fiscal Year Format Type Name +const ( + FISCAL_YEAR_FORMAT_DEFAULT FiscalYearFormat = 0 + FISCAL_YEAR_FORMAT_STARTYYYY_ENDYYYY FiscalYearFormat = 1 + FISCAL_YEAR_FORMAT_STARTYYYY_ENDYY FiscalYearFormat = 2 + FISCAL_YEAR_FORMAT_STARTYY_ENDYY FiscalYearFormat = 3 + FISCAL_YEAR_FORMAT_ENDYYYY FiscalYearFormat = 4 + FISCAL_YEAR_FORMAT_ENDYY FiscalYearFormat = 5 + FISCAL_YEAR_FORMAT_INVALID FiscalYearFormat = 255 // Invalid +) + +// String returns a textual representation of the long date format enum +func (f FiscalYearFormat) String() string { + switch f { + case FISCAL_YEAR_FORMAT_DEFAULT: + return "Default" + case FISCAL_YEAR_FORMAT_STARTYYYY_ENDYYYY: + return "StartYYYY-EndYYYY" + case FISCAL_YEAR_FORMAT_STARTYYYY_ENDYY: + return "StartYYYY-EndYY" + case FISCAL_YEAR_FORMAT_STARTYY_ENDYY: + return "StartYY-EndYY" + case FISCAL_YEAR_FORMAT_ENDYYYY: + return "EndYYYY" + case FISCAL_YEAR_FORMAT_ENDYY: + return "EndYY" + case FISCAL_YEAR_FORMAT_INVALID: + return "Invalid" + default: + return fmt.Sprintf("Invalid(%d)", int(f)) + } +} diff --git a/pkg/core/fiscalyear_test.go b/pkg/core/fiscalyear_test.go new file mode 100644 index 00000000..9cffd1b3 --- /dev/null +++ b/pkg/core/fiscalyear_test.go @@ -0,0 +1,127 @@ +package core + +import ( + "testing" + + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/stretchr/testify/assert" +) + +func TestNewFiscalYearStart_ValidMonthDay(t *testing.T) { + testCases := []struct { + month uint8 + day uint8 + expected FiscalYearStart + }{ + {1, 1, 0x0101}, // January 1 + {4, 15, 0x040F}, // April 15 + {7, 1, 0x0701}, // July 1 + {12, 31, 0x0C1F}, // December 31 + } + + for _, tc := range testCases { + fiscal, err := NewFiscalYearStart(tc.month, tc.day) + assert.Nil(t, err) + assert.Equal(t, tc.expected, fiscal) + } +} + +func TestNewFiscalYearStart_InvalidMonthDay(t *testing.T) { + testCases := []struct { + month uint8 + day uint8 + }{ + {0, 1}, // Month 0 (invalid) + {13, 1}, // Month 13 (invalid) + {1, 0}, // Day 0 (invalid) + {1, 32}, // Day 32 (invalid for January) + {2, 30}, // Day 30 (invalid for February) + {2, 29}, // Day 29 (leap day not permitted) + {4, 31}, // Day 31 (invalid for April) + {6, 31}, // Day 31 (invalid for June) + {9, 31}, // Day 31 (invalid for September) + {11, 32}, // Day 32 (invalid for November) + {255, 15}, // Invalid month + {5, 255}, // Invalid day + } + + for _, tc := range testCases { + fiscal, err := NewFiscalYearStart(tc.month, tc.day) + assert.Equal(t, FiscalYearStart(0), fiscal) + assert.Equal(t, errs.ErrFormatInvalid, err) + } +} + +func TestGetMonthDay_ValidFiscalYearStart(t *testing.T) { + testCases := []struct { + fiscalYear FiscalYearStart + month uint8 + day uint8 + }{ + {0x0101, 1, 1}, // January 1st + {0x0C1F, 12, 31}, // December 31st + {0x0701, 7, 1}, // July 1st + {0x040F, 4, 15}, // April 15th + } + + for _, tc := range testCases { + month, day, err := tc.fiscalYear.GetMonthDay() + assert.Nil(t, err) + assert.Equal(t, tc.month, month) + assert.Equal(t, tc.day, day) + } +} + +func TestGetMonthDay_InvalidFiscalYearStart(t *testing.T) { + testCases := []struct { + fiscalYear FiscalYearStart + }{ + {0x0000}, // 0/0 (invalid) + {0x0D01}, // Month 13 (invalid) + {0x0100}, // Day 0 (invalid) + {0x0120}, // January 32 (invalid) + {0x021D}, // February 29 (not permitted) + {0x021E}, // February 30 (invalid) + {0x041F}, // April 31 (invalid) + {0x061F}, // June 31 (invalid) + {0x091F}, // September 31 (invalid) + {0x0B20}, // November 32 (invalid) + {0xFF01}, // Invalid month + {0x01FF}, // Invalid day + {0}, // Zero value + } + + for _, tc := range testCases { + month, day, err := tc.fiscalYear.GetMonthDay() + assert.Equal(t, uint8(0), month) + assert.Equal(t, uint8(0), day) + assert.Equal(t, errs.ErrFormatInvalid, err) + } +} + +func TestFiscalYearStart_String(t *testing.T) { + testCases := []struct { + fiscalYear FiscalYearStart + expected string + }{ + {0x0101, "01-01"}, // January 1st + {0x0C1F, "12-31"}, // December 31st + {0x0701, "07-01"}, // July 1st + {0x040F, "04-15"}, // April 15th + {0x021D, "Invalid"}, // February 29th (leap day not permitted) + {0x0000, "Invalid"}, // Invalid date + {0x0D01, "Invalid"}, // Invalid month + {0x0120, "Invalid"}, // Invalid day + } + + for _, tc := range testCases { + assert.Equal(t, tc.expected, tc.fiscalYear.String()) + } +} + +func TestFiscalYearStartConstants(t *testing.T) { + assert.Equal(t, FiscalYearStart(0xFFFF), FISCAL_YEAR_START_INVALID) + assert.Equal(t, FiscalYearStart(0x0101), FISCAL_YEAR_START_DEFAULT) + assert.Equal(t, FiscalYearStart(0x0101), FISCAL_YEAR_START_MIN) + assert.Equal(t, FiscalYearStart(0x0C1F), FISCAL_YEAR_START_MAX) +} diff --git a/pkg/models/user.go b/pkg/models/user.go index 4542944a..d4dfad5f 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -82,37 +82,39 @@ func (s AmountColorType) String() string { // User represents user data stored in database type User struct { - Uid int64 `xorm:"PK"` - Username string `xorm:"VARCHAR(32) UNIQUE NOT NULL"` - Email string `xorm:"VARCHAR(100) UNIQUE NOT NULL"` - Nickname string `xorm:"VARCHAR(64) NOT NULL"` - Password string `xorm:"VARCHAR(64) NOT NULL"` - Salt string `xorm:"VARCHAR(10) NOT NULL"` - CustomAvatarType string `xorm:"VARCHAR(10)"` - DefaultAccountId int64 - TransactionEditScope TransactionEditScope `xorm:"TINYINT NOT NULL"` - Language string `xorm:"VARCHAR(10)"` - DefaultCurrency string `xorm:"VARCHAR(3) NOT NULL"` - FirstDayOfWeek core.WeekDay `xorm:"TINYINT NOT NULL"` - LongDateFormat core.LongDateFormat `xorm:"TINYINT"` - ShortDateFormat core.ShortDateFormat `xorm:"TINYINT"` - LongTimeFormat core.LongTimeFormat `xorm:"TINYINT"` - ShortTimeFormat core.ShortTimeFormat `xorm:"TINYINT"` - DecimalSeparator core.DecimalSeparator `xorm:"TINYINT"` - DigitGroupingSymbol core.DigitGroupingSymbol `xorm:"TINYINT"` - DigitGrouping core.DigitGroupingType `xorm:"TINYINT"` - CurrencyDisplayType core.CurrencyDisplayType `xorm:"TINYINT"` + Uid int64 `xorm:"PK"` + Username string `xorm:"VARCHAR(32) UNIQUE NOT NULL"` + Email string `xorm:"VARCHAR(100) UNIQUE NOT NULL"` + Nickname string `xorm:"VARCHAR(64) NOT NULL"` + Password string `xorm:"VARCHAR(64) NOT NULL"` + Salt string `xorm:"VARCHAR(10) NOT NULL"` + CustomAvatarType string `xorm:"VARCHAR(10)"` + DefaultAccountId int64 + TransactionEditScope TransactionEditScope `xorm:"TINYINT NOT NULL"` + Language string `xorm:"VARCHAR(10)"` + DefaultCurrency string `xorm:"VARCHAR(3) NOT NULL"` + FirstDayOfWeek core.WeekDay `xorm:"TINYINT NOT NULL"` + FiscalYearStart core.FiscalYearStart `xorm:"SMALLINT"` + LongDateFormat core.LongDateFormat `xorm:"TINYINT"` + ShortDateFormat core.ShortDateFormat `xorm:"TINYINT"` + LongTimeFormat core.LongTimeFormat `xorm:"TINYINT"` + ShortTimeFormat core.ShortTimeFormat `xorm:"TINYINT"` + FiscalYearFormat core.FiscalYearFormat `xorm:"TINYINT"` + DecimalSeparator core.DecimalSeparator `xorm:"TINYINT"` + DigitGroupingSymbol core.DigitGroupingSymbol `xorm:"TINYINT"` + DigitGrouping core.DigitGroupingType `xorm:"TINYINT"` + CurrencyDisplayType core.CurrencyDisplayType `xorm:"TINYINT"` CoordinateDisplayType core.CoordinateDisplayType `xorm:"TINYINT"` - ExpenseAmountColor AmountColorType `xorm:"TINYINT"` - IncomeAmountColor AmountColorType `xorm:"TINYINT"` - FeatureRestriction core.UserFeatureRestrictions - Disabled bool - Deleted bool `xorm:"NOT NULL"` - EmailVerified bool `xorm:"NOT NULL"` - CreatedUnixTime int64 - UpdatedUnixTime int64 - DeletedUnixTime int64 - LastLoginUnixTime int64 + ExpenseAmountColor AmountColorType `xorm:"TINYINT"` + IncomeAmountColor AmountColorType `xorm:"TINYINT"` + FeatureRestriction core.UserFeatureRestrictions + Disabled bool + Deleted bool `xorm:"NOT NULL"` + EmailVerified bool `xorm:"NOT NULL"` + CreatedUnixTime int64 + UpdatedUnixTime int64 + DeletedUnixTime int64 + LastLoginUnixTime int64 } // UserBasicInfo represents a view-object of user basic info @@ -127,10 +129,12 @@ type UserBasicInfo struct { Language string `json:"language"` DefaultCurrency string `json:"defaultCurrency"` FirstDayOfWeek core.WeekDay `json:"firstDayOfWeek"` + FiscalYearStart core.FiscalYearStart `json:"fiscalYearStart"` LongDateFormat core.LongDateFormat `json:"longDateFormat"` ShortDateFormat core.ShortDateFormat `json:"shortDateFormat"` LongTimeFormat core.LongTimeFormat `json:"longTimeFormat"` ShortTimeFormat core.ShortTimeFormat `json:"shortTimeFormat"` + FiscalYearFormat core.FiscalYearFormat `json:"fiscalYearFormat"` DecimalSeparator core.DecimalSeparator `json:"decimalSeparator"` DigitGroupingSymbol core.DigitGroupingSymbol `json:"digitGroupingSymbol"` DigitGrouping core.DigitGroupingType `json:"digitGrouping"` @@ -188,10 +192,12 @@ type UserProfileUpdateRequest struct { Language string `json:"language" binding:"omitempty,min=2,max=16"` DefaultCurrency string `json:"defaultCurrency" binding:"omitempty,len=3,validCurrency"` FirstDayOfWeek *core.WeekDay `json:"firstDayOfWeek" binding:"omitempty,min=0,max=6"` + FiscalYearStart *core.FiscalYearStart `json:"fiscalYearStart" binding:"omitempty,validFiscalYearStart"` LongDateFormat *core.LongDateFormat `json:"longDateFormat" binding:"omitempty,min=0,max=3"` ShortDateFormat *core.ShortDateFormat `json:"shortDateFormat" binding:"omitempty,min=0,max=3"` LongTimeFormat *core.LongTimeFormat `json:"longTimeFormat" binding:"omitempty,min=0,max=3"` ShortTimeFormat *core.ShortTimeFormat `json:"shortTimeFormat" binding:"omitempty,min=0,max=3"` + FiscalYearFormat *core.FiscalYearFormat `json:"fiscalYearFormat" binding:"omitempty,min=0,max=5"` DecimalSeparator *core.DecimalSeparator `json:"decimalSeparator" binding:"omitempty,min=0,max=3"` DigitGroupingSymbol *core.DigitGroupingSymbol `json:"digitGroupingSymbol" binding:"omitempty,min=0,max=4"` DigitGrouping *core.DigitGroupingType `json:"digitGrouping" binding:"omitempty,min=0,max=2"` @@ -268,11 +274,13 @@ func (u *User) ToUserBasicInfo(avatarProvider core.UserAvatarProviderType, avata Language: u.Language, DefaultCurrency: u.DefaultCurrency, FirstDayOfWeek: u.FirstDayOfWeek, + FiscalYearStart: u.FiscalYearStart, LongDateFormat: u.LongDateFormat, ShortDateFormat: u.ShortDateFormat, LongTimeFormat: u.LongTimeFormat, ShortTimeFormat: u.ShortTimeFormat, DecimalSeparator: u.DecimalSeparator, + FiscalYearFormat: u.FiscalYearFormat, DigitGroupingSymbol: u.DigitGroupingSymbol, DigitGrouping: u.DigitGrouping, CurrencyDisplayType: u.CurrencyDisplayType, diff --git a/pkg/services/users.go b/pkg/services/users.go index de1ddd97..03469e1e 100644 --- a/pkg/services/users.go +++ b/pkg/services/users.go @@ -289,6 +289,10 @@ func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLa updateCols = append(updateCols, "first_day_of_week") } + if core.FISCAL_YEAR_START_MIN <= user.FiscalYearStart && core.FISCAL_YEAR_START_MAX >= user.FiscalYearStart { + updateCols = append(updateCols, "fiscal_year_start") + } + if core.LONG_DATE_FORMAT_DEFAULT <= user.LongDateFormat && user.LongDateFormat <= core.LONG_DATE_FORMAT_D_M_YYYY { updateCols = append(updateCols, "long_date_format") } @@ -305,6 +309,10 @@ func (s *UserService) UpdateUser(c core.Context, user *models.User, modifyUserLa updateCols = append(updateCols, "short_time_format") } + if core.FISCAL_YEAR_FORMAT_DEFAULT <= user.FiscalYearFormat && user.FiscalYearFormat <= core.FISCAL_YEAR_FORMAT_ENDYY { + updateCols = append(updateCols, "fiscal_year_format") + } + if core.DECIMAL_SEPARATOR_DEFAULT <= user.DecimalSeparator && user.DecimalSeparator <= core.DECIMAL_SEPARATOR_COMMA { updateCols = append(updateCols, "decimal_separator") } diff --git a/pkg/validators/fiscal_year_start_date.go b/pkg/validators/fiscal_year_start_date.go new file mode 100644 index 00000000..426344a5 --- /dev/null +++ b/pkg/validators/fiscal_year_start_date.go @@ -0,0 +1,26 @@ +package validators + +import ( + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/validator/v10" + "github.com/mayswind/ezbookkeeping/pkg/core" +) + +// ValidateFiscalYearStart validates if a fiscal year start date is valid +func ValidateFiscalYearStart(fl validator.FieldLevel) bool { + date, ok := fl.Field().Interface().(core.FiscalYearStart) + if !ok { + return false + } + + // Use the core functionality to validate + _, _, err := date.GetMonthDay() + return err == nil +} + +// RegisterFiscalYearStartValidator registers the fiscal year start date validator +func RegisterFiscalYearStartValidator() { + if v, ok := binding.Validator.Engine().(*validator.Validate); ok { + v.RegisterValidation("validFiscalYearStart", ValidateFiscalYearStart) + } +} diff --git a/pkg/validators/fiscal_year_start_date_test.go b/pkg/validators/fiscal_year_start_date_test.go new file mode 100644 index 00000000..2c2d9dbb --- /dev/null +++ b/pkg/validators/fiscal_year_start_date_test.go @@ -0,0 +1,68 @@ +package validators + +import ( + "testing" + + "github.com/go-playground/validator/v10" + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/stretchr/testify/assert" +) + +type fiscalYearStartContainer struct { + FiscalYearStart core.FiscalYearStart `validate:"validFiscalYearStart"` +} + +func TestValidateFiscalYearStart_ValidValues(t *testing.T) { + validate := validator.New() + validate.RegisterValidation("validFiscalYearStart", ValidateFiscalYearStart) + + testCases := []struct { + name string + value core.FiscalYearStart + }{ + {"January 1", 0x0101}, // January 1 + {"December 31", 0x0C1F}, // December 31 + {"July 1", 0x0701}, // July 1 + {"April 15", 0x040F}, // April 15 + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + container := fiscalYearStartContainer{FiscalYearStart: tc.value} + err := validate.Struct(container) + assert.Nil(t, err) + }) + } +} + +func TestValidateFiscalYearStart_InvalidValues(t *testing.T) { + validate := validator.New() + validate.RegisterValidation("validFiscalYearStart", ValidateFiscalYearStart) + + testCases := []struct { + name string + value core.FiscalYearStart + }{ + {"Zero value", 0}, // Zero value + {"Month 0", 0x0001}, // Month 0 (invalid) + {"Month 13", 0x0D01}, // Month 13 (invalid) + {"Day 0", 0x0100}, // Day 0 (invalid) + {"January 32", 0x0120}, // January 32 (invalid) + {"February 29", 0x021D}, // February 29 (not permitted) + {"February 30", 0x021E}, // February 30 (invalid) + {"April 31", 0x041F}, // April 31 (invalid) + {"June 31", 0x061F}, // June 31 (invalid) + {"September 31", 0x091F}, // September 31 (invalid) + {"November 32", 0x0B20}, // November 32 (invalid) + {"Invalid month 255", 0xFF01}, // Invalid month + {"Invalid day 255", 0x01FF}, // Invalid day + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + container := fiscalYearStartContainer{FiscalYearStart: tc.value} + err := validate.Struct(container) + assert.NotNil(t, err) + }) + } +} diff --git a/src/components/base/DateRangeSelectionBase.ts b/src/components/base/DateRangeSelectionBase.ts index 1bdbde59..9d6c8e9b 100644 --- a/src/components/base/DateRangeSelectionBase.ts +++ b/src/components/base/DateRangeSelectionBase.ts @@ -81,9 +81,12 @@ export function useDateRangeSelectionBase(props: CommonDateRangeSelectionProps) DateRange.LastThirtyDays, DateRange.ThisWeek, DateRange.ThisMonth, - DateRange.ThisYear + DateRange.ThisYear, + DateRange.LastYear, + DateRange.ThisFiscalYear, + DateRange.LastFiscalYear ].forEach(dateRangeType => { - const dateRange = getDateRangeByDateType(dateRangeType.type, firstDayOfWeek.value) as TimeRangeAndDateType; + const dateRange = getDateRangeByDateType(dateRangeType.type, firstDayOfWeek.value, userStore.currentUserFiscalYearStart) as TimeRangeAndDateType; presetRanges.push({ label: tt(dateRangeType.name), diff --git a/src/components/base/FiscalYearStartSelectionBase.ts b/src/components/base/FiscalYearStartSelectionBase.ts new file mode 100644 index 00000000..529f8677 --- /dev/null +++ b/src/components/base/FiscalYearStartSelectionBase.ts @@ -0,0 +1,88 @@ +import { computed } from 'vue'; + +import { FiscalYearStart } from '@/core/fiscalyear.ts'; + +import { useUserStore } from '@/stores/user.ts'; + +import { useI18n } from '@/locales/helpers.ts'; + +import { arrangeArrayWithNewStartIndex } from '@/lib/common'; + +export interface FiscalYearStartSelectionBaseProps { + modelValue?: number; +} + +export interface FiscalYearStartSelectionBaseEmits { + (e: 'update:modelValue', value: number): void; +} + +export function useFiscalYearStartSelectionBase(props: FiscalYearStartSelectionBaseProps, emit: FiscalYearStartSelectionBaseEmits) { + const { getAllMinWeekdayNames, formatMonthDayToLongDay, getCurrentFiscalYearStartFormatted } = useI18n(); + + const dayNames = computed(() => arrangeArrayWithNewStartIndex(getAllMinWeekdayNames(), firstDayOfWeek.value)); + + const displayName = computed(() => { + let fy = FiscalYearStart.fromNumber(selectedFiscalYearStart.value); + + if ( fy ) { + return formatMonthDayToLongDay(fy.toMonthDashDayString()) + } + + return formatMonthDayToLongDay(FiscalYearStart.strictFromNumber(userStore.currentUserFiscalYearStart).toMonthDashDayString()); + + }); + + const disabledDates = (date: Date) => { + // Disable February 29 (leap day) + return date.getMonth() === 1 && date.getDate() === 29; + }; + + const firstDayOfWeek = computed(() => userStore.currentUserFirstDayOfWeek); + + const selectedFiscalYearStart = computed(() => { + return props.modelValue !== undefined ? props.modelValue : userStore.currentUserFiscalYearStart; + }); + + const userStore = useUserStore(); + + function selectedDisplayName(dateString: string): string { + let fy = FiscalYearStart.fromMonthDashDayString(dateString); + if ( fy ) { + return formatMonthDayToLongDay(fy.toMonthDashDayString()); + } + return displayName.value; + } + + function getModelValueToDateString(): string { + const input = selectedFiscalYearStart.value; + + let fy = FiscalYearStart.fromNumber(input); + + if ( fy ) { + return fy.toMonthDashDayString(); + } + + return getCurrentFiscalYearStartFormatted(); + } + + function getDateStringToModelValue(input: string): number { + const fyString = FiscalYearStart.fromMonthDashDayString(input); + if (fyString) { + return fyString.value; + } + return userStore.currentUserFiscalYearStart; + } + + return { + // functions + getDateStringToModelValue, + getModelValueToDateString, + selectedDisplayName, + // computed states + dayNames, + displayName, + disabledDates, + firstDayOfWeek, + selectedFiscalYearStart, + } +} diff --git a/src/components/base/TrendsChartBase.ts b/src/components/base/TrendsChartBase.ts index 80b7ddbb..f32cb1ff 100644 --- a/src/components/base/TrendsChartBase.ts +++ b/src/components/base/TrendsChartBase.ts @@ -9,6 +9,7 @@ import type { YearQuarterUnixTime, YearMonthUnixTime } from '@/core/datetime.ts'; +import type { FiscalYearUnixTime } from '@/core/fiscalyear.ts'; import type { ColorValue } from '@/core/color.ts'; import { DEFAULT_ICON_COLOR } from '@/consts/color.ts'; import type { YearMonthItems } from '@/models/transaction.ts'; @@ -19,6 +20,7 @@ export interface CommonTrendsChartProps { items: YearMonthItems[]; startYearMonth: string; endYearMonth: string; + fiscalYearStart: number; sortingType: number; dateAggregationType: number; idField?: string; @@ -40,7 +42,7 @@ export interface TrendsBarChartClickEvent { export function useTrendsChartBase(props: CommonTrendsChartProps) { const { tt } = useI18n(); - const allDateRanges = computed(() => getAllDateRanges(props.items, props.startYearMonth, props.endYearMonth, props.dateAggregationType)); + const allDateRanges = computed(() => getAllDateRanges(props.items, props.startYearMonth, props.endYearMonth, props.fiscalYearStart, props.dateAggregationType)); function getItemName(name: string): string { return props.translateName ? tt(name) : name; diff --git a/src/components/desktop/FiscalYearStartSelect.vue b/src/components/desktop/FiscalYearStartSelect.vue new file mode 100644 index 00000000..b057e0a0 --- /dev/null +++ b/src/components/desktop/FiscalYearStartSelect.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/src/components/desktop/TrendsChart.vue b/src/components/desktop/TrendsChart.vue index 68f00990..90f3cb51 100644 --- a/src/components/desktop/TrendsChart.vue +++ b/src/components/desktop/TrendsChart.vue @@ -28,7 +28,8 @@ import { import { getYearMonthFirstUnixTime, getYearMonthLastUnixTime, - getDateTypeByDateRange + getDateTypeByDateRange, + getFiscalYearFromUnixTime } from '@/lib/datetime.ts'; import { sortStatisticsItems @@ -69,7 +70,7 @@ const emit = defineEmits<{ }>(); const theme = useTheme(); -const { tt, formatUnixTimeToShortYear, formatYearQuarter, formatUnixTimeToShortYearMonth, formatAmountWithCurrency } = useI18n(); +const { tt, formatUnixTimeToShortYear, formatYearQuarter, formatUnixTimeToShortYearMonth, formatUnixTimeToFiscalYear, formatYearToFiscalYear, formatAmountWithCurrency } = useI18n(); const { allDateRanges, getItemName, getColor } = useTrendsChartBase(props); const userStore = useUserStore(); @@ -121,6 +122,8 @@ const allDisplayDateRanges = computed(() => { if (props.dateAggregationType === ChartDateAggregationType.Year.type) { allDisplayDateRanges.push(formatUnixTimeToShortYear(dateRange.minUnixTime)); + } else if (props.dateAggregationType === ChartDateAggregationType.FiscalYear.type && 'year' in dateRange) { + allDisplayDateRanges.push(formatUnixTimeToFiscalYear(dateRange.minUnixTime)); } else if (props.dateAggregationType === ChartDateAggregationType.Quarter.type && 'quarter' in dateRange) { allDisplayDateRanges.push(formatYearQuarter(dateRange.year, dateRange.quarter)); } else { // if (props.dateAggregationType === ChartDateAggregationType.Month.type) { @@ -150,6 +153,12 @@ const allSeries = computed(() => { if (props.dateAggregationType === ChartDateAggregationType.Year.type) { dateRangeKey = dataItem.year.toString(); + } else if (props.dateAggregationType === ChartDateAggregationType.FiscalYear.type) { + const fiscalYear = getFiscalYearFromUnixTime( + getYearMonthFirstUnixTime({ year: dataItem.year, month: dataItem.month }), + props.fiscalYearStart + ); + dateRangeKey = formatYearToFiscalYear(fiscalYear); } else if (props.dateAggregationType === ChartDateAggregationType.Quarter.type) { dateRangeKey = `${dataItem.year}-${Math.floor((dataItem.month - 1) / 3) + 1}`; } else { // if (props.dateAggregationType === ChartDateAggregationType.Month.type) { @@ -168,6 +177,8 @@ const allSeries = computed(() => { if (props.dateAggregationType === ChartDateAggregationType.Year.type) { dateRangeKey = dateRange.year.toString(); + } else if (props.dateAggregationType === ChartDateAggregationType.FiscalYear.type && 'year' in dateRange) { + dateRangeKey = formatYearToFiscalYear(dateRange.year); } else if (props.dateAggregationType === ChartDateAggregationType.Quarter.type && 'quarter' in dateRange) { dateRangeKey = `${dateRange.year}-${dateRange.quarter}`; } else if (props.dateAggregationType === ChartDateAggregationType.Month.type && 'month' in dateRange) { @@ -397,7 +408,7 @@ function clickItem(e: ECElementEvent): void { } } - const dateRangeType = getDateTypeByDateRange(minUnixTime, maxUnixTime, userStore.currentUserFirstDayOfWeek, DateRangeScene.Normal); + const dateRangeType = getDateTypeByDateRange(minUnixTime, maxUnixTime, userStore.currentUserFirstDayOfWeek, userStore.currentUserFiscalYearStart, DateRangeScene.Normal); emit('click', { itemId: itemId, diff --git a/src/components/mobile/FiscalYearStartSelectionSheet.vue b/src/components/mobile/FiscalYearStartSelectionSheet.vue new file mode 100644 index 00000000..277af410 --- /dev/null +++ b/src/components/mobile/FiscalYearStartSelectionSheet.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/components/mobile/TrendsBarChart.vue b/src/components/mobile/TrendsBarChart.vue index b5aa2890..104c9678 100644 --- a/src/components/mobile/TrendsBarChart.vue +++ b/src/components/mobile/TrendsBarChart.vue @@ -106,7 +106,8 @@ import { import { getYearMonthFirstUnixTime, getYearMonthLastUnixTime, - getDateTypeByDateRange + getDateTypeByDateRange, + getFiscalYearFromUnixTime } from '@/lib/datetime.ts'; import { sortStatisticsItems @@ -147,7 +148,7 @@ const emit = defineEmits<{ (e: 'click', value: TrendsBarChartClickEvent): void; }>(); -const { tt, formatUnixTimeToShortYear, formatYearQuarter, formatUnixTimeToShortYearMonth, formatAmountWithCurrency } = useI18n(); +const { tt, formatUnixTimeToShortYear, formatYearQuarter, formatUnixTimeToShortYearMonth, formatUnixTimeToFiscalYear, formatYearToFiscalYear, formatAmountWithCurrency } = useI18n(); const { allDateRanges, getItemName, getColor } = useTrendsChartBase(props); const userStore = useUserStore(); @@ -188,6 +189,12 @@ const allDisplayDataItems = computed(() => { if (props.dateAggregationType === ChartDateAggregationType.Year.type) { dateRangeKey = dataItem.year.toString(); + } else if (props.dateAggregationType === ChartDateAggregationType.FiscalYear.type) { + const fiscalYear = getFiscalYearFromUnixTime( + getYearMonthFirstUnixTime({ year: dataItem.year, month: dataItem.month }), + props.fiscalYearStart + ); + dateRangeKey = formatYearToFiscalYear(fiscalYear); } else if (props.dateAggregationType === ChartDateAggregationType.Quarter.type) { dateRangeKey = `${dataItem.year}-${Math.floor((dataItem.month - 1) / 3) + 1}`; } else { // if (props.dateAggregationType === ChartDateAggregationType.Month.type) { @@ -218,6 +225,8 @@ const allDisplayDataItems = computed(() => { if (props.dateAggregationType === ChartDateAggregationType.Year.type) { dateRangeKey = dateRange.year.toString(); + } else if (props.dateAggregationType === ChartDateAggregationType.FiscalYear.type) { + dateRangeKey = formatYearToFiscalYear(dateRange.year); } else if (props.dateAggregationType === ChartDateAggregationType.Quarter.type && 'quarter' in dateRange) { dateRangeKey = `${dateRange.year}-${dateRange.quarter}`; } else if (props.dateAggregationType === ChartDateAggregationType.Month.type && 'month' in dateRange) { @@ -228,6 +237,8 @@ const allDisplayDataItems = computed(() => { if (props.dateAggregationType === ChartDateAggregationType.Year.type) { displayDateRange = formatUnixTimeToShortYear(dateRange.minUnixTime); + } else if (props.dateAggregationType === ChartDateAggregationType.FiscalYear.type) { + displayDateRange = formatUnixTimeToFiscalYear(dateRange.minUnixTime); } else if (props.dateAggregationType === ChartDateAggregationType.Quarter.type && 'quarter' in dateRange) { displayDateRange = formatYearQuarter(dateRange.year, dateRange.quarter); } else { // if (props.dateAggregationType === ChartDateAggregationType.Month.type) { @@ -321,7 +332,7 @@ function clickItem(item: TrendsBarChartDataItem): void { } } - const dateRangeType = getDateTypeByDateRange(minUnixTime, maxUnixTime, userStore.currentUserFirstDayOfWeek, DateRangeScene.Normal); + const dateRangeType = getDateTypeByDateRange(minUnixTime, maxUnixTime, userStore.currentUserFirstDayOfWeek, userStore.currentUserFiscalYearStart, DateRangeScene.Normal); emit('click', { itemId: itemId, diff --git a/src/core/datetime.ts b/src/core/datetime.ts index 6ead8ebe..dc623e2f 100644 --- a/src/core/datetime.ts +++ b/src/core/datetime.ts @@ -512,6 +512,8 @@ export class DateRange implements TypeAndName { // Date ranges for normal and trend analysis scene public static readonly ThisYear = new DateRange(9, 'This year', false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis); public static readonly LastYear = new DateRange(10, 'Last year', false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis); + public static readonly ThisFiscalYear = new DateRange(11, 'This fiscal year', false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis); + public static readonly LastFiscalYear = new DateRange(12, 'Last fiscal year', false, DateRangeScene.Normal, DateRangeScene.TrendAnalysis); // Billing cycle date ranges for normal scene only public static readonly PreviousBillingCycle = new DateRange(51, 'Previous Billing Cycle', true, DateRangeScene.Normal); diff --git a/src/core/fiscalyear.ts b/src/core/fiscalyear.ts new file mode 100644 index 00000000..5940211f --- /dev/null +++ b/src/core/fiscalyear.ts @@ -0,0 +1,255 @@ +import type { TypeAndDisplayName } from '@/core/base.ts'; +import type { UnixTimeRange } from './datetime'; + +export class FiscalYearStart { + public static readonly Default = new FiscalYearStart(1, 1); + + public readonly day: number; + public readonly month: number; + public readonly value: number; + + private constructor(month: number, day: number) { + const [validMonth, validDay] = validateMonthDay(month, day); + this.day = validDay; + this.month = validMonth; + this.value = (validMonth << 8) | validDay; + } + + public static of(month: number, day: number): FiscalYearStart { + return new FiscalYearStart(month, day); + } + + public static valueOf(value: number): FiscalYearStart { + return FiscalYearStart.strictFromNumber(value); + } + + public static valuesFromNumber(value: number): number[] { + return FiscalYearStart.strictFromNumber(value).values(); + } + + public values(): number[] { + return [ + this.month, + this.day + ]; + } + + public static parse(valueString: string): FiscalYearStart | undefined { + return FiscalYearStart.strictFromMonthDashDayString(valueString); + } + + public static isValidType(value: number): boolean { + if (value < 0x0101 || value > 0x0C1F) { + return false; + } + + const month = (value >> 8) & 0xFF; + const day = value & 0xFF; + + try { + validateMonthDay(month, day); + return true; + } catch (error) { + return false; + } + } + + public isValid(): boolean { + try { + FiscalYearStart.validateMonthDay(this.month, this.day); + return true; + } catch (error) { + return false; + } + } + + public isDefault(): boolean { + return this.month === 1 && this.day === 1; + } + + public static validateMonthDay(month: number, day: number): [number, number] { + return validateMonthDay(month, day); + } + + public static strictFromMonthDayValues(month: number, day: number): FiscalYearStart { + return FiscalYearStart.of(month, day); + } + + /** + * Create a FiscalYearStart from a uint16 value (two bytes - month high, day low) + * @param value uint16 value (month in high byte, day in low byte) + * @returns FiscalYearStart instance + */ + public static strictFromNumber(value: number): FiscalYearStart { + if (value < 0 || value > 0xFFFF) { + throw new Error('Invalid uint16 value'); + } + + const month = (value >> 8) & 0xFF; // high byte + const day = value & 0xFF; // low byte + + try { + const [validMonth, validDay] = validateMonthDay(month, day); + return FiscalYearStart.of(validMonth, validDay); + } catch (error) { + throw new Error('Invalid uint16 value'); + } + } + + /** + * Create a FiscalYearStart from a month/day string + * @param input MM-dd string (e.g. "04-01" = 1 April) + * @returns FiscalYearStart instance + */ + public static strictFromMonthDashDayString(input: string): FiscalYearStart { + if (!input || !input.includes('-')) { + throw new Error('Invalid input string'); + } + + const parts = input.split('-'); + if (parts.length !== 2) { + throw new Error('Invalid input string'); + } + + const month = parseInt(parts[0], 10); + const day = parseInt(parts[1], 10); + + if (isNaN(month) || isNaN(day)) { + throw new Error('Invalid input string'); + } + + try { + const [validMonth, validDay] = validateMonthDay(month, day); + return FiscalYearStart.of(validMonth, validDay); + } catch (error) { + throw new Error('Invalid input string'); + } + } + + public static fromMonthDashDayString(input: string): FiscalYearStart | null { + try { + return FiscalYearStart.strictFromMonthDashDayString(input); + } catch (error) { + return null; + } + } + + public static fromNumber(value: number): FiscalYearStart | null { + try { + return FiscalYearStart.strictFromNumber(value); + } catch (error) { + return null; + } + } + + public static fromMonthDayValues(month: number, day: number): FiscalYearStart | null { + try { + return FiscalYearStart.strictFromMonthDayValues(month, day); + } catch (error) { + return null; + } + } + + public toMonthDashDayString(): string { + return `${this.month.toString().padStart(2, '0')}-${this.day.toString().padStart(2, '0')}`; + } + + public toMonthDayValues(): [string, string] { + return [ + `${this.month.toString().padStart(2, '0')}`, + `${this.day.toString().padStart(2, '0')}` + ] + } + + public toString(): string { + return this.toMonthDashDayString(); + } +} + +function validateMonthDay(month: number, day: number): [number, number] { + if (month < 1 || month > 12 || day < 1) { + throw new Error('Invalid month or day'); + } + + let maxDays = 31; + switch (month) { + // January, March, May, July, August, October, December + case 1: case 3: case 5: case 7: case 8: case 10: case 12: + maxDays = 31; + break; + // April, June, September, November + case 4: case 6: case 9: case 11: + maxDays = 30; + break; + // February + case 2: + maxDays = 28; // Disallow fiscal year start on leap day + break; + } + + if (day > maxDays) { + throw new Error('Invalid day for given month'); + } + + return [month, day]; +} + +export class FiscalYearUnixTime implements UnixTimeRange { + public readonly year: number; + public readonly minUnixTime: number; + public readonly maxUnixTime: number; + + private constructor(fiscalYear: number, minUnixTime: number, maxUnixTime: number) { + this.year = fiscalYear; + this.minUnixTime = minUnixTime; + this.maxUnixTime = maxUnixTime; + } + + public static of(fiscalYear: number, minUnixTime: number, maxUnixTime: number): FiscalYearUnixTime { + return new FiscalYearUnixTime(fiscalYear, minUnixTime, maxUnixTime); + } +} + +export const LANGUAGE_DEFAULT_FISCAL_YEAR_FORMAT_VALUE: number = 0; + +export class FiscalYearFormat implements TypeAndDisplayName { + private static readonly allInstances: FiscalYearFormat[] = []; + private static readonly allInstancesByType: Record = {}; + private static readonly allInstancesByTypeName: Record = {}; + + public static readonly StartYYYY_EndYYYY = new FiscalYearFormat(1, 'StartYYYY_EndYYYY'); + public static readonly StartYYYY_EndYY = new FiscalYearFormat(2, 'StartYYYY_EndYY'); + public static readonly StartYY_EndYY = new FiscalYearFormat(3, 'StartYY_EndYY'); + public static readonly EndYYYY = new FiscalYearFormat(4, 'EndYYYY'); + public static readonly EndYY = new FiscalYearFormat(5, 'EndYY'); + + public static readonly Default = FiscalYearFormat.EndYYYY; + + public readonly type: number; + public readonly displayName: string; + + private constructor(type: number, displayName: string) { + this.type = type; + this.displayName = displayName; + + FiscalYearFormat.allInstances.push(this); + FiscalYearFormat.allInstancesByType[type] = this; + FiscalYearFormat.allInstancesByTypeName[displayName] = this; + } + + public static all(): Record { + return FiscalYearFormat.allInstancesByTypeName; + } + + public static values(): FiscalYearFormat[] { + return FiscalYearFormat.allInstances; + } + + public static valueOf(type: number): FiscalYearFormat | undefined { + return FiscalYearFormat.allInstancesByType[type]; + } + + public static parse(displayName: string): FiscalYearFormat | undefined { + return FiscalYearFormat.allInstancesByTypeName[displayName]; + } +} diff --git a/src/core/map.ts b/src/core/map.ts new file mode 100644 index 00000000..65d73d4e --- /dev/null +++ b/src/core/map.ts @@ -0,0 +1,4 @@ +export interface MapPosition { + latitude: number; + longitude: number; +} diff --git a/src/core/statistics.ts b/src/core/statistics.ts index a4693eb2..1c92d1cb 100644 --- a/src/core/statistics.ts +++ b/src/core/statistics.ts @@ -158,6 +158,7 @@ export class ChartDateAggregationType implements TypeAndName { public static readonly Month = new ChartDateAggregationType(0, 'Aggregate by Month'); public static readonly Quarter = new ChartDateAggregationType(1, 'Aggregate by Quarter'); public static readonly Year = new ChartDateAggregationType(2, 'Aggregate by Year'); + public static readonly FiscalYear = new ChartDateAggregationType(3, 'Aggregate by Fiscal Year'); public static readonly Default = ChartDateAggregationType.Month; diff --git a/src/desktop-main.ts b/src/desktop-main.ts index 086157f5..d4b80247 100644 --- a/src/desktop-main.ts +++ b/src/desktop-main.ts @@ -84,6 +84,7 @@ import LanguageSelectButton from '@/components/desktop/LanguageSelectButton.vue' import CurrencySelect from '@/components/desktop/CurrencySelect.vue'; import DateTimeSelect from '@/components/desktop/DateTimeSelect.vue'; import DateSelect from '@/components/desktop/DateSelect.vue'; +import FiscalYearStartSelect from '@/components/desktop/FiscalYearStartSelect.vue'; import ColorSelect from '@/components/desktop/ColorSelect.vue'; import IconSelect from '@/components/desktop/IconSelect.vue'; import TwoColumnSelect from '@/components/desktop/TwoColumnSelect.vue'; @@ -461,6 +462,7 @@ app.component('LanguageSelectButton', LanguageSelectButton); app.component('CurrencySelect', CurrencySelect); app.component('DateTimeSelect', DateTimeSelect); app.component('DateSelect', DateSelect); +app.component('FiscalYearStartSelect', FiscalYearStartSelect); app.component('ColorSelect', ColorSelect); app.component('IconSelect', IconSelect); app.component('TwoColumnSelect', TwoColumnSelect); diff --git a/src/lib/__tests__/fiscal_year.data.json b/src/lib/__tests__/fiscal_year.data.json new file mode 100644 index 00000000..5a4127eb --- /dev/null +++ b/src/lib/__tests__/fiscal_year.data.json @@ -0,0 +1,1588 @@ +{ + "test_cases_getFiscalYearFromUnixTime": [ + { + "date": "2022-01-01", + "expected": { + "January 1": 2022, + "April 1": 2022, + "October 1": 2022 + } + }, + { + "date": "2022-03-31", + "expected": { + "January 1": 2022, + "April 1": 2022, + "October 1": 2022 + } + }, + { + "date": "2022-04-01", + "expected": { + "January 1": 2022, + "April 1": 2023, + "October 1": 2022 + } + }, + { + "date": "2022-09-30", + "expected": { + "January 1": 2022, + "April 1": 2023, + "October 1": 2022 + } + }, + { + "date": "2022-10-01", + "expected": { + "January 1": 2022, + "April 1": 2023, + "October 1": 2023 + } + }, + { + "date": "2022-12-31", + "expected": { + "January 1": 2022, + "April 1": 2023, + "October 1": 2023 + } + }, + { + "date": "2023-01-01", + "expected": { + "January 1": 2023, + "April 1": 2023, + "October 1": 2023 + } + }, + { + "date": "2023-03-31", + "expected": { + "January 1": 2023, + "April 1": 2023, + "October 1": 2023 + } + }, + { + "date": "2023-04-01", + "expected": { + "January 1": 2023, + "April 1": 2024, + "October 1": 2023 + } + }, + { + "date": "2023-09-30", + "expected": { + "January 1": 2023, + "April 1": 2024, + "October 1": 2023 + } + }, + { + "date": "2023-10-01", + "expected": { + "January 1": 2023, + "April 1": 2024, + "October 1": 2024 + } + }, + { + "date": "2023-12-31", + "expected": { + "January 1": 2023, + "April 1": 2024, + "October 1": 2024 + } + }, + { + "date": "2024-01-01", + "expected": { + "January 1": 2024, + "April 1": 2024, + "October 1": 2024 + } + }, + { + "date": "2024-03-31", + "expected": { + "January 1": 2024, + "April 1": 2024, + "October 1": 2024 + } + }, + { + "date": "2024-04-01", + "expected": { + "January 1": 2024, + "April 1": 2025, + "October 1": 2024 + } + }, + { + "date": "2024-09-30", + "expected": { + "January 1": 2024, + "April 1": 2025, + "October 1": 2024 + } + }, + { + "date": "2024-10-01", + "expected": { + "January 1": 2024, + "April 1": 2025, + "October 1": 2025 + } + }, + { + "date": "2024-12-31", + "expected": { + "January 1": 2024, + "April 1": 2025, + "October 1": 2025 + } + }, + { + "date": "2025-01-01", + "expected": { + "January 1": 2025, + "April 1": 2025, + "October 1": 2025 + } + }, + { + "date": "2025-03-31", + "expected": { + "January 1": 2025, + "April 1": 2025, + "October 1": 2025 + } + }, + { + "date": "2025-04-01", + "expected": { + "January 1": 2025, + "April 1": 2026, + "October 1": 2025 + } + }, + { + "date": "2025-09-30", + "expected": { + "January 1": 2025, + "April 1": 2026, + "October 1": 2025 + } + }, + { + "date": "2025-10-01", + "expected": { + "January 1": 2025, + "April 1": 2026, + "October 1": 2026 + } + }, + { + "date": "2025-12-31", + "expected": { + "January 1": 2025, + "April 1": 2026, + "October 1": 2026 + } + } + ], + "test_cases_getFiscalYearStartUnixTime": [ + { + "date": "2022-01-01", + "expected": { + "January 1": { + "unixTime": 1640995200, + "unixTimeISO": "2022-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1617235200, + "unixTimeISO": "2021-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1633046400, + "unixTimeISO": "2021-10-01T00:00:00Z" + } + } + }, + { + "date": "2022-03-31", + "expected": { + "January 1": { + "unixTime": 1640995200, + "unixTimeISO": "2022-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1617235200, + "unixTimeISO": "2021-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1633046400, + "unixTimeISO": "2021-10-01T00:00:00Z" + } + } + }, + { + "date": "2022-04-01", + "expected": { + "January 1": { + "unixTime": 1640995200, + "unixTimeISO": "2022-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1648771200, + "unixTimeISO": "2022-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1633046400, + "unixTimeISO": "2021-10-01T00:00:00Z" + } + } + }, + { + "date": "2022-09-30", + "expected": { + "January 1": { + "unixTime": 1640995200, + "unixTimeISO": "2022-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1648771200, + "unixTimeISO": "2022-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1633046400, + "unixTimeISO": "2021-10-01T00:00:00Z" + } + } + }, + { + "date": "2022-10-01", + "expected": { + "January 1": { + "unixTime": 1640995200, + "unixTimeISO": "2022-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1648771200, + "unixTimeISO": "2022-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1664582400, + "unixTimeISO": "2022-10-01T00:00:00Z" + } + } + }, + { + "date": "2022-12-31", + "expected": { + "January 1": { + "unixTime": 1640995200, + "unixTimeISO": "2022-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1648771200, + "unixTimeISO": "2022-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1664582400, + "unixTimeISO": "2022-10-01T00:00:00Z" + } + } + }, + { + "date": "2023-01-01", + "expected": { + "January 1": { + "unixTime": 1672531200, + "unixTimeISO": "2023-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1648771200, + "unixTimeISO": "2022-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1664582400, + "unixTimeISO": "2022-10-01T00:00:00Z" + } + } + }, + { + "date": "2023-03-31", + "expected": { + "January 1": { + "unixTime": 1672531200, + "unixTimeISO": "2023-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1648771200, + "unixTimeISO": "2022-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1664582400, + "unixTimeISO": "2022-10-01T00:00:00Z" + } + } + }, + { + "date": "2023-04-01", + "expected": { + "January 1": { + "unixTime": 1672531200, + "unixTimeISO": "2023-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1680307200, + "unixTimeISO": "2023-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1664582400, + "unixTimeISO": "2022-10-01T00:00:00Z" + } + } + }, + { + "date": "2023-09-30", + "expected": { + "January 1": { + "unixTime": 1672531200, + "unixTimeISO": "2023-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1680307200, + "unixTimeISO": "2023-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1664582400, + "unixTimeISO": "2022-10-01T00:00:00Z" + } + } + }, + { + "date": "2023-10-01", + "expected": { + "January 1": { + "unixTime": 1672531200, + "unixTimeISO": "2023-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1680307200, + "unixTimeISO": "2023-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1696118400, + "unixTimeISO": "2023-10-01T00:00:00Z" + } + } + }, + { + "date": "2023-12-31", + "expected": { + "January 1": { + "unixTime": 1672531200, + "unixTimeISO": "2023-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1680307200, + "unixTimeISO": "2023-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1696118400, + "unixTimeISO": "2023-10-01T00:00:00Z" + } + } + }, + { + "date": "2024-01-01", + "expected": { + "January 1": { + "unixTime": 1704067200, + "unixTimeISO": "2024-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1680307200, + "unixTimeISO": "2023-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1696118400, + "unixTimeISO": "2023-10-01T00:00:00Z" + } + } + }, + { + "date": "2024-03-31", + "expected": { + "January 1": { + "unixTime": 1704067200, + "unixTimeISO": "2024-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1680307200, + "unixTimeISO": "2023-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1696118400, + "unixTimeISO": "2023-10-01T00:00:00Z" + } + } + }, + { + "date": "2024-04-01", + "expected": { + "January 1": { + "unixTime": 1704067200, + "unixTimeISO": "2024-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1711929600, + "unixTimeISO": "2024-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1696118400, + "unixTimeISO": "2023-10-01T00:00:00Z" + } + } + }, + { + "date": "2024-09-30", + "expected": { + "January 1": { + "unixTime": 1704067200, + "unixTimeISO": "2024-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1711929600, + "unixTimeISO": "2024-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1696118400, + "unixTimeISO": "2023-10-01T00:00:00Z" + } + } + }, + { + "date": "2024-10-01", + "expected": { + "January 1": { + "unixTime": 1704067200, + "unixTimeISO": "2024-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1711929600, + "unixTimeISO": "2024-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1727740800, + "unixTimeISO": "2024-10-01T00:00:00Z" + } + } + }, + { + "date": "2024-12-31", + "expected": { + "January 1": { + "unixTime": 1704067200, + "unixTimeISO": "2024-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1711929600, + "unixTimeISO": "2024-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1727740800, + "unixTimeISO": "2024-10-01T00:00:00Z" + } + } + }, + { + "date": "2025-01-01", + "expected": { + "January 1": { + "unixTime": 1735689600, + "unixTimeISO": "2025-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1711929600, + "unixTimeISO": "2024-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1727740800, + "unixTimeISO": "2024-10-01T00:00:00Z" + } + } + }, + { + "date": "2025-03-31", + "expected": { + "January 1": { + "unixTime": 1735689600, + "unixTimeISO": "2025-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1711929600, + "unixTimeISO": "2024-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1727740800, + "unixTimeISO": "2024-10-01T00:00:00Z" + } + } + }, + { + "date": "2025-04-01", + "expected": { + "January 1": { + "unixTime": 1735689600, + "unixTimeISO": "2025-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1743465600, + "unixTimeISO": "2025-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1727740800, + "unixTimeISO": "2024-10-01T00:00:00Z" + } + } + }, + { + "date": "2025-09-30", + "expected": { + "January 1": { + "unixTime": 1735689600, + "unixTimeISO": "2025-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1743465600, + "unixTimeISO": "2025-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1727740800, + "unixTimeISO": "2024-10-01T00:00:00Z" + } + } + }, + { + "date": "2025-10-01", + "expected": { + "January 1": { + "unixTime": 1735689600, + "unixTimeISO": "2025-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1743465600, + "unixTimeISO": "2025-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1759276800, + "unixTimeISO": "2025-10-01T00:00:00Z" + } + } + }, + { + "date": "2025-12-31", + "expected": { + "January 1": { + "unixTime": 1735689600, + "unixTimeISO": "2025-01-01T00:00:00Z" + }, + "April 1": { + "unixTime": 1743465600, + "unixTimeISO": "2025-04-01T00:00:00Z" + }, + "October 1": { + "unixTime": 1759276800, + "unixTimeISO": "2025-10-01T00:00:00Z" + } + } + } + ], + "test_cases_getFiscalYearEndUnixTime": [ + { + "date": "2022-01-01", + "expected": { + "January 1": { + "unixTime": 1672531199, + "unixTimeISO": "2022-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1648771199, + "unixTimeISO": "2022-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1664582399, + "unixTimeISO": "2022-09-30T23:59:59Z" + } + } + }, + { + "date": "2022-03-31", + "expected": { + "January 1": { + "unixTime": 1672531199, + "unixTimeISO": "2022-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1648771199, + "unixTimeISO": "2022-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1664582399, + "unixTimeISO": "2022-09-30T23:59:59Z" + } + } + }, + { + "date": "2022-04-01", + "expected": { + "January 1": { + "unixTime": 1672531199, + "unixTimeISO": "2022-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1680307199, + "unixTimeISO": "2023-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1664582399, + "unixTimeISO": "2022-09-30T23:59:59Z" + } + } + }, + { + "date": "2022-09-30", + "expected": { + "January 1": { + "unixTime": 1672531199, + "unixTimeISO": "2022-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1680307199, + "unixTimeISO": "2023-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1664582399, + "unixTimeISO": "2022-09-30T23:59:59Z" + } + } + }, + { + "date": "2022-10-01", + "expected": { + "January 1": { + "unixTime": 1672531199, + "unixTimeISO": "2022-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1680307199, + "unixTimeISO": "2023-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1696118399, + "unixTimeISO": "2023-09-30T23:59:59Z" + } + } + }, + { + "date": "2022-12-31", + "expected": { + "January 1": { + "unixTime": 1672531199, + "unixTimeISO": "2022-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1680307199, + "unixTimeISO": "2023-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1696118399, + "unixTimeISO": "2023-09-30T23:59:59Z" + } + } + }, + { + "date": "2023-01-01", + "expected": { + "January 1": { + "unixTime": 1704067199, + "unixTimeISO": "2023-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1680307199, + "unixTimeISO": "2023-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1696118399, + "unixTimeISO": "2023-09-30T23:59:59Z" + } + } + }, + { + "date": "2023-03-31", + "expected": { + "January 1": { + "unixTime": 1704067199, + "unixTimeISO": "2023-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1680307199, + "unixTimeISO": "2023-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1696118399, + "unixTimeISO": "2023-09-30T23:59:59Z" + } + } + }, + { + "date": "2023-04-01", + "expected": { + "January 1": { + "unixTime": 1704067199, + "unixTimeISO": "2023-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1711929599, + "unixTimeISO": "2024-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1696118399, + "unixTimeISO": "2023-09-30T23:59:59Z" + } + } + }, + { + "date": "2023-09-30", + "expected": { + "January 1": { + "unixTime": 1704067199, + "unixTimeISO": "2023-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1711929599, + "unixTimeISO": "2024-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1696118399, + "unixTimeISO": "2023-09-30T23:59:59Z" + } + } + }, + { + "date": "2023-10-01", + "expected": { + "January 1": { + "unixTime": 1704067199, + "unixTimeISO": "2023-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1711929599, + "unixTimeISO": "2024-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1727740799, + "unixTimeISO": "2024-09-30T23:59:59Z" + } + } + }, + { + "date": "2023-12-31", + "expected": { + "January 1": { + "unixTime": 1704067199, + "unixTimeISO": "2023-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1711929599, + "unixTimeISO": "2024-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1727740799, + "unixTimeISO": "2024-09-30T23:59:59Z" + } + } + }, + { + "date": "2024-01-01", + "expected": { + "January 1": { + "unixTime": 1735689599, + "unixTimeISO": "2024-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1711929599, + "unixTimeISO": "2024-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1727740799, + "unixTimeISO": "2024-09-30T23:59:59Z" + } + } + }, + { + "date": "2024-03-31", + "expected": { + "January 1": { + "unixTime": 1735689599, + "unixTimeISO": "2024-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1711929599, + "unixTimeISO": "2024-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1727740799, + "unixTimeISO": "2024-09-30T23:59:59Z" + } + } + }, + { + "date": "2024-04-01", + "expected": { + "January 1": { + "unixTime": 1735689599, + "unixTimeISO": "2024-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1743465599, + "unixTimeISO": "2025-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1727740799, + "unixTimeISO": "2024-09-30T23:59:59Z" + } + } + }, + { + "date": "2024-09-30", + "expected": { + "January 1": { + "unixTime": 1735689599, + "unixTimeISO": "2024-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1743465599, + "unixTimeISO": "2025-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1727740799, + "unixTimeISO": "2024-09-30T23:59:59Z" + } + } + }, + { + "date": "2024-10-01", + "expected": { + "January 1": { + "unixTime": 1735689599, + "unixTimeISO": "2024-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1743465599, + "unixTimeISO": "2025-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1759276799, + "unixTimeISO": "2025-09-30T23:59:59Z" + } + } + }, + { + "date": "2024-12-31", + "expected": { + "January 1": { + "unixTime": 1735689599, + "unixTimeISO": "2024-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1743465599, + "unixTimeISO": "2025-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1759276799, + "unixTimeISO": "2025-09-30T23:59:59Z" + } + } + }, + { + "date": "2025-01-01", + "expected": { + "January 1": { + "unixTime": 1767225599, + "unixTimeISO": "2025-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1743465599, + "unixTimeISO": "2025-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1759276799, + "unixTimeISO": "2025-09-30T23:59:59Z" + } + } + }, + { + "date": "2025-03-31", + "expected": { + "January 1": { + "unixTime": 1767225599, + "unixTimeISO": "2025-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1743465599, + "unixTimeISO": "2025-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1759276799, + "unixTimeISO": "2025-09-30T23:59:59Z" + } + } + }, + { + "date": "2025-04-01", + "expected": { + "January 1": { + "unixTime": 1767225599, + "unixTimeISO": "2025-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1775001599, + "unixTimeISO": "2026-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1759276799, + "unixTimeISO": "2025-09-30T23:59:59Z" + } + } + }, + { + "date": "2025-09-30", + "expected": { + "January 1": { + "unixTime": 1767225599, + "unixTimeISO": "2025-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1775001599, + "unixTimeISO": "2026-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1759276799, + "unixTimeISO": "2025-09-30T23:59:59Z" + } + } + }, + { + "date": "2025-10-01", + "expected": { + "January 1": { + "unixTime": 1767225599, + "unixTimeISO": "2025-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1775001599, + "unixTimeISO": "2026-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1790812799, + "unixTimeISO": "2026-09-30T23:59:59Z" + } + } + }, + { + "date": "2025-12-31", + "expected": { + "January 1": { + "unixTime": 1767225599, + "unixTimeISO": "2025-12-31T23:59:59Z" + }, + "April 1": { + "unixTime": 1775001599, + "unixTimeISO": "2026-03-31T23:59:59Z" + }, + "October 1": { + "unixTime": 1790812799, + "unixTimeISO": "2026-09-30T23:59:59Z" + } + } + } + ], + "test_cases_getFiscalYearTimeRangeFromUnixTime": [ + { + "date": "2022-01-01", + "expected": { + "January 1": { + "year": 2022, + "minUnixTime": 1640995200, + "maxUnixTime": 1672531199 + }, + "April 1": { + "year": 2022, + "minUnixTime": 1617235200, + "maxUnixTime": 1648771199 + }, + "October 1": { + "year": 2022, + "minUnixTime": 1633046400, + "maxUnixTime": 1664582399 + } + } + }, + { + "date": "2022-03-31", + "expected": { + "January 1": { + "year": 2022, + "minUnixTime": 1640995200, + "maxUnixTime": 1672531199 + }, + "April 1": { + "year": 2022, + "minUnixTime": 1617235200, + "maxUnixTime": 1648771199 + }, + "October 1": { + "year": 2022, + "minUnixTime": 1633046400, + "maxUnixTime": 1664582399 + } + } + }, + { + "date": "2022-04-01", + "expected": { + "January 1": { + "year": 2022, + "minUnixTime": 1640995200, + "maxUnixTime": 1672531199 + }, + "April 1": { + "year": 2023, + "minUnixTime": 1648771200, + "maxUnixTime": 1680307199 + }, + "October 1": { + "year": 2022, + "minUnixTime": 1633046400, + "maxUnixTime": 1664582399 + } + } + }, + { + "date": "2022-09-30", + "expected": { + "January 1": { + "year": 2022, + "minUnixTime": 1640995200, + "maxUnixTime": 1672531199 + }, + "April 1": { + "year": 2023, + "minUnixTime": 1648771200, + "maxUnixTime": 1680307199 + }, + "October 1": { + "year": 2022, + "minUnixTime": 1633046400, + "maxUnixTime": 1664582399 + } + } + }, + { + "date": "2022-10-01", + "expected": { + "January 1": { + "year": 2022, + "minUnixTime": 1640995200, + "maxUnixTime": 1672531199 + }, + "April 1": { + "year": 2023, + "minUnixTime": 1648771200, + "maxUnixTime": 1680307199 + }, + "October 1": { + "year": 2023, + "minUnixTime": 1664582400, + "maxUnixTime": 1696118399 + } + } + }, + { + "date": "2022-12-31", + "expected": { + "January 1": { + "year": 2022, + "minUnixTime": 1640995200, + "maxUnixTime": 1672531199 + }, + "April 1": { + "year": 2023, + "minUnixTime": 1648771200, + "maxUnixTime": 1680307199 + }, + "October 1": { + "year": 2023, + "minUnixTime": 1664582400, + "maxUnixTime": 1696118399 + } + } + }, + { + "date": "2023-01-01", + "expected": { + "January 1": { + "year": 2023, + "minUnixTime": 1672531200, + "maxUnixTime": 1704067199 + }, + "April 1": { + "year": 2023, + "minUnixTime": 1648771200, + "maxUnixTime": 1680307199 + }, + "October 1": { + "year": 2023, + "minUnixTime": 1664582400, + "maxUnixTime": 1696118399 + } + } + }, + { + "date": "2023-03-31", + "expected": { + "January 1": { + "year": 2023, + "minUnixTime": 1672531200, + "maxUnixTime": 1704067199 + }, + "April 1": { + "year": 2023, + "minUnixTime": 1648771200, + "maxUnixTime": 1680307199 + }, + "October 1": { + "year": 2023, + "minUnixTime": 1664582400, + "maxUnixTime": 1696118399 + } + } + }, + { + "date": "2023-04-01", + "expected": { + "January 1": { + "year": 2023, + "minUnixTime": 1672531200, + "maxUnixTime": 1704067199 + }, + "April 1": { + "year": 2024, + "minUnixTime": 1680307200, + "maxUnixTime": 1711929599 + }, + "October 1": { + "year": 2023, + "minUnixTime": 1664582400, + "maxUnixTime": 1696118399 + } + } + }, + { + "date": "2023-09-30", + "expected": { + "January 1": { + "year": 2023, + "minUnixTime": 1672531200, + "maxUnixTime": 1704067199 + }, + "April 1": { + "year": 2024, + "minUnixTime": 1680307200, + "maxUnixTime": 1711929599 + }, + "October 1": { + "year": 2023, + "minUnixTime": 1664582400, + "maxUnixTime": 1696118399 + } + } + }, + { + "date": "2023-10-01", + "expected": { + "January 1": { + "year": 2023, + "minUnixTime": 1672531200, + "maxUnixTime": 1704067199 + }, + "April 1": { + "year": 2024, + "minUnixTime": 1680307200, + "maxUnixTime": 1711929599 + }, + "October 1": { + "year": 2024, + "minUnixTime": 1696118400, + "maxUnixTime": 1727740799 + } + } + }, + { + "date": "2023-12-31", + "expected": { + "January 1": { + "year": 2023, + "minUnixTime": 1672531200, + "maxUnixTime": 1704067199 + }, + "April 1": { + "year": 2024, + "minUnixTime": 1680307200, + "maxUnixTime": 1711929599 + }, + "October 1": { + "year": 2024, + "minUnixTime": 1696118400, + "maxUnixTime": 1727740799 + } + } + }, + { + "date": "2024-01-01", + "expected": { + "January 1": { + "year": 2024, + "minUnixTime": 1704067200, + "maxUnixTime": 1735689599 + }, + "April 1": { + "year": 2024, + "minUnixTime": 1680307200, + "maxUnixTime": 1711929599 + }, + "October 1": { + "year": 2024, + "minUnixTime": 1696118400, + "maxUnixTime": 1727740799 + } + } + }, + { + "date": "2024-03-31", + "expected": { + "January 1": { + "year": 2024, + "minUnixTime": 1704067200, + "maxUnixTime": 1735689599 + }, + "April 1": { + "year": 2024, + "minUnixTime": 1680307200, + "maxUnixTime": 1711929599 + }, + "October 1": { + "year": 2024, + "minUnixTime": 1696118400, + "maxUnixTime": 1727740799 + } + } + }, + { + "date": "2024-04-01", + "expected": { + "January 1": { + "year": 2024, + "minUnixTime": 1704067200, + "maxUnixTime": 1735689599 + }, + "April 1": { + "year": 2025, + "minUnixTime": 1711929600, + "maxUnixTime": 1743465599 + }, + "October 1": { + "year": 2024, + "minUnixTime": 1696118400, + "maxUnixTime": 1727740799 + } + } + }, + { + "date": "2024-09-30", + "expected": { + "January 1": { + "year": 2024, + "minUnixTime": 1704067200, + "maxUnixTime": 1735689599 + }, + "April 1": { + "year": 2025, + "minUnixTime": 1711929600, + "maxUnixTime": 1743465599 + }, + "October 1": { + "year": 2024, + "minUnixTime": 1696118400, + "maxUnixTime": 1727740799 + } + } + }, + { + "date": "2024-10-01", + "expected": { + "January 1": { + "year": 2024, + "minUnixTime": 1704067200, + "maxUnixTime": 1735689599 + }, + "April 1": { + "year": 2025, + "minUnixTime": 1711929600, + "maxUnixTime": 1743465599 + }, + "October 1": { + "year": 2025, + "minUnixTime": 1727740800, + "maxUnixTime": 1759276799 + } + } + }, + { + "date": "2024-12-31", + "expected": { + "January 1": { + "year": 2024, + "minUnixTime": 1704067200, + "maxUnixTime": 1735689599 + }, + "April 1": { + "year": 2025, + "minUnixTime": 1711929600, + "maxUnixTime": 1743465599 + }, + "October 1": { + "year": 2025, + "minUnixTime": 1727740800, + "maxUnixTime": 1759276799 + } + } + }, + { + "date": "2025-01-01", + "expected": { + "January 1": { + "year": 2025, + "minUnixTime": 1735689600, + "maxUnixTime": 1767225599 + }, + "April 1": { + "year": 2025, + "minUnixTime": 1711929600, + "maxUnixTime": 1743465599 + }, + "October 1": { + "year": 2025, + "minUnixTime": 1727740800, + "maxUnixTime": 1759276799 + } + } + }, + { + "date": "2025-03-31", + "expected": { + "January 1": { + "year": 2025, + "minUnixTime": 1735689600, + "maxUnixTime": 1767225599 + }, + "April 1": { + "year": 2025, + "minUnixTime": 1711929600, + "maxUnixTime": 1743465599 + }, + "October 1": { + "year": 2025, + "minUnixTime": 1727740800, + "maxUnixTime": 1759276799 + } + } + }, + { + "date": "2025-04-01", + "expected": { + "January 1": { + "year": 2025, + "minUnixTime": 1735689600, + "maxUnixTime": 1767225599 + }, + "April 1": { + "year": 2026, + "minUnixTime": 1743465600, + "maxUnixTime": 1775001599 + }, + "October 1": { + "year": 2025, + "minUnixTime": 1727740800, + "maxUnixTime": 1759276799 + } + } + }, + { + "date": "2025-09-30", + "expected": { + "January 1": { + "year": 2025, + "minUnixTime": 1735689600, + "maxUnixTime": 1767225599 + }, + "April 1": { + "year": 2026, + "minUnixTime": 1743465600, + "maxUnixTime": 1775001599 + }, + "October 1": { + "year": 2025, + "minUnixTime": 1727740800, + "maxUnixTime": 1759276799 + } + } + }, + { + "date": "2025-10-01", + "expected": { + "January 1": { + "year": 2025, + "minUnixTime": 1735689600, + "maxUnixTime": 1767225599 + }, + "April 1": { + "year": 2026, + "minUnixTime": 1743465600, + "maxUnixTime": 1775001599 + }, + "October 1": { + "year": 2026, + "minUnixTime": 1759276800, + "maxUnixTime": 1790812799 + } + } + }, + { + "date": "2025-12-31", + "expected": { + "January 1": { + "year": 2025, + "minUnixTime": 1735689600, + "maxUnixTime": 1767225599 + }, + "April 1": { + "year": 2026, + "minUnixTime": 1743465600, + "maxUnixTime": 1775001599 + }, + "October 1": { + "year": 2026, + "minUnixTime": 1759276800, + "maxUnixTime": 1790812799 + } + } + } + ], + "test_cases_getAllFiscalYearsStartAndEndUnixTimes": [ + { + "startYearMonth": "2024-01", + "endYearMonth": "2026-12", + "fiscalYearStart": "07-01", + "fiscalYearStartId": "July 1", + "expected": [ + { + "year": 2024, + "minUnixTime": 1704067200, + "maxUnixTime": 1719791999 + }, + { + "year": 2025, + "minUnixTime": 1719792000, + "maxUnixTime": 1751327999 + }, + { + "year": 2026, + "minUnixTime": 1751328000, + "maxUnixTime": 1782863999 + }, + { + "year": 2027, + "minUnixTime": 1782864000, + "maxUnixTime": 1798761599 + } + ] + }, + { + "startYearMonth": "2024-01", + "endYearMonth": "2024-12", + "fiscalYearStart": "07-01", + "fiscalYearStartId": "July 1", + "expected": [ + { + "year": 2024, + "minUnixTime": 1704067200, + "maxUnixTime": 1719791999 + }, + { + "year": 2025, + "minUnixTime": 1719792000, + "maxUnixTime": 1735689599 + } + ] + }, + { + "startYearMonth": "2024-01", + "endYearMonth": "2024-12", + "fiscalYearStart": "01-01", + "fiscalYearStartId": "January 1", + "expected": [ + { + "year": 2024, + "minUnixTime": 1704067200, + "maxUnixTime": 1735689599 + } + ] + } + ], + "test_cases_getFiscalYearTimeRangeFromYear": [ + { + "year": 2023, + "fiscalYearStart": "07-01", + "expected": { + "year": 2023, + "minUnixTime": 1656633600, + "maxUnixTime": 1688169599 + } + }, + { + "year": 2024, + "fiscalYearStart": "07-01", + "expected": { + "year": 2024, + "minUnixTime": 1688169600, + "maxUnixTime": 1719791999 + } + }, + { + "year": 2024, + "fiscalYearStart": "01-01", + "expected": { + "year": 2024, + "minUnixTime": 1704067200, + "maxUnixTime": 1735689599 + } + } + ] +} \ No newline at end of file diff --git a/src/lib/__tests__/fiscal_year.ts b/src/lib/__tests__/fiscal_year.ts new file mode 100644 index 00000000..bfdaaeac --- /dev/null +++ b/src/lib/__tests__/fiscal_year.ts @@ -0,0 +1,291 @@ +// Unit tests for fiscal year functions +import moment from 'moment-timezone'; +import { describe, expect, test, beforeAll } from '@jest/globals'; +import fs from 'fs'; +import path from 'path'; + +// Import all the fiscal year functions from the lib +import { + getFiscalYearFromUnixTime, + getFiscalYearStartUnixTime, + getFiscalYearEndUnixTime, + getFiscalYearTimeRangeFromUnixTime, + getAllFiscalYearsStartAndEndUnixTimes, + getFiscalYearTimeRangeFromYear +} from '@/lib/datetime.ts'; + +import { formatUnixTime } from '@/lib/datetime.ts'; +import { FiscalYearStart, FiscalYearUnixTime } from '@/core/fiscalyear.ts'; + +// Set test environment timezone to UTC, since the test data constants are in UTC +beforeAll(() => { + moment.tz.setDefault('UTC'); +}); + +// UTILITIES + +function importTestData(datasetName: string): any[] { + const data = JSON.parse( + fs.readFileSync(path.join(__dirname, 'fiscal_year.data.json'), 'utf8') + ); + if (!data || typeof data[datasetName] === 'undefined') { + throw new Error(`${datasetName} is undefined or missing in the data object.`); + } + return data[datasetName]; +} + +function formatUnixTimeISO(unixTime: number): string { + return formatUnixTime(unixTime, 'YYYY-MM-DD[T]HH:mm:ss[Z]'); +} + +function getTestTitleFormatDate(testFiscalYearStartId: string, testCaseDateString: string): string { + return `FY_START: ${testFiscalYearStartId.padStart(10, ' ')}; DATE: ${moment(testCaseDateString).format('MMMM D, YYYY')}`; +} + +function getTestTitleFormatString(testFiscalYearStartId: string, testCaseString: string): string { + return `FY_START: ${testFiscalYearStartId.padStart(10, ' ')}; ${testCaseString}`; +} + +// FISCAL YEAR START CONFIGURATION +type FiscalYearStartConfig = { + id: string; + monthDateString: string; + value: number; +}; + +const TEST_FISCAL_YEAR_START_PRESETS: Record = { + 'January 1': { + id: 'January 1', + monthDateString: '01-01', + value: 0x0101, + }, + 'April 1': { + id: 'April 1', + monthDateString: '04-01', + value: 0x0401, + }, + 'October 1': { + id: 'October 1', + monthDateString: '10-01', + value: 0x0A01, + }, +}; + +// VALIDATE FISCAL YEAR START PRESETS +describe('validateFiscalYearStart', () => { + Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => { + test(`should return true if fiscal year start value (uint16) is valid: id: ${testFiscalYearStart.id}; value: 0x${testFiscalYearStart.value.toString(16)}`, () => { + expect(FiscalYearStart.isValidType(testFiscalYearStart.value)).toBe(true); + }); + + test(`returns same month-date string for valid fiscal year start value: id: ${testFiscalYearStart.id}; value: 0x${testFiscalYearStart.value.toString(16)}`, () => { + const fiscalYearStart = FiscalYearStart.strictFromNumber(testFiscalYearStart.value); + expect(fiscalYearStart.toString()).toStrictEqual(testFiscalYearStart.monthDateString); + }); + }); +}); + + +// VALIDATE INVALID FISCAL YEAR START VALUES +const TestCase_invalidFiscalYearValues = [ + 0x0000, // Invalid: L0/0 + 0x0D01, // Invalid: Month 13 + 0x0100, // Invalid: Day 0 + 0x0120, // Invalid: January 32 + 0x021D, // Invalid: February 29 (not permitted) + 0x021E, // Invalid: February 30 + 0x041F, // Invalid: April 31 + 0x061F, // Invalid: June 31 + 0x091F, // Invalid: September 31 + 0x0B20, // Invalid: November 32 + 0xFFFF, // Invalid: Largest uint16 +] + +describe('validateFiscalYearStartInvalidValues', () => { + TestCase_invalidFiscalYearValues.forEach((testCase) => { + test(`should return false if fiscal year start value (uint16) is invalid: value: 0x${testCase.toString(16)}`, () => { + expect(FiscalYearStart.isValidType(testCase)).toBe(false); + }); + }); +}); + +// VALIDATE LEAP DAY FEBRUARY 29 IS NOT VALID +describe('validateFiscalYearStartLeapDay', () => { + test(`should return false if fiscal year start value (uint16) for February 29 is invalid: value: 0x0229}`, () => { + expect(FiscalYearStart.isValidType(0x0229)).toBe(false); + }); + + test(`should return error if fiscal year month-day string "02-29" is used to create fiscal year start object`, () => { + expect(() => FiscalYearStart.strictFromMonthDashDayString('02-29')).toThrow(); + }); + + test(`should return error if integers "02" and "29" are used to create fiscal year start object`, () => { + expect(() => FiscalYearStart.validateMonthDay(2, 29)).toThrow(); + }); +}); + +// FISCAL YEAR FROM UNIX TIME +type TestCase_getFiscalYearFromUnixTime = { + date: string; + unixTime: number; + expected: { + [fiscalYearStartId: string]: number; + }; +}; + +let TEST_CASES_GET_FISCAL_YEAR_FROM_UNIX_TIME: TestCase_getFiscalYearFromUnixTime[]; +TEST_CASES_GET_FISCAL_YEAR_FROM_UNIX_TIME = importTestData('test_cases_getFiscalYearFromUnixTime') as TestCase_getFiscalYearFromUnixTime[]; + +describe('getFiscalYearFromUnixTime', () => { + Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => { + TEST_CASES_GET_FISCAL_YEAR_FROM_UNIX_TIME.forEach((testCase) => { + test(`returns correct fiscal year for ${getTestTitleFormatDate(testFiscalYearStart.id, testCase.date)}`, () => { + const testCaseUnixTime = moment(testCase.date).unix(); + const fiscalYear = getFiscalYearFromUnixTime(testCaseUnixTime, testFiscalYearStart.value); + const expected = testCase.expected[testFiscalYearStart.id]; + expect(fiscalYear).toBe(expected); + }); + }); + }); +}); + + +// FISCAL YEAR START UNIX TIME +type TestCase_getFiscalYearStartUnixTime = { + date: string; + expected: { + [fiscalYearStart: string]: { + unixTime: number; + unixTimeISO: string; + }; + }; +} + +let TEST_CASES_GET_FISCAL_YEAR_START_UNIX_TIME: TestCase_getFiscalYearStartUnixTime[]; +TEST_CASES_GET_FISCAL_YEAR_START_UNIX_TIME = importTestData('test_cases_getFiscalYearStartUnixTime') as TestCase_getFiscalYearStartUnixTime[]; + +describe('getFiscalYearStartUnixTime', () => { + Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => { + TEST_CASES_GET_FISCAL_YEAR_START_UNIX_TIME.forEach((testCase) => { + test(`returns correct start unix time for ${getTestTitleFormatDate(testFiscalYearStart.id, testCase.date)}`, () => { + const testCaseUnixTime = moment(testCase.date).unix(); + const startUnixTime = getFiscalYearStartUnixTime(testCaseUnixTime, testFiscalYearStart.value); + const expected = testCase.expected[testFiscalYearStart.id]; + const unixTimeISO = formatUnixTimeISO(startUnixTime); + + expect({ unixTime: startUnixTime, ISO: unixTimeISO }).toStrictEqual({ unixTime: expected.unixTime, ISO: expected.unixTimeISO }); + }); + }); + }); +}); + + +// FISCAL YEAR END UNIX TIME +type TestCase_getFiscalYearEndUnixTime = { + date: string; + expected: { + [fiscalYearStart: string]: { + unixTime: number; + unixTimeISO: string; + }; + }; +} + +let TEST_CASES_GET_FISCAL_YEAR_END_UNIX_TIME: TestCase_getFiscalYearEndUnixTime[]; +TEST_CASES_GET_FISCAL_YEAR_END_UNIX_TIME = importTestData('test_cases_getFiscalYearEndUnixTime') as TestCase_getFiscalYearEndUnixTime[]; + +describe('getFiscalYearEndUnixTime', () => { + Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => { + TEST_CASES_GET_FISCAL_YEAR_END_UNIX_TIME.forEach((testCase) => { + test(`returns correct end unix time for ${getTestTitleFormatDate(testFiscalYearStart.id, testCase.date)}`, () => { + const testCaseUnixTime = moment(testCase.date).unix(); + const endUnixTime = getFiscalYearEndUnixTime(testCaseUnixTime, testFiscalYearStart.value); + const expected = testCase.expected[testFiscalYearStart.id]; + const unixTimeISO = formatUnixTimeISO(endUnixTime); + + expect({ unixTime: endUnixTime, ISO: unixTimeISO }).toStrictEqual({ unixTime: expected.unixTime, ISO: expected.unixTimeISO }); + + }); + }); + }); +}); + +// GET FISCAL YEAR UNIX TIME RANGE +type TestCase_getFiscalYearTimeRangeFromUnixTime = { + date: string; + expected: { + [fiscalYearStart: string]: FiscalYearUnixTime[] + } +} + +let TEST_CASES_GET_FISCAL_YEAR_UNIX_TIME_RANGE: TestCase_getFiscalYearTimeRangeFromUnixTime[]; +TEST_CASES_GET_FISCAL_YEAR_UNIX_TIME_RANGE = importTestData('test_cases_getFiscalYearTimeRangeFromUnixTime') as TestCase_getFiscalYearTimeRangeFromUnixTime[]; + +describe('getFiscalYearTimeRangeFromUnixTime', () => { + Object.values(TEST_FISCAL_YEAR_START_PRESETS).forEach((testFiscalYearStart) => { + TEST_CASES_GET_FISCAL_YEAR_UNIX_TIME_RANGE.forEach((testCase) => { + test(`returns correct fiscal year unix time range for ${getTestTitleFormatDate(testFiscalYearStart.id, testCase.date)}`, () => { + const testCaseUnixTime = moment(testCase.date).unix(); + const fiscalYearUnixTimeRange = getFiscalYearTimeRangeFromUnixTime(testCaseUnixTime, testFiscalYearStart.value); + expect(fiscalYearUnixTimeRange).toStrictEqual(testCase.expected[testFiscalYearStart.id]); + }); + }); + }); +}); + +// GET ALL FISCAL YEAR START AND END UNIX TIMES +type TestCase_getAllFiscalYearsStartAndEndUnixTimes = { + startYearMonth: string; + endYearMonth: string; + fiscalYearStart: string; + fiscalYearStartId: string; + expected: FiscalYearUnixTime[] +} + +let TEST_CASES_GET_ALL_FISCAL_YEARS_START_AND_END_UNIX_TIMES: TestCase_getAllFiscalYearsStartAndEndUnixTimes[]; +TEST_CASES_GET_ALL_FISCAL_YEARS_START_AND_END_UNIX_TIMES = importTestData('test_cases_getAllFiscalYearsStartAndEndUnixTimes') as TestCase_getAllFiscalYearsStartAndEndUnixTimes[]; + +describe('getAllFiscalYearsStartAndEndUnixTimes', () => { + TEST_CASES_GET_ALL_FISCAL_YEARS_START_AND_END_UNIX_TIMES.forEach((testCase) => { + const fiscalYearStart = FiscalYearStart.strictFromMonthDashDayString(testCase.fiscalYearStart); + test(`returns correct fiscal year start and end unix times for ${getTestTitleFormatString(testCase.fiscalYearStartId, `${testCase.startYearMonth} to ${testCase.endYearMonth}`)}`, () => { + const fiscalYearStartAndEndUnixTimes = getAllFiscalYearsStartAndEndUnixTimes(testCase.startYearMonth, testCase.endYearMonth, fiscalYearStart.value); + + // Convert results to include ISO strings for better test output + const resultWithISO = fiscalYearStartAndEndUnixTimes.map(data => ({ + ...data, + minUnixTimeISO: formatUnixTimeISO(data.minUnixTime), + maxUnixTimeISO: formatUnixTimeISO(data.maxUnixTime) + })); + + // Convert expected to include ISO strings + const expectedWithISO = testCase.expected.map(data => ({ + ...data, + minUnixTimeISO: formatUnixTimeISO(data.minUnixTime), + maxUnixTimeISO: formatUnixTimeISO(data.maxUnixTime) + })); + + expect(resultWithISO).toStrictEqual(expectedWithISO); + }); + }); +}); + +// GET FISCAL YEAR RANGE FROM YEAR +type TestCase_getFiscalYearTimeRangeFromYear = { + year: number; + fiscalYearStart: string; + expected: FiscalYearUnixTime; +} + +let TEST_CASES_GET_FISCAL_YEAR_RANGE_FROM_YEAR: TestCase_getFiscalYearTimeRangeFromYear[]; +TEST_CASES_GET_FISCAL_YEAR_RANGE_FROM_YEAR = importTestData('test_cases_getFiscalYearTimeRangeFromYear') as TestCase_getFiscalYearTimeRangeFromYear[]; + +describe('getFiscalYearTimeRangeFromYear', () => { + TEST_CASES_GET_FISCAL_YEAR_RANGE_FROM_YEAR.forEach((testCase) => { + const fiscalYearStart = FiscalYearStart.strictFromMonthDashDayString(testCase.fiscalYearStart); + test(`returns correct fiscal year unix time range for input year integer ${testCase.year} and FY_START: ${testCase.fiscalYearStart}`, () => { + const fiscalYearRange = getFiscalYearTimeRangeFromYear(testCase.year, fiscalYearStart.value); + expect(fiscalYearRange).toStrictEqual(testCase.expected); + }); + }); +}); diff --git a/src/lib/datetime.ts b/src/lib/datetime.ts index d6ac6fe9..85c00e5a 100644 --- a/src/lib/datetime.ts +++ b/src/lib/datetime.ts @@ -22,6 +22,10 @@ import { DateRange, LANGUAGE_DEFAULT_DATE_TIME_FORMAT_VALUE } from '@/core/datetime.ts'; +import { + type FiscalYearUnixTime, + FiscalYearStart +} from '@/core/fiscalyear.ts'; import { isObject, isString, @@ -207,6 +211,10 @@ export function formatDate(date: string, format: string): string { return moment(date, 'YYYY-MM-DD').format(format); } +export function formatMonthDay(monthDay: string, format: string): string { + return moment(monthDay, 'MM-DD').format(format); +} + export function getUnixTime(date: SupportedDate): number { return moment(date).unix(); } @@ -434,6 +442,64 @@ export function getAllYearsStartAndEndUnixTimes(startYearMonth: YearMonth | stri return allYearTimes; } +export function getAllFiscalYearsStartAndEndUnixTimes(startYearMonth: YearMonth | string, endYearMonth: YearMonth | string, fiscalYearStart: number): FiscalYearUnixTime[] { + // user selects date range: start=2024-01 and end=2026-12 + // result should be 4x FiscalYearUnixTime made up of: + // - 2024-01->2024-06 (FY 24) - input start year-month->end of fiscal year in which the input start year-month falls + // - 2024-07->2025-06 (FY 25) - complete fiscal year + // - 2025-07->2026-06 (FY 26) - complete fiscal year + // - 2026-07->2026-12 (FY 27) - start of fiscal year->end of fiscal year in which the input end year-month falls + + const allFiscalYearTimes: FiscalYearUnixTime[] = []; + const range = getStartEndYearMonthRange(startYearMonth, endYearMonth); + + if (!range) { + return allFiscalYearTimes; + } + + const inputStartUnixTime = getYearMonthFirstUnixTime(range.startYearMonth); + const inputEndUnixTime = getYearMonthLastUnixTime(range.endYearMonth); + const fiscalYearStartMonth = FiscalYearStart.strictFromNumber(fiscalYearStart).month; + + // Loop over 1 year before and 1 year after the input date range + // to include fiscal years that start in the previous calendar year. + for (let year = range.startYearMonth.year - 1; year <= range.endYearMonth.year + 1; year++) { + const thisYearMonthUnixTime = getYearMonthFirstUnixTime({ year: year, month: fiscalYearStartMonth }); + const fiscalStartTime = getFiscalYearStartUnixTime(thisYearMonthUnixTime, fiscalYearStart); + const fiscalEndTime = getFiscalYearEndUnixTime(thisYearMonthUnixTime, fiscalYearStart); + + const fiscalYear = getFiscalYearFromUnixTime(fiscalStartTime, fiscalYearStart); + + if (fiscalStartTime <= inputEndUnixTime && fiscalEndTime >= inputStartUnixTime) { + let minUnixTime = fiscalStartTime; + let maxUnixTime = fiscalEndTime; + + // Cap the min and max unix times to the input date range + if (minUnixTime < inputStartUnixTime) { + minUnixTime = inputStartUnixTime; + } + + if (maxUnixTime > inputEndUnixTime) { + maxUnixTime = inputEndUnixTime; + } + + const fiscalYearTime: FiscalYearUnixTime = { + year: fiscalYear, + minUnixTime: minUnixTime, + maxUnixTime: maxUnixTime, + }; + + allFiscalYearTimes.push(fiscalYearTime); + } + + if (fiscalStartTime > inputEndUnixTime) { + break; + } + } + + return allFiscalYearTimes; +} + export function getAllQuartersStartAndEndUnixTimes(startYearMonth: YearMonth | string, endYearMonth: YearMonth | string): YearQuarterUnixTime[] { const allYearQuarterTimes: YearQuarterUnixTime[] = []; const range = getStartEndYearMonthRange(startYearMonth, endYearMonth); @@ -551,9 +617,9 @@ export function getShiftedDateRange(minTime: number, maxTime: number, scale: num }; } -export function getShiftedDateRangeAndDateType(minTime: number, maxTime: number, scale: number, firstDayOfWeek: number, scene: DateRangeScene): TimeRangeAndDateType { +export function getShiftedDateRangeAndDateType(minTime: number, maxTime: number, scale: number, firstDayOfWeek: number, fiscalYearStart: number, scene: DateRangeScene): TimeRangeAndDateType { const newDateRange = getShiftedDateRange(minTime, maxTime, scale); - const newDateType = getDateTypeByDateRange(newDateRange.minTime, newDateRange.maxTime, firstDayOfWeek, scene); + const newDateType = getDateTypeByDateRange(newDateRange.minTime, newDateRange.maxTime, firstDayOfWeek, fiscalYearStart, scene); return { dateType: newDateType, @@ -562,13 +628,13 @@ export function getShiftedDateRangeAndDateType(minTime: number, maxTime: number, }; } -export function getShiftedDateRangeAndDateTypeForBillingCycle(minTime: number, maxTime: number, scale: number, firstDayOfWeek: number, scene: number, statementDate: number | undefined | null): TimeRangeAndDateType | null { +export function getShiftedDateRangeAndDateTypeForBillingCycle(minTime: number, maxTime: number, scale: number, firstDayOfWeek: number, fiscalYearStart: number, scene: number, statementDate: number | undefined | null): TimeRangeAndDateType | null { if (!statementDate || !DateRange.PreviousBillingCycle.isAvailableForScene(scene) || !DateRange.CurrentBillingCycle.isAvailableForScene(scene)) { return null; } - const previousBillingCycleRange = getDateRangeByBillingCycleDateType(DateRange.PreviousBillingCycle.type, firstDayOfWeek, statementDate); - const currentBillingCycleRange = getDateRangeByBillingCycleDateType(DateRange.CurrentBillingCycle.type, firstDayOfWeek, statementDate); + const previousBillingCycleRange = getDateRangeByBillingCycleDateType(DateRange.PreviousBillingCycle.type, firstDayOfWeek, fiscalYearStart, statementDate); + const currentBillingCycleRange = getDateRangeByBillingCycleDateType(DateRange.CurrentBillingCycle.type, firstDayOfWeek, fiscalYearStart, statementDate); if (previousBillingCycleRange && getUnixTimeBeforeUnixTime(previousBillingCycleRange.maxTime, 1, 'months') === maxTime && getUnixTimeBeforeUnixTime(previousBillingCycleRange.minTime, 1, 'months') === minTime && scale === 1) { return previousBillingCycleRange; @@ -583,7 +649,7 @@ export function getShiftedDateRangeAndDateTypeForBillingCycle(minTime: number, m return null; } -export function getDateTypeByDateRange(minTime: number, maxTime: number, firstDayOfWeek: number, scene: DateRangeScene): number { +export function getDateTypeByDateRange(minTime: number, maxTime: number, firstDayOfWeek: number, fiscalYearStart: number, scene: DateRangeScene): number { const allDateRanges = DateRange.values(); let newDateType = DateRange.Custom.type; @@ -594,7 +660,7 @@ export function getDateTypeByDateRange(minTime: number, maxTime: number, firstDa continue; } - const range = getDateRangeByDateType(dateRange.type, firstDayOfWeek); + const range = getDateRangeByDateType(dateRange.type, firstDayOfWeek, fiscalYearStart); if (range && range.minTime === minTime && range.maxTime === maxTime) { newDateType = dateRange.type; @@ -605,13 +671,13 @@ export function getDateTypeByDateRange(minTime: number, maxTime: number, firstDa return newDateType; } -export function getDateTypeByBillingCycleDateRange(minTime: number, maxTime: number, firstDayOfWeek: number, scene: DateRangeScene, statementDate: number | undefined | null): number | null { +export function getDateTypeByBillingCycleDateRange(minTime: number, maxTime: number, firstDayOfWeek: number, fiscalYearStart: number, scene: DateRangeScene, statementDate: number | undefined | null): number | null { if (!statementDate || !DateRange.PreviousBillingCycle.isAvailableForScene(scene) || !DateRange.CurrentBillingCycle.isAvailableForScene(scene)) { return null; } - const previousBillingCycleRange = getDateRangeByBillingCycleDateType(DateRange.PreviousBillingCycle.type, firstDayOfWeek, statementDate); - const currentBillingCycleRange = getDateRangeByBillingCycleDateType(DateRange.CurrentBillingCycle.type, firstDayOfWeek, statementDate); + const previousBillingCycleRange = getDateRangeByBillingCycleDateType(DateRange.PreviousBillingCycle.type, firstDayOfWeek, fiscalYearStart, statementDate); + const currentBillingCycleRange = getDateRangeByBillingCycleDateType(DateRange.CurrentBillingCycle.type, firstDayOfWeek, fiscalYearStart, statementDate); if (previousBillingCycleRange && previousBillingCycleRange.maxTime === maxTime && previousBillingCycleRange.minTime === minTime) { return previousBillingCycleRange.dateType; @@ -622,7 +688,7 @@ export function getDateTypeByBillingCycleDateRange(minTime: number, maxTime: num return null; } -export function getDateRangeByDateType(dateType: number | undefined, firstDayOfWeek: number): TimeRangeAndDateType | null { +export function getDateRangeByDateType(dateType: number | undefined, firstDayOfWeek: number, fiscalYearStart: number): TimeRangeAndDateType | null { let maxTime = 0; let minTime = 0; @@ -659,6 +725,12 @@ export function getDateRangeByDateType(dateType: number | undefined, firstDayOfW } else if (dateType === DateRange.LastYear.type) { // Last year maxTime = getUnixTimeBeforeUnixTime(getThisYearLastUnixTime(), 1, 'years'); minTime = getUnixTimeBeforeUnixTime(getThisYearFirstUnixTime(), 1, 'years'); + } else if (dateType === DateRange.ThisFiscalYear.type) { // This fiscal year + maxTime = getFiscalYearEndUnixTime(getTodayFirstUnixTime(), fiscalYearStart); + minTime = getFiscalYearStartUnixTime(getTodayFirstUnixTime(), fiscalYearStart); + } else if (dateType === DateRange.LastFiscalYear.type) { // Last fiscal year + maxTime = getUnixTimeBeforeUnixTime(getFiscalYearEndUnixTime(getTodayFirstUnixTime(), fiscalYearStart), 1, 'years'); + minTime = getUnixTimeBeforeUnixTime(getFiscalYearStartUnixTime(getTodayFirstUnixTime(), fiscalYearStart), 1, 'years'); } else if (dateType === DateRange.RecentTwelveMonths.type) { // Recent 12 months maxTime = getThisMonthLastUnixTime(); minTime = getUnixTimeBeforeUnixTime(getThisMonthFirstUnixTime(), 11, 'months'); @@ -688,7 +760,7 @@ export function getDateRangeByDateType(dateType: number | undefined, firstDayOfW }; } -export function getDateRangeByBillingCycleDateType(dateType: number, firstDayOfWeek: number, statementDate: number | undefined | null): TimeRangeAndDateType | null { +export function getDateRangeByBillingCycleDateType(dateType: number, firstDayOfWeek: number, fiscalYearStart: number, statementDate: number | undefined | null): TimeRangeAndDateType | null { let maxTime = 0; let minTime = 0; @@ -710,9 +782,9 @@ export function getDateRangeByBillingCycleDateType(dateType: number, firstDayOfW let fallbackDateRange = null; if (dateType === DateRange.CurrentBillingCycle.type) { // same as This Month - fallbackDateRange = getDateRangeByDateType(DateRange.ThisMonth.type, firstDayOfWeek); + fallbackDateRange = getDateRangeByDateType(DateRange.ThisMonth.type, firstDayOfWeek, fiscalYearStart); } else if (dateType === DateRange.PreviousBillingCycle.type) { // same as Last Month - fallbackDateRange = getDateRangeByDateType(DateRange.LastMonth.type, firstDayOfWeek); + fallbackDateRange = getDateRangeByDateType(DateRange.LastMonth.type, firstDayOfWeek, fiscalYearStart); } if (fallbackDateRange) { @@ -775,8 +847,8 @@ export function getRecentDateRangeIndexByDateType(allRecentMonthDateRanges: Loca return -1; } -export function getRecentDateRangeIndex(allRecentMonthDateRanges: LocalizedRecentMonthDateRange[], dateType: number, minTime: number, maxTime: number, firstDayOfWeek: number): number { - let dateRange = getDateRangeByDateType(dateType, firstDayOfWeek); +export function getRecentDateRangeIndex(allRecentMonthDateRanges: LocalizedRecentMonthDateRange[], dateType: number, minTime: number, maxTime: number, firstDayOfWeek: number, fiscalYearStart: number): number { + let dateRange = getDateRangeByDateType(dateType, firstDayOfWeek, fiscalYearStart); if (dateRange && dateRange.dateType === DateRange.All.type) { return getRecentDateRangeIndexByDateType(allRecentMonthDateRanges, DateRange.All.type); @@ -805,18 +877,18 @@ export function getRecentDateRangeIndex(allRecentMonthDateRanges: LocalizedRecen return getRecentDateRangeIndexByDateType(allRecentMonthDateRanges, DateRange.Custom.type); } -export function getFullMonthDateRange(minTime: number, maxTime: number, firstDayOfWeek: number): TimeRangeAndDateType | null { +export function getFullMonthDateRange(minTime: number, maxTime: number, firstDayOfWeek: number, fiscalYearStart: number): TimeRangeAndDateType | null { if (isDateRangeMatchOneMonth(minTime, maxTime)) { return null; } if (!minTime) { - return getDateRangeByDateType(DateRange.ThisMonth.type, firstDayOfWeek); + return getDateRangeByDateType(DateRange.ThisMonth.type, firstDayOfWeek, fiscalYearStart); } const monthFirstUnixTime = getMonthFirstUnixTimeBySpecifiedUnixTime(minTime); const monthLastUnixTime = getMonthLastUnixTimeBySpecifiedUnixTime(minTime); - const dateType = getDateTypeByDateRange(monthFirstUnixTime, monthLastUnixTime, firstDayOfWeek, DateRangeScene.Normal); + const dateType = getDateTypeByDateRange(monthFirstUnixTime, monthLastUnixTime, firstDayOfWeek, fiscalYearStart, DateRangeScene.Normal); const dateRange: TimeRangeAndDateType = { dateType: dateType, @@ -937,3 +1009,115 @@ export function isDateRangeMatchOneMonth(minTime: number, maxTime: number): bool return isDateRangeMatchFullMonths(minTime, maxTime); } + +export function getFiscalYearFromUnixTime(unixTime: number, fiscalYearStart: number): number { + const date = moment.unix(unixTime); + + // For January 1 fiscal year start, fiscal year matches calendar year + if (fiscalYearStart === 0x0101) { + return date.year(); + } + + // Get date components + const month = date.month() + 1; // 1-index + const day = date.date(); + const year = date.year(); + + const [fiscalYearStartMonth, fiscalYearStartDay] = FiscalYearStart.strictFromNumber(fiscalYearStart).values(); + + // For other fiscal year starts: + // If input time comes before the fiscal year start day in the calendar year, + // it belongs to the fiscal year that ends in the current calendar year + if (month < fiscalYearStartMonth || (month === fiscalYearStartMonth && day < fiscalYearStartDay)) { + return year; + } + + // If input time is on or after the fiscal year start day in the calendar year, + // it belongs to the fiscal year that ends in the next calendar year + return year + 1; +} + +export function getFiscalYearStartUnixTime(unixTime: number, fiscalYearStart: number): number { + const date = moment.unix(unixTime); + + // For January 1 fiscal year start, fiscal year start time is always January 1 in the input calendar year + if (fiscalYearStart === 0x0101) { + return moment().year(date.year()).month(0).date(1).hour(0).minute(0).second(0).millisecond(0).unix(); + } + + const [fiscalYearStartMonth, fiscalYearStartDay] = FiscalYearStart.strictFromNumber(fiscalYearStart).values(); + const month = date.month() + 1; // 1-index + const day = date.date(); + const year = date.year(); + + // For other fiscal year starts: + // If input time comes before the fiscal year start day in the calendar year, + // the relevant fiscal year has a start date in Calendar Year = Input Year, and end date in Calendar Year = Input Year + 1. + // If input time comes on or after the fiscal year start day in the calendar year, + // the relevant fiscal year has a start date in Calendar Year = Input Year - 1, and end date in Calendar Year = Input Year. + let startYear = year - 1; + if (month > fiscalYearStartMonth || (month === fiscalYearStartMonth && day >= fiscalYearStartDay)) { + startYear = year; + } + + return moment().set({ + year: startYear, + month: fiscalYearStartMonth - 1, // 0-index + date: fiscalYearStartDay, + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + }).unix(); +} + +export function getFiscalYearEndUnixTime(unixTime: number, fiscalYearStart: number): number { + const fiscalYearStartTime = moment.unix(getFiscalYearStartUnixTime(unixTime, fiscalYearStart)); + return fiscalYearStartTime.add(1, 'year').subtract(1, 'second').unix(); +} + +export function getCurrentFiscalYear(fiscalYearStart: number): number { + const date = moment(); + return getFiscalYearFromUnixTime(date.unix(), fiscalYearStart); +} + +export function getFiscalYearTimeRangeFromUnixTime(unixTime: number, fiscalYearStart: number): FiscalYearUnixTime { + const start = getFiscalYearStartUnixTime(unixTime, fiscalYearStart); + const end = getFiscalYearEndUnixTime(unixTime, fiscalYearStart); + return { + year: getFiscalYearFromUnixTime(unixTime, fiscalYearStart), + minUnixTime: start, + maxUnixTime: end, + }; +} + +export function getFiscalYearTimeRangeFromYear(year: number, fiscalYearStart: number): FiscalYearUnixTime { + const fiscalYear = year; + const fiscalYearStartObj = FiscalYearStart.strictFromNumber(fiscalYearStart); + + // For a specified fiscal year (e.g., 2023), the start date is in the previous calendar year + // unless fiscal year starts on January 1 + const calendarStartYear = fiscalYearStart === 0x0101 ? fiscalYear : fiscalYear - 1; + + // Create the timestamp for the start of the fiscal year + const fiscalYearStartUnixTime = moment().set({ + year: calendarStartYear, + month: fiscalYearStartObj.month - 1, // 0-index + date: fiscalYearStartObj.day, + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + }).unix(); + + // Fiscal year end is one year after start minus 1 second + const fiscalYearEndUnixTime = moment.unix(fiscalYearStartUnixTime).add(1, 'year').subtract(1, 'second').unix(); + + return { + year: fiscalYear, + minUnixTime: fiscalYearStartUnixTime, + maxUnixTime: fiscalYearEndUnixTime, + }; +} + + diff --git a/src/lib/statistics.ts b/src/lib/statistics.ts index 09b0a18b..9eb17428 100644 --- a/src/lib/statistics.ts +++ b/src/lib/statistics.ts @@ -1,4 +1,5 @@ import type { YearMonth, YearUnixTime, YearQuarterUnixTime, YearMonthUnixTime } from '@/core/datetime.ts'; +import type { FiscalYearUnixTime } from '@/core/fiscalyear.ts'; import { ChartSortingType, ChartDateAggregationType } from '@/core/statistics.ts'; import type { YearMonthItems, @@ -8,7 +9,8 @@ import type { import { getAllMonthsStartAndEndUnixTimes, getAllQuartersStartAndEndUnixTimes, - getAllYearsStartAndEndUnixTimes + getAllYearsStartAndEndUnixTimes, + getAllFiscalYearsStartAndEndUnixTimes } from '@/lib/datetime.ts'; export function sortStatisticsItems(items: T[], sortingType: number): void { @@ -46,7 +48,7 @@ export function sortStatisticsItems(items: YearMonthItems[], startYearMonth: YearMonth | string, endYearMonth: YearMonth | string, dateAggregationType: number): YearUnixTime[] | YearQuarterUnixTime[] | YearMonthUnixTime[] { +export function getAllDateRanges(items: YearMonthItems[], startYearMonth: YearMonth | string, endYearMonth: YearMonth | string, fiscalYearStart: number, dateAggregationType: number): YearUnixTime[] | YearQuarterUnixTime[] | YearMonthUnixTime[] | FiscalYearUnixTime[] { if ((!startYearMonth || !endYearMonth) && items && items.length) { let minYear = Number.MAX_SAFE_INTEGER, minMonth = Number.MAX_SAFE_INTEGER, maxYear = 0, maxMonth = 0; @@ -78,6 +80,8 @@ export function getAllDateRanges(items: YearMonthItems[] if (dateAggregationType === ChartDateAggregationType.Year.type) { return getAllYearsStartAndEndUnixTimes(startYearMonth, endYearMonth); + } else if (dateAggregationType === ChartDateAggregationType.FiscalYear.type) { + return getAllFiscalYearsStartAndEndUnixTimes(startYearMonth, endYearMonth, fiscalYearStart); } else if (dateAggregationType === ChartDateAggregationType.Quarter.type) { return getAllQuartersStartAndEndUnixTimes(startYearMonth, endYearMonth); } else { // if (dateAggregationType === ChartDateAggregationType.Month.type) { diff --git a/src/locales/en.json b/src/locales/en.json index d7302734..be96c7d3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -19,6 +19,7 @@ "default": { "currency": "USD", "firstDayOfWeek": "Sunday", + "fiscalYearFormat": "EndYYYY", "longDateFormat": "MMDDYYYY", "shortDateFormat": "MMDDYYYY", "longTimeFormat": "HHMMSSA", @@ -85,6 +86,13 @@ "q3": "{year}Q3", "q4": "{year}Q4" }, + "fiscalYear": { + "StartYYYY_EndYYYY": "FY {StartYYYY}-{EndYYYY}", + "StartYYYY_EndYY": "FY {StartYYYY}-{EndYY}", + "StartYY_EndYY": "FY {StartYY}-{EndYY}", + "EndYYYY": "FY {EndYYYY}", + "EndYY": "FY {EndYY}" + }, "misc": { "multiTextJoinSeparator": ", ", "hoursBehindDefaultTimezone": "{hours} hour(s) behind default timezone", @@ -1196,6 +1204,7 @@ "oldPassword": "Current Password", "defaultCurrency": "Default Currency", "firstDayOfWeek": "First Day of Week", + "fiscalYearStart": "Fiscal Year Start Date", "transactionEditScope": "Editable Transaction Range", "name": "Name", "category": "Category", @@ -1366,6 +1375,8 @@ "Last month": "Last month", "This year": "This year", "Last year": "Last year", + "This fiscal year": "This fiscal year", + "Last fiscal year": "Last fiscal year", "Recent 12 months": "Recent 12 months", "Recent 24 months": "Recent 24 months", "Recent 36 months": "Recent 36 months", @@ -1447,10 +1458,12 @@ "Default Currency": "Default Currency", "Default Account": "Default Account", "First Day of Week": "First Day of Week", + "Fiscal Year Start Date": "Fiscal Year Start Date", "Long Date Format": "Long Date Format", "Short Date Format": "Short Date Format", "Long Time Format": "Long Time Format", "Short Time Format": "Short Time Format", + "Fiscal Year Format": "Fiscal Year Format", "Decimal Separator": "Decimal Separator", "Digit Grouping Symbol": "Digit Grouping Symbol", "Digit Grouping": "Digit Grouping", @@ -1807,6 +1820,7 @@ "Aggregate by Month": "Aggregate by Month", "Aggregate by Quarter": "Aggregate by Quarter", "Aggregate by Year": "Aggregate by Year", + "Aggregate by Fiscal Year": "Aggregate by Fiscal Year", "Filter Accounts": "Filter Accounts", "Filter Transaction Categories": "Filter Transaction Categories", "Filter Transaction Tags": "Filter Transaction Tags", diff --git a/src/locales/helpers.ts b/src/locales/helpers.ts index c503f86d..966cb2cb 100644 --- a/src/locales/helpers.ts +++ b/src/locales/helpers.ts @@ -12,6 +12,7 @@ import { type LocalizedDateTimeFormat, type LocalizedDateRange, type LocalizedRecentMonthDateRange, + type UnixTimeRange, Month, WeekDay, MeridiemIndicator, @@ -46,6 +47,13 @@ import { CurrencySortingType } from '@/core/currency.ts'; +import { + FiscalYearStart, + FiscalYearFormat, + FiscalYearUnixTime, + LANGUAGE_DEFAULT_FISCAL_YEAR_FORMAT_VALUE, +} from '@/core/fiscalyear.ts'; + import { CoordinateDisplayType } from '@/core/coordinate.ts'; @@ -120,21 +128,25 @@ import { } from '@/lib/common.ts'; import { - isPM, - formatUnixTime, formatCurrentTime, formatDate, - parseDateFromUnixTime, - getYear, - getTimezoneOffset, - getTimezoneOffsetMinutes, + formatMonthDay, + formatUnixTime, getBrowserTimezoneOffset, getBrowserTimezoneOffsetMinutes, - getTimeDifferenceHoursAndMinutes, + getCurrentUnixTime, getDateTimeFormatType, + getFiscalYearTimeRangeFromUnixTime, + getFiscalYearTimeRangeFromYear, getRecentMonthDateRanges, + getTimeDifferenceHoursAndMinutes, + getTimezoneOffset, + getTimezoneOffsetMinutes, + getYear, + isDateRangeMatchFullMonths, isDateRangeMatchFullYears, - isDateRangeMatchFullMonths + isPM, + parseDateFromUnixTime, } from '@/lib/datetime.ts'; import { @@ -629,6 +641,14 @@ export function useI18n() { return t('default.firstDayOfWeek'); } + function getDefaultFiscalYearStart(): string { + return t('default.fiscalYearStart'); + } + + function getDefaultFiscalYearFormat(): string { + return t('default.fiscalYearFormat'); + } + function getAllLanguageOptions(includeSystemDefault: boolean): LanguageOption[] { const ret: LanguageOption[] = []; @@ -766,7 +786,7 @@ export function useI18n() { function getLocalizedDateTimeFormats(type: string, allFormatMap: Record, allFormatArray: T[], languageDefaultTypeNameKey: string, systemDefaultFormatType: T): LocalizedDateTimeFormat[] { const defaultFormat = getLocalizedDateTimeFormat(type, allFormatMap, allFormatArray, LANGUAGE_DEFAULT_DATE_TIME_FORMAT_VALUE, languageDefaultTypeNameKey, systemDefaultFormatType); const ret: LocalizedDateTimeFormat[] = []; - + ret.push({ type: LANGUAGE_DEFAULT_DATE_TIME_FORMAT_VALUE, format: defaultFormat, @@ -786,7 +806,7 @@ export function useI18n() { return ret; } - + function getAllDateRanges(scene: DateRangeScene, includeCustom?: boolean, includeBillingCycle?: boolean): LocalizedDateRange[] { const ret: LocalizedDateRange[] = []; const allDateRanges = DateRange.values(); @@ -919,6 +939,37 @@ export function useI18n() { ]; } + function getAllFiscalYearFormats(): FiscalYearFormat[] { + const now = getCurrentUnixTime(); + let fiscalYearStart = userStore.currentUserFiscalYearStart; + if (!fiscalYearStart) { + fiscalYearStart = FiscalYearStart.Default.value; + } + let nowFiscalYearRange = getFiscalYearTimeRangeFromUnixTime(now, userStore.currentUserFiscalYearStart); + + const ret: FiscalYearFormat[] = []; + + let defaultFiscalYearFormatType = FiscalYearFormat.parse(t('default.fiscalYearFormat')); + if (!defaultFiscalYearFormatType) { + defaultFiscalYearFormatType = FiscalYearFormat.Default; + } + ret.push({ + type: LANGUAGE_DEFAULT_FISCAL_YEAR_FORMAT_VALUE, + displayName: `${t('Language Default')} (${formatTimeRangeToFiscalYearFormat(defaultFiscalYearFormatType, nowFiscalYearRange)})` + }); + + const allFiscalYearFormats = FiscalYearFormat.values(); + for (let i = 0; i < allFiscalYearFormats.length; i++) { + const type = allFiscalYearFormats[i]; + ret.push({ + type: type.type, + displayName: formatTimeRangeToFiscalYearFormat(type, nowFiscalYearRange), + }); + } + + return ret; + } + function getAllDigitGroupingTypes(): LocalizedDigitGroupingType[] { const defaultDigitGroupingTypeName = t('default.digitGrouping'); let defaultDigitGroupingType = DigitGroupingType.parse(defaultDigitGroupingTypeName); @@ -1315,6 +1366,21 @@ export function useI18n() { return digitGroupingType.type; } + function getCurrentFiscalYearFormatType(): number { + let fiscalYearFormat = FiscalYearFormat.valueOf(userStore.currentUserFiscalYearFormat); + + if (!fiscalYearFormat) { + const defaultFiscalYearFormatTypeName = t('default.fiscalYearFormat'); + fiscalYearFormat = FiscalYearFormat.parse(defaultFiscalYearFormatTypeName); + + if (!fiscalYearFormat) { + fiscalYearFormat = FiscalYearFormat.Default; + } + } + + return fiscalYearFormat.type; + } + function getCurrencyName(currencyCode: string): string { return t(`currency.name.${currencyCode}`); } @@ -1347,6 +1413,10 @@ export function useI18n() { return formatDate(date, getLocalizedLongDateFormat()); } + function formatMonthDayToLongDay(monthDay: string): string { + return formatMonthDay(monthDay, getLocalizedLongMonthDayFormat()); + } + function formatYearQuarter(year: number, quarter: number): string { if (1 <= quarter && quarter <= 4) { return t('format.yearQuarter.q' + quarter, { @@ -1357,7 +1427,7 @@ export function useI18n() { return ''; } } - + function formatDateRange(dateType: number, startTime: number, endTime: number): string { if (dateType === DateRange.All.type) { return t(DateRange.All.name); @@ -1406,6 +1476,53 @@ export function useI18n() { return `${displayStartTime} ~ ${displayEndTime}`; } + function formatTimeRangeToFiscalYearFormat(format: FiscalYearFormat, timeRange: FiscalYearUnixTime | UnixTimeRange): string { + if (!format) { + format = FiscalYearFormat.Default; + } + + return t('format.fiscalYear.' + format.displayName, { + StartYYYY: formatUnixTime(timeRange.minUnixTime, 'YYYY'), + StartYY: formatUnixTime(timeRange.minUnixTime, 'YY'), + EndYYYY: formatUnixTime(timeRange.maxUnixTime, 'YYYY'), + EndYY: formatUnixTime(timeRange.maxUnixTime, 'YY'), + }); + } + + function formatUnixTimeToFiscalYear(unixTime: number): string { + let fiscalYearFormat = FiscalYearFormat.valueOf(getCurrentFiscalYearFormatType()); + + if (!fiscalYearFormat) { + fiscalYearFormat = FiscalYearFormat.Default; + } + + let timeRange = getFiscalYearTimeRangeFromUnixTime(unixTime, userStore.currentUserFiscalYearStart); + + return formatTimeRangeToFiscalYearFormat(fiscalYearFormat, timeRange); + } + + function formatYearToFiscalYear(year: number) { + let fiscalYearFormat = FiscalYearFormat.valueOf(getCurrentFiscalYearFormatType()); + + if (!fiscalYearFormat) { + fiscalYearFormat = FiscalYearFormat.Default; + } + + let timeRange = getFiscalYearTimeRangeFromYear(year, userStore.currentUserFiscalYearStart); + + return formatTimeRangeToFiscalYearFormat(fiscalYearFormat, timeRange); + } + + function formatFiscalYearStart(fiscalYearStart: number) { + let fy = FiscalYearStart.fromNumber(fiscalYearStart); + + if ( !fy ) { + fy = FiscalYearStart.Default; + } + + return formatMonthDayToLongDay(fy.toMonthDashDayString()); + } + function getTimezoneDifferenceDisplayText(utcOffset: number): string { const defaultTimezoneOffset = getTimezoneOffsetMinutes(); const offsetTime = getTimeDifferenceHoursAndMinutes(utcOffset - defaultTimezoneOffset); @@ -1699,6 +1816,8 @@ export function useI18n() { // get localization default type getDefaultCurrency, getDefaultFirstDayOfWeek, + getDefaultFiscalYearStart, + getDefaultFiscalYearFormat, // get all localized info of specified type getAllLanguageOptions, getAllEnableDisableOptions, @@ -1714,6 +1833,7 @@ export function useI18n() { getAllShortDateFormats: () => getLocalizedDateTimeFormats('shortDate', ShortDateFormat.all(), ShortDateFormat.values(), 'shortDateFormat', ShortDateFormat.Default), getAllLongTimeFormats: () => getLocalizedDateTimeFormats('longTime', LongTimeFormat.all(), LongTimeFormat.values(), 'longTimeFormat', LongTimeFormat.Default), getAllShortTimeFormats: () => getLocalizedDateTimeFormats('shortTime', ShortTimeFormat.all(), ShortTimeFormat.values(), 'shortTimeFormat', ShortTimeFormat.Default), + getAllFiscalYearFormats, getAllDateRanges, getAllRecentMonthDateRanges, getAllTimezones, @@ -1750,6 +1870,8 @@ export function useI18n() { getWeekdayLongName, getMultiMonthdayShortNames, getMultiWeekdayLongNames, + getCurrentFiscalYearStartFormatted: () => formatMonthDayToLongDay(FiscalYearStart.strictFromNumber(userStore.currentUserFiscalYearStart).toMonthDashDayString()), + getCurrentFiscalYearFormatType, getCurrentDecimalSeparator, getCurrentDigitGroupingSymbol, getCurrentDigitGroupingType, @@ -1776,8 +1898,13 @@ export function useI18n() { formatUnixTimeToLongTime: (unixTime: number, utcOffset?: number, currentUtcOffset?: number) => formatUnixTime(unixTime, getLocalizedLongTimeFormat(), utcOffset, currentUtcOffset), formatUnixTimeToShortTime: (unixTime: number, utcOffset?: number, currentUtcOffset?: number) => formatUnixTime(unixTime, getLocalizedShortTimeFormat(), utcOffset, currentUtcOffset), formatDateToLongDate, + formatMonthDayToLongDay, formatYearQuarter, formatDateRange, + formatFiscalYearStart, + formatTimeRangeToFiscalYearFormat, + formatUnixTimeToFiscalYear, + formatYearToFiscalYear, getTimezoneDifferenceDisplayText, appendDigitGroupingSymbol: getNumberWithDigitGroupingSymbol, parseAmount: getParsedAmountNumber, diff --git a/src/mobile-main.ts b/src/mobile-main.ts index 1bcb73d2..7e303691 100644 --- a/src/mobile-main.ts +++ b/src/mobile-main.ts @@ -92,6 +92,7 @@ import PasswordInputSheet from '@/components/mobile/PasswordInputSheet.vue'; import PasscodeInputSheet from '@/components/mobile/PasscodeInputSheet.vue'; import DateTimeSelectionSheet from '@/components/mobile/DateTimeSelectionSheet.vue'; import DateSelectionSheet from '@/components/mobile/DateSelectionSheet.vue'; +import FiscalYearStartSelectionSheet from '@/components/mobile/FiscalYearStartSelectionSheet.vue'; import DateRangeSelectionSheet from '@/components/mobile/DateRangeSelectionSheet.vue'; import MonthSelectionSheet from '@/components/mobile/MonthSelectionSheet.vue'; import MonthRangeSelectionSheet from '@/components/mobile/MonthRangeSelectionSheet.vue'; @@ -179,6 +180,7 @@ app.component('PasswordInputSheet', PasswordInputSheet); app.component('PasscodeInputSheet', PasscodeInputSheet); app.component('DateTimeSelectionSheet', DateTimeSelectionSheet); app.component('DateSelectionSheet', DateSelectionSheet); +app.component('FiscalYearStartSelectionSheet', FiscalYearStartSelectionSheet); app.component('DateRangeSelectionSheet', DateRangeSelectionSheet); app.component('MonthSelectionSheet', MonthSelectionSheet); app.component('MonthRangeSelectionSheet', MonthRangeSelectionSheet); diff --git a/src/models/user.ts b/src/models/user.ts index 1c817fba..4b521874 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -5,6 +5,7 @@ import { CoordinateDisplayType } from '@/core/coordinate.ts'; import { PresetAmountColor } from '@/core/color.ts'; import type { LocalizedPresetCategory } from '@/core/category.ts'; import { TransactionEditScopeType } from '@/core/transaction.ts'; +import { FiscalYearFormat, FiscalYearStart } from '@/core/fiscalyear'; export class User { public username: string = ''; @@ -18,6 +19,8 @@ export class User { public defaultAccountId: string = ''; public transactionEditScope: number = 1; + public fiscalYearStart: number = 0; + public fiscalYearFormat: number = 0; public longDateFormat: number = 0; public shortDateFormat: number = 0; public longTimeFormat: number = 0; @@ -45,10 +48,12 @@ export class User { this.firstDayOfWeek = user.firstDayOfWeek; this.defaultAccountId = user.defaultAccountId; this.transactionEditScope = user.transactionEditScope; + this.fiscalYearStart = user.fiscalYearStart; this.longDateFormat = user.longDateFormat; this.shortDateFormat = user.shortDateFormat; this.longTimeFormat = user.longTimeFormat; this.shortTimeFormat = user.shortTimeFormat; + this.fiscalYearFormat = user.fiscalYearFormat; this.decimalSeparator = user.decimalSeparator; this.digitGroupingSymbol = user.digitGroupingSymbol; this.digitGrouping = user.digitGrouping; @@ -82,10 +87,12 @@ export class User { language: this.language, defaultCurrency: this.defaultCurrency, firstDayOfWeek: this.firstDayOfWeek, + fiscalYearStart: this.fiscalYearStart, longDateFormat: this.longDateFormat, shortDateFormat: this.shortDateFormat, longTimeFormat: this.longTimeFormat, shortTimeFormat: this.shortTimeFormat, + fiscalYearFormat: this.fiscalYearFormat, decimalSeparator: this.decimalSeparator, digitGroupingSymbol: this.digitGroupingSymbol, digitGrouping: this.digitGrouping, @@ -100,10 +107,12 @@ export class User { const user = new User(userInfo.language, userInfo.defaultCurrency, userInfo.firstDayOfWeek); user.defaultAccountId = userInfo.defaultAccountId; user.transactionEditScope = userInfo.transactionEditScope; + user.fiscalYearStart = userInfo.fiscalYearStart; user.longDateFormat = userInfo.longDateFormat; user.shortDateFormat = userInfo.shortDateFormat; user.longTimeFormat = userInfo.longTimeFormat; user.shortTimeFormat = userInfo.shortTimeFormat; + user.fiscalYearFormat = userInfo.fiscalYearFormat; user.decimalSeparator = userInfo.decimalSeparator; user.digitGroupingSymbol = userInfo.digitGroupingSymbol; user.digitGrouping = userInfo.digitGrouping; @@ -130,11 +139,13 @@ export interface UserBasicInfo { readonly transactionEditScope: number; readonly language: string; readonly defaultCurrency: string; + readonly fiscalYearStart: number; readonly firstDayOfWeek: number; readonly longDateFormat: number; readonly shortDateFormat: number; readonly longTimeFormat: number; readonly shortTimeFormat: number; + readonly fiscalYearFormat: number; readonly decimalSeparator: number; readonly digitGroupingSymbol: number; readonly digitGrouping: number; @@ -182,10 +193,12 @@ export interface UserProfileUpdateRequest { readonly language?: string; readonly defaultCurrency?: string; readonly firstDayOfWeek?: number; + readonly fiscalYearStart?: number; readonly longDateFormat?: number; readonly shortDateFormat?: number; readonly longTimeFormat?: number; readonly shortTimeFormat?: number; + readonly fiscalYearFormat?: number; readonly decimalSeparator?: number; readonly digitGroupingSymbol?: number; readonly digitGrouping?: number; @@ -215,10 +228,12 @@ export const EMPTY_USER_BASIC_INFO: UserBasicInfo = { language: '', defaultCurrency: '', firstDayOfWeek: -1, + fiscalYearStart: FiscalYearStart.Default.value, longDateFormat: LongDateFormat.Default.type, shortDateFormat: ShortDateFormat.Default.type, longTimeFormat: LongTimeFormat.Default.type, shortTimeFormat: ShortTimeFormat.Default.type, + fiscalYearFormat: FiscalYearFormat.Default.type, decimalSeparator: DecimalSeparator.LanguageDefaultType, digitGroupingSymbol: DigitGroupingSymbol.LanguageDefaultType, digitGrouping: DigitGroupingType.LanguageDefaultType, diff --git a/src/stores/statistics.ts b/src/stores/statistics.ts index 55db63a8..b59e4b9e 100644 --- a/src/stores/statistics.ts +++ b/src/stores/statistics.ts @@ -747,7 +747,7 @@ export const useStatisticsStore = defineStore('statistics', () => { transactionStatisticsFilter.value.categoricalChartEndTime = 0; } } else { - const categoricalChartDateRange = getDateRangeByDateType(transactionStatisticsFilter.value.categoricalChartDateType, userStore.currentUserFirstDayOfWeek); + const categoricalChartDateRange = getDateRangeByDateType(transactionStatisticsFilter.value.categoricalChartDateType, userStore.currentUserFirstDayOfWeek, userStore.currentUserFiscalYearStart); if (categoricalChartDateRange) { transactionStatisticsFilter.value.categoricalChartDateType = categoricalChartDateRange.dateType; @@ -792,7 +792,7 @@ export const useStatisticsStore = defineStore('statistics', () => { transactionStatisticsFilter.value.trendChartEndYearMonth = ''; } } else { - const trendChartDateRange = getDateRangeByDateType(transactionStatisticsFilter.value.trendChartDateType, userStore.currentUserFirstDayOfWeek); + const trendChartDateRange = getDateRangeByDateType(transactionStatisticsFilter.value.trendChartDateType, userStore.currentUserFirstDayOfWeek, userStore.currentUserFiscalYearStart); if (trendChartDateRange) { transactionStatisticsFilter.value.trendChartDateType = trendChartDateRange.dateType; diff --git a/src/stores/user.ts b/src/stores/user.ts index 06191ede..98eeccbf 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -66,6 +66,11 @@ export const useUserStore = defineStore('user', () => { return isNumber(userInfo.firstDayOfWeek) && WeekDay.valueOf(userInfo.firstDayOfWeek) ? userInfo.firstDayOfWeek : settingsStore.localeDefaultSettings.firstDayOfWeek; }); + const currentUserFiscalYearStart = computed(() => { + const userInfo = currentUserBasicInfo.value || EMPTY_USER_BASIC_INFO; + return userInfo.fiscalYearStart; + }); + const currentUserLongDateFormat = computed(() => { const userInfo = currentUserBasicInfo.value || EMPTY_USER_BASIC_INFO; return userInfo.longDateFormat; @@ -86,6 +91,11 @@ export const useUserStore = defineStore('user', () => { return userInfo.shortTimeFormat; }); + const currentUserFiscalYearFormat = computed(() => { + const userInfo = currentUserBasicInfo.value || EMPTY_USER_BASIC_INFO; + return userInfo.fiscalYearFormat; + }); + const currentUserDecimalSeparator = computed(() => { const userInfo = currentUserBasicInfo.value || EMPTY_USER_BASIC_INFO; return userInfo.decimalSeparator; @@ -321,10 +331,12 @@ export const useUserStore = defineStore('user', () => { currentUserLanguage, currentUserDefaultCurrency, currentUserFirstDayOfWeek, + currentUserFiscalYearStart, currentUserLongDateFormat, currentUserShortDateFormat, currentUserLongTimeFormat, currentUserShortTimeFormat, + currentUserFiscalYearFormat, currentUserDecimalSeparator, currentUserDigitGroupingSymbol, currentUserDigitGrouping, diff --git a/src/views/base/statistics/StatisticsTransactionPageBase.ts b/src/views/base/statistics/StatisticsTransactionPageBase.ts index 9245df7d..efac0e7f 100644 --- a/src/views/base/statistics/StatisticsTransactionPageBase.ts +++ b/src/views/base/statistics/StatisticsTransactionPageBase.ts @@ -37,6 +37,7 @@ export function useStatisticsTransactionPageBase() { const showAccountBalance = computed(() => settingsStore.appSettings.showAccountBalance); const defaultCurrency = computed(() => userStore.currentUserDefaultCurrency); const firstDayOfWeek = computed(() => userStore.currentUserFirstDayOfWeek); + const fiscalYearStart = computed(() => userStore.currentUserFiscalYearStart); const allDateRanges = computed(() => { if (analysisType.value === StatisticsAnalysisType.CategoricalAnalysis) { @@ -224,6 +225,7 @@ export function useStatisticsTransactionPageBase() { showAccountBalance, defaultCurrency, firstDayOfWeek, + fiscalYearStart, allDateRanges, allSortingTypes, allDateAggregationTypes, diff --git a/src/views/base/transactions/TransactionListPageBase.ts b/src/views/base/transactions/TransactionListPageBase.ts index 6305452f..2a570e74 100644 --- a/src/views/base/transactions/TransactionListPageBase.ts +++ b/src/views/base/transactions/TransactionListPageBase.ts @@ -102,6 +102,7 @@ export function useTransactionListPageBase() { const currentTimezoneOffsetMinutes = computed(() => getTimezoneOffsetMinutes(settingsStore.appSettings.timeZone)); const firstDayOfWeek = computed(() => userStore.currentUserFirstDayOfWeek); + const fiscalYearStart = computed(() => userStore.currentUserFiscalYearStart); const defaultCurrency = computed(() => getUnifiedSelectedAccountsCurrencyOrDefaultCurrency(allAccountsMap.value, queryAllFilterAccountIds.value, userStore.currentUserDefaultCurrency)); const showTotalAmountInTransactionListPage = computed(() => settingsStore.appSettings.showTotalAmountInTransactionListPage); const showTagInTransactionListPage = computed(() => settingsStore.appSettings.showTagInTransactionListPage); @@ -365,6 +366,7 @@ export function useTransactionListPageBase() { // computed states currentTimezoneOffsetMinutes, firstDayOfWeek, + fiscalYearStart, defaultCurrency, showTotalAmountInTransactionListPage, showTagInTransactionListPage, diff --git a/src/views/base/users/UserProfilePageBase.ts b/src/views/base/users/UserProfilePageBase.ts index d99e270e..f8683a6f 100644 --- a/src/views/base/users/UserProfilePageBase.ts +++ b/src/views/base/users/UserProfilePageBase.ts @@ -26,6 +26,7 @@ export function useUserProfilePageBase() { getAllShortDateFormats, getAllLongTimeFormats, getAllShortTimeFormats, + getAllFiscalYearFormats, getAllDecimalSeparators, getAllDigitGroupingSymbols, getAllDigitGroupingTypes, @@ -60,6 +61,7 @@ export function useUserProfilePageBase() { const allShortDateFormats = computed(() => getAllShortDateFormats()); const allLongTimeFormats = computed(() => getAllLongTimeFormats()); const allShortTimeFormats = computed(() => getAllShortTimeFormats()); + const allFiscalYearFormats = computed(() => getAllFiscalYearFormats()); const allDecimalSeparators = computed(() => getAllDecimalSeparators()); const allDigitGroupingSymbols = computed(() => getAllDigitGroupingSymbols()); const allDigitGroupingTypes = computed(() => getAllDigitGroupingTypes()); @@ -99,11 +101,13 @@ export function useUserProfilePageBase() { newProfile.value.transactionEditScope === oldProfile.value.transactionEditScope && newProfile.value.language === oldProfile.value.language && newProfile.value.defaultCurrency === oldProfile.value.defaultCurrency && + newProfile.value.fiscalYearStart === oldProfile.value.fiscalYearStart && newProfile.value.firstDayOfWeek === oldProfile.value.firstDayOfWeek && newProfile.value.longDateFormat === oldProfile.value.longDateFormat && newProfile.value.shortDateFormat === oldProfile.value.shortDateFormat && newProfile.value.longTimeFormat === oldProfile.value.longTimeFormat && newProfile.value.shortTimeFormat === oldProfile.value.shortTimeFormat && + newProfile.value.fiscalYearFormat === oldProfile.value.fiscalYearFormat && newProfile.value.decimalSeparator === oldProfile.value.decimalSeparator && newProfile.value.digitGroupingSymbol === oldProfile.value.digitGroupingSymbol && newProfile.value.digitGrouping === oldProfile.value.digitGrouping && @@ -194,6 +198,7 @@ export function useUserProfilePageBase() { allShortDateFormats, allLongTimeFormats, allShortTimeFormats, + allFiscalYearFormats, allDecimalSeparators, allDigitGroupingSymbols, allDigitGroupingTypes, diff --git a/src/views/desktop/statistics/TransactionPage.vue b/src/views/desktop/statistics/TransactionPage.vue index 2105b004..19c055cd 100644 --- a/src/views/desktop/statistics/TransactionPage.vue +++ b/src/views/desktop/statistics/TransactionPage.vue @@ -248,6 +248,7 @@ :end-year-month="query.trendChartEndYearMonth" :sorting-type="querySortingType" :date-aggregation-type="trendDateAggregationType" + :fiscal-year-start="fiscalYearStart" :items="[]" :skeleton="true" id-field="id" @@ -262,6 +263,7 @@ :end-year-month="query.trendChartEndYearMonth" :sorting-type="querySortingType" :date-aggregation-type="trendDateAggregationType" + :fiscal-year-start="fiscalYearStart" :items="trendsAnalysisData && trendsAnalysisData.items && trendsAnalysisData.items.length ? trendsAnalysisData.items : []" :translate-name="translateNameInTrendsChart" :show-value="showAmountInChart" @@ -404,6 +406,7 @@ const { trendDateAggregationType, defaultCurrency, firstDayOfWeek, + fiscalYearStart, allDateRanges, allSortingTypes, allDateAggregationTypes, @@ -746,7 +749,7 @@ function setDateFilter(dateType: number): void { } } - const dateRange = getDateRangeByDateType(dateType, firstDayOfWeek.value); + const dateRange = getDateRangeByDateType(dateType, firstDayOfWeek.value, fiscalYearStart.value); if (!dateRange) { return; @@ -783,7 +786,7 @@ function setCustomDateFilter(startTime: number | string, endTime: number | strin let changed = false; if (analysisType.value === StatisticsAnalysisType.CategoricalAnalysis && isNumber(startTime) && isNumber(endTime)) { - const chartDateType = getDateTypeByDateRange(startTime, endTime, firstDayOfWeek.value, DateRangeScene.Normal); + const chartDateType = getDateTypeByDateRange(startTime, endTime, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal); changed = statisticsStore.updateTransactionStatisticsFilter({ categoricalChartDateType: chartDateType, @@ -793,7 +796,7 @@ function setCustomDateFilter(startTime: number | string, endTime: number | strin showCustomDateRangeDialog.value = false; } else if (analysisType.value === StatisticsAnalysisType.TrendAnalysis && isString(startTime) && isString(endTime)) { - const chartDateType = getDateTypeByDateRange(getYearMonthFirstUnixTime(startTime), getYearMonthLastUnixTime(endTime), firstDayOfWeek.value, DateRangeScene.TrendAnalysis); + const chartDateType = getDateTypeByDateRange(getYearMonthFirstUnixTime(startTime), getYearMonthLastUnixTime(endTime), firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.TrendAnalysis); changed = statisticsStore.updateTransactionStatisticsFilter({ trendChartDateType: chartDateType, @@ -819,7 +822,7 @@ function shiftDateRange(scale: number): void { let changed = false; if (analysisType.value === StatisticsAnalysisType.CategoricalAnalysis) { - const newDateRange = getShiftedDateRangeAndDateType(query.value.categoricalChartStartTime, query.value.categoricalChartEndTime, scale, firstDayOfWeek.value, DateRangeScene.Normal); + const newDateRange = getShiftedDateRangeAndDateType(query.value.categoricalChartStartTime, query.value.categoricalChartEndTime, scale, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal); changed = statisticsStore.updateTransactionStatisticsFilter({ categoricalChartDateType: newDateRange.dateType, @@ -827,7 +830,7 @@ function shiftDateRange(scale: number): void { categoricalChartEndTime: newDateRange.maxTime }); } else if (analysisType.value === StatisticsAnalysisType.TrendAnalysis) { - const newDateRange = getShiftedDateRangeAndDateType(getYearMonthFirstUnixTime(query.value.trendChartStartYearMonth), getYearMonthLastUnixTime(query.value.trendChartEndYearMonth), scale, firstDayOfWeek.value, DateRangeScene.TrendAnalysis); + const newDateRange = getShiftedDateRangeAndDateType(getYearMonthFirstUnixTime(query.value.trendChartStartYearMonth), getYearMonthLastUnixTime(query.value.trendChartEndYearMonth), scale, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.TrendAnalysis); changed = statisticsStore.updateTransactionStatisticsFilter({ trendChartDateType: newDateRange.dateType, diff --git a/src/views/desktop/transactions/ListPage.vue b/src/views/desktop/transactions/ListPage.vue index c10aa731..bb5b7e27 100644 --- a/src/views/desktop/transactions/ListPage.vue +++ b/src/views/desktop/transactions/ListPage.vue @@ -780,6 +780,7 @@ const { currentCalendarDate, currentTimezoneOffsetMinutes, firstDayOfWeek, + fiscalYearStart, defaultCurrency, showTotalAmountInTransactionListPage, showTagInTransactionListPage, @@ -943,7 +944,7 @@ const transactions = computed(() => { }); const recentDateRangeIndex = computed({ - get: () => getRecentDateRangeIndex(recentMonthDateRanges.value, query.value.dateType, query.value.minTime, query.value.maxTime, firstDayOfWeek.value), + get: () => getRecentDateRangeIndex(recentMonthDateRanges.value, query.value.dateType, query.value.minTime, query.value.maxTime, firstDayOfWeek.value, fiscalYearStart.value), set: (value) => { if (value < 0 || value >= recentMonthDateRanges.value.length) { value = 0; @@ -1093,7 +1094,7 @@ function updateUrlWhenChanged(changed: boolean): void { } function init(initProps: TransactionListProps): void { - let dateRange: TimeRangeAndDateType | null = getDateRangeByDateType(initProps.initDateType ? parseInt(initProps.initDateType) : undefined, firstDayOfWeek.value); + let dateRange: TimeRangeAndDateType | null = getDateRangeByDateType(initProps.initDateType ? parseInt(initProps.initDateType) : undefined, firstDayOfWeek.value, fiscalYearStart.value); if (!dateRange && initProps.initDateType && initProps.initMaxTime && initProps.initMinTime && (DateRange.isBillingCycle(parseInt(initProps.initDateType)) || initProps.initDateType === DateRange.Custom.type.toString()) && @@ -1258,9 +1259,9 @@ function changeDateFilter(dateRange: TimeRangeAndDateType | number | null): void if (isNumber(dateRange)) { if (DateRange.isBillingCycle(dateRange)) { - dateRange = getDateRangeByBillingCycleDateType(dateRange, firstDayOfWeek.value, accountsStore.getAccountStatementDate(query.value.accountIds)); + dateRange = getDateRangeByBillingCycleDateType(dateRange, firstDayOfWeek.value, fiscalYearStart.value, accountsStore.getAccountStatementDate(query.value.accountIds)); } else { - dateRange = getDateRangeByDateType(dateRange, firstDayOfWeek.value); + dateRange = getDateRangeByDateType(dateRange, firstDayOfWeek.value, fiscalYearStart.value); } } @@ -1296,10 +1297,10 @@ function changeCustomDateFilter(minTime: number, maxTime: number): void { return; } - let dateType: number | null = getDateTypeByBillingCycleDateRange(minTime, maxTime, firstDayOfWeek.value, DateRangeScene.Normal, accountsStore.getAccountStatementDate(query.value.accountIds)); + let dateType: number | null = getDateTypeByBillingCycleDateRange(minTime, maxTime, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal, accountsStore.getAccountStatementDate(query.value.accountIds)); if (!dateType) { - dateType = getDateTypeByDateRange(minTime, maxTime, firstDayOfWeek.value, DateRangeScene.Normal); + dateType = getDateTypeByDateRange(minTime, maxTime, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal); } if (pageType.value === TransactionListPageType.Calendar.type) { @@ -1365,11 +1366,11 @@ function shiftDateRange(startTime: number, endTime: number, scale: number): void let newDateRange: TimeRangeAndDateType | null = null; if (DateRange.isBillingCycle(query.value.dateType) || query.value.dateType === DateRange.Custom.type) { - newDateRange = getShiftedDateRangeAndDateTypeForBillingCycle(startTime, endTime, scale, firstDayOfWeek.value, DateRangeScene.Normal, accountsStore.getAccountStatementDate(query.value.accountIds)); + newDateRange = getShiftedDateRangeAndDateTypeForBillingCycle(startTime, endTime, scale, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal, accountsStore.getAccountStatementDate(query.value.accountIds)); } if (!newDateRange) { - newDateRange = getShiftedDateRangeAndDateType(startTime, endTime, scale, firstDayOfWeek.value, DateRangeScene.Normal); + newDateRange = getShiftedDateRangeAndDateType(startTime, endTime, scale, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal); } if (pageType.value === TransactionListPageType.Calendar.type) { diff --git a/src/views/desktop/user/settings/tabs/UserBasicSettingTab.vue b/src/views/desktop/user/settings/tabs/UserBasicSettingTab.vue index 9d1e73a9..e42a97fd 100644 --- a/src/views/desktop/user/settings/tabs/UserBasicSettingTab.vue +++ b/src/views/desktop/user/settings/tabs/UserBasicSettingTab.vue @@ -142,6 +142,16 @@ v-model="newProfile.firstDayOfWeek" /> + + + + @@ -200,6 +210,19 @@ v-model="newProfile.shortTimeFormat" /> + + + + @@ -375,6 +398,7 @@ const { allShortDateFormats, allLongTimeFormats, allShortTimeFormats, + allFiscalYearFormats, allDecimalSeparators, allDigitGroupingSymbols, allDigitGroupingTypes, diff --git a/src/views/mobile/statistics/TransactionPage.vue b/src/views/mobile/statistics/TransactionPage.vue index 5c29345a..fa617880 100644 --- a/src/views/mobile/statistics/TransactionPage.vue +++ b/src/views/mobile/statistics/TransactionPage.vue @@ -204,6 +204,7 @@ :end-year-month="query.trendChartEndYearMonth" :sorting-type="query.sortingType" :date-aggregation-type="trendDateAggregationType" + :fiscal-year-start="fiscalYearStart" :items="trendsAnalysisData && trendsAnalysisData.items && trendsAnalysisData.items.length ? trendsAnalysisData.items : []" :translate-name="translateNameInTrendsChart" :default-currency="defaultCurrency" @@ -367,6 +368,7 @@ const { trendDateAggregationType, defaultCurrency, firstDayOfWeek, + fiscalYearStart, allDateRanges, allSortingTypes, allDateAggregationTypes, @@ -590,7 +592,7 @@ function setDateFilter(dateType: number): void { } } - const dateRange = getDateRangeByDateType(dateType, firstDayOfWeek.value); + const dateRange = getDateRangeByDateType(dateType, firstDayOfWeek.value, fiscalYearStart.value); if (!dateRange) { return; @@ -627,7 +629,7 @@ function setCustomDateFilter(startTime: number | string, endTime: number | strin let changed = false; if (analysisType.value === StatisticsAnalysisType.CategoricalAnalysis && isNumber(startTime) && isNumber(endTime)) { - const chartDateType = getDateTypeByDateRange(startTime, endTime, firstDayOfWeek.value, DateRangeScene.Normal); + const chartDateType = getDateTypeByDateRange(startTime, endTime, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal); changed = statisticsStore.updateTransactionStatisticsFilter({ categoricalChartDateType: chartDateType, @@ -637,7 +639,7 @@ function setCustomDateFilter(startTime: number | string, endTime: number | strin showCustomDateRangeSheet.value = false; } else if (analysisType.value === StatisticsAnalysisType.TrendAnalysis && isString(startTime) && isString(endTime)) { - const chartDateType = getDateTypeByDateRange(getYearMonthFirstUnixTime(startTime), getYearMonthLastUnixTime(endTime), firstDayOfWeek.value, DateRangeScene.TrendAnalysis); + const chartDateType = getDateTypeByDateRange(getYearMonthFirstUnixTime(startTime), getYearMonthLastUnixTime(endTime), firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.TrendAnalysis); changed = statisticsStore.updateTransactionStatisticsFilter({ trendChartDateType: chartDateType, @@ -661,7 +663,7 @@ function shiftDateRange(scale: number): void { let changed = false; if (analysisType.value === StatisticsAnalysisType.CategoricalAnalysis) { - const newDateRange = getShiftedDateRangeAndDateType(query.value.categoricalChartStartTime, query.value.categoricalChartEndTime, scale, firstDayOfWeek.value, DateRangeScene.Normal); + const newDateRange = getShiftedDateRangeAndDateType(query.value.categoricalChartStartTime, query.value.categoricalChartEndTime, scale, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal); changed = statisticsStore.updateTransactionStatisticsFilter({ categoricalChartDateType: newDateRange.dateType, @@ -669,7 +671,7 @@ function shiftDateRange(scale: number): void { categoricalChartEndTime: newDateRange.maxTime }); } else if (analysisType.value === StatisticsAnalysisType.TrendAnalysis) { - const newDateRange = getShiftedDateRangeAndDateType(getYearMonthFirstUnixTime(query.value.trendChartStartYearMonth), getYearMonthLastUnixTime(query.value.trendChartEndYearMonth), scale, firstDayOfWeek.value, DateRangeScene.TrendAnalysis); + const newDateRange = getShiftedDateRangeAndDateType(getYearMonthFirstUnixTime(query.value.trendChartStartYearMonth), getYearMonthLastUnixTime(query.value.trendChartEndYearMonth), scale, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.TrendAnalysis); changed = statisticsStore.updateTransactionStatisticsFilter({ trendChartDateType: newDateRange.dateType, diff --git a/src/views/mobile/transactions/ListPage.vue b/src/views/mobile/transactions/ListPage.vue index acd3ff42..08b7f0aa 100644 --- a/src/views/mobile/transactions/ListPage.vue +++ b/src/views/mobile/transactions/ListPage.vue @@ -671,6 +671,7 @@ const { currentCalendarDate, currentTimezoneOffsetMinutes, firstDayOfWeek, + fiscalYearStart, defaultCurrency, showTotalAmountInTransactionListPage, showTagInTransactionListPage, @@ -920,7 +921,7 @@ function getCategoryListItemCheckedClass(category: TransactionCategory, queryCat function init(): void { const initQuery = props.f7route.query; - let dateRange: TimeRangeAndDateType | null = getDateRangeByDateType(initQuery['dateType'] ? parseInt(initQuery['dateType']) : undefined, firstDayOfWeek.value); + let dateRange: TimeRangeAndDateType | null = getDateRangeByDateType(initQuery['dateType'] ? parseInt(initQuery['dateType']) : undefined, firstDayOfWeek.value, fiscalYearStart.value); if (!dateRange && initQuery['dateType'] && initQuery['maxTime'] && initQuery['minTime'] && (DateRange.isBillingCycle(parseInt(initQuery['dateType'])) || initQuery['dateType'] === DateRange.Custom.type.toString()) && @@ -1037,7 +1038,7 @@ function changePageType(type: number): void { currentCalendarDate.value = getValidMonthDayOrCurrentDayShortDate(query.value.minTime, currentCalendarDate.value); if (pageType.value === TransactionListPageType.Calendar.type) { - const dateRange = getFullMonthDateRange(query.value.minTime, query.value.maxTime, firstDayOfWeek.value); + const dateRange = getFullMonthDateRange(query.value.minTime, query.value.maxTime, firstDayOfWeek.value, fiscalYearStart.value); if (dateRange) { const changed = transactionsStore.updateTransactionListFilter({ @@ -1079,9 +1080,9 @@ function changeDateFilter(dateType: number): void { let dateRange: TimeRangeAndDateType | null = null; if (DateRange.isBillingCycle(dateType)) { - dateRange = getDateRangeByBillingCycleDateType(dateType, firstDayOfWeek.value, accountsStore.getAccountStatementDate(query.value.accountIds)); + dateRange = getDateRangeByBillingCycleDateType(dateType, firstDayOfWeek.value, fiscalYearStart.value, accountsStore.getAccountStatementDate(query.value.accountIds)); } else { - dateRange = getDateRangeByDateType(dateType, firstDayOfWeek.value); + dateRange = getDateRangeByDateType(dateType, firstDayOfWeek.value, fiscalYearStart.value); } if (!dateRange) { @@ -1090,7 +1091,7 @@ function changeDateFilter(dateType: number): void { if (pageType.value === TransactionListPageType.Calendar.type) { currentCalendarDate.value = getValidMonthDayOrCurrentDayShortDate(dateRange.minTime, currentCalendarDate.value); - const fullMonthDateRange = getFullMonthDateRange(dateRange.minTime, dateRange.maxTime, firstDayOfWeek.value); + const fullMonthDateRange = getFullMonthDateRange(dateRange.minTime, dateRange.maxTime, firstDayOfWeek.value, fiscalYearStart.value); if (fullMonthDateRange) { dateRange = fullMonthDateRange; @@ -1116,15 +1117,15 @@ function changeCustomDateFilter(minTime: number, maxTime: number): void { return; } - let dateType: number | null = getDateTypeByBillingCycleDateRange(minTime, maxTime, firstDayOfWeek.value, DateRangeScene.Normal, accountsStore.getAccountStatementDate(query.value.accountIds)); + let dateType: number | null = getDateTypeByBillingCycleDateRange(minTime, maxTime, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal, accountsStore.getAccountStatementDate(query.value.accountIds)); if (!dateType) { - dateType = getDateTypeByDateRange(minTime, maxTime, firstDayOfWeek.value, DateRangeScene.Normal); + dateType = getDateTypeByDateRange(minTime, maxTime, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal); } if (pageType.value === TransactionListPageType.Calendar.type) { currentCalendarDate.value = getValidMonthDayOrCurrentDayShortDate(minTime, currentCalendarDate.value); - const dateRange = getFullMonthDateRange(minTime, maxTime, firstDayOfWeek.value); + const dateRange = getFullMonthDateRange(minTime, maxTime, firstDayOfWeek.value, fiscalYearStart.value); if (dateRange) { minTime = dateRange.minTime; @@ -1154,7 +1155,7 @@ function changeCustomMonthDateFilter(yearMonth: string): void { const minTime = getYearMonthFirstUnixTime(yearMonth); const maxTime = getYearMonthLastUnixTime(yearMonth); - const dateType = getDateTypeByDateRange(minTime, maxTime, firstDayOfWeek.value, DateRangeScene.Normal); + const dateType = getDateTypeByDateRange(minTime, maxTime, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal); if (pageType.value === TransactionListPageType.Calendar.type) { currentCalendarDate.value = getValidMonthDayOrCurrentDayShortDate(minTime, currentCalendarDate.value); @@ -1181,16 +1182,16 @@ function shiftDateRange(minTime: number, maxTime: number, scale: number): void { let newDateRange: TimeRangeAndDateType | null = null; if (DateRange.isBillingCycle(query.value.dateType) || query.value.dateType === DateRange.Custom.type) { - newDateRange = getShiftedDateRangeAndDateTypeForBillingCycle(minTime, maxTime, scale, firstDayOfWeek.value, DateRangeScene.Normal, accountsStore.getAccountStatementDate(query.value.accountIds)); + newDateRange = getShiftedDateRangeAndDateTypeForBillingCycle(minTime, maxTime, scale, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal, accountsStore.getAccountStatementDate(query.value.accountIds)); } if (!newDateRange) { - newDateRange = getShiftedDateRangeAndDateType(minTime, maxTime, scale, firstDayOfWeek.value, DateRangeScene.Normal); + newDateRange = getShiftedDateRangeAndDateType(minTime, maxTime, scale, firstDayOfWeek.value, fiscalYearStart.value, DateRangeScene.Normal); } if (pageType.value === TransactionListPageType.Calendar.type) { currentCalendarDate.value = getValidMonthDayOrCurrentDayShortDate(newDateRange.minTime, currentCalendarDate.value); - const fullMonthDateRange = getFullMonthDateRange(newDateRange.minTime, newDateRange.maxTime, firstDayOfWeek.value); + const fullMonthDateRange = getFullMonthDateRange(newDateRange.minTime, newDateRange.maxTime, firstDayOfWeek.value, fiscalYearStart.value); if (fullMonthDateRange) { newDateRange = fullMonthDateRange; diff --git a/src/views/mobile/users/UserProfilePage.vue b/src/views/mobile/users/UserProfilePage.vue index 93cd548c..956ae41d 100644 --- a/src/views/mobile/users/UserProfilePage.vue +++ b/src/views/mobile/users/UserProfilePage.vue @@ -202,6 +202,20 @@ v-model="newProfile.firstDayOfWeek"> + + + + + + @@ -284,6 +298,26 @@ v-model="newProfile.shortTimeFormat"> + + + + + @@ -482,7 +516,7 @@ const props = defineProps<{ f7router: Router.Router; }>(); -const { tt, getAllLanguageOptions, getAllCurrencies, getCurrencyName } = useI18n(); +const { tt, getAllLanguageOptions, getAllCurrencies, getCurrencyName, formatFiscalYearStart } = useI18n(); const { showAlert, showToast, routeBackOnError } = useI18nUIComponents(); const { @@ -499,6 +533,7 @@ const { allShortDateFormats, allLongTimeFormats, allShortTimeFormats, + allFiscalYearFormats, allDecimalSeparators, allDigitGroupingSymbols, allDigitGroupingTypes, @@ -533,10 +568,12 @@ const showEditableTransactionRangePopup = ref(false); const showLanguagePopup = ref(false); const showDefaultCurrencyPopup = ref(false); const showFirstDayOfWeekPopup = ref(false); +const showFiscalYearStartSheet = ref(false); const showLongDateFormatPopup = ref(false); const showShortDateFormatPopup = ref(false); const showLongTimeFormatPopup = ref(false); const showShortTimeFormatPopup = ref(false); +const showFiscalYearFormatPopup = ref(false); const showCurrencyDisplayTypePopup = ref(false); const showDigitGroupingPopup = ref(false); const showDigitGroupingSymbolPopup = ref(false); @@ -560,6 +597,7 @@ const currentLanguageName = computed(() => { }); const currentDayOfWeekName = computed(() => findDisplayNameByType(allWeekDays.value, newProfile.value.firstDayOfWeek)); +const currentFiscalYearStartDate = computed( () => formatFiscalYearStart(newProfile.value.fiscalYearStart) ); function init(): void { loading.value = true;