From 707c77f0cf5bb213f9abed30a15c6fa934c52b5f Mon Sep 17 00:00:00 2001 From: Jory Hogeveen Date: Sat, 23 Nov 2024 10:25:07 +0100 Subject: [PATCH] Feature/extend allocations by ETF holding with parent ETFs (#4044) * Extend allocations by ETF holding with parent ETFs * Update changelog --------- Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> --- CHANGELOG.md | 1 + .../allocations/allocations-page.component.ts | 34 ++++- .../allocations/allocations-page.html | 1 + apps/client/src/styles/table.scss | 27 ++-- .../holding-with-parents.interface.ts | 5 + libs/common/src/lib/interfaces/index.ts | 2 + .../top-holdings/top-holdings.component.html | 129 +++++++++++++++--- .../top-holdings/top-holdings.component.scss | 32 ++++- .../top-holdings/top-holdings.component.ts | 51 +++++-- 9 files changed, 236 insertions(+), 46 deletions(-) create mode 100644 libs/common/src/lib/interfaces/holding-with-parents.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bad82b8d..57d4e72e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Extended the allocations by ETF holding on the allocations page by the parent ETFs (experimental) - Upgraded `countries-and-timezones` from version `3.4.1` to `3.7.2` - Upgraded `Nx` from version `20.0.6` to `20.1.2` diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index e647c54fb..fa5a4751c 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -7,7 +7,7 @@ import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { prettifySymbol } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, - Holding, + HoldingWithParents, PortfolioDetails, PortfolioPosition, User @@ -86,7 +86,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { value: number; }; }; - public topHoldings: Holding[]; + public topHoldings: HoldingWithParents[]; public topHoldingsMap: { [name: string]: { name: string; value: number }; }; @@ -490,6 +490,36 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { name, allocationInPercentage: this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0, + parents: Object.entries(this.portfolioDetails.holdings) + .map(([symbol, holding]) => { + if (holding.holdings.length > 0) { + const currentParentHolding = holding.holdings.find( + (parentHolding) => { + return parentHolding.name === name; + } + ); + + return currentParentHolding + ? { + allocationInPercentage: + currentParentHolding.valueInBaseCurrency / value, + name: holding.name, + position: holding, + symbol: prettifySymbol(symbol), + valueInBaseCurrency: + currentParentHolding.valueInBaseCurrency + } + : null; + } + + return null; + }) + .filter((item) => { + return item !== null; + }) + .sort((a, b) => { + return b.allocationInPercentage - a.allocationInPercentage; + }), valueInBaseCurrency: value }; }) diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html index 3431501f5..f2dff76f3 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html @@ -347,6 +347,7 @@ [locale]="user?.settings?.locale" [pageSize]="10" [topHoldings]="topHoldings" + (holdingClicked)="onSymbolChartClicked($event)" /> diff --git a/apps/client/src/styles/table.scss b/apps/client/src/styles/table.scss index f232cb1af..8c0f5c283 100644 --- a/apps/client/src/styles/table.scss +++ b/apps/client/src/styles/table.scss @@ -1,5 +1,10 @@ @mixin gf-table($darkTheme: false) { --mat-table-background-color: var(--light-background); + --mat-table-background-color-even: rgba(var(--palette-foreground-base), 0.02); + --mat-table-background-color-hover: rgba( + var(--palette-foreground-base), + 0.04 + ); .mat-footer-row, .mat-row { @@ -21,16 +26,24 @@ .mat-mdc-row { &:nth-child(even) { - background-color: whitesmoke; + background-color: var(--mat-table-background-color-even); } &:hover { - background-color: #e6e6e6 !important; + background-color: var(--mat-table-background-color-hover) !important; } } @if $darkTheme { --mat-table-background-color: var(--dark-background); + --mat-table-background-color-even: rgba( + var(--palette-foreground-base-dark), + 0.02 + ); + --mat-table-background-color-hover: rgba( + var(--palette-foreground-base-dark), + 0.04 + ); .mat-mdc-footer-row { .mat-mdc-footer-cell { @@ -40,15 +53,5 @@ ); } } - - .mat-mdc-row { - &:nth-child(even) { - background-color: #222222; - } - - &:hover { - background-color: #303030 !important; - } - } } } diff --git a/libs/common/src/lib/interfaces/holding-with-parents.interface.ts b/libs/common/src/lib/interfaces/holding-with-parents.interface.ts new file mode 100644 index 000000000..df3f32967 --- /dev/null +++ b/libs/common/src/lib/interfaces/holding-with-parents.interface.ts @@ -0,0 +1,5 @@ +import { Holding } from './holding.interface'; + +export interface HoldingWithParents extends Holding { + parents?: Holding[]; +} diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index eca147066..eb28a6d16 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -19,6 +19,7 @@ import type { Export } from './export.interface'; import type { FilterGroup } from './filter-group.interface'; import type { Filter } from './filter.interface'; import type { HistoricalDataItem } from './historical-data-item.interface'; +import type { HoldingWithParents } from './holding-with-parents.interface'; import type { Holding } from './holding.interface'; import type { InfoItem } from './info-item.interface'; import type { InvestmentItem } from './investment-item.interface'; @@ -80,6 +81,7 @@ export { FilterGroup, HistoricalDataItem, Holding, + HoldingWithParents, ImportResponse, InfoItem, InvestmentItem, diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.html b/libs/ui/src/lib/top-holdings/top-holdings.component.html index 72463da4a..d42d742b2 100644 --- a/libs/ui/src/lib/top-holdings/top-holdings.component.html +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -1,14 +1,18 @@
+ + + + + + - @@ -57,8 +51,107 @@ + + + + - + +
+ Name @@ -17,12 +21,7 @@ - + Value @@ -37,12 +36,7 @@ - + Allocation % +
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
{{ parentHolding?.name }}
+
+
+ {{ + parentHolding?.symbol | gfSymbol + }} +
+
+ Name + +
+ +
+
+ Value + +
+ +
+
+ Allocation + % +
+
+
+
diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.scss b/libs/ui/src/lib/top-holdings/top-holdings.component.scss index 990b8b294..b3e811a2c 100644 --- a/libs/ui/src/lib/top-holdings/top-holdings.component.scss +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.scss @@ -1,11 +1,33 @@ :host { display: block; - .gf-table { - th { - ::ng-deep { - .mat-sort-header-container { - justify-content: inherit; + .holdings-table { + table-layout: auto; + + tr { + &:not(.expanded) + tr.holding-detail td { + border-bottom: 0; + } + + &.expanded { + > td { + font-weight: bold; + } + } + + &.holding-detail { + height: 0; + } + + .holding-parents-table { + --table-padding: 0.5em; + + tr { + height: auto; + + td { + padding: var(--table-padding); + } } } } diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.ts b/libs/ui/src/lib/top-holdings/top-holdings.component.ts index 0a3f0e977..3d3712bcc 100644 --- a/libs/ui/src/lib/top-holdings/top-holdings.component.ts +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.ts @@ -1,33 +1,56 @@ +import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { getLocale } from '@ghostfolio/common/helper'; -import { Holding } from '@ghostfolio/common/interfaces'; +import { + AssetProfileIdentifier, + HoldingWithParents, + PortfolioPosition +} from '@ghostfolio/common/interfaces'; import { GfValueComponent } from '@ghostfolio/ui/value'; +import { + animate, + state, + style, + transition, + trigger +} from '@angular/animations'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, Component, + EventEmitter, Input, OnChanges, OnDestroy, + Output, ViewChild } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator'; -import { MatSort, MatSortModule } from '@angular/material/sort'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; -import { get } from 'lodash'; +import { DataSource } from '@prisma/client'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { Subject } from 'rxjs'; @Component({ + animations: [ + trigger('detailExpand', [ + state('collapsed,void', style({ height: '0px', minHeight: '0' })), + state('expanded', style({ height: '*' })), + transition( + 'expanded <=> collapsed', + animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)') + ) + ]) + ], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, + GfSymbolModule, GfValueComponent, MatButtonModule, MatPaginatorModule, - MatSortModule, MatTableModule, NgxSkeletonLoaderModule ], @@ -41,12 +64,20 @@ export class GfTopHoldingsComponent implements OnChanges, OnDestroy { @Input() baseCurrency: string; @Input() locale = getLocale(); @Input() pageSize = Number.MAX_SAFE_INTEGER; - @Input() topHoldings: Holding[]; + @Input() topHoldings: HoldingWithParents[]; + @Input() positions: { + [symbol: string]: Pick & { + dataSource?: DataSource; + name: string; + value: number; + }; + } = {}; + + @Output() holdingClicked = new EventEmitter(); @ViewChild(MatPaginator) paginator: MatPaginator; - @ViewChild(MatSort) sort: MatSort; - public dataSource = new MatTableDataSource(); + public dataSource = new MatTableDataSource(); public displayedColumns: string[] = [ 'name', 'valueInBaseCurrency', @@ -61,14 +92,16 @@ export class GfTopHoldingsComponent implements OnChanges, OnDestroy { this.dataSource = new MatTableDataSource(this.topHoldings); this.dataSource.paginator = this.paginator; - this.dataSource.sort = this.sort; - this.dataSource.sortingDataAccessor = get; if (this.topHoldings) { this.isLoading = false; } } + public onClickHolding(assetProfileIdentifier: AssetProfileIdentifier) { + this.holdingClicked.emit(assetProfileIdentifier); + } + public onShowAllHoldings() { this.pageSize = Number.MAX_SAFE_INTEGER;