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';