Feature/add filters to analytics page (#1559)

* Add filters to analysis page

* Update changelog
This commit is contained in:
Thomas Kaul 2022-12-29 10:31:21 +01:00 committed by GitHub
parent dd7a6f1562
commit b20fa55b79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 178 additions and 23 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added support for filtering on the analysis page
- Added the price to the `Subscription` database schema - Added the price to the `Subscription` database schema
### Changed ### Changed

View File

@ -189,11 +189,21 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getDividends( public async getDividends(
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('groupBy') groupBy?: GroupBy @Query('tags') filterByTags?: string
): Promise<PortfolioDividends> { ): Promise<PortfolioDividends> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
let dividends = await this.portfolioService.getDividends({ let dividends = await this.portfolioService.getDividends({
dateRange, dateRange,
filters,
groupBy, groupBy,
impersonationId impersonationId
}); });
@ -229,11 +239,21 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getInvestments( public async getInvestments(
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('groupBy') groupBy?: GroupBy @Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> { ): Promise<PortfolioInvestments> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
let investments = await this.portfolioService.getInvestments({ let investments = await this.portfolioService.getInvestments({
dateRange, dateRange,
filters,
groupBy, groupBy,
impersonationId impersonationId
}); });
@ -271,10 +291,20 @@ export class PortfolioController {
@Version('2') @Version('2')
public async getPerformanceV2( public async getPerformanceV2(
@Headers('impersonation-id') impersonationId: string, @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<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
const performanceInformation = await this.portfolioService.getPerformance({ const performanceInformation = await this.portfolioService.getPerformance({
dateRange, dateRange,
filters,
impersonationId, impersonationId,
userId: this.request.user.id userId: this.request.user.id
}); });
@ -329,12 +359,22 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions( public async getPositions(
@Headers('impersonation-id') impersonationId: string, @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<PortfolioPositions> { ): Promise<PortfolioPositions> {
const result = await this.portfolioService.getPositions( const filters = this.apiService.buildFiltersFromQueryParams({
impersonationId, filterByAccounts,
dateRange filterByAssetClasses,
); filterByTags
});
const result = await this.portfolioService.getPositions({
dateRange,
filters,
impersonationId
});
if ( if (
impersonationId || impersonationId ||

View File

@ -210,16 +210,19 @@ export class PortfolioService {
public async getDividends({ public async getDividends({
dateRange, dateRange,
filters,
groupBy, groupBy,
impersonationId impersonationId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
impersonationId: string; impersonationId: string;
}): Promise<InvestmentItem[]> { }): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const activities = await this.orderService.getOrders({ const activities = await this.orderService.getOrders({
filters,
userId, userId,
types: ['DIVIDEND'], types: ['DIVIDEND'],
userCurrency: this.request.user.Settings.settings.baseCurrency userCurrency: this.request.user.Settings.settings.baseCurrency
@ -248,10 +251,12 @@ export class PortfolioService {
public async getInvestments({ public async getInvestments({
dateRange, dateRange,
filters,
groupBy, groupBy,
impersonationId impersonationId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
impersonationId: string; impersonationId: string;
}): Promise<InvestmentItem[]> { }): Promise<InvestmentItem[]> {
@ -259,6 +264,7 @@ export class PortfolioService {
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters,
userId, userId,
includeDrafts: true includeDrafts: true
}); });
@ -343,11 +349,13 @@ export class PortfolioService {
public async getChart({ public async getChart({
dateRange = 'max', dateRange = 'max',
filters,
impersonationId, impersonationId,
userCurrency, userCurrency,
userId userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[];
impersonationId: string; impersonationId: string;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
@ -356,6 +364,7 @@ export class PortfolioService {
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters,
userId userId
}); });
@ -397,15 +406,15 @@ export class PortfolioService {
} }
public async getDetails({ public async getDetails({
impersonationId,
dateRange = 'max', dateRange = 'max',
filters, filters,
impersonationId,
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
impersonationId: string;
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<PortfolioDetails & { hasErrors: boolean }> { }): Promise<PortfolioDetails & { hasErrors: boolean }> {
@ -850,14 +859,20 @@ export class PortfolioService {
} }
} }
public async getPositions( public async getPositions({
aImpersonationId: string, dateRange = 'max',
aDateRange: DateRange = 'max' filters,
): Promise<{ hasErrors: boolean; positions: Position[] }> { impersonationId
const userId = await this.getUserId(aImpersonationId, this.request.user.id); }: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters,
userId userId
}); });
@ -877,7 +892,7 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(aDateRange, portfolioStart); const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate startDate
); );
@ -928,10 +943,12 @@ export class PortfolioService {
public async getPerformance({ public async getPerformance({
dateRange = 'max', dateRange = 'max',
filters,
impersonationId, impersonationId,
userId userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[];
impersonationId: string; impersonationId: string;
userId: string; userId: string;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
@ -941,6 +958,7 @@ export class PortfolioService {
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters,
userId userId
}); });
@ -996,6 +1014,7 @@ export class PortfolioService {
const historicalDataContainer = await this.getChart({ const historicalDataContainer = await this.getChart({
dateRange, dateRange,
filters,
impersonationId, impersonationId,
userCurrency, userCurrency,
userId userId

View File

@ -8,6 +8,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
Filter,
HistoricalDataItem, HistoricalDataItem,
Position, Position,
User User
@ -15,12 +16,13 @@ import {
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types'; 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 { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
@ -29,6 +31,8 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './analysis-page.html' templateUrl: './analysis-page.html'
}) })
export class AnalysisPageComponent implements OnDestroy, OnInit { export class AnalysisPageComponent implements OnDestroy, OnInit {
public activeFilters: Filter[] = [];
public allFilters: Filter[];
public benchmarkDataItems: HistoricalDataItem[] = []; public benchmarkDataItems: HistoricalDataItem[] = [];
public benchmarks: Partial<SymbolProfile>[]; public benchmarks: Partial<SymbolProfile>[];
public bottom3: Position[]; public bottom3: Position[];
@ -37,6 +41,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public dividendsByMonth: InvestmentItem[]; public dividendsByMonth: InvestmentItem[];
public dividendTimelineDataLabel = $localize`Dividend`; public dividendTimelineDataLabel = $localize`Dividend`;
public filters$ = new Subject<Filter[]>();
public firstOrderDate: Date; public firstOrderDate: Date;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public investments: InvestmentItem[]; public investments: InvestmentItem[];
@ -50,6 +55,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
]; ];
public performanceDataItems: HistoricalDataItem[]; public performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[]; public performanceDataItemsInPercentage: HistoricalDataItem[];
public placeholder = '';
public portfolioEvolutionDataLabel = $localize`Deposit`; public portfolioEvolutionDataLabel = $localize`Deposit`;
public top3: Position[]; public top3: Position[];
public user: User; public user: User;
@ -95,12 +101,63 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!aId; 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 this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = 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(); this.update();
} }
}); });
@ -198,6 +255,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
filters: this.activeFilters,
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -235,6 +293,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchDividends({ .fetchDividends({
filters: this.activeFilters,
groupBy: 'month', groupBy: 'month',
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange
}) })
@ -247,6 +306,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchInvestments({ .fetchInvestments({
filters: this.activeFilters,
groupBy: 'month', groupBy: 'month',
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange
}) })
@ -258,7 +318,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}); });
this.dataService this.dataService
.fetchPositions({ range: this.user?.settings?.dateRange }) .fetchPositions({
filters: this.activeFilters,
range: this.user?.settings?.dateRange
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ positions }) => { .subscribe(({ positions }) => {
const positionsSorted = sortBy( const positionsSorted = sortBy(

View File

@ -8,6 +8,12 @@
(change)="onChangeDateRange($event.value)" (change)="onChangeDateRange($event.value)"
></gf-toggle> ></gf-toggle>
</div> </div>
<gf-activities-filter
[allFilters]="allFilters"
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[placeholder]="placeholder"
(valueChanged)="filters$.next($event)"
></gf-activities-filter>
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col-lg"> <div class="col-lg">
<gf-benchmark-comparator <gf-benchmark-comparator

View File

@ -4,6 +4,7 @@ import { MatCardModule } from '@angular/material/card';
import { GfBenchmarkComparatorModule } from '@ghostfolio/client/components/benchmark-comparator/benchmark-comparator.module'; import { GfBenchmarkComparatorModule } from '@ghostfolio/client/components/benchmark-comparator/benchmark-comparator.module';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -16,6 +17,7 @@ import { AnalysisPageComponent } from './analysis-page.component';
imports: [ imports: [
AnalysisPageRoutingModule, AnalysisPageRoutingModule,
CommonModule, CommonModule,
GfActivitiesFilterModule,
GfBenchmarkComparatorModule, GfBenchmarkComparatorModule,
GfInvestmentChartModule, GfInvestmentChartModule,
GfPremiumIndicatorModule, GfPremiumIndicatorModule,

View File

@ -102,14 +102,20 @@ export class DataService {
} }
public fetchDividends({ public fetchDividends({
filters,
groupBy = 'month', groupBy = 'month',
range range
}: { }: {
filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
range: DateRange; range: DateRange;
}) { }) {
let params = this.buildFiltersAsQueryParams({ filters });
params = params.append('groupBy', groupBy);
params = params.append('range', range);
return this.http.get<PortfolioDividends>('/api/v1/portfolio/dividends', { return this.http.get<PortfolioDividends>('/api/v1/portfolio/dividends', {
params: { groupBy, range } params
}); });
} }
@ -191,15 +197,21 @@ export class DataService {
} }
public fetchInvestments({ public fetchInvestments({
filters,
groupBy = 'month', groupBy = 'month',
range range
}: { }: {
filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
range: DateRange; range: DateRange;
}) { }) {
let params = this.buildFiltersAsQueryParams({ filters });
params = params.append('groupBy', groupBy);
params = params.append('range', range);
return this.http.get<PortfolioInvestments>( return this.http.get<PortfolioInvestments>(
'/api/v1/portfolio/investments', '/api/v1/portfolio/investments',
{ params: { groupBy, range } } { params }
); );
} }
@ -224,12 +236,17 @@ export class DataService {
} }
public fetchPositions({ public fetchPositions({
filters,
range range
}: { }: {
filters?: Filter[];
range: DateRange; range: DateRange;
}): Observable<PortfolioPositions> { }): Observable<PortfolioPositions> {
let params = this.buildFiltersAsQueryParams({ filters });
params = params.append('range', range);
return this.http.get<PortfolioPositions>('/api/v1/portfolio/positions', { return this.http.get<PortfolioPositions>('/api/v1/portfolio/positions', {
params: { range } params
}); });
} }
@ -284,12 +301,19 @@ export class DataService {
} }
public fetchPortfolioPerformance({ public fetchPortfolioPerformance({
filters,
range range
}: { }: {
filters?: Filter[];
range: DateRange; range: DateRange;
}): Observable<PortfolioPerformanceResponse> { }): Observable<PortfolioPerformanceResponse> {
let params = this.buildFiltersAsQueryParams({ filters });
params = params.append('range', range);
return this.http return this.http
.get<any>(`/api/v2/portfolio/performance`, { params: { range } }) .get<any>(`/api/v2/portfolio/performance`, {
params
})
.pipe( .pipe(
map((response) => { map((response) => {
if (response.firstOrderDate) { if (response.firstOrderDate) {