Feature/improve analysis page (#609)

* Improve analysis page (show y-axis, extend chart in relation to days in market)

* Update changelog
This commit is contained in:
Thomas Kaul 2022-01-01 12:09:49 +01:00 committed by GitHub
parent 0179823ad9
commit e54638a684
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 122 additions and 80 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the portfolio analysis page: show the y-axis and extend the chart in relation to the days in market
- Restructured the about page - Restructured the about page
- Start refactoring _transactions_ to _activities_ - Start refactoring _transactions_ to _activities_
- Refactored the demo user id - Refactored the demo user id

View File

@ -10,12 +10,12 @@ import { baseCurrency } from '@ghostfolio/common/config';
import { import {
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments,
PortfolioPerformance, PortfolioPerformance,
PortfolioPublicDetails, PortfolioPublicDetails,
PortfolioReport, PortfolioReport,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
@ -48,42 +48,6 @@ export class PortfolioController {
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async findAll(
@Headers('impersonation-id') impersonationId: string,
@Res() res: Response
): Promise<InvestmentItem[]> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json([]);
}
let investments = await this.portfolioService.getInvestments(
impersonationId
);
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment),
1
);
investments = investments.map((item) => ({
date: item.date,
investment: item.investment / maxInvestment
}));
}
return <any>res.json(investments);
}
@Get('chart') @Get('chart')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getChart( public async getChart(
@ -200,6 +164,42 @@ export class PortfolioController {
return <any>res.json({ accounts, hasError, holdings }); return <any>res.json({ accounts, hasError, holdings });
} }
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string,
@Res() res: Response
): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({});
}
let investments = await this.portfolioService.getInvestments(
impersonationId
);
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment),
1
);
investments = investments.map((item) => ({
date: item.date,
investment: item.investment / maxInvestment
}));
}
return <any>res.json({ firstOrderDate: investments[0]?.date, investments });
}
@Get('performance') @Get('performance')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPerformance( public async getPerformance(

View File

@ -55,7 +55,7 @@ import {
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty, sortBy } from 'lodash';
import { import {
HistoricalDataContainer, HistoricalDataContainer,
@ -150,12 +150,33 @@ export class PortfolioService {
return []; return [];
} }
return portfolioCalculator.getInvestments().map((item) => { const investments = portfolioCalculator.getInvestments().map((item) => {
return { return {
date: item.date, date: item.date,
investment: item.investment.toNumber() investment: item.investment.toNumber()
}; };
}); });
// Add investment of today
const investmentOfToday = investments.filter((investment) => {
return investment.date === format(new Date(), DATE_FORMAT);
});
if (investmentOfToday.length <= 0) {
const pastInvestments = investments.filter((investment) => {
return isBefore(parseDate(investment.date), new Date());
});
const lastInvestment = pastInvestments[pastInvestments.length - 1];
investments.push({
date: format(new Date(), DATE_FORMAT),
investment: lastInvestment?.investment ?? 0
});
}
return sortBy(investments, (investment) => {
return investment.date;
});
} }
public async getChart( public async getChart(

View File

@ -10,6 +10,7 @@ import {
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { primaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { import {
Chart, Chart,
@ -19,7 +20,7 @@ import {
PointElement, PointElement,
TimeScale TimeScale
} from 'chart.js'; } from 'chart.js';
import { addMonths, isAfter, parseISO, subMonths } from 'date-fns'; import { addDays, isAfter, parseISO, subDays } from 'date-fns';
@Component({ @Component({
selector: 'gf-investment-chart', selector: 'gf-investment-chart',
@ -27,8 +28,10 @@ import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
templateUrl: './investment-chart.component.html', templateUrl: './investment-chart.component.html',
styleUrls: ['./investment-chart.component.scss'] styleUrls: ['./investment-chart.component.scss']
}) })
export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit { export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() daysInMarket: number;
@Input() investments: InvestmentItem[]; @Input() investments: InvestmentItem[];
@Input() isInPercent = false;
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
@ -45,8 +48,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
); );
} }
public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
if (this.investments) { if (this.investments) {
this.initialize(); this.initialize();
@ -61,19 +62,25 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
this.isLoading = true; this.isLoading = true;
if (this.investments?.length > 0) { if (this.investments?.length > 0) {
// Extend chart by three months (before) // Extend chart by 5% of days in market (before)
const firstItem = this.investments[0]; const firstItem = this.investments[0];
this.investments.unshift({ this.investments.unshift({
...firstItem, ...firstItem,
date: subMonths(parseISO(firstItem.date), 3).toISOString(), date: subDays(
parseISO(firstItem.date),
this.daysInMarket * 0.05 || 90
).toISOString(),
investment: 0 investment: 0
}); });
// Extend chart by three months (after) // Extend chart by 5% of days in market (after)
const lastItem = this.investments[this.investments.length - 1]; const lastItem = this.investments[this.investments.length - 1];
this.investments.push({ this.investments.push({
...lastItem, ...lastItem,
date: addMonths(new Date(), 3).toISOString() date: addDays(
parseDate(lastItem.date),
this.daysInMarket * 0.05 || 90
).toISOString()
}); });
} }
@ -136,12 +143,26 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
} }
}, },
y: { y: {
display: false, display: !this.isInPercent,
grid: { grid: {
display: false display: false
}, },
ticks: { ticks: {
display: false display: true,
callback: (tickValue, index, ticks) => {
if (index === 0 || index === ticks.length - 1) {
// Only print last and first legend entry
if (typeof tickValue === 'number') {
return tickValue.toFixed(2);
}
return tickValue;
}
return '';
},
mirror: true,
z: 1
} }
} }
} }

View File

@ -7,7 +7,6 @@ import { InvestmentChartComponent } from './investment-chart.component';
@NgModule({ @NgModule({
declarations: [InvestmentChartComponent], declarations: [InvestmentChartComponent],
exports: [InvestmentChartComponent], exports: [InvestmentChartComponent],
imports: [CommonModule, NgxSkeletonLoaderModule], imports: [CommonModule, NgxSkeletonLoaderModule]
providers: []
}) })
export class GfInvestmentChartModule {} export class GfInvestmentChartModule {}

View File

@ -2,9 +2,9 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; 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 { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { ToggleOption } from '@ghostfolio/common/types'; import { differenceInDays } from 'date-fns';
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 { takeUntil } from 'rxjs/operators';
@ -16,28 +16,10 @@ 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 accounts: { public daysInMarket: number;
[symbol: string]: Pick<PortfolioPosition, 'name'> & { value: number };
};
public continents: {
[code: string]: { name: string; value: number };
};
public countries: {
[code: string]: { name: string; value: number };
};
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public period = 'current';
public periodOptions: ToggleOption[] = [
{ label: 'Initial', value: 'original' },
{ label: 'Current', value: 'current' }
];
public investments: InvestmentItem[]; public investments: InvestmentItem[];
public portfolioPositions: { [symbol: string]: PortfolioPosition };
public positions: { [symbol: string]: any };
public sectors: {
[name: string]: { name: string; value: number };
};
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -69,8 +51,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchInvestments() .fetchInvestments()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe(({ firstOrderDate, investments }) => {
this.investments = response; this.daysInMarket = differenceInDays(new Date(), firstOrderDate);
this.investments = investments;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

View File

@ -11,6 +11,8 @@
<mat-card-content> <mat-card-content>
<gf-investment-chart <gf-investment-chart
class="h-100" class="h-100"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investments" [investments]="investments"
></gf-investment-chart> ></gf-investment-chart>
</mat-card-content> </mat-card-content>

View File

@ -5,7 +5,6 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioPositionDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface'; import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface'; import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
@ -23,13 +22,13 @@ import {
InfoItem, InfoItem,
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments,
PortfolioPerformance, PortfolioPerformance,
PortfolioPublicDetails, PortfolioPublicDetails,
PortfolioReport, PortfolioReport,
PortfolioSummary, PortfolioSummary,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { DataSource, Order as OrderModel } from '@prisma/client'; import { DataSource, Order as OrderModel } from '@prisma/client';
@ -124,6 +123,18 @@ export class DataService {
return info; return info;
} }
public fetchInvestments(): Observable<PortfolioInvestments> {
return this.http.get<any>('/api/portfolio/investments').pipe(
map((response) => {
if (response.firstOrderDate) {
response.firstOrderDate = parseISO(response.firstOrderDate);
}
return response;
})
);
}
public fetchSymbolItem({ public fetchSymbolItem({
dataSource, dataSource,
includeHistoricalData = false, includeHistoricalData = false,
@ -170,10 +181,6 @@ export class DataService {
); );
} }
public fetchInvestments() {
return this.http.get<InvestmentItem[]>('/api/portfolio/investments');
}
public fetchPortfolioDetails(aParams: { [param: string]: any }) { public fetchPortfolioDetails(aParams: { [param: string]: any }) {
return this.http.get<PortfolioDetails>('/api/portfolio/details', { return this.http.get<PortfolioDetails>('/api/portfolio/details', {
params: aParams params: aParams

View File

@ -8,6 +8,7 @@ import { Export } from './export.interface';
import { InfoItem } from './info-item.interface'; import { InfoItem } from './info-item.interface';
import { PortfolioChart } from './portfolio-chart.interface'; import { PortfolioChart } from './portfolio-chart.interface';
import { PortfolioDetails } from './portfolio-details.interface'; import { PortfolioDetails } from './portfolio-details.interface';
import { PortfolioInvestments } from './portfolio-investments.interface';
import { PortfolioItem } from './portfolio-item.interface'; import { PortfolioItem } from './portfolio-item.interface';
import { PortfolioOverview } from './portfolio-overview.interface'; import { PortfolioOverview } from './portfolio-overview.interface';
import { PortfolioPerformance } from './portfolio-performance.interface'; import { PortfolioPerformance } from './portfolio-performance.interface';
@ -33,6 +34,7 @@ export {
InfoItem, InfoItem,
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments,
PortfolioItem, PortfolioItem,
PortfolioOverview, PortfolioOverview,
PortfolioPerformance, PortfolioPerformance,

View File

@ -0,0 +1,6 @@
import { InvestmentItem } from './investment-item.interface';
export interface PortfolioInvestments {
firstOrderDate: Date;
investments: InvestmentItem[];
}