From 05e5ba0457b41c472eaa6f7c6ae6f5e13099d617 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:18:36 +0200 Subject: [PATCH] Respect activity type and date range filter --- .../app/activities/delete-activities.dto.ts | 8 +++++-- .../src/app/activities/get-activities.dto.ts | 6 ++--- apps/api/src/app/export/export.controller.ts | 13 +++++++++- apps/api/src/app/export/export.service.ts | 6 +++++ .../activities/activities-page.component.ts | 9 +++++-- .../common/src/lib/calculation-helper.spec.ts | 24 ++++++++++++++++++- libs/common/src/lib/calculation-helper.ts | 17 +++++-------- libs/common/src/lib/config.ts | 14 +++++++++++ libs/common/src/lib/types/date-range.type.ts | 12 +++------- libs/ui/src/lib/services/data.service.ts | 8 ++++++- 10 files changed, 87 insertions(+), 30 deletions(-) diff --git a/apps/api/src/app/activities/delete-activities.dto.ts b/apps/api/src/app/activities/delete-activities.dto.ts index 7a4f79bfc0..cc5ac017c0 100644 --- a/apps/api/src/app/activities/delete-activities.dto.ts +++ b/apps/api/src/app/activities/delete-activities.dto.ts @@ -1,10 +1,14 @@ +import { DATE_RANGES } from '@ghostfolio/common/config'; import { DateRange } from '@ghostfolio/common/types'; import { Type as ActivityType } from '@prisma/client'; import { Transform, TransformFnParams } from 'class-transformer'; -import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { IsEnum, IsOptional, IsString, Matches } from 'class-validator'; import { isString } from 'lodash'; +// A named date range or a calendar year like '2024', '2023', '2022', etc. +const DATE_RANGE_PATTERN = new RegExp(`^(${DATE_RANGES.join('|')}|\\d{4})$`); + export class DeleteActivitiesDto { @IsOptional() @IsString() @@ -26,7 +30,7 @@ export class DeleteActivitiesDto { dataSource?: string; @IsOptional() - @IsString() + @Matches(DATE_RANGE_PATTERN) range?: DateRange; @IsOptional() diff --git a/apps/api/src/app/activities/get-activities.dto.ts b/apps/api/src/app/activities/get-activities.dto.ts index 65106ff853..cf5e064f18 100644 --- a/apps/api/src/app/activities/get-activities.dto.ts +++ b/apps/api/src/app/activities/get-activities.dto.ts @@ -1,6 +1,6 @@ import { Prisma } from '@prisma/client'; import { Type } from 'class-transformer'; -import { IsIn, IsInt, IsOptional, IsString, Min } from 'class-validator'; +import { IsIn, IsInt, IsOptional, Min } from 'class-validator'; import { DeleteActivitiesDto } from './delete-activities.dto'; @@ -11,9 +11,9 @@ export class GetActivitiesDto extends DeleteActivitiesDto { @Type(() => Number) skip?: number; + @IsIn(Object.values(Prisma.OrderScalarFieldEnum)) @IsOptional() - @IsString() - sortColumn?: string; + sortColumn?: keyof typeof Prisma.OrderScalarFieldEnum; @IsIn(['asc', 'desc'] as Prisma.SortOrder[]) @IsOptional() diff --git a/apps/api/src/app/export/export.controller.ts b/apps/api/src/app/export/export.controller.ts index 4f4f4e6ddf..c9a54cb5a0 100644 --- a/apps/api/src/app/export/export.controller.ts +++ b/apps/api/src/app/export/export.controller.ts @@ -2,8 +2,9 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard' import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { ExportResponse } from '@ghostfolio/common/interfaces'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import { Controller, @@ -37,12 +38,20 @@ export class ExportController { @Query('activityTypes') filterByTypes?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, + @Query('range') dateRange?: DateRange, @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string ): Promise { const activityIds = filterByActivityIds?.split(',') ?? []; const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? []; + let endDate: Date; + let startDate: Date; + + if (dateRange) { + ({ endDate, startDate } = getIntervalFromDateRange({ dateRange })); + } + const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, @@ -54,7 +63,9 @@ export class ExportController { return this.exportService.export({ activityIds, activityTypes, + endDate, filters, + startDate, userId: this.request.user.id, userSettings: this.request.user.settings.settings }); diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index a944795628..6e920553b7 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -25,13 +25,17 @@ export class ExportService { public async export({ activityIds, activityTypes, + endDate, filters, + startDate, userId, userSettings }: { activityIds?: string[]; activityTypes?: ActivityType[]; + endDate?: Date; filters?: Filter[]; + startDate?: Date; userId: string; userSettings: UserSettings; }): Promise { @@ -41,7 +45,9 @@ export class ExportService { const platformsMap: { [platformId: string]: Platform } = {}; let { activities } = await this.activitiesService.getActivities({ + endDate, filters, + startDate, userId, includeDrafts: true, sortColumn: 'date', 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 1b4019b23e..87da2fb844 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 @@ -91,9 +91,13 @@ export class GfActivitiesPageComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { if (state?.user) { + const previousDateRange = this.getDateRange(); + this.updateUser(state.user); - this.pageIndex = 0; + if (previousDateRange !== this.getDateRange()) { + this.pageIndex = 0; + } this.fetchActivities(); @@ -164,7 +168,8 @@ export class GfActivitiesPageComponent implements OnInit { activityTypes: this.activityTypesFilter.length ? this.activityTypesFilter : undefined, - filters: this.userService.getFilters() + filters: this.userService.getFilters(), + range: this.getDateRange() }; } diff --git a/libs/common/src/lib/calculation-helper.spec.ts b/libs/common/src/lib/calculation-helper.spec.ts index 69621ec0a8..75a24edbab 100644 --- a/libs/common/src/lib/calculation-helper.spec.ts +++ b/libs/common/src/lib/calculation-helper.spec.ts @@ -1,8 +1,30 @@ import { Big } from 'big.js'; +import { format } from 'date-fns'; -import { getAnnualizedPerformancePercent } from './calculation-helper'; +import { + getAnnualizedPerformancePercent, + getIntervalFromDateRange +} from './calculation-helper'; +import { DATE_FORMAT } from './helper'; describe('CalculationHelper', () => { + describe('interval from date range', () => { + it('Get interval of a calendar year', async () => { + const { endDate, startDate } = getIntervalFromDateRange({ + dateRange: '2024' + }); + + // The boundaries are expressed in the local time zone, therefore the + // calendar days must hold independently of the time zone the tests run in + expect(format(startDate, DATE_FORMAT)).toEqual('2023-12-31'); + expect(format(endDate, DATE_FORMAT)).toEqual('2024-12-31'); + + // The start date is exclusive, hence the first instant of the year is + // part of the interval + expect(startDate.getTime()).toEqual(new Date(2024, 0, 1).getTime() - 1); + }); + }); + describe('annualized performance percentage', () => { it('Get annualized performance', async () => { expect( diff --git a/libs/common/src/lib/calculation-helper.ts b/libs/common/src/lib/calculation-helper.ts index 8fceea828b..d49663d6cf 100644 --- a/libs/common/src/lib/calculation-helper.ts +++ b/libs/common/src/lib/calculation-helper.ts @@ -1,4 +1,3 @@ -import { UTCDate } from '@date-fns/utc'; import { Big } from 'big.js'; import { endOfDay, @@ -79,17 +78,13 @@ export function getIntervalFromDateRange(params: { break; default: { // '2024', '2023', '2022', etc. - const yearStartDate = new UTCDate(`${dateRange}-01-01`); + const yearStartDate = new Date(Number(dateRange), 0, 1); - // Derive the boundaries of the calendar year in UTC to be independent of - // the server's time zone, but hand out plain dates as the consumers apply - // local time zone semantics. As the start date is exclusive, the last - // millisecond of the preceding year is used. - endDate = new Date(endOfYear(yearStartDate).getTime()); - startDate = max([ - startDate, - new Date(subMilliseconds(yearStartDate, 1).getTime()) - ]); + // Derive the boundaries of the calendar year in the local time zone, as + // the consumers apply local time zone semantics. As the start date is + // exclusive, the last millisecond of the preceding year is used. + endDate = endOfYear(yearStartDate); + startDate = max([startDate, subMilliseconds(yearStartDate, 1)]); } } diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 08fb99e28b..bb4ace0c59 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -67,6 +67,20 @@ export const DATA_GATHERING_QUEUE_PRIORITY_MEDIUM = Math.round( DATA_GATHERING_QUEUE_PRIORITY_LOW / 2 ); +/** + * The named date ranges, complemented by the calendar years like '2024', + * '2023', '2022', etc. + */ +export const DATE_RANGES = [ + '1d', + '1y', + '5y', + 'max', + 'mtd', + 'wtd', + 'ytd' +] as const; + export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE = 'PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE'; export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH = 1; diff --git a/libs/common/src/lib/types/date-range.type.ts b/libs/common/src/lib/types/date-range.type.ts index 09fa3c15b2..5c25cfe395 100644 --- a/libs/common/src/lib/types/date-range.type.ts +++ b/libs/common/src/lib/types/date-range.type.ts @@ -1,9 +1,3 @@ -export type DateRange = - | '1d' - | '1y' - | '5y' - | 'max' - | 'mtd' - | 'wtd' - | 'ytd' - | string; // '2024', '2023', '2022', etc. +import type { DATE_RANGES } from '../config'; + +export type DateRange = (typeof DATE_RANGES)[number] | string; // '2024', '2023', '2022', etc. diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index 16f93fef89..870719702a 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -475,11 +475,13 @@ export class DataService { public fetchExport({ activityIds, activityTypes, - filters + filters, + range }: { activityIds?: string[]; activityTypes?: string[]; filters?: Filter[]; + range?: DateRange; } = {}) { let params = this.buildFiltersAsQueryParams({ filters }); @@ -491,6 +493,10 @@ export class DataService { params = params.append('activityTypes', activityTypes.join(',')); } + if (range) { + params = params.append('range', range); + } + return this.http.get('/api/v1/export', { params });