Browse Source

Respect activity type and date range filter

pull/7492/head
Thomas Kaul 1 month ago
parent
commit
05e5ba0457
  1. 8
      apps/api/src/app/activities/delete-activities.dto.ts
  2. 6
      apps/api/src/app/activities/get-activities.dto.ts
  3. 13
      apps/api/src/app/export/export.controller.ts
  4. 6
      apps/api/src/app/export/export.service.ts
  5. 9
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  6. 24
      libs/common/src/lib/calculation-helper.spec.ts
  7. 17
      libs/common/src/lib/calculation-helper.ts
  8. 14
      libs/common/src/lib/config.ts
  9. 12
      libs/common/src/lib/types/date-range.type.ts
  10. 8
      libs/ui/src/lib/services/data.service.ts

8
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 { DateRange } from '@ghostfolio/common/types';
import { Type as ActivityType } from '@prisma/client'; import { Type as ActivityType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer'; 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'; 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 { export class DeleteActivitiesDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@ -26,7 +30,7 @@ export class DeleteActivitiesDto {
dataSource?: string; dataSource?: string;
@IsOptional() @IsOptional()
@IsString() @Matches(DATE_RANGE_PATTERN)
range?: DateRange; range?: DateRange;
@IsOptional() @IsOptional()

6
apps/api/src/app/activities/get-activities.dto.ts

@ -1,6 +1,6 @@
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { Type } from 'class-transformer'; 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'; import { DeleteActivitiesDto } from './delete-activities.dto';
@ -11,9 +11,9 @@ export class GetActivitiesDto extends DeleteActivitiesDto {
@Type(() => Number) @Type(() => Number)
skip?: number; skip?: number;
@IsIn(Object.values(Prisma.OrderScalarFieldEnum))
@IsOptional() @IsOptional()
@IsString() sortColumn?: keyof typeof Prisma.OrderScalarFieldEnum;
sortColumn?: string;
@IsIn(['asc', 'desc'] as Prisma.SortOrder[]) @IsIn(['asc', 'desc'] as Prisma.SortOrder[])
@IsOptional() @IsOptional()

13
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 { 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 { 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { ExportResponse } from '@ghostfolio/common/interfaces'; import { ExportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
@ -37,12 +38,20 @@ export class ExportController {
@Query('activityTypes') filterByTypes?: string, @Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange,
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<ExportResponse> { ): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? []; const activityIds = filterByActivityIds?.split(',') ?? [];
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? []; const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? [];
let endDate: Date;
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getIntervalFromDateRange({ dateRange }));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -54,7 +63,9 @@ export class ExportController {
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
activityTypes, activityTypes,
endDate,
filters, filters,
startDate,
userId: this.request.user.id, userId: this.request.user.id,
userSettings: this.request.user.settings.settings userSettings: this.request.user.settings.settings
}); });

6
apps/api/src/app/export/export.service.ts

@ -25,13 +25,17 @@ export class ExportService {
public async export({ public async export({
activityIds, activityIds,
activityTypes, activityTypes,
endDate,
filters, filters,
startDate,
userId, userId,
userSettings userSettings
}: { }: {
activityIds?: string[]; activityIds?: string[];
activityTypes?: ActivityType[]; activityTypes?: ActivityType[];
endDate?: Date;
filters?: Filter[]; filters?: Filter[];
startDate?: Date;
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
}): Promise<ExportResponse> { }): Promise<ExportResponse> {
@ -41,7 +45,9 @@ export class ExportService {
const platformsMap: { [platformId: string]: Platform } = {}; const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.activitiesService.getActivities({ let { activities } = await this.activitiesService.getActivities({
endDate,
filters, filters,
startDate,
userId, userId,
includeDrafts: true, includeDrafts: true,
sortColumn: 'date', sortColumn: 'date',

9
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -91,9 +91,13 @@ export class GfActivitiesPageComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
const previousDateRange = this.getDateRange();
this.updateUser(state.user); this.updateUser(state.user);
this.pageIndex = 0; if (previousDateRange !== this.getDateRange()) {
this.pageIndex = 0;
}
this.fetchActivities(); this.fetchActivities();
@ -164,7 +168,8 @@ export class GfActivitiesPageComponent implements OnInit {
activityTypes: this.activityTypesFilter.length activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter ? this.activityTypesFilter
: undefined, : undefined,
filters: this.userService.getFilters() filters: this.userService.getFilters(),
range: this.getDateRange()
}; };
} }

24
libs/common/src/lib/calculation-helper.spec.ts

@ -1,8 +1,30 @@
import { Big } from 'big.js'; 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('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', () => { describe('annualized performance percentage', () => {
it('Get annualized performance', async () => { it('Get annualized performance', async () => {
expect( expect(

17
libs/common/src/lib/calculation-helper.ts

@ -1,4 +1,3 @@
import { UTCDate } from '@date-fns/utc';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
endOfDay, endOfDay,
@ -79,17 +78,13 @@ export function getIntervalFromDateRange(params: {
break; break;
default: { default: {
// '2024', '2023', '2022', etc. // '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 // Derive the boundaries of the calendar year in the local time zone, as
// the server's time zone, but hand out plain dates as the consumers apply // the consumers apply local time zone semantics. As the start date is
// local time zone semantics. As the start date is exclusive, the last // exclusive, the last millisecond of the preceding year is used.
// millisecond of the preceding year is used. endDate = endOfYear(yearStartDate);
endDate = new Date(endOfYear(yearStartDate).getTime()); startDate = max([startDate, subMilliseconds(yearStartDate, 1)]);
startDate = max([
startDate,
new Date(subMilliseconds(yearStartDate, 1).getTime())
]);
} }
} }

14
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 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 = export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE =
'PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE'; 'PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE';
export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH = 1; export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH = 1;

12
libs/common/src/lib/types/date-range.type.ts

@ -1,9 +1,3 @@
export type DateRange = import type { DATE_RANGES } from '../config';
| '1d'
| '1y' export type DateRange = (typeof DATE_RANGES)[number] | string; // '2024', '2023', '2022', etc.
| '5y'
| 'max'
| 'mtd'
| 'wtd'
| 'ytd'
| string; // '2024', '2023', '2022', etc.

8
libs/ui/src/lib/services/data.service.ts

@ -475,11 +475,13 @@ export class DataService {
public fetchExport({ public fetchExport({
activityIds, activityIds,
activityTypes, activityTypes,
filters filters,
range
}: { }: {
activityIds?: string[]; activityIds?: string[];
activityTypes?: string[]; activityTypes?: string[];
filters?: Filter[]; filters?: Filter[];
range?: DateRange;
} = {}) { } = {}) {
let params = this.buildFiltersAsQueryParams({ filters }); let params = this.buildFiltersAsQueryParams({ filters });
@ -491,6 +493,10 @@ export class DataService {
params = params.append('activityTypes', activityTypes.join(',')); params = params.append('activityTypes', activityTypes.join(','));
} }
if (range) {
params = params.append('range', range);
}
return this.http.get<ExportResponse>('/api/v1/export', { return this.http.get<ExportResponse>('/api/v1/export', {
params params
}); });

Loading…
Cancel
Save