Browse Source

Bugfix/deletion of activities to respect activity type and date range filter (#7492)

* Respect activity type and date range filter

* Update changelog
pull/7494/head
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
031b3b68c7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 9
      CHANGELOG.md
  2. 43
      apps/api/src/app/activities/activities-filter.dto.ts
  3. 83
      apps/api/src/app/activities/activities.controller.ts
  4. 9
      apps/api/src/app/activities/activities.service.ts
  5. 27
      apps/api/src/app/activities/get-activities.dto.ts
  6. 39
      apps/api/src/app/export/export.controller.ts
  7. 6
      apps/api/src/app/export/export.service.ts
  8. 14
      apps/api/src/app/export/get-export.dto.ts
  9. 5
      apps/client/src/app/app.component.ts
  10. 33
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  11. 24
      libs/common/src/lib/calculation-helper.spec.ts
  12. 13
      libs/common/src/lib/calculation-helper.ts
  13. 14
      libs/common/src/lib/config.ts
  14. 12
      libs/common/src/lib/types/date-range.type.ts
  15. 11
      libs/ui/src/lib/activities-table/activities-table.component.html
  16. 7
      libs/ui/src/lib/activities-table/activities-table.component.ts
  17. 34
      libs/ui/src/lib/services/data.service.ts

9
CHANGELOG.md

@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added support for the date range filter in the export functionality
- Added support for the date range filter on the portfolio activities page
### Changed ### Changed
- Improved the style of the tabs in the account detail dialog on mobile - Improved the style of the tabs in the account detail dialog on mobile
@ -19,10 +24,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the search functionality by trimming the query - Improved the search functionality by trimming the query
- Improved the log output in the search functionality of the _Yahoo Finance_ service for unsupported queries - Improved the log output in the search functionality of the _Yahoo Finance_ service for unsupported queries
- Improved the performance of the property service by caching the properties in memory - Improved the performance of the property service by caching the properties in memory
- Improved the validation of the query parameters in the activities endpoints
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
### Fixed ### Fixed
- Fixed the calendar year date range in time zones with a negative _UTC_ offset
- Fixed the deletion of activities to respect the activity type filter on the activities page (experimental)
- Fixed the deletion of activities to respect the date range filter on the activities page
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Equity) - Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Equity)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Fixed Income) - Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Fixed Income)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment) - Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment)

43
apps/api/src/app/activities/activities-filter.dto.ts

@ -0,0 +1,43 @@
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, 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 ActivitiesFilterDto {
@IsOptional()
@IsString()
accounts?: string;
@IsEnum(ActivityType, { each: true })
@IsOptional()
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityTypes?: ActivityType[];
@IsOptional()
@IsString()
assetClasses?: string;
@IsOptional()
@IsString()
dataSource?: string;
@IsOptional()
@Matches(DATE_RANGE_PATTERN)
range?: DateRange;
@IsOptional()
@IsString()
symbol?: string;
@IsOptional()
@IsString()
tags?: string;
}

83
apps/api/src/app/activities/activities.controller.ts

@ -18,7 +18,7 @@ import {
ActivityResponse ActivityResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -29,7 +29,6 @@ import {
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseIntPipe,
Post, Post,
Put, Put,
Query, Query,
@ -38,11 +37,13 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Order, Prisma, Type as ActivityType } from '@prisma/client'; import { Order } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ActivitiesFilterDto } from './activities-filter.dto';
import { ActivitiesService } from './activities.service'; import { ActivitiesService } from './activities.service';
import { GetActivitiesDto } from './get-activities.dto';
@Controller('activities') @Controller('activities')
export class ActivitiesController { export class ActivitiesController {
@ -60,22 +61,38 @@ export class ActivitiesController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteActivities( public async deleteActivities(
@Query('accounts') filterByAccounts?: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query() query: ActivitiesFilterDto
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<number> { ): Promise<number> {
if (impersonationId) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let endDate: Date;
let startDate: Date;
if (query.range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: query.range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: query.accounts,
filterByAssetClasses, filterByAssetClasses: query.assetClasses,
filterByDataSource, filterByDataSource: query.dataSource,
filterBySymbol, filterBySymbol: query.symbol,
filterByTags filterByTags: query.tags
}); });
return this.activitiesService.deleteActivities({ return this.activitiesService.deleteActivities({
endDate,
filters, filters,
startDate,
types: query.activityTypes,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@ -108,51 +125,41 @@ export class ActivitiesController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllActivities( public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query() query: GetActivitiesDto
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange,
@Query('skip', new ParseIntPipe({ optional: true })) skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('take', new ParseIntPipe({ optional: true })) take?: number
): Promise<ActivitiesResponse> { ): Promise<ActivitiesResponse> {
let endDate: Date; let endDate: Date;
let startDate: Date; let startDate: Date;
if (dateRange) { if (query.range) {
({ endDate, startDate } = getIntervalFromDateRange({ dateRange })); ({ endDate, startDate } = getIntervalFromDateRange({
dateRange: query.range
}));
} }
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: query.accounts,
filterByAssetClasses, filterByAssetClasses: query.assetClasses,
filterByDataSource, filterByDataSource: query.dataSource,
filterBySymbol, filterBySymbol: query.symbol,
filterByTags filterByTags: query.tags
}); });
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
endDate, endDate,
filters, filters,
skip,
sortColumn,
sortDirection,
startDate, startDate,
take,
types,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
skip: query.skip,
sortColumn: query.sortColumn,
sortDirection: query.sortDirection,
take: query.take,
types: query.activityTypes,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });

9
apps/api/src/app/activities/activities.service.ts

@ -361,14 +361,23 @@ export class ActivitiesService {
} }
public async deleteActivities({ public async deleteActivities({
endDate,
filters, filters,
startDate,
types,
userId userId
}: { }: {
endDate?: Date;
filters?: Filter[]; filters?: Filter[];
startDate?: Date;
types?: ActivityType[];
userId: string; userId: string;
}): Promise<number> { }): Promise<number> {
const { activities } = await this.getActivities({ const { activities } = await this.getActivities({
endDate,
filters, filters,
startDate,
types,
userId, userId,
includeDrafts: true, includeDrafts: true,
userCurrency: undefined, userCurrency: undefined,

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

@ -0,0 +1,27 @@
import { Prisma } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Min } from 'class-validator';
import { ActivitiesFilterDto } from './activities-filter.dto';
export class GetActivitiesDto extends ActivitiesFilterDto {
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
skip?: number;
@IsIn(Object.values(Prisma.OrderScalarFieldEnum))
@IsOptional()
sortColumn?: keyof typeof Prisma.OrderScalarFieldEnum;
@IsIn(['asc', 'desc'] as Prisma.SortOrder[])
@IsOptional()
sortDirection?: Prisma.SortOrder;
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
take?: number;
}

39
apps/api/src/app/export/export.controller.ts

@ -2,6 +2,7 @@ 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 { RequestWithUser } from '@ghostfolio/common/types';
@ -15,9 +16,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { GetExportDto } from './get-export.dto';
@Controller('export') @Controller('export')
export class ExportController { export class ExportController {
@ -31,30 +32,30 @@ export class ExportController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async export( public async export(@Query() query: GetExportDto): Promise<ExportResponse> {
@Query('accounts') filterByAccounts?: string, let endDate: Date;
@Query('activityIds') filterByActivityIds?: string, let startDate: Date;
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string, if (query.range) {
@Query('dataSource') filterByDataSource?: string, ({ endDate, startDate } = getIntervalFromDateRange({
@Query('symbol') filterBySymbol?: string, dateRange: query.range
@Query('tags') filterByTags?: string }));
): Promise<ExportResponse> { }
const activityIds = filterByActivityIds?.split(',') ?? [];
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: query.accounts,
filterByAssetClasses, filterByAssetClasses: query.assetClasses,
filterByDataSource, filterByDataSource: query.dataSource,
filterBySymbol, filterBySymbol: query.symbol,
filterByTags filterByTags: query.tags
}); });
return this.exportService.export({ return this.exportService.export({
activityIds, endDate,
activityTypes,
filters, filters,
startDate,
activityIds: query.activityIds,
activityTypes: query.activityTypes,
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',

14
apps/api/src/app/export/get-export.dto.ts

@ -0,0 +1,14 @@
import { ActivitiesFilterDto } from '@ghostfolio/api/app/activities/activities-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsOptional, IsUUID } from 'class-validator';
import { isString } from 'lodash';
export class GetExportDto extends ActivitiesFilterDto {
@IsOptional()
@IsUUID(undefined, { each: true })
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityIds?: string[];
}

5
apps/client/src/app/app.component.ts

@ -135,7 +135,10 @@ export class GfAppComponent implements OnInit {
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.home.subRoutes?.holdings.path) || internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path && (this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute)) && !this.currentSubRoute) ||
(this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute ===
internalRoutes.portfolio.subRoutes?.activities.path)) &&
this.user?.settings?.viewMode !== 'ZEN' this.user?.settings?.viewMode !== 'ZEN'
) { ) {
this.hasPermissionToChangeDateRange = true; this.hasPermissionToChangeDateRange = true;

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

@ -10,7 +10,6 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DateRange } from '@ghostfolio/common/types';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -92,8 +91,14 @@ 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);
if (previousDateRange !== this.getDateRange()) {
this.pageIndex = 0;
}
this.fetchActivities(); this.fetchActivities();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
@ -120,7 +125,11 @@ export class GfActivitiesPageComponent implements OnInit {
protected onDeleteActivities() { protected onDeleteActivities() {
this.dataService this.dataService
.deleteActivities({ .deleteActivities({
filters: this.userService.getFilters() activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined,
filters: this.userService.getFilters(),
range: this.getDateRange()
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
@ -159,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()
}; };
} }
@ -277,16 +287,13 @@ export class GfActivitiesPageComponent implements OnInit {
this.dataSource = undefined; this.dataSource = undefined;
this.totalItems = undefined; this.totalItems = undefined;
const dateRange = this.user?.settings?.dateRange;
const range = this.isCalendarYear(dateRange) ? dateRange : undefined;
this.dataService this.dataService
.fetchActivities({ .fetchActivities({
range,
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(),
skip: this.pageIndex * this.pageSize, skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn, sortColumn: this.sortColumn,
sortDirection: this.sortDirection, sortDirection: this.sortDirection,
@ -311,12 +318,16 @@ export class GfActivitiesPageComponent implements OnInit {
}); });
} }
private isCalendarYear(dateRange?: DateRange) { private getDateRange() {
if (!dateRange) { const dateRange = this.user?.settings?.dateRange;
return false;
// Omit the date ranges which do not apply to activities: '1d' spans today
// only, while 'max' would exclude drafts dated in the future
if (!dateRange || ['1d', 'max'].includes(dateRange)) {
return undefined;
} }
return /^\d{4}$/.test(dateRange); return dateRange;
} }
private updateUser(aUser: User) { private updateUser(aUser: User) {

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(

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

@ -7,6 +7,7 @@ import {
startOfWeek, startOfWeek,
startOfYear, startOfYear,
subDays, subDays,
subMilliseconds,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isFinite, isNumber } from 'lodash'; import { isFinite, isNumber } from 'lodash';
@ -75,10 +76,16 @@ export function getIntervalFromDateRange(params: {
break; break;
case 'max': case 'max':
break; break;
default: default: {
// '2024', '2023', '2022', etc. // '2024', '2023', '2022', etc.
endDate = endOfYear(new Date(dateRange)); const yearStartDate = new Date(Number(dateRange), 0, 1);
startDate = max([startDate, new Date(dateRange)]);
// 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)]);
}
} }
return { endDate, startDate }; return { endDate, startDate };

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.

11
libs/ui/src/lib/activities-table/activities-table.component.html

@ -58,12 +58,19 @@
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
[disabled]="dataSource()?.data.length === 0" [disabled]="!canExportActivities()"
(click)="onExport()" (click)="onExport()"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="cloud-download-outline" /> <ion-icon class="mr-2" name="cloud-download-outline" />
<span i18n>Export Activities</span> @if (canExportActivities()) {
<span i18n
>Export {{ totalItems > 1 ? totalItems : '' }}
{totalItems, plural, =1 {Activity} other {Activities}}</span
>
} @else {
<span i18n>Export Activities</span>
}
</span> </span>
</button> </button>
} }

7
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -289,6 +289,13 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
); );
} }
public canExportActivities() {
return (
(this.dataSource()?.data.length ?? 0) > 0 &&
this.hasPermissionToExportActivities
);
}
public isExcludedFromAnalysis(activity: Activity) { public isExcludedFromAnalysis(activity: Activity) {
return ( return (
(activity.account && isAccountExcluded(activity.account)) ?? (activity.account && isAccountExcluded(activity.account)) ??

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

@ -329,8 +329,24 @@ export class DataService {
return this.http.delete<AccountBalance>(`/api/v1/account-balance/${aId}`); return this.http.delete<AccountBalance>(`/api/v1/account-balance/${aId}`);
} }
public deleteActivities({ filters }: { filters?: Filter[] }) { public deleteActivities({
const params = this.buildFiltersAsQueryParams({ filters }); activityTypes,
filters,
range
}: {
activityTypes?: string[];
filters?: Filter[];
range?: DateRange;
}) {
let params = this.buildFiltersAsQueryParams({ filters });
if (activityTypes?.length) {
params = params.append('activityTypes', activityTypes.join(','));
}
if (range) {
params = params.append('range', range);
}
return this.http.delete<number>('/api/v1/activities', { params }); return this.http.delete<number>('/api/v1/activities', { params });
} }
@ -459,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 });
@ -475,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
}); });
@ -509,8 +531,7 @@ export class DataService {
public fetchInfo(): InfoItem { public fetchInfo(): InfoItem {
const info = cloneDeep((window as any).info); const info = cloneDeep((window as any).info);
const utmSource = window.localStorage.getItem('utm_source') as const utmSource = window.localStorage.getItem('utm_source') as
| 'ios' 'ios' | 'trusted-web-activity';
| 'trusted-web-activity';
info.globalPermissions = filterGlobalPermissions( info.globalPermissions = filterGlobalPermissions(
info.globalPermissions, info.globalPermissions,
@ -949,8 +970,7 @@ export class DataService {
public updateInfo() { public updateInfo() {
this.http.get<InfoItem>('/api/v1/info').subscribe((info) => { this.http.get<InfoItem>('/api/v1/info').subscribe((info) => {
const utmSource = window.localStorage.getItem('utm_source') as const utmSource = window.localStorage.getItem('utm_source') as
| 'ios' 'ios' | 'trusted-web-activity';
| 'trusted-web-activity';
info.globalPermissions = filterGlobalPermissions( info.globalPermissions = filterGlobalPermissions(
info.globalPermissions, info.globalPermissions,

Loading…
Cancel
Save