diff --git a/CHANGELOG.md b/CHANGELOG.md index f5ed45c44..35083a942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added a dialog for the benchmarks in the markets overview + ## 2.89.0 - 2024-06-14 ### Added diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 67bb9e03c..ca19d63bc 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -25,6 +25,7 @@ import { AccessModule } from './access/access.module'; import { AccountModule } from './account/account.module'; import { AdminModule } from './admin/admin.module'; import { AppController } from './app.controller'; +import { AssetModule } from './asset/asset.module'; import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthModule } from './auth/auth.module'; import { BenchmarkModule } from './benchmark/benchmark.module'; @@ -51,6 +52,7 @@ import { UserModule } from './user/user.module'; AdminModule, AccessModule, AccountModule, + AssetModule, AuthDeviceModule, AuthModule, BenchmarkModule, diff --git a/apps/api/src/app/asset/asset.controller.ts b/apps/api/src/app/asset/asset.controller.ts new file mode 100644 index 000000000..828320f82 --- /dev/null +++ b/apps/api/src/app/asset/asset.controller.ts @@ -0,0 +1,29 @@ +import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; +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 type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces'; + +import { Controller, Get, Param, UseInterceptors } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; +import { pick } from 'lodash'; + +@Controller('asset') +export class AssetController { + public constructor(private readonly adminService: AdminService) {} + + @Get(':dataSource/:symbol') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getAsset( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const { assetProfile, marketData } = + await this.adminService.getMarketDataBySymbol({ dataSource, symbol }); + + return { + marketData, + assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol']) + }; + } +} diff --git a/apps/api/src/app/asset/asset.module.ts b/apps/api/src/app/asset/asset.module.ts new file mode 100644 index 000000000..168585ed8 --- /dev/null +++ b/apps/api/src/app/asset/asset.module.ts @@ -0,0 +1,17 @@ +import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; + +import { Module } from '@nestjs/common'; + +import { AssetController } from './asset.controller'; + +@Module({ + controllers: [AssetController], + imports: [ + AdminModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule + ] +}) +export class AssetModule {} diff --git a/apps/api/src/app/benchmark/benchmark.controller.ts b/apps/api/src/app/benchmark/benchmark.controller.ts index 7ac0e8c96..9c6331498 100644 --- a/apps/api/src/app/benchmark/benchmark.controller.ts +++ b/apps/api/src/app/benchmark/benchmark.controller.ts @@ -105,7 +105,7 @@ export class BenchmarkController { @Get(':dataSource/:symbol/:startDateString') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) - public async getBenchmarkMarketDataBySymbol( + public async getBenchmarkMarketDataForUser( @Param('dataSource') dataSource: DataSource, @Param('startDateString') startDateString: string, @Param('symbol') symbol: string, @@ -117,7 +117,7 @@ export class BenchmarkController { ); const userCurrency = this.request.user.Settings.settings.baseCurrency; - return this.benchmarkService.getMarketDataBySymbol({ + return this.benchmarkService.getMarketDataForUser({ dataSource, endDate, startDate, diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index 6f2047210..f4f2d7848 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -153,6 +153,7 @@ export class BenchmarkService { } return { + dataSource: benchmarkAssetProfiles[index].dataSource, marketCondition: this.getMarketCondition( performancePercentFromAllTimeHigh ), @@ -163,6 +164,7 @@ export class BenchmarkService { performancePercent: performancePercentFromAllTimeHigh } }, + symbol: benchmarkAssetProfiles[index].symbol, trend50d: benchmarkTrends[index].trend50d, trend200d: benchmarkTrends[index].trend200d }; @@ -213,7 +215,7 @@ export class BenchmarkService { .sort((a, b) => a.name.localeCompare(b.name)); } - public async getMarketDataBySymbol({ + public async getMarketDataForUser({ dataSource, endDate = new Date(), startDate, diff --git a/apps/client/src/app/components/home-market/home-market.component.ts b/apps/client/src/app/components/home-market/home-market.component.ts index 481b913fb..3a42a9ebc 100644 --- a/apps/client/src/app/components/home-market/home-market.component.ts +++ b/apps/client/src/app/components/home-market/home-market.component.ts @@ -11,6 +11,7 @@ import { import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -21,6 +22,7 @@ import { takeUntil } from 'rxjs/operators'; }) export class HomeMarketComponent implements OnDestroy, OnInit { public benchmarks: Benchmark[]; + public deviceType: string; public fearAndGreedIndex: number; public fearLabel = $localize`Fear`; public greedLabel = $localize`Greed`; @@ -36,8 +38,10 @@ export class HomeMarketComponent implements OnDestroy, OnInit { public constructor( private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, + private deviceService: DeviceDetectorService, private userService: UserService ) { + this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.info = this.dataService.fetchInfo(); this.isLoading = true; diff --git a/apps/client/src/app/components/home-market/home-market.html b/apps/client/src/app/components/home-market/home-market.html index 8406cd2ff..c362fdd18 100644 --- a/apps/client/src/app/components/home-market/home-market.html +++ b/apps/client/src/app/components/home-market/home-market.html @@ -32,6 +32,7 @@
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 2ce073b17..0450e32ae 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 @@ -285,7 +285,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.isLoadingBenchmarkComparator = true; this.dataService - .fetchBenchmarkBySymbol({ + .fetchBenchmarkForUser({ dataSource, symbol, range: this.user?.settings?.dateRange, diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 241bb1d3e..64e498d12 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -19,6 +19,7 @@ import { Access, AccountBalancesResponse, Accounts, + AdminMarketDataDetails, BenchmarkMarketDataDetails, BenchmarkResponse, Export, @@ -284,7 +285,21 @@ export class DataService { return this.http.get('/api/v1/access'); } - public fetchBenchmarkBySymbol({ + public fetchAsset({ + dataSource, + symbol + }: UniqueAsset): Observable { + return this.http.get(`/api/v1/asset/${dataSource}/${symbol}`).pipe( + map((data) => { + for (const item of data.marketData) { + item.date = parseISO(item.date); + } + return data; + }) + ); + } + + public fetchBenchmarkForUser({ dataSource, range, startDate, diff --git a/libs/common/src/lib/interfaces/benchmark.interface.ts b/libs/common/src/lib/interfaces/benchmark.interface.ts index 2d63b677c..bf85cd752 100644 --- a/libs/common/src/lib/interfaces/benchmark.interface.ts +++ b/libs/common/src/lib/interfaces/benchmark.interface.ts @@ -3,6 +3,7 @@ import { BenchmarkTrend } from '@ghostfolio/common/types/'; import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; export interface Benchmark { + dataSource: EnhancedSymbolProfile['dataSource']; marketCondition: 'ALL_TIME_HIGH' | 'BEAR_MARKET' | 'NEUTRAL_MARKET'; name: EnhancedSymbolProfile['name']; performances: { @@ -11,6 +12,7 @@ export interface Benchmark { performancePercent: number; }; }; + symbol: EnhancedSymbolProfile['symbol']; trend50d: BenchmarkTrend; trend200d: BenchmarkTrend; } diff --git a/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.scss b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.scss new file mode 100644 index 000000000..02f5d58a1 --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.scss @@ -0,0 +1,12 @@ +:host { + display: block; + + .mat-mdc-dialog-content { + max-height: unset; + + gf-line-chart { + aspect-ratio: 16 / 9; + margin: 0 -0.5rem; + } + } +} diff --git a/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.ts b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.ts new file mode 100644 index 000000000..73af9e681 --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.ts @@ -0,0 +1,87 @@ +import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; +import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + AdminMarketDataDetails, + LineChartItem +} from '@ghostfolio/common/interfaces'; +import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; + +import { CommonModule } from '@angular/common'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Inject, + OnDestroy, + OnInit +} from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef +} from '@angular/material/dialog'; +import { format } from 'date-fns'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { BenchmarkDetailDialogParams } from './interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'd-flex flex-column h-100' }, + imports: [ + CommonModule, + GfDialogFooterModule, + GfDialogHeaderModule, + GfLineChartComponent, + MatDialogModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-benchmark-detail-dialog', + standalone: true, + styleUrls: ['./benchmark-detail-dialog.component.scss'], + templateUrl: 'benchmark-detail-dialog.html' +}) +export class GfBenchmarkDetailDialogComponent implements OnDestroy, OnInit { + public assetProfile: AdminMarketDataDetails['assetProfile']; + public historicalDataItems: LineChartItem[]; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: BenchmarkDetailDialogParams + ) {} + + public ngOnInit() { + this.dataService + .fetchAsset({ + dataSource: this.data.dataSource, + symbol: this.data.symbol + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ assetProfile, marketData }) => { + this.assetProfile = assetProfile; + + this.historicalDataItems = marketData.map(({ date, marketPrice }) => { + return { date: format(date, DATE_FORMAT), value: marketPrice }; + }); + + this.changeDetectorRef.markForCheck(); + }); + } + + public onClose() { + this.dialogRef.close(); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } +} diff --git a/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html new file mode 100644 index 000000000..23196f162 --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html @@ -0,0 +1,30 @@ + + +
+
+ +
+
+ + diff --git a/libs/ui/src/lib/benchmark/benchmark-detail-dialog/interfaces/interfaces.ts b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/interfaces/interfaces.ts new file mode 100644 index 000000000..291f4c973 --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/interfaces/interfaces.ts @@ -0,0 +1,11 @@ +import { ColorScheme } from '@ghostfolio/common/types'; + +import { DataSource } from '@prisma/client'; + +export interface BenchmarkDetailDialogParams { + colorScheme: ColorScheme; + dataSource: DataSource; + deviceType: string; + locale: string; + symbol: string; +} diff --git a/libs/ui/src/lib/benchmark/benchmark.component.html b/libs/ui/src/lib/benchmark/benchmark.component.html index 9497c63f4..ec92554de 100644 --- a/libs/ui/src/lib/benchmark/benchmark.component.html +++ b/libs/ui/src/lib/benchmark/benchmark.component.html @@ -110,5 +110,15 @@ - + diff --git a/libs/ui/src/lib/benchmark/benchmark.component.ts b/libs/ui/src/lib/benchmark/benchmark.component.ts index 07e70c2fd..4dd4aa079 100644 --- a/libs/ui/src/lib/benchmark/benchmark.component.ts +++ b/libs/ui/src/lib/benchmark/benchmark.component.ts @@ -1,6 +1,8 @@ import { getLocale, resolveMarketCondition } from '@ghostfolio/common/helper'; -import { Benchmark, User } from '@ghostfolio/common/interfaces'; +import { Benchmark, UniqueAsset, User } from '@ghostfolio/common/interfaces'; import { translate } from '@ghostfolio/ui/i18n'; +import { GfTrendIndicatorComponent } from '@ghostfolio/ui/trend-indicator'; +import { GfValueComponent } from '@ghostfolio/ui/value'; import { CommonModule } from '@angular/common'; import { @@ -8,13 +10,17 @@ import { ChangeDetectionStrategy, Component, Input, - OnChanges + OnChanges, + OnDestroy } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { MatTableModule } from '@angular/material/table'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { Subject, takeUntil } from 'rxjs'; -import { GfTrendIndicatorComponent } from '../trend-indicator'; -import { GfValueComponent } from '../value'; +import { GfBenchmarkDetailDialogComponent } from './benchmark-detail-dialog/benchmark-detail-dialog.component'; +import { BenchmarkDetailDialogParams } from './benchmark-detail-dialog/interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -23,7 +29,8 @@ import { GfValueComponent } from '../value'; GfTrendIndicatorComponent, GfValueComponent, MatTableModule, - NgxSkeletonLoaderModule + NgxSkeletonLoaderModule, + RouterModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-benchmark', @@ -31,8 +38,9 @@ import { GfValueComponent } from '../value'; styleUrls: ['./benchmark.component.scss'], templateUrl: './benchmark.component.html' }) -export class GfBenchmarkComponent implements OnChanges { +export class GfBenchmarkComponent implements OnChanges, OnDestroy { @Input() benchmarks: Benchmark[]; + @Input() deviceType: string; @Input() locale = getLocale(); @Input() user: User; @@ -40,7 +48,28 @@ export class GfBenchmarkComponent implements OnChanges { public resolveMarketCondition = resolveMarketCondition; public translate = translate; - public constructor() {} + private unsubscribeSubject = new Subject(); + + public constructor( + private dialog: MatDialog, + private route: ActivatedRoute, + private router: Router + ) { + this.route.queryParams + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((params) => { + if ( + params['benchmarkDetailDialog'] && + params['dataSource'] && + params['symbol'] + ) { + this.openBenchmarkDetailDialog({ + dataSource: params['dataSource'], + symbol: params['symbol'] + }); + } + }); + } public ngOnChanges() { if (this.user?.settings?.isExperimentalFeatures) { @@ -54,4 +83,36 @@ export class GfBenchmarkComponent implements OnChanges { ]; } } + + public onOpenBenchmarkDialog({ dataSource, symbol }: UniqueAsset) { + this.router.navigate([], { + queryParams: { dataSource, symbol, benchmarkDetailDialog: true } + }); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private openBenchmarkDetailDialog({ dataSource, symbol }: UniqueAsset) { + const dialogRef = this.dialog.open(GfBenchmarkDetailDialogComponent, { + data: { + dataSource, + symbol, + colorScheme: this.user?.settings?.colorScheme, + deviceType: this.deviceType, + locale: this.locale + }, + height: this.deviceType === 'mobile' ? '97.5vh' : undefined, + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.router.navigate(['.'], { relativeTo: this.route }); + }); + } }