From f1eeee05258a372365be6e61430df97f0fa65d4d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 30 Mar 2024 13:06:42 +0100 Subject: [PATCH] Feature/extend date range support by specific years (#3190) * Extend date range support by specific years * Support date range in benchmark endpoint * Support date range in activities endpoint * Update changelog --- CHANGELOG.md | 5 + .../src/app/benchmark/benchmark.controller.ts | 13 ++- apps/api/src/app/order/order.controller.ts | 8 +- apps/api/src/app/order/order.service.ts | 16 +++ .../src/app/portfolio/portfolio.controller.ts | 6 +- .../src/app/portfolio/portfolio.service.ts | 104 ++++-------------- .../src/app/user/update-user-setting.dto.ts | 16 ++- apps/api/src/app/user/user.service.ts | 26 +++-- apps/api/src/helper/portfolio.helper.ts | 59 ++++++++++ .../investment-chart.component.ts | 54 +-------- .../activities/activities-page.component.ts | 3 + .../analysis/analysis-page.component.ts | 1 + apps/client/src/app/services/data.service.ts | 17 ++- .../src/app/services/user/user.service.ts | 4 + .../src/lib/interfaces/user.interface.ts | 1 + libs/common/src/lib/types/date-range.type.ts | 10 +- .../src/lib/assistant/assistant.component.ts | 67 +++++++---- .../lib/assistant/interfaces/interfaces.ts | 6 + 18 files changed, 240 insertions(+), 176 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11726fb72..01cf52485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added the date range support in the activities table on the portfolio activities page (experimental) +- Extended the date range support by specific years (`2023`, `2022`, `2021`, etc.) in the assistant (experimental) + ### Changed - Improved the usability to delete an asset profile in the historical market data table and the asset profile details dialog of the admin control diff --git a/apps/api/src/app/benchmark/benchmark.controller.ts b/apps/api/src/app/benchmark/benchmark.controller.ts index d3b91c6df..184214384 100644 --- a/apps/api/src/app/benchmark/benchmark.controller.ts +++ b/apps/api/src/app/benchmark/benchmark.controller.ts @@ -1,5 +1,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import type { @@ -8,7 +9,7 @@ import type { UniqueAsset } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -19,6 +20,7 @@ import { Inject, Param, Post, + Query, UseGuards, UseInterceptors } from '@nestjs/common'; @@ -106,13 +108,18 @@ export class BenchmarkController { public async getBenchmarkMarketDataBySymbol( @Param('dataSource') dataSource: DataSource, @Param('startDateString') startDateString: string, - @Param('symbol') symbol: string + @Param('symbol') symbol: string, + @Query('range') dateRange: DateRange = 'max' ): Promise { - const startDate = new Date(startDateString); + const { endDate, startDate } = getInterval( + dateRange, + new Date(startDateString) + ); const userCurrency = this.request.user.Settings.settings.baseCurrency; return this.benchmarkService.getMarketDataBySymbol({ dataSource, + endDate, startDate, symbol, userCurrency diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index dbcf6dedb..2f9825d6b 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -1,5 +1,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; @@ -8,7 +9,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -84,6 +85,7 @@ export class OrderController { @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, + @Query('range') dateRange: DateRange = 'max', @Query('skip') skip?: number, @Query('sortColumn') sortColumn?: string, @Query('sortDirection') sortDirection?: Prisma.SortOrder, @@ -96,14 +98,18 @@ export class OrderController { filterByTags }); + const { endDate, startDate } = getInterval(dateRange); + const impersonationUserId = await this.impersonationService.validateImpersonationId(impersonationId); const userCurrency = this.request.user.Settings.settings.baseCurrency; const { activities, count } = await this.orderService.getOrders({ + endDate, filters, sortColumn, sortDirection, + startDate, userCurrency, includeDrafts: true, skip: isNaN(skip) ? undefined : skip, diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index a65e30d53..126b04a07 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -198,22 +198,26 @@ export class OrderService { } public async getOrders({ + endDate, filters, includeDrafts = false, skip, sortColumn, sortDirection, + startDate, take = Number.MAX_SAFE_INTEGER, types, userCurrency, userId, withExcludedAccounts = false }: { + endDate?: Date; filters?: Filter[]; includeDrafts?: boolean; skip?: number; sortColumn?: string; sortDirection?: Prisma.SortOrder; + startDate?: Date; take?: number; types?: ActivityType[]; userCurrency: string; @@ -225,6 +229,18 @@ export class OrderService { ]; const where: Prisma.OrderWhereInput = { userId }; + if (endDate || startDate) { + where.AND = []; + + if (endDate) { + where.AND.push({ date: { lte: endDate } }); + } + + if (startDate) { + where.AND.push({ date: { gt: startDate } }); + } + } + const { ACCOUNT: filtersByAccount, ASSET_CLASS: filtersByAssetClass, diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index c2dffebeb..6047b7abd 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -6,6 +6,7 @@ import { hasNotDefinedValuesInObject, nullifyValuesInObject } from '@ghostfolio/api/helper/object.helper'; +import { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; @@ -236,8 +237,12 @@ export class PortfolioController { await this.impersonationService.validateImpersonationId(impersonationId); const userCurrency = this.request.user.Settings.settings.baseCurrency; + const { endDate, startDate } = getInterval(dateRange); + const { activities } = await this.orderService.getOrders({ + endDate, filters, + startDate, userCurrency, userId: impersonationUserId || this.request.user.id, types: ['DIVIDEND'] @@ -245,7 +250,6 @@ export class PortfolioController { let dividends = await this.portfolioService.getDividends({ activities, - dateRange, groupBy }); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 7fe82504f..8384427c3 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -5,7 +5,10 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; -import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +import { + getFactor, + getInterval +} from '@ghostfolio/api/helper/portfolio.helper'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; @@ -73,16 +76,8 @@ import { isBefore, isSameMonth, isSameYear, - isValid, - max, - min, parseISO, - set, - startOfWeek, - startOfMonth, - startOfYear, - subDays, - subYears + set } from 'date-fns'; import { isEmpty, last, uniq, uniqBy } from 'lodash'; @@ -221,11 +216,9 @@ export class PortfolioService { public async getDividends({ activities, - dateRange = 'max', groupBy }: { activities: Activity[]; - dateRange?: DateRange; groupBy?: GroupBy; }): Promise { let dividends = activities.map(({ date, valueInBaseCurrency }) => { @@ -239,14 +232,7 @@ export class PortfolioService { dividends = this.getDividendsByGroup({ dividends, groupBy }); } - const startDate = this.getStartDate( - dateRange, - parseDate(dividends[0]?.date) - ); - - return dividends.filter(({ date }) => { - return !isBefore(parseDate(date), startDate); - }); + return dividends; } public async getInvestments({ @@ -375,7 +361,7 @@ export class PortfolioService { exchangeRateDataService: this.exchangeRateDataService }); - const startDate = this.getStartDate( + const { startDate } = getInterval( dateRange, portfolioCalculator.getStartDate() ); @@ -960,7 +946,10 @@ export class PortfolioService { const userId = await this.getUserId(impersonationId, this.request.user.id); const user = await this.userService.user({ id: userId }); + const { endDate, startDate } = getInterval(dateRange); + const { activities } = await this.orderService.getOrders({ + endDate, filters, userId, types: ['BUY', 'SELL'], @@ -981,12 +970,10 @@ export class PortfolioService { exchangeRateDataService: this.exchangeRateDataService }); - const startDate = this.getStartDate( - dateRange, - portfolioCalculator.getStartDate() + const currentPositions = await portfolioCalculator.getCurrentPositions( + startDate, + endDate ); - const currentPositions = - await portfolioCalculator.getCurrentPositions(startDate); let positions = currentPositions.positions.filter(({ quantity }) => { return !quantity.eq(0); @@ -1133,7 +1120,10 @@ export class PortfolioService { ) ); + const { endDate, startDate } = getInterval(dateRange); + const { activities } = await this.orderService.getOrders({ + endDate, filters, userCurrency, userId, @@ -1169,16 +1159,6 @@ export class PortfolioService { exchangeRateDataService: this.exchangeRateDataService }); - const portfolioStart = min( - [ - parseDate(accountBalanceItems[0]?.date), - portfolioCalculator.getStartDate() - ].filter((date) => { - return isValid(date); - }) - ); - - const startDate = this.getStartDate(dateRange, portfolioStart); const { currentValueInBaseCurrency, errors, @@ -1192,7 +1172,7 @@ export class PortfolioService { netPerformancePercentageWithCurrencyEffect, netPerformanceWithCurrencyEffect, totalInvestment - } = await portfolioCalculator.getCurrentPositions(startDate); + } = await portfolioCalculator.getCurrentPositions(startDate, endDate); let currentNetPerformance = netPerformance; @@ -1448,11 +1428,11 @@ export class PortfolioService { userId = await this.getUserId(impersonationId, userId); - const startDate = this.getStartDate( + const { endDate, startDate } = getInterval( dateRange, portfolioCalculator.getStartDate() ); - const endDate = new Date(); + const daysInMarket = differenceInDays(endDate, startDate) + 1; const step = withDataDecimation ? Math.round(daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)) @@ -1617,52 +1597,6 @@ export class PortfolioService { }; } - private getStartDate(aDateRange: DateRange, portfolioStart: Date) { - switch (aDateRange) { - case '1d': - portfolioStart = max([ - portfolioStart, - subDays(new Date().setHours(0, 0, 0, 0), 1) - ]); - break; - case 'mtd': - portfolioStart = max([ - portfolioStart, - subDays(startOfMonth(new Date().setHours(0, 0, 0, 0)), 1) - ]); - break; - case 'wtd': - portfolioStart = max([ - portfolioStart, - subDays( - startOfWeek(new Date().setHours(0, 0, 0, 0), { weekStartsOn: 1 }), - 1 - ) - ]); - break; - case 'ytd': - portfolioStart = max([ - portfolioStart, - subDays(startOfYear(new Date().setHours(0, 0, 0, 0)), 1) - ]); - break; - case '1y': - portfolioStart = max([ - portfolioStart, - subYears(new Date().setHours(0, 0, 0, 0), 1) - ]); - break; - case '5y': - portfolioStart = max([ - portfolioStart, - subYears(new Date().setHours(0, 0, 0, 0), 5) - ]); - break; - } - - return portfolioStart; - } - private getStreaks({ investments, savingsRate diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index 7b09ced10..d260b3aaf 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -14,6 +14,7 @@ import { IsOptional, IsString } from 'class-validator'; +import { eachYearOfInterval, format } from 'date-fns'; export class UpdateUserSettingDto { @IsNumber() @@ -32,7 +33,20 @@ export class UpdateUserSettingDto { @IsOptional() colorScheme?: ColorScheme; - @IsIn(['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) + @IsIn([ + '1d', + '1y', + '5y', + 'max', + 'mtd', + 'wtd', + 'ytd', + ...eachYearOfInterval({ end: new Date(), start: new Date(0) }).map( + (date) => { + return format(date, 'yyyy'); + } + ) + ]) @IsOptional() dateRange?: DateRange; diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 8b7c88560..4cc60770f 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -51,13 +51,22 @@ export class UserService { { Account, id, permissions, Settings, subscription }: UserWithSettings, aLocale = locale ): Promise { - const access = await this.prismaService.access.findMany({ - include: { - User: true - }, - orderBy: { alias: 'asc' }, - where: { GranteeUser: { id } } - }); + let [access, firstActivity, tags] = await Promise.all([ + this.prismaService.access.findMany({ + include: { + User: true + }, + orderBy: { alias: 'asc' }, + where: { GranteeUser: { id } } + }), + this.prismaService.order.findFirst({ + orderBy: { + date: 'asc' + }, + where: { userId: id } + }), + this.tagService.getByUser(id) + ]); let systemMessage: SystemMessage; @@ -69,8 +78,6 @@ export class UserService { systemMessage = systemMessageProperty; } - let tags = await this.tagService.getByUser(id); - if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && subscription.type === 'Basic' @@ -91,6 +98,7 @@ export class UserService { }; }), accounts: Account, + dateOfFirstActivity: firstActivity?.date ?? new Date(), settings: { ...(Settings.settings), locale: (Settings.settings)?.locale ?? aLocale diff --git a/apps/api/src/helper/portfolio.helper.ts b/apps/api/src/helper/portfolio.helper.ts index 01b532cbf..730f34bde 100644 --- a/apps/api/src/helper/portfolio.helper.ts +++ b/apps/api/src/helper/portfolio.helper.ts @@ -1,4 +1,17 @@ +import { resetHours } from '@ghostfolio/common/helper'; +import { DateRange } from '@ghostfolio/common/types'; + import { Type as ActivityType } from '@prisma/client'; +import { + endOfDay, + max, + subDays, + startOfMonth, + startOfWeek, + startOfYear, + subYears, + endOfYear +} from 'date-fns'; export function getFactor(activityType: ActivityType) { let factor: number; @@ -19,3 +32,49 @@ export function getFactor(activityType: ActivityType) { return factor; } + +export function getInterval( + aDateRange: DateRange, + portfolioStart = new Date(0) +) { + let endDate = endOfDay(new Date()); + let startDate = portfolioStart; + + switch (aDateRange) { + case '1d': + startDate = max([startDate, subDays(resetHours(new Date()), 1)]); + break; + case 'mtd': + startDate = max([ + startDate, + subDays(startOfMonth(resetHours(new Date())), 1) + ]); + break; + case 'wtd': + startDate = max([ + startDate, + subDays(startOfWeek(resetHours(new Date()), { weekStartsOn: 1 }), 1) + ]); + break; + case 'ytd': + startDate = max([ + startDate, + subDays(startOfYear(resetHours(new Date())), 1) + ]); + break; + case '1y': + startDate = max([startDate, subYears(resetHours(new Date()), 1)]); + break; + case '5y': + startDate = max([startDate, subYears(resetHours(new Date()), 5)]); + break; + case 'max': + break; + default: + // '2024', '2023', '2022', etc. + endDate = endOfYear(new Date(aDateRange)); + startDate = max([startDate, new Date(aDateRange)]); + } + + return { endDate, startDate }; +} diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.ts b/apps/client/src/app/components/investment-chart/investment-chart.component.ts index 56de9d438..429eaae6f 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.ts +++ b/apps/client/src/app/components/investment-chart/investment-chart.component.ts @@ -6,7 +6,6 @@ import { } from '@ghostfolio/common/chart-helper'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { - DATE_FORMAT, getBackgroundColor, getDateFormatString, getLocale, @@ -39,16 +38,8 @@ import { } from 'chart.js'; import 'chartjs-adapter-date-fns'; import annotationPlugin from 'chartjs-plugin-annotation'; -import { - addDays, - format, - isAfter, - isValid, - min, - parseISO, - subDays -} from 'date-fns'; -import { first, last } from 'lodash'; +import { isAfter, isValid, min, subDays } from 'date-fns'; +import { first } from 'lodash'; @Component({ selector: 'gf-investment-chart', @@ -112,46 +103,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { Object.assign({}, item) ); - if (!this.groupBy && this.investments?.length > 0) { - let date: string; - - if (this.range === 'max') { - // Extend chart by 5% of days in market (before) - date = format( - subDays( - parseISO(this.investments[0].date), - this.daysInMarket * 0.05 || 90 - ), - DATE_FORMAT - ); - this.investments.unshift({ - date, - investment: 0 - }); - this.values.unshift({ - date, - value: 0 - }); - } - - // Extend chart by 5% of days in market (after) - date = format( - addDays( - parseDate(last(this.investments).date), - this.daysInMarket * 0.05 || 90 - ), - DATE_FORMAT - ); - this.investments.push({ - date, - investment: last(this.investments).investment - }); - this.values.push({ - date, - value: last(this.values).value - }); - } - const chartData: ChartData<'bar' | 'line'> = { labels: this.historicalDataItems.map(({ date }) => { return parseDate(date); @@ -303,7 +254,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { display: false }, min: scaleXMin, - suggestedMax: new Date().toISOString(), type: 'time', time: { tooltipFormat: getDateFormatString(this.locale), diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index 190fc673e..3e45ec037 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts @@ -124,6 +124,9 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit { this.dataService .fetchActivities({ filters: this.userService.getFilters(), + range: this.user?.settings?.isExperimentalFeatures + ? this.user?.settings?.dateRange + : undefined, skip: this.pageIndex * this.pageSize, sortColumn: this.sortColumn, sortDirection: this.sortDirection, diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index c0569b9f4..184297b26 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -352,6 +352,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { .fetchBenchmarkBySymbol({ dataSource, symbol, + range: this.user?.settings?.dateRange, startDate: this.firstOrderDate }) .pipe(takeUntil(this.unsubscribeSubject)) diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index c263b0cb0..088512cec 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -159,12 +159,14 @@ export class DataService { public fetchActivities({ filters, + range, skip, sortColumn, sortDirection, take }: { filters?: Filter[]; + range?: DateRange; skip?: number; sortColumn?: string; sortDirection?: SortDirection; @@ -172,6 +174,10 @@ export class DataService { }): Observable { let params = this.buildFiltersAsQueryParams({ filters }); + if (range) { + params = params.append('range', range); + } + if (skip) { params = params.append('skip', skip); } @@ -269,16 +275,25 @@ export class DataService { public fetchBenchmarkBySymbol({ dataSource, + range, startDate, symbol }: { + range: DateRange; startDate: Date; } & UniqueAsset): Observable { + let params = new HttpParams(); + + if (range) { + params = params.append('range', range); + } + return this.http.get( `/api/v1/benchmark/${dataSource}/${symbol}/${format( startDate, DATE_FORMAT - )}` + )}`, + { params } ); } diff --git a/apps/client/src/app/services/user/user.service.ts b/apps/client/src/app/services/user/user.service.ts index d8cb63d0b..b1c98bde7 100644 --- a/apps/client/src/app/services/user/user.service.ts +++ b/apps/client/src/app/services/user/user.service.ts @@ -82,6 +82,10 @@ export class UserService extends ObservableStore { private fetchUser(): Observable { return this.http.get('/api/v1/user').pipe( map((user) => { + if (user.dateOfFirstActivity) { + user.dateOfFirstActivity = parseISO(user.dateOfFirstActivity); + } + if (user.settings?.retirementDate) { user.settings.retirementDate = parseISO(user.settings.retirementDate); } diff --git a/libs/common/src/lib/interfaces/user.interface.ts b/libs/common/src/lib/interfaces/user.interface.ts index 16893de6d..2891314a0 100644 --- a/libs/common/src/lib/interfaces/user.interface.ts +++ b/libs/common/src/lib/interfaces/user.interface.ts @@ -13,6 +13,7 @@ export interface User { id: string; }[]; accounts: Account[]; + dateOfFirstActivity: Date; id: string; permissions: string[]; settings: UserSettings; diff --git a/libs/common/src/lib/types/date-range.type.ts b/libs/common/src/lib/types/date-range.type.ts index 41aa877de..09fa3c15b 100644 --- a/libs/common/src/lib/types/date-range.type.ts +++ b/libs/common/src/lib/types/date-range.type.ts @@ -1 +1,9 @@ -export type DateRange = '1d' | '1y' | '5y' | 'max' | 'mtd' | 'wtd' | 'ytd'; +export type DateRange = + | '1d' + | '1y' + | '5y' + | 'max' + | 'mtd' + | 'wtd' + | 'ytd' + | string; // '2024', '2023', '2022', etc. diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index f4f9beea1..bd8355125 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -24,6 +24,7 @@ import { import { FormBuilder, FormControl } from '@angular/forms'; import { MatMenuTrigger } from '@angular/material/menu'; import { Account, AssetClass } from '@prisma/client'; +import { eachYearOfInterval, format } from 'date-fns'; import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs'; import { catchError, @@ -35,7 +36,11 @@ import { } from 'rxjs/operators'; import { AssistantListItemComponent } from './assistant-list-item/assistant-list-item.component'; -import { ISearchResultItem, ISearchResults } from './interfaces/interfaces'; +import { + IDateRangeOption, + ISearchResultItem, + ISearchResults +} from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -95,27 +100,7 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit { public accounts: Account[] = []; public assetClasses: Filter[] = []; public dateRangeFormControl = new FormControl(undefined); - public readonly dateRangeOptions = [ - { label: $localize`Today`, value: '1d' }, - { - label: $localize`Week to date` + ' (' + $localize`WTD` + ')', - value: 'wtd' - }, - { - label: $localize`Month to date` + ' (' + $localize`MTD` + ')', - value: 'mtd' - }, - { - label: $localize`Year to date` + ' (' + $localize`YTD` + ')', - value: 'ytd' - }, - { label: '1 ' + $localize`year` + ' (' + $localize`1Y` + ')', value: '1y' }, - { - label: '5 ' + $localize`years` + ' (' + $localize`5Y` + ')', - value: '5y' - }, - { label: $localize`Max`, value: 'max' } - ]; + public dateRangeOptions: IDateRangeOption[] = []; public filterForm = this.formBuilder.group({ account: new FormControl(undefined), assetClass: new FormControl(undefined), @@ -199,6 +184,44 @@ export class AssistantComponent implements OnChanges, OnDestroy, OnInit { } public ngOnChanges() { + this.dateRangeOptions = [ + { label: $localize`Today`, value: '1d' }, + { + label: $localize`Week to date` + ' (' + $localize`WTD` + ')', + value: 'wtd' + }, + { + label: $localize`Month to date` + ' (' + $localize`MTD` + ')', + value: 'mtd' + }, + { + label: $localize`Year to date` + ' (' + $localize`YTD` + ')', + value: 'ytd' + }, + { + label: '1 ' + $localize`year` + ' (' + $localize`1Y` + ')', + value: '1y' + }, + { + label: '5 ' + $localize`years` + ' (' + $localize`5Y` + ')', + value: '5y' + }, + { label: $localize`Max`, value: 'max' } + ]; + + if (this.user?.settings?.isExperimentalFeatures) { + this.dateRangeOptions = this.dateRangeOptions.concat( + eachYearOfInterval({ + end: new Date(), + start: this.user?.dateOfFirstActivity ?? new Date() + }) + .map((date) => { + return { label: format(date, 'yyyy'), value: format(date, 'yyyy') }; + }) + .slice(0, -1) + ); + } + this.dateRangeFormControl.setValue(this.user?.settings?.dateRange ?? null); this.filterForm.setValue( diff --git a/libs/ui/src/lib/assistant/interfaces/interfaces.ts b/libs/ui/src/lib/assistant/interfaces/interfaces.ts index 99f70dbe1..2597ccef0 100644 --- a/libs/ui/src/lib/assistant/interfaces/interfaces.ts +++ b/libs/ui/src/lib/assistant/interfaces/interfaces.ts @@ -1,4 +1,10 @@ import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { DateRange } from '@ghostfolio/common/types'; + +export interface IDateRangeOption { + label: string; + value: DateRange; +} export interface ISearchResultItem extends UniqueAsset { assetSubClassString: string;