diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f0cdabbb..5172bd090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added support for filtering on the analysis page - Added the price to the `Subscription` database schema ### Changed diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 52bea14f4..3a1fa9898 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -189,11 +189,21 @@ export class PortfolioController { @UseGuards(AuthGuard('jwt')) public async getDividends( @Headers('impersonation-id') impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('groupBy') groupBy?: GroupBy, @Query('range') dateRange: DateRange = 'max', - @Query('groupBy') groupBy?: GroupBy + @Query('tags') filterByTags?: string ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByTags + }); + let dividends = await this.portfolioService.getDividends({ dateRange, + filters, groupBy, impersonationId }); @@ -229,11 +239,21 @@ export class PortfolioController { @UseGuards(AuthGuard('jwt')) public async getInvestments( @Headers('impersonation-id') impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('groupBy') groupBy?: GroupBy, @Query('range') dateRange: DateRange = 'max', - @Query('groupBy') groupBy?: GroupBy + @Query('tags') filterByTags?: string ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByTags + }); + let investments = await this.portfolioService.getInvestments({ dateRange, + filters, groupBy, impersonationId }); @@ -271,10 +291,20 @@ export class PortfolioController { @Version('2') public async getPerformanceV2( @Headers('impersonation-id') impersonationId: string, - @Query('range') dateRange: DateRange = 'max' + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('range') dateRange: DateRange = 'max', + @Query('tags') filterByTags?: string ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByTags + }); + const performanceInformation = await this.portfolioService.getPerformance({ dateRange, + filters, impersonationId, userId: this.request.user.id }); @@ -329,12 +359,22 @@ export class PortfolioController { @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getPositions( @Headers('impersonation-id') impersonationId: string, - @Query('range') dateRange: DateRange = 'max' + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('range') dateRange: DateRange = 'max', + @Query('tags') filterByTags?: string ): Promise { - const result = await this.portfolioService.getPositions( - impersonationId, - dateRange - ); + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByTags + }); + + const result = await this.portfolioService.getPositions({ + dateRange, + filters, + impersonationId + }); if ( impersonationId || diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 1a99c1b5b..21654f531 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -210,16 +210,19 @@ export class PortfolioService { public async getDividends({ dateRange, + filters, groupBy, impersonationId }: { dateRange: DateRange; + filters?: Filter[]; groupBy?: GroupBy; impersonationId: string; }): Promise { const userId = await this.getUserId(impersonationId, this.request.user.id); const activities = await this.orderService.getOrders({ + filters, userId, types: ['DIVIDEND'], userCurrency: this.request.user.Settings.settings.baseCurrency @@ -248,10 +251,12 @@ export class PortfolioService { public async getInvestments({ dateRange, + filters, groupBy, impersonationId }: { dateRange: DateRange; + filters?: Filter[]; groupBy?: GroupBy; impersonationId: string; }): Promise { @@ -259,6 +264,7 @@ export class PortfolioService { const { portfolioOrders, transactionPoints } = await this.getTransactionPoints({ + filters, userId, includeDrafts: true }); @@ -343,11 +349,13 @@ export class PortfolioService { public async getChart({ dateRange = 'max', + filters, impersonationId, userCurrency, userId }: { dateRange?: DateRange; + filters?: Filter[]; impersonationId: string; userCurrency: string; userId: string; @@ -356,6 +364,7 @@ export class PortfolioService { const { portfolioOrders, transactionPoints } = await this.getTransactionPoints({ + filters, userId }); @@ -397,15 +406,15 @@ export class PortfolioService { } public async getDetails({ - impersonationId, dateRange = 'max', filters, + impersonationId, userId, withExcludedAccounts = false }: { - impersonationId: string; dateRange?: DateRange; filters?: Filter[]; + impersonationId: string; userId: string; withExcludedAccounts?: boolean; }): Promise { @@ -850,14 +859,20 @@ export class PortfolioService { } } - public async getPositions( - aImpersonationId: string, - aDateRange: DateRange = 'max' - ): Promise<{ hasErrors: boolean; positions: Position[] }> { - const userId = await this.getUserId(aImpersonationId, this.request.user.id); + public async getPositions({ + dateRange = 'max', + filters, + impersonationId + }: { + dateRange?: DateRange; + filters?: Filter[]; + impersonationId: string; + }): Promise<{ hasErrors: boolean; positions: Position[] }> { + const userId = await this.getUserId(impersonationId, this.request.user.id); const { portfolioOrders, transactionPoints } = await this.getTransactionPoints({ + filters, userId }); @@ -877,7 +892,7 @@ export class PortfolioService { portfolioCalculator.setTransactionPoints(transactionPoints); const portfolioStart = parseDate(transactionPoints[0].date); - const startDate = this.getStartDate(aDateRange, portfolioStart); + const startDate = this.getStartDate(dateRange, portfolioStart); const currentPositions = await portfolioCalculator.getCurrentPositions( startDate ); @@ -928,10 +943,12 @@ export class PortfolioService { public async getPerformance({ dateRange = 'max', + filters, impersonationId, userId }: { dateRange?: DateRange; + filters?: Filter[]; impersonationId: string; userId: string; }): Promise { @@ -941,6 +958,7 @@ export class PortfolioService { const { portfolioOrders, transactionPoints } = await this.getTransactionPoints({ + filters, userId }); @@ -996,6 +1014,7 @@ export class PortfolioService { const historicalDataContainer = await this.getChart({ dateRange, + filters, impersonationId, userCurrency, userId 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 2f7e3ff58..99c22a076 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 @@ -8,6 +8,7 @@ import { DataService } from '@ghostfolio/client/services/data.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { + Filter, HistoricalDataItem, Position, User @@ -15,12 +16,13 @@ import { import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types'; -import { DataSource, SymbolProfile } from '@prisma/client'; +import { translate } from '@ghostfolio/ui/i18n'; +import { AssetClass, DataSource, SymbolProfile } from '@prisma/client'; import { differenceInDays } from 'date-fns'; import { sortBy } from 'lodash'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators'; @Component({ host: { class: 'page' }, @@ -29,6 +31,8 @@ import { takeUntil } from 'rxjs/operators'; templateUrl: './analysis-page.html' }) export class AnalysisPageComponent implements OnDestroy, OnInit { + public activeFilters: Filter[] = []; + public allFilters: Filter[]; public benchmarkDataItems: HistoricalDataItem[] = []; public benchmarks: Partial[]; public bottom3: Position[]; @@ -37,6 +41,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { public deviceType: string; public dividendsByMonth: InvestmentItem[]; public dividendTimelineDataLabel = $localize`Dividend`; + public filters$ = new Subject(); public firstOrderDate: Date; public hasImpersonationId: boolean; public investments: InvestmentItem[]; @@ -50,6 +55,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { ]; public performanceDataItems: HistoricalDataItem[]; public performanceDataItemsInPercentage: HistoricalDataItem[]; + public placeholder = ''; public portfolioEvolutionDataLabel = $localize`Deposit`; public top3: Position[]; public user: User; @@ -95,12 +101,63 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.hasImpersonationId = !!aId; }); + this.filters$ + .pipe( + distinctUntilChanged(), + map((filters) => { + this.activeFilters = filters; + this.placeholder = + this.activeFilters.length <= 0 + ? $localize`Filter by account or tag...` + : ''; + + this.update(); + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe(() => {}); + this.userService.stateChanged .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((state) => { if (state?.user) { this.user = state.user; + const accountFilters: Filter[] = this.user.accounts + .filter(({ accountType }) => { + return accountType === 'SECURITIES'; + }) + .map(({ id, name }) => { + return { + id, + label: name, + type: 'ACCOUNT' + }; + }); + + const assetClassFilters: Filter[] = []; + for (const assetClass of Object.keys(AssetClass)) { + assetClassFilters.push({ + id: assetClass, + label: translate(assetClass), + type: 'ASSET_CLASS' + }); + } + + const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => { + return { + id, + label: name, + type: 'TAG' + }; + }); + + this.allFilters = [ + ...accountFilters, + ...assetClassFilters, + ...tagFilters + ]; + this.update(); } }); @@ -198,6 +255,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.dataService .fetchPortfolioPerformance({ + filters: this.activeFilters, range: this.user?.settings?.dateRange }) .pipe(takeUntil(this.unsubscribeSubject)) @@ -235,6 +293,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.dataService .fetchDividends({ + filters: this.activeFilters, groupBy: 'month', range: this.user?.settings?.dateRange }) @@ -247,6 +306,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.dataService .fetchInvestments({ + filters: this.activeFilters, groupBy: 'month', range: this.user?.settings?.dateRange }) @@ -258,7 +318,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { }); this.dataService - .fetchPositions({ range: this.user?.settings?.dateRange }) + .fetchPositions({ + filters: this.activeFilters, + range: this.user?.settings?.dateRange + }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ positions }) => { const positionsSorted = sortBy( diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 2f54f96d3..7c80842c6 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -8,6 +8,12 @@ (change)="onChangeDateRange($event.value)" > +
('/api/v1/portfolio/dividends', { - params: { groupBy, range } + params }); } @@ -191,15 +197,21 @@ export class DataService { } public fetchInvestments({ + filters, groupBy = 'month', range }: { + filters?: Filter[]; groupBy?: GroupBy; range: DateRange; }) { + let params = this.buildFiltersAsQueryParams({ filters }); + params = params.append('groupBy', groupBy); + params = params.append('range', range); + return this.http.get( '/api/v1/portfolio/investments', - { params: { groupBy, range } } + { params } ); } @@ -224,12 +236,17 @@ export class DataService { } public fetchPositions({ + filters, range }: { + filters?: Filter[]; range: DateRange; }): Observable { + let params = this.buildFiltersAsQueryParams({ filters }); + params = params.append('range', range); + return this.http.get('/api/v1/portfolio/positions', { - params: { range } + params }); } @@ -284,12 +301,19 @@ export class DataService { } public fetchPortfolioPerformance({ + filters, range }: { + filters?: Filter[]; range: DateRange; }): Observable { + let params = this.buildFiltersAsQueryParams({ filters }); + params = params.append('range', range); + return this.http - .get(`/api/v2/portfolio/performance`, { params: { range } }) + .get(`/api/v2/portfolio/performance`, { + params + }) .pipe( map((response) => { if (response.firstOrderDate) {