From 580a6e861a4c2481256501898f1cf2daa3391721 Mon Sep 17 00:00:00 2001 From: Robert Patch Date: Tue, 7 Apr 2026 17:20:48 -0700 Subject: [PATCH] feat(009): FMV Plaid drilldown, allocations v2 page, and allocation UI components --- apps/api/src/app/admin/dev-seed.service.ts | 2 + apps/api/src/app/plaid/plaid.service.ts | 49 +- .../src/app/pages/fmv/fmv-page.component.html | 27 +- .../src/app/pages/fmv/fmv-page.component.ts | 20 + .../allocations-v2-page.component.ts | 389 +++++++++++++++ .../allocations-v2/allocations-v2-page.html | 452 ++++++++++++++++++ .../allocations-v2-page.routes.ts | 14 + .../allocations-v2/allocations-v2-page.scss | 41 ++ .../allocations/allocations-page.component.ts | 4 +- .../allocations/allocations-page.html | 28 +- .../portfolio/portfolio-page.component.ts | 8 + .../pages/portfolio/portfolio-page.routes.ts | 7 + libs/common/src/lib/routes/routes.ts | 5 + .../allocation-cards-grid.component.html | 49 ++ .../allocation-cards-grid.component.scss | 113 +++++ .../allocation-cards-grid.component.ts | 212 ++++++++ .../ui/src/lib/allocation-cards-grid/index.ts | 1 + .../allocation-donut-cards.component.html | 49 ++ .../allocation-donut-cards.component.scss | 119 +++++ .../allocation-donut-cards.component.ts | 286 +++++++++++ .../src/lib/allocation-donut-cards/index.ts | 1 + 21 files changed, 1842 insertions(+), 34 deletions(-) create mode 100644 apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.component.ts create mode 100644 apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.html create mode 100644 apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.routes.ts create mode 100644 apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.scss create mode 100644 libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.html create mode 100644 libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.scss create mode 100644 libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.ts create mode 100644 libs/ui/src/lib/allocation-cards-grid/index.ts create mode 100644 libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.html create mode 100644 libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.scss create mode 100644 libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.ts create mode 100644 libs/ui/src/lib/allocation-donut-cards/index.ts diff --git a/apps/api/src/app/admin/dev-seed.service.ts b/apps/api/src/app/admin/dev-seed.service.ts index 4772a62a1..f0fe03dfb 100644 --- a/apps/api/src/app/admin/dev-seed.service.ts +++ b/apps/api/src/app/admin/dev-seed.service.ts @@ -41,6 +41,7 @@ export class DevSeedService { await this.prismaService.accountBalance.deleteMany({}); const orders = await this.prismaService.order.deleteMany({}); const accounts = await this.prismaService.account.deleteMany({}); + const plaidItems = await this.prismaService.plaidItem.deleteMany({}); const marketData = await this.prismaService.marketData.deleteMany({}); const symbolProfiles = await this.prismaService.symbolProfile.deleteMany({}); @@ -50,6 +51,7 @@ export class DevSeedService { const result = { accountBalances: accountBalances.count, accounts: accounts.count, + plaidItems: plaidItems.count, assetValuations: assetValuations.count, distributions: distributions.count, documents: documents.count, diff --git a/apps/api/src/app/plaid/plaid.service.ts b/apps/api/src/app/plaid/plaid.service.ts index 87b3edeef..5b5f9541e 100644 --- a/apps/api/src/app/plaid/plaid.service.ts +++ b/apps/api/src/app/plaid/plaid.service.ts @@ -153,17 +153,48 @@ export class PlaidService { }); } - // Create PlaidItem - const plaidItem = await this.prismaService.plaidItem.create({ - data: { - accessToken: encryptedToken, - institutionId, - institutionName, - itemId, - userId - } + // Check for existing PlaidItem for the same institution + user + const existingPlaidItem = await this.prismaService.plaidItem.findFirst({ + include: { accounts: true }, + where: { institutionId, userId } }); + let plaidItem; + + if (existingPlaidItem) { + // Update existing PlaidItem with new access token and item ID + plaidItem = await this.prismaService.plaidItem.update({ + data: { + accessToken: encryptedToken, + error: null, + itemId, + lastSyncedAt: null + }, + where: { id: existingPlaidItem.id } + }); + + this.logger.log( + `Reusing existing PlaidItem ${existingPlaidItem.id} for institution ${institutionId}` + ); + + // Unlink orphaned accounts from the old connection + await this.prismaService.account.updateMany({ + data: { plaidAccountId: null, plaidItemId: null }, + where: { plaidItemId: existingPlaidItem.id } + }); + } else { + // Create new PlaidItem + plaidItem = await this.prismaService.plaidItem.create({ + data: { + accessToken: encryptedToken, + institutionId, + institutionName, + itemId, + userId + } + }); + } + // Create accounts for each Plaid account const createdAccounts = []; for (const acct of accounts) { diff --git a/apps/client/src/app/pages/fmv/fmv-page.component.html b/apps/client/src/app/pages/fmv/fmv-page.component.html index 70739773d..974ca7358 100644 --- a/apps/client/src/app/pages/fmv/fmv-page.component.html +++ b/apps/client/src/app/pages/fmv/fmv-page.component.html @@ -77,14 +77,25 @@ } - +
+ + +
} diff --git a/apps/client/src/app/pages/fmv/fmv-page.component.ts b/apps/client/src/app/pages/fmv/fmv-page.component.ts index 5b5231181..1122ba283 100644 --- a/apps/client/src/app/pages/fmv/fmv-page.component.ts +++ b/apps/client/src/app/pages/fmv/fmv-page.component.ts @@ -104,6 +104,26 @@ export class FmvPageComponent implements OnInit { this.openAccountDetailDialog(account.id); } + public onDeletePlaidItem(plaidItemId: string) { + this.plaidLinkService + .deleteItem(plaidItemId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.snackBar.open('Connection removed', undefined, { + duration: 2000 + }); + this.fetchAccounts(); + this.fetchPlaidItems(); + }, + error: () => { + this.snackBar.open('Failed to remove connection', undefined, { + duration: 3000 + }); + } + }); + } + public onRefreshPlaidItem(plaidItemId: string) { this.plaidLinkService .triggerSync(plaidItemId) diff --git a/apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.component.ts b/apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.component.ts new file mode 100644 index 000000000..a86f060ed --- /dev/null +++ b/apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.component.ts @@ -0,0 +1,389 @@ +import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { prettifySymbol } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + PortfolioDetails, + PortfolioPosition, + User +} from '@ghostfolio/common/interfaces'; +import { MarketAdvanced } from '@ghostfolio/common/types'; +import { translate } from '@ghostfolio/ui/i18n'; +import { GfAllocationCardsGridComponent } from '@ghostfolio/ui/allocation-cards-grid'; +import { GfAllocationDonutCardsComponent } from '@ghostfolio/ui/allocation-donut-cards'; +import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; +import { DataService } from '@ghostfolio/ui/services'; + +import { + ChangeDetectorRef, + Component, + DestroyRef, + OnInit +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatCardModule } from '@angular/material/card'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTabsModule } from '@angular/material/tabs'; +import { Router } from '@angular/router'; +import { + Account, + AssetClass, + AssetSubClass, + DataSource, + Platform +} from '@prisma/client'; +import { isNumber } from 'lodash'; + +@Component({ + imports: [ + GfAllocationCardsGridComponent, + GfAllocationDonutCardsComponent, + GfPremiumIndicatorComponent, + MatCardModule, + MatProgressBarModule, + MatTabsModule + ], + selector: 'gf-allocations-v2-page', + styleUrls: ['./allocations-v2-page.scss'], + templateUrl: './allocations-v2-page.html' +}) +export class GfAllocationsV2PageComponent implements OnInit { + public accounts: { + [id: string]: Pick & { + id: string; + value: number; + }; + }; + public continents: { + [code: string]: { name: string; value: number }; + }; + public countries: { + [code: string]: { name: string; value: number }; + }; + public hasImpersonationId: boolean; + public holdings: { + [symbol: string]: Pick< + PortfolioPosition, + | 'assetClass' + | 'assetClassLabel' + | 'assetSubClass' + | 'assetSubClassLabel' + | 'currency' + | 'exchange' + | 'name' + > & { etfProvider: string; value: number }; + }; + public isLoading = false; + public marketsAdvanced: { + [key in MarketAdvanced]: { + id: MarketAdvanced; + name: string; + value: number; + }; + }; + public platforms: { + [id: string]: Pick & { + id: string; + value: number; + }; + }; + public portfolioDetails: PortfolioDetails; + public sectors: { + [name: string]: { name: string; value: number }; + }; + public symbols: { + [name: string]: { + dataSource?: DataSource; + name: string; + symbol: string; + value: number; + }; + }; + public UNKNOWN_KEY = UNKNOWN_KEY; + public user: User; + + // Toggle between donut-cards (Option 1) and cards-grid (Option 3) + public selectedTabIndex = 0; + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + private destroyRef: DestroyRef, + private impersonationStorageService: ImpersonationStorageService, + private router: Router, + private userService: UserService + ) {} + + public ngOnInit() { + this.impersonationStorageService + .onChangeHasImpersonation() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((impersonationId) => { + this.hasImpersonationId = !!impersonationId; + }); + + this.userService.stateChanged + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + + this.isLoading = true; + this.initialize(); + + this.fetchPortfolioDetails() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((portfolioDetails) => { + this.initialize(); + this.portfolioDetails = portfolioDetails; + this.initializeAllocationsData(); + this.isLoading = false; + this.changeDetectorRef.markForCheck(); + }); + + this.changeDetectorRef.markForCheck(); + } + }); + + this.initialize(); + } + + public onSymbolClicked({ dataSource, symbol }: AssetProfileIdentifier) { + if (dataSource && symbol) { + this.router.navigate([], { + queryParams: { dataSource, symbol, holdingDetailDialog: true } + }); + } + } + + private extractEtfProvider({ + assetSubClass, + name + }: { + assetSubClass: PortfolioPosition['assetSubClass']; + name: string; + }) { + if (assetSubClass === 'ETF') { + const [firstWord] = name.split(' '); + return firstWord; + } + return UNKNOWN_KEY; + } + + private fetchPortfolioDetails() { + return this.dataService.fetchPortfolioDetails({ + filters: this.userService.getFilters(), + withMarkets: true + }); + } + + private initialize() { + this.accounts = {}; + this.continents = { + [UNKNOWN_KEY]: { name: UNKNOWN_KEY, value: 0 } + }; + this.countries = { + [UNKNOWN_KEY]: { name: UNKNOWN_KEY, value: 0 } + }; + this.holdings = {}; + this.marketsAdvanced = { + [UNKNOWN_KEY]: { id: UNKNOWN_KEY, name: UNKNOWN_KEY, value: 0 }, + asiaPacific: { + id: 'asiaPacific', + name: translate('Asia-Pacific'), + value: 0 + }, + emergingMarkets: { + id: 'emergingMarkets', + name: translate('Emerging Markets'), + value: 0 + }, + europe: { id: 'europe', name: translate('Europe'), value: 0 }, + japan: { id: 'japan', name: translate('Japan'), value: 0 }, + northAmerica: { + id: 'northAmerica', + name: translate('North America'), + value: 0 + }, + otherMarkets: { + id: 'otherMarkets', + name: translate('Other Markets'), + value: 0 + } + }; + this.platforms = {}; + this.portfolioDetails = { + accounts: {}, + createdAt: undefined, + holdings: {}, + platforms: {}, + summary: undefined + }; + this.sectors = { + [UNKNOWN_KEY]: { name: UNKNOWN_KEY, value: 0 } + }; + this.symbols = { + [UNKNOWN_KEY]: { name: UNKNOWN_KEY, symbol: UNKNOWN_KEY, value: 0 } + }; + } + + private initializeAllocationsData() { + for (const [ + id, + { name, valueInBaseCurrency, valueInPercentage } + ] of Object.entries(this.portfolioDetails.accounts)) { + let value = 0; + if (this.hasImpersonationId) { + value = valueInPercentage; + } else { + value = valueInBaseCurrency; + } + this.accounts[id] = { id, name, value }; + } + + for (const [symbol, position] of Object.entries( + this.portfolioDetails.holdings + )) { + let value = 0; + if (this.hasImpersonationId) { + value = position.allocationInPercentage; + } else { + value = position.valueInBaseCurrency; + } + + this.holdings[symbol] = { + value, + assetClass: position.assetClass || (UNKNOWN_KEY as AssetClass), + assetClassLabel: position.assetClassLabel || UNKNOWN_KEY, + assetSubClass: + position.assetSubClass || (UNKNOWN_KEY as AssetSubClass), + assetSubClassLabel: position.assetSubClassLabel || UNKNOWN_KEY, + currency: position.currency, + etfProvider: this.extractEtfProvider({ + assetSubClass: position.assetSubClass, + name: position.name + }), + exchange: position.exchange, + name: position.name + }; + + if (position.assetClass !== AssetClass.LIQUIDITY) { + if (position.countries.length > 0) { + for (const country of position.countries) { + const { code, continent, name, weight } = country; + if (this.continents[continent]?.value) { + this.continents[continent].value += + weight * + (isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : position.valueInPercentage); + } else { + this.continents[continent] = { + name: continent, + value: + weight * + (isNumber(position.valueInBaseCurrency) + ? this.portfolioDetails.holdings[symbol] + .valueInBaseCurrency + : this.portfolioDetails.holdings[symbol] + .valueInPercentage) + }; + } + + if (this.countries[code]?.value) { + this.countries[code].value += + weight * + (isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : position.valueInPercentage); + } else { + this.countries[code] = { + name, + value: + weight * + (isNumber(position.valueInBaseCurrency) + ? this.portfolioDetails.holdings[symbol] + .valueInBaseCurrency + : this.portfolioDetails.holdings[symbol] + .valueInPercentage) + }; + } + } + } else { + this.continents[UNKNOWN_KEY].value += isNumber( + position.valueInBaseCurrency + ) + ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency + : this.portfolioDetails.holdings[symbol].valueInPercentage; + + this.countries[UNKNOWN_KEY].value += isNumber( + position.valueInBaseCurrency + ) + ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency + : this.portfolioDetails.holdings[symbol].valueInPercentage; + } + + if (position.sectors.length > 0) { + for (const sector of position.sectors) { + const { name, weight } = sector; + if (this.sectors[name]?.value) { + this.sectors[name].value += + weight * + (isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : position.valueInPercentage); + } else { + this.sectors[name] = { + name, + value: + weight * + (isNumber(position.valueInBaseCurrency) + ? this.portfolioDetails.holdings[symbol] + .valueInBaseCurrency + : this.portfolioDetails.holdings[symbol] + .valueInPercentage) + }; + } + } + } else { + this.sectors[UNKNOWN_KEY].value += isNumber( + position.valueInBaseCurrency + ) + ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency + : this.portfolioDetails.holdings[symbol].valueInPercentage; + } + } + + this.symbols[prettifySymbol(symbol)] = { + dataSource: position.dataSource, + name: position.name, + symbol: prettifySymbol(symbol), + value: isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : position.valueInPercentage + }; + } + + Object.values(this.portfolioDetails.marketsAdvanced).forEach( + ({ id, valueInBaseCurrency, valueInPercentage }) => { + this.marketsAdvanced[id].value = isNumber(valueInBaseCurrency) + ? valueInBaseCurrency + : valueInPercentage; + } + ); + + for (const [ + id, + { name, valueInBaseCurrency, valueInPercentage } + ] of Object.entries(this.portfolioDetails.platforms)) { + let value = 0; + if (this.hasImpersonationId) { + value = valueInPercentage; + } else { + value = valueInBaseCurrency; + } + this.platforms[id] = { id, name, value }; + } + } +} diff --git a/apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.html b/apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.html new file mode 100644 index 000000000..3699dee12 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.html @@ -0,0 +1,452 @@ +
+
+
+

+ Allocations — Comparison View +

+
+
+ + + + + +
+ + + By Platform + + + + + + + + + + By Currency + @if (user?.subscription?.type === 'Basic') { + + } + + + + + + + + + + + By Asset Class + @if (user?.subscription?.type === 'Basic') { + + } + + + + + + + + + + By Holding + + + + + + + + + + By Sector + @if (user?.subscription?.type === 'Basic') { + + } + + + + + + + + + + + By Continent + @if (user?.subscription?.type === 'Basic') { + + } + + + + + + + + + + + By Market + @if (user?.subscription?.type === 'Basic') { + + } + + + + + + + + + + By Account + + + + + + + + + + By Country + @if (user?.subscription?.type === 'Basic') { + + } + + + + + + +
+
+ + + +
+ + + By Platform + + + + + + + + + + By Currency + @if (user?.subscription?.type === 'Basic') { + + } + + + + + + + + + + + By Asset Class + @if (user?.subscription?.type === 'Basic') { + + } + + + + + + + + + + By Holding + + + + + + + + + + By Sector + @if (user?.subscription?.type === 'Basic') { + + } + + + + + + + + + + + By Continent + @if (user?.subscription?.type === 'Basic') { + + } + + + + + + + + + + + By Market + @if (user?.subscription?.type === 'Basic') { + + } + + + + + + + + + + By Account + + + + + + + + + + By Country + @if (user?.subscription?.type === 'Basic') { + + } + + + + + + +
+
+
+
diff --git a/apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.routes.ts b/apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.routes.ts new file mode 100644 index 000000000..fb52c993f --- /dev/null +++ b/apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.routes.ts @@ -0,0 +1,14 @@ +import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; + +import { Routes } from '@angular/router'; + +import { GfAllocationsV2PageComponent } from './allocations-v2-page.component'; + +export const routes: Routes = [ + { + canActivate: [AuthGuard], + component: GfAllocationsV2PageComponent, + path: '', + title: $localize`Allocations V2` + } +]; diff --git a/apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.scss b/apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.scss new file mode 100644 index 000000000..490ca6fdb --- /dev/null +++ b/apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.scss @@ -0,0 +1,41 @@ +:host { + display: block; + + .charts-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 0; + padding-top: 1rem; + } + + .chart-full-width { + grid-column: 1 / -1; + } + + .mat-mdc-card { + .mat-mdc-card-header { + ::ng-deep { + .mat-mdc-card-header-text { + flex: 1 1 auto; + overflow: hidden; + } + } + } + } + + .mat-mdc-tab-group { + ::ng-deep { + .mat-mdc-tab-body-wrapper { + flex: 1; + } + } + } +} + +@media (max-width: 768px) { + :host { + .charts-grid { + grid-template-columns: 1fr; + } + } +} 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 5226c3c12..0954f68a5 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 @@ -14,7 +14,7 @@ import { import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { Market, MarketAdvanced } from '@ghostfolio/common/types'; import { translate } from '@ghostfolio/ui/i18n'; -import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; +import { GfAllocationDonutCardsComponent } from '@ghostfolio/ui/allocation-donut-cards'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; import { GfTopHoldingsComponent } from '@ghostfolio/ui/top-holdings'; @@ -45,7 +45,7 @@ import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ imports: [ - GfPortfolioProportionChartComponent, + GfAllocationDonutCardsComponent, GfPremiumIndicatorComponent, GfTopHoldingsComponent, GfValueComponent, 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 8d5503840..811400f32 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html @@ -45,7 +45,7 @@ > - - - - @@ -134,7 +133,7 @@ - - - - By Account - @@ -310,7 +308,7 @@ - import('./allocations/allocations-page.routes').then((m) => m.routes) }, + { + path: internalRoutes.portfolio.subRoutes.allocationsV2.path, + loadChildren: () => + import('./allocations-v2/allocations-v2-page.routes').then( + (m) => m.routes + ) + }, { path: internalRoutes.portfolio.subRoutes.fire.path, loadChildren: () => diff --git a/libs/common/src/lib/routes/routes.ts b/libs/common/src/lib/routes/routes.ts index dc86c83d1..6e95c2f51 100644 --- a/libs/common/src/lib/routes/routes.ts +++ b/libs/common/src/lib/routes/routes.ts @@ -137,6 +137,11 @@ export const internalRoutes: Record = { routerLink: ['/portfolio', 'allocations'], title: $localize`Allocations` }, + allocationsV2: { + path: 'allocations-v2', + routerLink: ['/portfolio', 'allocations-v2'], + title: $localize`Allocations V2` + }, analysis: { path: undefined, // Default sub route routerLink: ['/portfolio'], diff --git a/libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.html b/libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.html new file mode 100644 index 000000000..250a89f76 --- /dev/null +++ b/libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.html @@ -0,0 +1,49 @@ +@if (isLoading) { + +} @else { +
+ +
+ @for (card of cards; track card.key) { +
+ } +
+ + +
+ @for (card of cards; track card.key) { +
+
+
+
+ {{ card.percentage | percent : '1.1-1' }} +
+
{{ card.name }}
+ @if (!isInPercent) { +
+ {{ card.value | currency : baseCurrency : 'symbol' : '1.0-0' }} +
+ } +
+
+ } +
+ + +
+ Total + {{ totalFormatted }} +
+
+} diff --git a/libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.scss b/libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.scss new file mode 100644 index 000000000..70cd77aec --- /dev/null +++ b/libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.scss @@ -0,0 +1,113 @@ +:host { + display: block; +} + +.allocation-cards-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +// Proportional stacked bar +.proportion-bar { + display: flex; + height: 8px; + border-radius: 4px; + overflow: hidden; + gap: 2px; +} + +.bar-segment { + min-width: 4px; + transition: opacity 0.2s; + cursor: default; + border-radius: 2px; + + &:hover { + opacity: 0.75; + } +} + +// Cards grid +.cards-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 0.5rem; +} + +.allocation-card { + display: flex; + border-radius: 10px; + overflow: hidden; + background: var(--surface-color, rgba(128, 128, 128, 0.04)); + border: 1px solid var(--border-color, rgba(128, 128, 128, 0.1)); + cursor: pointer; + transition: box-shadow 0.2s ease, transform 0.15s ease; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); + } +} + +.card-accent { + width: 4px; + flex-shrink: 0; +} + +.card-body { + padding: 0.6rem 0.7rem; + min-width: 0; + flex: 1; +} + +.card-percentage { + font-weight: 700; + font-size: 1.1rem; + line-height: 1.2; + letter-spacing: -0.02em; +} + +.card-name { + font-size: 0.75rem; + opacity: 0.55; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.1rem; + line-height: 1.3; +} + +.card-value { + font-weight: 600; + font-size: 0.8rem; + margin-top: 0.2rem; +} + +// Total +.total-row { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 0.5rem; + border-top: 1px solid var(--border-color, rgba(128, 128, 128, 0.12)); +} + +.total-label { + font-size: 0.8rem; + opacity: 0.5; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.total-value { + font-weight: 700; + font-size: 1rem; +} + +// Responsive +@media (max-width: 480px) { + .cards-container { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.ts b/libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.ts new file mode 100644 index 000000000..2f0efa2d8 --- /dev/null +++ b/libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.ts @@ -0,0 +1,212 @@ +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { getLocale, getTextColor } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + PortfolioPosition +} from '@ghostfolio/common/interfaces'; +import { ColorScheme } from '@ghostfolio/common/types'; + +import { CommonModule, CurrencyPipe, PercentPipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Input, + OnChanges, + output, + SimpleChanges +} from '@angular/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import OpenColor from 'open-color'; + +import { translate } from '../i18n'; + +const { + blue, + cyan, + grape, + green, + indigo, + lime, + orange, + pink, + red, + teal, + violet, + yellow +} = OpenColor; + +export interface AllocationCard { + color: string; + key: string; + name: string; + percentage: number; + value: number; +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + CurrencyPipe, + MatTooltipModule, + NgxSkeletonLoaderModule, + PercentPipe + ], + selector: 'gf-allocation-cards-grid', + styleUrls: ['./allocation-cards-grid.component.scss'], + templateUrl: './allocation-cards-grid.component.html' +}) +export class GfAllocationCardsGridComponent implements OnChanges { + @Input() baseCurrency: string; + @Input() colorScheme: ColorScheme; + @Input() data: { + [symbol: string]: Pick & { + dataSource?: DataSource; + name: string; + value: number; + }; + } = {}; + @Input() isInPercent = false; + @Input() keys: string[] = []; + @Input() locale = getLocale(); + @Input() maxItems = 12; + + public cards: AllocationCard[] = []; + public isLoading = true; + public totalValue = 0; + + protected readonly cardClicked = output(); + + private readonly OTHER_KEY = 'OTHER'; + + public ngOnChanges(_changes: SimpleChanges) { + if (this.data) { + this.initialize(); + } + } + + public onCardClick(card: AllocationCard) { + const entry = Object.entries(this.data).find(([, item]) => { + return item.name === card.name && item.dataSource; + }); + + if (entry) { + const [symbol, item] = entry; + if (item.dataSource) { + this.cardClicked.emit({ dataSource: item.dataSource, symbol }); + } + } + } + + public get totalFormatted(): string { + if (this.isInPercent) { + return '100%'; + } + if (this.totalValue >= 1_000_000) { + return `$${(this.totalValue / 1_000_000).toFixed(1)}M`; + } + if (this.totalValue >= 1_000) { + return `$${(this.totalValue / 1_000).toFixed(0)}K`; + } + return `$${this.totalValue.toFixed(0)}`; + } + + private initialize() { + this.isLoading = true; + + const chartData: { + [key: string]: { name: string; value: Big }; + } = {}; + + const primaryKey = this.keys?.[0]; + + if (primaryKey) { + Object.keys(this.data).forEach((symbol) => { + const asset = this.data[symbol]; + const assetValue = asset.value || 0; + const keyValue = + (asset[primaryKey] as string)?.toUpperCase() || UNKNOWN_KEY; + + if (chartData[keyValue]) { + chartData[keyValue].value = + chartData[keyValue].value.plus(assetValue); + } else { + chartData[keyValue] = { + name: (asset[primaryKey] as string) || UNKNOWN_KEY, + value: new Big(assetValue) + }; + } + }); + } else { + Object.keys(this.data).forEach((symbol) => { + chartData[symbol] = { + name: this.data[symbol].name, + value: new Big(this.data[symbol].value || 0) + }; + }); + } + + let sorted = Object.entries(chartData) + .sort(([, a], [, b]) => b.value.minus(a.value).toNumber()) + .filter(([, item]) => item.value.gt(0)); + + if (this.maxItems && sorted.length > this.maxItems) { + const rest = sorted.splice(this.maxItems); + const otherValue = rest.reduce( + (sum, [, item]) => sum.plus(item.value), + new Big(0) + ); + sorted.push([this.OTHER_KEY, { name: 'Other', value: otherValue }]); + } + + this.totalValue = sorted.reduce( + (sum, [, item]) => sum + item.value.toNumber(), + 0 + ); + + const palette = this.getColorPalette(); + this.cards = sorted.map(([key, item], index) => { + let color: string; + if (key === this.OTHER_KEY) { + color = `rgba(${getTextColor(this.colorScheme)}, 0.24)`; + } else if (key === UNKNOWN_KEY) { + color = `rgba(${getTextColor(this.colorScheme)}, 0.12)`; + } else { + color = palette[index % palette.length]; + } + + const percentage = + this.totalValue > 0 ? item.value.toNumber() / this.totalValue : 0; + + return { + color, + key, + name: translate(item.name) || key, + percentage, + value: item.value.toNumber() + }; + }); + + this.isLoading = false; + } + + private getColorPalette(): string[] { + return [ + blue[5], + teal[5], + lime[5], + orange[5], + pink[5], + violet[5], + indigo[5], + cyan[5], + green[5], + yellow[5], + red[5], + grape[5] + ]; + } +} diff --git a/libs/ui/src/lib/allocation-cards-grid/index.ts b/libs/ui/src/lib/allocation-cards-grid/index.ts new file mode 100644 index 000000000..05239c7d4 --- /dev/null +++ b/libs/ui/src/lib/allocation-cards-grid/index.ts @@ -0,0 +1 @@ +export * from './allocation-cards-grid.component'; diff --git a/libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.html b/libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.html new file mode 100644 index 000000000..fe22cde53 --- /dev/null +++ b/libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.html @@ -0,0 +1,49 @@ +@if (isLoading) { + +} @else { +
+ +
+ +
+ {{ totalFormatted }} + Total +
+
+ + +
+ @for (slice of slices; track slice.key) { +
+
+ + {{ + slice.percentage | percent : '1.1-1' + }} +
+
{{ slice.name }}
+ @if (!isInPercent) { +
+ {{ + slice.value | currency : baseCurrency : 'symbol' : '1.0-0' + }} +
+ } +
+ } +
+
+} diff --git a/libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.scss b/libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.scss new file mode 100644 index 000000000..5a85ffa1b --- /dev/null +++ b/libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.scss @@ -0,0 +1,119 @@ +:host { + display: block; +} + +.allocation-donut-cards { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.donut-wrapper { + position: relative; + width: 180px; + height: 180px; + flex-shrink: 0; + + canvas { + width: 100% !important; + height: 100% !important; + } +} + +.donut-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + pointer-events: none; +} + +.donut-total { + font-size: 1.15rem; + font-weight: 600; + display: block; + line-height: 1.2; +} + +.donut-label { + font-size: 0.7rem; + opacity: 0.5; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.detail-cards { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + flex: 1; + min-width: 0; +} + +.detail-card { + padding: 0.5rem 0.65rem; + border-radius: 8px; + background: var(--surface-color, rgba(128, 128, 128, 0.06)); + border: 1px solid var(--border-color, rgba(128, 128, 128, 0.12)); + cursor: pointer; + transition: box-shadow 0.2s, transform 0.15s; + min-width: 100px; + flex: 0 1 auto; + + &:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transform: translateY(-1px); + } +} + +.card-top { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.15rem; +} + +.color-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.card-pct { + font-weight: 600; + font-size: 0.85rem; + line-height: 1; +} + +.card-name { + font-size: 0.75rem; + opacity: 0.65; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; +} + +.card-value { + font-weight: 600; + font-size: 0.8rem; + margin-top: 0.1rem; +} + +// Responsive: stack vertically on small screens +@media (max-width: 480px) { + .allocation-donut-cards { + flex-direction: column; + } + + .donut-wrapper { + width: 150px; + height: 150px; + } + + .detail-cards { + justify-content: center; + } +} diff --git a/libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.ts b/libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.ts new file mode 100644 index 000000000..1da9c3055 --- /dev/null +++ b/libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.ts @@ -0,0 +1,286 @@ +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { getLocale, getTextColor } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + PortfolioPosition +} from '@ghostfolio/common/interfaces'; +import { ColorScheme } from '@ghostfolio/common/types'; + +import { CommonModule, CurrencyPipe, PercentPipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + output, + SimpleChanges, + viewChild +} from '@angular/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { + ArcElement, + Chart, + type ChartData, + type ChartDataset, + DoughnutController, + LinearScale, + Tooltip +} from 'chart.js'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import OpenColor from 'open-color'; + +import { translate } from '../i18n'; + +const { + blue, + cyan, + grape, + green, + indigo, + lime, + orange, + pink, + red, + teal, + violet, + yellow +} = OpenColor; + +export interface AllocationSlice { + color: string; + key: string; + name: string; + percentage: number; + value: number; +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + CurrencyPipe, + MatTooltipModule, + NgxSkeletonLoaderModule, + PercentPipe + ], + selector: 'gf-allocation-donut-cards', + styleUrls: ['./allocation-donut-cards.component.scss'], + templateUrl: './allocation-donut-cards.component.html' +}) +export class GfAllocationDonutCardsComponent implements OnChanges, OnDestroy { + @Input() baseCurrency: string; + @Input() colorScheme: ColorScheme; + @Input() data: { + [symbol: string]: Pick & { + dataSource?: DataSource; + name: string; + value: number; + }; + } = {}; + @Input() isInPercent = false; + @Input() keys: string[] = []; + @Input() locale = getLocale(); + @Input() maxItems = 12; + @Input() title = ''; + + public chart: Chart<'doughnut'>; + public isLoading = true; + public slices: AllocationSlice[] = []; + public totalValue = 0; + + protected readonly sliceClicked = output(); + + private readonly OTHER_KEY = 'OTHER'; + + private readonly chartCanvas = + viewChild>('donutCanvas'); + + public constructor() { + Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip); + } + + public ngOnChanges(_changes: SimpleChanges) { + if (this.data) { + this.initialize(); + } + } + + public ngOnDestroy() { + this.chart?.destroy(); + } + + public onSliceClick(slice: AllocationSlice) { + const entry = Object.entries(this.data).find(([, item]) => { + return item.name === slice.name || item.dataSource; + }); + + if (entry) { + const [symbol, item] = entry; + if (item.dataSource) { + this.sliceClicked.emit({ dataSource: item.dataSource, symbol }); + } + } + } + + public get totalFormatted(): string { + if (this.isInPercent) { + return '100%'; + } + if (this.totalValue >= 1_000_000) { + return `$${(this.totalValue / 1_000_000).toFixed(1)}M`; + } + if (this.totalValue >= 1_000) { + return `$${(this.totalValue / 1_000).toFixed(0)}K`; + } + return `$${this.totalValue.toFixed(0)}`; + } + + private initialize() { + this.isLoading = true; + + const chartData: { + [key: string]: { name: string; value: Big }; + } = {}; + + const primaryKey = this.keys?.[0]; + + if (primaryKey) { + Object.keys(this.data).forEach((symbol) => { + const asset = this.data[symbol]; + const assetValue = asset.value || 0; + const keyValue = (asset[primaryKey] as string)?.toUpperCase() || UNKNOWN_KEY; + + if (chartData[keyValue]) { + chartData[keyValue].value = chartData[keyValue].value.plus(assetValue); + } else { + chartData[keyValue] = { + name: (asset[primaryKey] as string) || UNKNOWN_KEY, + value: new Big(assetValue) + }; + } + }); + } else { + Object.keys(this.data).forEach((symbol) => { + chartData[symbol] = { + name: this.data[symbol].name, + value: new Big(this.data[symbol].value || 0) + }; + }); + } + + // Sort descending by value + let sorted = Object.entries(chartData) + .sort(([, a], [, b]) => b.value.minus(a.value).toNumber()) + .filter(([, item]) => item.value.gt(0)); + + // Group overflow into "Other" + if (this.maxItems && sorted.length > this.maxItems) { + const rest = sorted.splice(this.maxItems); + const otherValue = rest.reduce( + (sum, [, item]) => sum.plus(item.value), + new Big(0) + ); + sorted.push([this.OTHER_KEY, { name: 'Other', value: otherValue }]); + } + + // Calculate total + this.totalValue = sorted.reduce( + (sum, [, item]) => sum + item.value.toNumber(), + 0 + ); + + // Build slices with colors + const palette = this.getColorPalette(); + this.slices = sorted.map(([key, item], index) => { + let color: string; + if (key === this.OTHER_KEY) { + color = `rgba(${getTextColor(this.colorScheme)}, 0.24)`; + } else if (key === UNKNOWN_KEY) { + color = `rgba(${getTextColor(this.colorScheme)}, 0.12)`; + } else { + color = palette[index % palette.length]; + } + + const percentage = + this.totalValue > 0 ? item.value.toNumber() / this.totalValue : 0; + + return { + color, + key, + name: translate(item.name) || key, + percentage, + value: item.value.toNumber() + }; + }); + + // Build chart + this.buildChart(); + this.isLoading = false; + } + + private buildChart() { + const canvas = this.chartCanvas(); + if (!canvas) { + return; + } + + const backgrounds = this.slices.map((s) => s.color); + const values = this.slices.map((s) => s.value); + + const datasets: ChartDataset<'doughnut'>[] = [ + { + backgroundColor: backgrounds.length > 0 ? backgrounds : [`rgba(${getTextColor(this.colorScheme)}, 0.12)`], + borderWidth: 0, + data: values.length > 0 ? values : [1], + hoverOffset: 4 + } + ]; + + const data: ChartData<'doughnut'> = { + datasets, + labels: this.slices.map((s) => s.name) + }; + + if (this.chart) { + this.chart.data = data; + this.chart.update(); + } else { + this.chart = new Chart<'doughnut'>(canvas.nativeElement, { + data, + options: { + animation: false, + cutout: '75%', + layout: { padding: 0 }, + plugins: { + legend: { display: false }, + tooltip: { enabled: false } + }, + responsive: true, + maintainAspectRatio: true + }, + type: 'doughnut' + }); + } + } + + private getColorPalette(): string[] { + return [ + blue[5], + teal[5], + lime[5], + orange[5], + pink[5], + violet[5], + indigo[5], + cyan[5], + green[5], + yellow[5], + red[5], + grape[5] + ]; + } +} diff --git a/libs/ui/src/lib/allocation-donut-cards/index.ts b/libs/ui/src/lib/allocation-donut-cards/index.ts new file mode 100644 index 000000000..f38e73ba7 --- /dev/null +++ b/libs/ui/src/lib/allocation-donut-cards/index.ts @@ -0,0 +1 @@ +export * from './allocation-donut-cards.component';