Browse Source

feat(009): FMV Plaid drilldown, allocations v2 page, and allocation UI components

pull/6701/head
Robert Patch 2 months ago
parent
commit
580a6e861a
  1. 2
      apps/api/src/app/admin/dev-seed.service.ts
  2. 35
      apps/api/src/app/plaid/plaid.service.ts
  3. 11
      apps/client/src/app/pages/fmv/fmv-page.component.html
  4. 20
      apps/client/src/app/pages/fmv/fmv-page.component.ts
  5. 389
      apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.component.ts
  6. 452
      apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.html
  7. 14
      apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.routes.ts
  8. 41
      apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.scss
  9. 4
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  10. 28
      apps/client/src/app/pages/portfolio/allocations/allocations-page.html
  11. 8
      apps/client/src/app/pages/portfolio/portfolio-page.component.ts
  12. 7
      apps/client/src/app/pages/portfolio/portfolio-page.routes.ts
  13. 5
      libs/common/src/lib/routes/routes.ts
  14. 49
      libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.html
  15. 113
      libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.scss
  16. 212
      libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.ts
  17. 1
      libs/ui/src/lib/allocation-cards-grid/index.ts
  18. 49
      libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.html
  19. 119
      libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.scss
  20. 286
      libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.ts
  21. 1
      libs/ui/src/lib/allocation-donut-cards/index.ts

2
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,

35
apps/api/src/app/plaid/plaid.service.ts

@ -153,8 +153,38 @@ export class PlaidService {
});
}
// Create PlaidItem
const plaidItem = await this.prismaService.plaidItem.create({
// 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,
@ -163,6 +193,7 @@ export class PlaidService {
userId
}
});
}
// Create accounts for each Plaid account
const createdAccounts = [];

11
apps/client/src/app/pages/fmv/fmv-page.component.html

@ -77,6 +77,7 @@
</div>
}
</div>
<div class="d-flex align-items-center">
<button
mat-icon-button
matTooltip="Refresh"
@ -85,6 +86,16 @@
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
matTooltip="Remove connection"
i18n-matTooltip
color="warn"
(click)="onDeletePlaidItem(item.id)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</mat-card-content>
</mat-card>
}

20
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)

389
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<Account, 'name'> & {
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<Platform, 'name'> & {
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 };
}
}
}

452
apps/client/src/app/pages/portfolio/allocations-v2/allocations-v2-page.html

@ -0,0 +1,452 @@
<div class="container">
<div class="row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>
Allocations — Comparison View
</h1>
</div>
</div>
<!-- Tab toggle between the two new visualization styles -->
<mat-tab-group
[(selectedIndex)]="selectedTabIndex"
animationDuration="200ms"
class="mb-4"
>
<!-- TAB 1: Donut + Cards (recommended hybrid) -->
<mat-tab label="Donut + Cards">
<div class="charts-grid">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n
>By Platform</mat-card-title
>
</mat-card-header>
<mat-card-content>
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="platforms"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['id']"
[locale]="user?.settings?.locale"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title
class="align-items-center d-flex text-truncate"
>
<span i18n>By Currency</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="holdings"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['currency']"
[locale]="user?.settings?.locale"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title
class="align-items-center d-flex text-truncate"
>
<span i18n>By Asset Class</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="holdings"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['assetClassLabel', 'assetSubClassLabel']"
[locale]="user?.settings?.locale"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="chart-full-width mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n
>By Holding</mat-card-title
>
</mat-card-header>
<mat-card-content>
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="symbols"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['symbol']"
[locale]="user?.settings?.locale"
[maxItems]="15"
(sliceClicked)="onSymbolClicked($event)"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title
class="align-items-center d-flex text-truncate"
>
<span i18n>By Sector</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="sectors"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['name']"
[locale]="user?.settings?.locale"
[maxItems]="10"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title
class="align-items-center d-flex text-truncate"
>
<span i18n>By Continent</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="continents"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['name']"
[locale]="user?.settings?.locale"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title
class="align-items-center d-flex text-truncate"
>
<span i18n>By Market</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="marketsAdvanced"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[locale]="user?.settings?.locale"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n
>By Account</mat-card-title
>
</mat-card-header>
<mat-card-content>
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="accounts"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['id']"
[locale]="user?.settings?.locale"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title
class="align-items-center d-flex text-truncate"
>
<span i18n>By Country</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="countries"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['name']"
[locale]="user?.settings?.locale"
[maxItems]="10"
/>
</mat-card-content>
</mat-card>
</div>
</mat-tab>
<!-- TAB 2: Cards Grid only (Option 3) -->
<mat-tab label="Cards Grid">
<div class="charts-grid">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n
>By Platform</mat-card-title
>
</mat-card-header>
<mat-card-content>
<gf-allocation-cards-grid
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="platforms"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['id']"
[locale]="user?.settings?.locale"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title
class="align-items-center d-flex text-truncate"
>
<span i18n>By Currency</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-allocation-cards-grid
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="holdings"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['currency']"
[locale]="user?.settings?.locale"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title
class="align-items-center d-flex text-truncate"
>
<span i18n>By Asset Class</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-allocation-cards-grid
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="holdings"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['assetClassLabel', 'assetSubClassLabel']"
[locale]="user?.settings?.locale"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="chart-full-width mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n
>By Holding</mat-card-title
>
</mat-card-header>
<mat-card-content>
<gf-allocation-cards-grid
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="symbols"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['symbol']"
[locale]="user?.settings?.locale"
[maxItems]="15"
(cardClicked)="onSymbolClicked($event)"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title
class="align-items-center d-flex text-truncate"
>
<span i18n>By Sector</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-allocation-cards-grid
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="sectors"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['name']"
[locale]="user?.settings?.locale"
[maxItems]="10"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title
class="align-items-center d-flex text-truncate"
>
<span i18n>By Continent</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-allocation-cards-grid
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="continents"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['name']"
[locale]="user?.settings?.locale"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title
class="align-items-center d-flex text-truncate"
>
<span i18n>By Market</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-allocation-cards-grid
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="marketsAdvanced"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[locale]="user?.settings?.locale"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n
>By Account</mat-card-title
>
</mat-card-header>
<mat-card-content>
<gf-allocation-cards-grid
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="accounts"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['id']"
[locale]="user?.settings?.locale"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title
class="align-items-center d-flex text-truncate"
>
<span i18n>By Country</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-allocation-cards-grid
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="countries"
[isInPercent]="
hasImpersonationId || user?.settings?.isRestrictedView
"
[keys]="['name']"
[locale]="user?.settings?.locale"
[maxItems]="10"
/>
</mat-card-content>
</mat-card>
</div>
</mat-tab>
</mat-tab-group>
</div>

14
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`
}
];

41
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;
}
}
}

4
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,

28
apps/client/src/app/pages/portfolio/allocations/allocations-page.html

@ -45,7 +45,7 @@
>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="platforms"
@ -67,7 +67,7 @@
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="holdings"
@ -89,7 +89,7 @@
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="holdings"
@ -108,17 +108,16 @@
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
<gf-allocation-donut-cards
class="mx-auto"
cursor="pointer"
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="symbols"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['symbol']"
[locale]="user?.settings?.locale"
[showLabels]="deviceType !== 'mobile'"
(proportionChartClicked)="onSymbolChartClicked($event)"
[maxItems]="15"
(sliceClicked)="onSymbolChartClicked($event)"
/>
</mat-card-content>
</mat-card>
@ -134,7 +133,7 @@
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="sectors"
@ -157,7 +156,7 @@
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="continents"
@ -179,7 +178,7 @@
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="marketsAdvanced"
@ -268,7 +267,7 @@
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="countries"
@ -286,15 +285,14 @@
<mat-card-title class="text-truncate" i18n>By Account</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
cursor="pointer"
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="accounts"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['id']"
[locale]="user?.settings?.locale"
(proportionChartClicked)="onAccountChartClicked($event)"
(sliceClicked)="onAccountChartClicked($event)"
/>
</mat-card-content>
</mat-card>
@ -310,7 +308,7 @@
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
<gf-allocation-donut-cards
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[data]="holdings"

8
apps/client/src/app/pages/portfolio/portfolio-page.component.ts

@ -15,6 +15,7 @@ import { addIcons } from 'ionicons';
import {
analyticsOutline,
calculatorOutline,
gridOutline,
pieChartOutline,
scanOutline,
swapVerticalOutline
@ -61,6 +62,12 @@ export class PortfolioPageComponent implements OnInit {
routerLink:
internalRoutes.portfolio.subRoutes.allocations.routerLink
},
{
iconName: 'grid-outline',
label: internalRoutes.portfolio.subRoutes.allocationsV2.title,
routerLink:
internalRoutes.portfolio.subRoutes.allocationsV2.routerLink
},
{
iconName: 'calculator-outline',
label: internalRoutes.portfolio.subRoutes.fire.title,
@ -81,6 +88,7 @@ export class PortfolioPageComponent implements OnInit {
addIcons({
analyticsOutline,
calculatorOutline,
gridOutline,
pieChartOutline,
scanOutline,
swapVerticalOutline

7
apps/client/src/app/pages/portfolio/portfolio-page.routes.ts

@ -24,6 +24,13 @@ export const routes: Routes = [
loadChildren: () =>
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: () =>

5
libs/common/src/lib/routes/routes.ts

@ -137,6 +137,11 @@ export const internalRoutes: Record<string, InternalRoute> = {
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'],

49
libs/ui/src/lib/allocation-cards-grid/allocation-cards-grid.component.html

@ -0,0 +1,49 @@
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
[theme]="{ height: '200px', width: '100%', borderRadius: '12px' }"
/>
} @else {
<div class="allocation-cards-grid">
<!-- Stacked bar at top showing proportional segments -->
<div class="proportion-bar">
@for (card of cards; track card.key) {
<div
class="bar-segment"
[style.width.%]="card.percentage * 100"
[style.background]="card.color"
[matTooltip]="card.name + ': ' + (card.percentage | percent : '1.1-1')"
></div>
}
</div>
<!-- Cards -->
<div class="cards-container">
@for (card of cards; track card.key) {
<div
class="allocation-card"
(click)="onCardClick(card)"
>
<div class="card-accent" [style.background]="card.color"></div>
<div class="card-body">
<div class="card-percentage">
{{ card.percentage | percent : '1.1-1' }}
</div>
<div class="card-name">{{ card.name }}</div>
@if (!isInPercent) {
<div class="card-value">
{{ card.value | currency : baseCurrency : 'symbol' : '1.0-0' }}
</div>
}
</div>
</div>
}
</div>
<!-- Total -->
<div class="total-row">
<span class="total-label" i18n>Total</span>
<span class="total-value">{{ totalFormatted }}</span>
</div>
</div>
}

113
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);
}
}

212
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<PortfolioPosition, 'type'> & {
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<AssetProfileIdentifier>();
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]
];
}
}

1
libs/ui/src/lib/allocation-cards-grid/index.ts

@ -0,0 +1 @@
export * from './allocation-cards-grid.component';

49
libs/ui/src/lib/allocation-donut-cards/allocation-donut-cards.component.html

@ -0,0 +1,49 @@
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
[theme]="{ height: '200px', width: '100%', borderRadius: '12px' }"
/>
} @else {
<div class="allocation-donut-cards">
<!-- Donut -->
<div class="donut-wrapper">
<canvas #donutCanvas></canvas>
<div class="donut-center">
<span class="donut-total">{{ totalFormatted }}</span>
<span class="donut-label" i18n>Total</span>
</div>
</div>
<!-- Detail cards -->
<div class="detail-cards">
@for (slice of slices; track slice.key) {
<div
class="detail-card"
[matTooltip]="
slice.name +
': ' +
(isInPercent
? (slice.percentage | percent : '1.1-1')
: (slice.value | currency : baseCurrency : 'symbol' : '1.0-0'))
"
(click)="onSliceClick(slice)"
>
<div class="card-top">
<span class="color-dot" [style.background]="slice.color"></span>
<span class="card-pct">{{
slice.percentage | percent : '1.1-1'
}}</span>
</div>
<div class="card-name">{{ slice.name }}</div>
@if (!isInPercent) {
<div class="card-value">
{{
slice.value | currency : baseCurrency : 'symbol' : '1.0-0'
}}
</div>
}
</div>
}
</div>
</div>
}

119
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;
}
}

286
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<PortfolioPosition, 'type'> & {
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<AssetProfileIdentifier>();
private readonly OTHER_KEY = 'OTHER';
private readonly chartCanvas =
viewChild<ElementRef<HTMLCanvasElement>>('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]
];
}
}

1
libs/ui/src/lib/allocation-donut-cards/index.ts

@ -0,0 +1 @@
export * from './allocation-donut-cards.component';
Loading…
Cancel
Save