Browse Source

Merge branch 'main' into bugfix/market-price-fallback-before-first-historical-market-data

pull/7761/head
Thomas Kaul 2 months ago
committed by GitHub
parent
commit
e20b761620
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 18
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  3. 7
      apps/api/src/app/endpoints/ai/ai.service.ts
  4. 16
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  5. 20
      apps/api/src/app/endpoints/public/public.service.ts
  6. 49
      apps/api/src/app/portfolio/portfolio.controller.ts
  7. 49
      apps/api/src/helper/portfolio.helper.ts
  8. 9
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  9. 4
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  10. 3
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  11. 2
      apps/client/src/app/components/header/header.component.ts
  12. 30
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  13. 6
      apps/client/src/app/components/home-holdings/home-holdings.html
  14. 2
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  15. 2
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  16. 20
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  17. 12
      apps/client/src/app/components/markets/markets.component.ts
  18. 2
      apps/client/src/app/components/markets/markets.html
  19. 2
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html
  20. 8
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  21. 3
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  22. 4
      apps/client/src/app/pages/accounts/accounts-page.html
  23. 2
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  24. 3
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  25. 220
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  26. 8
      apps/client/src/app/pages/portfolio/allocations/allocations-page.html
  27. 2
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  28. 53
      apps/client/src/app/pages/public/public-page.component.ts
  29. 4
      apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.html
  30. 12
      apps/client/tsconfig.json
  31. 1
      libs/common/src/lib/config.ts
  32. 24
      libs/common/src/lib/helper.ts
  33. 1
      libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts
  34. 6
      libs/common/src/lib/types/toggle-option.type.ts
  35. 2
      libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html
  36. 8
      libs/ui/src/lib/line-chart/line-chart.component.ts
  37. 1
      libs/ui/src/lib/portfolio-proportion-chart/index.ts
  38. 6
      libs/ui/src/lib/services/data.service.ts
  39. 2
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  40. 11
      libs/ui/src/lib/toggle/toggle.component.ts
  41. 8
      package-lock.json
  42. 2
      package.json

4
CHANGELOG.md

@ -9,10 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Extended the tool to get the accounts of the portfolio in the server of the Model Context Protocol (MCP) to support the filtering by account (experimental)
- Upgraded `undici` from version `8.5.0` to `8.10.0`
- Upgraded `uuid` from version `14.0.1` to `14.0.2`
### Fixed
- Fixed the cash positions being included in the by continent, by country and by sector charts on the allocations page and the public page
- Fixed the allocations in percentage exceeding 100% in the restricted view
- Fixed the portfolio calculation for holdings with activities before the first known historical market price by falling back to the unit price of the activity
## 3.64.0 - 2026-08-30

18
apps/api/src/app/endpoints/ai/ai.service.spec.ts

@ -29,13 +29,16 @@ interface AiServiceWithMarkdownTable {
}
function createAccount({
id = 'account-a-id',
isExcluded = false,
name = 'Account A'
}: {
id?: string;
isExcluded?: boolean;
name?: string;
} = {}) {
return {
id,
name,
activitiesCount: 3,
allocationInPercentage: 0.25,
@ -86,6 +89,7 @@ describe('AiService', () => {
describe('getAccountsTableColumnNames', () => {
it('gives no column with a monetary value', () => {
expect(AiService.getAccountsTableColumnNames()).toEqual([
'Id',
'Name',
'Currency',
'Platform',
@ -121,10 +125,20 @@ describe('AiService', () => {
expect(result).not.toContain('2000');
});
// The accountIds parameter of the tool takes the identifiers, hence the
// table has to give them
it('gives the identifier of an account', async () => {
const aiService = createAiService([createAccount()]);
const result = await aiService.getAccountsTable({ userId: 'user-id' });
expect(result).toContain('account-a-id');
});
it('marks an account which is excluded from the analysis', async () => {
const aiService = createAiService([
createAccount({ isExcluded: true }),
createAccount({ name: 'Account B' })
createAccount({ id: 'account-b-id', name: 'Account B' })
]);
const result = await aiService.getAccountsTable({ userId: 'user-id' });
@ -132,7 +146,7 @@ describe('AiService', () => {
const [rowOfAccountA, rowOfAccountB] = result
.split('\n')
.filter((line) => {
return line.startsWith('Account ');
return line.startsWith('account-');
});
expect(rowOfAccountA).toContain('true');

7
apps/api/src/app/endpoints/ai/ai.service.ts

@ -30,9 +30,11 @@ export class AiService {
| 'ALLOCATION_PERCENTAGE'
| 'CURRENCY'
| 'EXCLUDED_FROM_ANALYSIS'
| 'ID'
| 'NAME'
| 'PLATFORM';
} & ColumnDescriptor)[] = [
{ key: 'ID', name: 'Id' },
{ key: 'NAME', name: 'Name' },
{ key: 'CURRENCY', name: 'Currency' },
{ key: 'PLATFORM', name: 'Platform' },
@ -160,6 +162,7 @@ export class AiService {
activitiesCount,
allocationInPercentage,
currency,
id,
name: label,
platform,
tags
@ -183,6 +186,10 @@ export class AiService {
row[name] = isAccountExcluded({ tags }).toString();
break;
case 'ID':
row[name] = id;
break;
case 'NAME':
row[name] = label ?? '';
break;

16
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -8,6 +8,7 @@ import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'
import {
DATE_RANGES,
DEFAULT_LANGUAGE_CODE,
MCP_MAX_ACCOUNTS,
MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config';
import { scopes } from '@ghostfolio/common/scopes';
@ -20,6 +21,14 @@ import { McpController, Tool } from '@rekog/mcp-nest';
import { z } from 'zod';
const GET_ACCOUNTS_PARAMETERS = z.object({
accountIds: z
.array(z.string().min(1))
.min(1)
.max(MCP_MAX_ACCOUNTS)
.optional()
.describe(
`The identifiers of the accounts to get, at most ${MCP_MAX_ACCOUNTS}`
),
assetClasses: z
.array(z.enum(AssetClass))
.min(1)
@ -104,9 +113,14 @@ export class GhostfolioMcpController {
public async getAccounts(
@Impersonation() { userId }: ImpersonationContext,
@Payload()
{ assetClasses, holding }: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
{
accountIds,
assetClasses,
holding
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
) {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts: accountIds?.join(','),
filterByAssetClasses: assetClasses?.join(','),
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol

20
apps/api/src/app/endpoints/public/public.service.ts

@ -6,18 +6,14 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { getSum } from '@ghostfolio/common/helper';
import { getSum, isCashPosition } from '@ghostfolio/common/helper';
import {
AccessSettings,
PublicPortfolioResponse
} from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
Type as ActivityType
} from '@prisma/client';
import { Type as ActivityType } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -173,23 +169,19 @@ export class PublicService {
assetProfile: {
...holding.assetProfile,
assetClass:
hasDetails ||
holding.assetProfile.assetClass === AssetClass.LIQUIDITY
hasDetails || isCashPosition(holding.assetProfile)
? holding.assetProfile.assetClass
: undefined,
assetClassLabel:
hasDetails ||
holding.assetProfile.assetClass === AssetClass.LIQUIDITY
hasDetails || isCashPosition(holding.assetProfile)
? holding.assetProfile.assetClassLabel
: undefined,
assetSubClass:
hasDetails ||
holding.assetProfile.assetSubClass === AssetSubClass.CASH
hasDetails || isCashPosition(holding.assetProfile)
? holding.assetProfile.assetSubClass
: undefined,
assetSubClassLabel:
hasDetails ||
holding.assetProfile.assetSubClass === AssetSubClass.CASH
hasDetails || isCashPosition(holding.assetProfile)
? holding.assetProfile.assetSubClassLabel
: undefined,
holdings: holding.assetProfile.holdings?.map(

49
apps/api/src/app/portfolio/portfolio.controller.ts

@ -6,6 +6,7 @@ import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
import { convertValuesToPercentages } from '@ghostfolio/api/helper/portfolio.helper';
import { PerformanceLoggingInterceptor } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
@ -15,6 +16,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { isCashPosition } from '@ghostfolio/common/helper';
import {
PortfolioDetails,
PortfolioDividendsResponse,
@ -44,7 +46,7 @@ import {
Version
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -128,38 +130,7 @@ export class PortfolioController {
!hasScope(impersonationScopes, scopes.portfolioReadValues) ||
isRestrictedView(this.request.user)
) {
const totalInvestment = holdings
.map(({ investment }) => {
return investment;
})
.reduce((a, b) => a + b, 0);
const totalValue = holdings
.filter(({ assetProfile }) => {
return (
assetProfile.assetClass !== AssetClass.LIQUIDITY &&
assetProfile.assetSubClass !== AssetSubClass.CASH
);
})
.map(({ valueInBaseCurrency }) => {
return valueInBaseCurrency;
})
.reduce((a, b) => {
return a + b;
}, 0);
for (const holding of holdings) {
holding.investment = holding.investment / totalInvestment;
holding.valueInPercentage = holding.valueInBaseCurrency / totalValue;
}
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
accounts[name].valueInPercentage = valueInBaseCurrency / totalValue;
}
for (const [name, { valueInBaseCurrency }] of Object.entries(platforms)) {
platforms[name].valueInPercentage = valueInBaseCurrency / totalValue;
}
convertValuesToPercentages({ accounts, holdings, platforms });
}
if (
@ -208,23 +179,19 @@ export class PortfolioController {
assetProfile: {
...portfolioPosition.assetProfile,
assetClass:
hasDetails ||
portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
hasDetails || isCashPosition(portfolioPosition.assetProfile)
? portfolioPosition.assetProfile.assetClass
: undefined,
assetClassLabel:
hasDetails ||
portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
hasDetails || isCashPosition(portfolioPosition.assetProfile)
? portfolioPosition.assetProfile.assetClassLabel
: undefined,
assetSubClass:
hasDetails ||
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
hasDetails || isCashPosition(portfolioPosition.assetProfile)
? portfolioPosition.assetProfile.assetSubClass
: undefined,
assetSubClassLabel:
hasDetails ||
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
hasDetails || isCashPosition(portfolioPosition.assetProfile)
? portfolioPosition.assetProfile.assetSubClassLabel
: undefined,
...(hasDetails

49
apps/api/src/helper/portfolio.helper.ts

@ -1,5 +1,54 @@
import { PortfolioDetails } from '@ghostfolio/common/interfaces';
import { Type as ActivityType } from '@prisma/client';
export function convertValuesToPercentages({
accounts,
holdings,
platforms
}: {
accounts: PortfolioDetails['accounts'];
holdings: PortfolioDetails['holdings'];
platforms: PortfolioDetails['platforms'];
}) {
const totalInvestment = holdings
.map(({ investment }) => {
return investment;
})
.reduce((a, b) => {
return a + b;
}, 0);
const totalValue = holdings
.map(({ valueInBaseCurrency }) => {
return valueInBaseCurrency;
})
.reduce((a, b) => {
return a + b;
}, 0);
for (const holding of holdings) {
holding.investment = totalInvestment
? holding.investment / totalInvestment
: 0;
holding.valueInPercentage = totalValue
? holding.valueInBaseCurrency / totalValue
: 0;
}
for (const account of Object.values(accounts)) {
account.valueInPercentage = totalValue
? account.valueInBaseCurrency / totalValue
: 0;
}
for (const platform of Object.values(platforms)) {
platform.valueInPercentage = totalValue
? platform.valueInBaseCurrency / totalValue
: 0;
}
}
export function getFactor(activityType: ActivityType) {
let factor: number;

9
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -71,10 +71,10 @@
type="button"
[disabled]="
!canDeleteAssetProfile({
activitiesCount: assetProfile?.activitiesCount,
activitiesCount: assetProfile?.activitiesCount ?? 0,
isBenchmark: isBenchmark,
symbol: data.symbol,
watchedByCount: assetProfile?.watchedByCount
watchedByCount: assetProfile?.watchedByCount ?? 0
})
"
(click)="
@ -272,8 +272,8 @@
>
</div>
@if (
assetProfile?.countries?.length > 0 ||
assetProfile?.sectors?.length > 0
(assetProfile?.countries?.length ?? 0) > 0 ||
(assetProfile?.sectors?.length ?? 0) > 0
) {
@if (
assetProfile?.countries?.length === 1 &&
@ -660,7 +660,6 @@
formControlName="headers"
matInput
type="text"
[matAutocomplete]="auto"
></textarea>
</mat-form-field>
</div>

4
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -230,13 +230,13 @@
<div class="mb-5 row">
<div class="col">
<h2 class="text-center" i18n>Platforms</h2>
<gf-admin-platform [locale]="user?.settings?.locale" />
<gf-admin-platform [locale]="user?.settings?.locale ?? DEFAULT_LOCALE" />
</div>
</div>
<div class="row">
<div class="col">
<h2 class="text-center" i18n>Tags</h2>
<gf-admin-tag [locale]="user?.settings?.locale" />
<gf-admin-tag [locale]="user?.settings?.locale ?? DEFAULT_LOCALE" />
</div>
</div>
</div>

3
apps/client/src/app/components/admin-settings/admin-settings.component.ts

@ -3,6 +3,7 @@ import { GfAdminTagComponent } from '@ghostfolio/client/components/admin-tag/adm
import { GfDataProviderStatusComponent } from '@ghostfolio/client/components/data-provider-status/data-provider-status.component';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_LOCALE,
E_MAIL_LINE_BREAK,
PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config';
@ -98,6 +99,8 @@ export class GfAdminSettingsComponent implements OnInit {
public pricingUrl: string;
public user: User;
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,

2
apps/client/src/app/components/header/header.component.ts

@ -220,7 +220,7 @@ export class GfHeaderComponent implements OnChanges {
this.assistentMenuTriggerElement().closeMenu();
}
protected impersonateAccount(aId: string) {
protected impersonateAccount(aId: string | null) {
if (aId) {
this.impersonationStorageService.setId(aId);
} else {

30
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -1,4 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import {
AssetProfileIdentifier,
PortfolioPosition,
@ -50,26 +51,29 @@ import { DeviceDetectorService } from 'ngx-device-detector';
export class GfHomeHoldingsComponent implements OnInit {
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected deviceType: string;
protected hasPermissionToAccessHoldingsChart: boolean;
protected hasPermissionToCreateActivity: boolean;
protected holdings: PortfolioPosition[] | undefined;
protected holdingsViewMode: HoldingsViewMode =
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE;
protected readonly holdingsViewModeOptions: ToggleOption[] = [
{
iconName: 'reorder-four-outline',
title: $localize`Table`,
value: 'TABLE'
},
{
iconName: 'grid-outline',
title: $localize`Chart`,
value: 'CHART'
}
];
protected readonly holdingsViewModeOptions: ToggleOption<HoldingsViewMode>[] =
[
{
iconName: 'reorder-four-outline',
title: $localize`Table`,
value: 'TABLE'
},
{
iconName: 'grid-outline',
title: $localize`Chart`,
value: 'CHART'
}
];
protected holdingType: HoldingType = 'ACTIVE';
protected readonly holdingTypeOptions: ToggleOption[] = [
protected readonly holdingTypeOptions: ToggleOption<HoldingType>[] = [
{ label: $localize`Active`, value: 'ACTIVE' },
{ label: $localize`Closed`, value: 'CLOSED' }
];

6
apps/client/src/app/components/home-holdings/home-holdings.html

@ -37,11 +37,11 @@
}
<div [class.d-none]="holdingsViewMode !== 'TABLE'">
<gf-holdings-table
[holdings]="holdings"
[locale]="user?.settings?.locale"
[holdings]="holdings ?? []"
[locale]="user?.settings?.locale ?? DEFAULT_LOCALE"
(holdingClicked)="onHoldingClicked($event)"
/>
@if (hasPermissionToCreateActivity && holdings?.length > 0) {
@if (hasPermissionToCreateActivity && (holdings?.length ?? 0) > 0) {
<div class="text-center">
<a
class="mt-3"

2
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -44,6 +44,8 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
templateUrl: './home-watchlist.html'
})
export class GfHomeWatchlistComponent implements OnInit {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected hasPermissionToCreateWatchlistItem: boolean;
protected hasPermissionToDeleteWatchlistItem: boolean;
protected user: User;

2
apps/client/src/app/components/home-watchlist/home-watchlist.html

@ -13,7 +13,7 @@
[benchmarks]="watchlist"
[deviceType]="deviceType()"
[hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem"
[locale]="user?.settings?.locale || undefined"
[locale]="user?.settings?.locale ?? DEFAULT_LOCALE"
[showIcon]="true"
[user]="user"
(itemDeleted)="onWatchlistItemDeleted($event)"

20
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -53,16 +53,16 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
templateUrl: './investment-chart.component.html'
})
export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
@Input() public readonly benchmarkDataItems: InvestmentItem[] = [];
@Input() public readonly benchmarkDataLabel = '';
@Input() public readonly colorScheme: ColorScheme;
@Input() public readonly currency: string;
@Input() public readonly groupBy: GroupBy;
@Input() public readonly historicalDataItems: LineChartItem[] = [];
@Input() public readonly isInPercentage = false;
@Input() public readonly isLoading = false;
@Input() public readonly locale = getLocale();
@Input() public readonly savingsRate = 0;
@Input() public benchmarkDataItems: InvestmentItem[] = [];
@Input() public benchmarkDataLabel = '';
@Input() public colorScheme: ColorScheme;
@Input() public currency: string;
@Input() public groupBy: GroupBy;
@Input() public historicalDataItems: LineChartItem[] = [];
@Input() public isInPercentage = false;
@Input() public isLoading = false;
@Input() public locale = getLocale();
@Input() public savingsRate = 0;
private readonly chartCanvas =
viewChild.required<ElementRef<HTMLCanvasElement>>('chartCanvas');

12
apps/client/src/app/components/markets/markets.component.ts

@ -1,4 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import {
Benchmark,
@ -43,16 +44,19 @@ import { DeviceDetectorService } from 'ngx-device-detector';
templateUrl: './markets.html'
})
export class GfMarketsComponent implements OnInit {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected readonly benchmarks = signal<Benchmark[] | undefined>(undefined);
protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
protected readonly fearAndGreedIndexModeOptions: ToggleOption[] = [
{ label: $localize`Stocks`, value: 'STOCKS' },
{ label: $localize`Cryptocurrencies`, value: 'CRYPTOCURRENCIES' }
];
protected readonly fearAndGreedIndexModeOptions: ToggleOption<FearAndGreedIndexMode>[] =
[
{ label: $localize`Stocks`, value: 'STOCKS' },
{ label: $localize`Cryptocurrencies`, value: 'CRYPTOCURRENCIES' }
];
protected readonly fearLabel = $localize`Fear`;
protected readonly greedLabel = $localize`Greed`;

2
apps/client/src/app/components/markets/markets.html

@ -52,7 +52,7 @@
<gf-benchmark
[benchmarks]="benchmarks()"
[deviceType]="deviceType()"
[locale]="user?.settings?.locale || undefined"
[locale]="user?.settings?.locale ?? DEFAULT_LOCALE"
[showSymbol]="false"
[user]="user"
/>

2
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html

@ -1,7 +1,7 @@
<div class="container p-0">
<div class="no-gutters row">
<div class="status-container text-muted text-right">
@if (errors()?.length > 0 && !isLoading()) {
@if ((errors()?.length ?? 0) > 0 && !isLoading()) {
<ion-icon
i18n-title
name="time-outline"

8
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -183,7 +183,7 @@
<ng-container i18n>Holdings</ng-container>
@if (
!hasImpersonationId &&
summary?.totalValueInBaseCurrency > 0 &&
(summary?.totalValueInBaseCurrency ?? 0) > 0 &&
user?.settings?.isExperimentalFeatures
) {
<gf-value
@ -261,7 +261,7 @@
<ng-container i18n>Cash</ng-container>
@if (
!hasImpersonationId &&
summary?.totalValueInBaseCurrency > 0 &&
(summary?.totalValueInBaseCurrency ?? 0) > 0 &&
user?.settings?.isExperimentalFeatures
) {
<gf-value
@ -324,7 +324,7 @@
<ng-container i18n>Excluded from Analysis</ng-container>
@if (
!hasImpersonationId &&
summary?.totalValueInBaseCurrency > 0 &&
(summary?.totalValueInBaseCurrency ?? 0) > 0 &&
user?.settings?.isExperimentalFeatures
) {
<gf-value
@ -412,7 +412,7 @@
<ng-container i18n>Emergency Fund</ng-container>
@if (
!hasImpersonationId &&
summary?.totalValueInBaseCurrency > 0 &&
(summary?.totalValueInBaseCurrency ?? 0) > 0 &&
user?.settings?.isExperimentalFeatures
) {
<gf-value

3
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -1,5 +1,6 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { TransferBalanceDto } from '@ghostfolio/common/dtos';
import { AccountsResponse, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -39,6 +40,8 @@ import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-ba
templateUrl: './accounts-page.html'
})
export class GfAccountsPageComponent implements OnInit {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected accounts: AccountWithValue[] | undefined;
protected activitiesCount = 0;
protected hasPermissionToCreateAccount: boolean;

4
apps/client/src/app/pages/accounts/accounts-page.html

@ -3,12 +3,12 @@
<div class="col">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Accounts</h1>
<gf-accounts-table
[accounts]="accounts"
[accounts]="accounts ?? []"
[activitiesCount]="activitiesCount"
[baseCurrency]="user?.settings?.baseCurrency"
[hasPermissionToDeleteAccount]="hasPermissionToDeleteAccount"
[hasPermissionToUpdateAccount]="hasPermissionToUpdateAccount"
[locale]="user?.settings?.locale"
[locale]="user?.settings?.locale ?? DEFAULT_LOCALE"
[showActions]="
(hasPermissionToDeleteAccount || hasPermissionToUpdateAccount) &&
!user.settings.isRestrictedView

2
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -79,6 +79,8 @@ import { ActivityType } from './types/activity-type.type';
templateUrl: 'create-or-update-activity-dialog.html'
})
export class GfCreateOrUpdateActivityDialogComponent {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected activityForm: FormGroup;
protected readonly assetClassOptions: AssetClassSelectorOption[] =

3
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -210,6 +210,7 @@
currencyOfAssetProfile ===
activityForm.get('currencyOfUnitPrice')?.value &&
currentMarketPrice &&
data.activity.type &&
['BUY', 'SELL'].includes(data.activity.type) &&
isToday(activityForm.get('date')?.value)
) {
@ -308,7 +309,7 @@
<gf-value
class="flex-grow-1"
[isCurrency]="true"
[locale]="data.user?.settings?.locale"
[locale]="data.user?.settings?.locale ?? DEFAULT_LOCALE"
[unit]="
activityForm.get('currencyOfUnitPrice')?.value ??
data.user?.settings?.baseCurrency

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

@ -8,11 +8,12 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
canOpenHoldingDetail,
convertValuesToPercentagesOfTotal,
getAssetProfileIdentifier,
getCountryName
getCountryName,
isCashPosition
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
HoldingWithParents,
PortfolioDetails,
PortfolioPosition,
@ -22,7 +23,10 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { MarketAdvanced } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import {
GfPortfolioProportionChartComponent,
PortfolioProportionChartClickEvent
} from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services';
import { GfTopHoldingsComponent } from '@ghostfolio/ui/top-holdings';
@ -206,7 +210,9 @@ export class GfAllocationsPageComponent implements OnInit {
this.initialize();
}
protected onAccountChartClicked({ accountId }: { accountId: string }) {
protected onAccountChartClicked(event: PortfolioProportionChartClickEvent) {
const accountId = 'accountId' in event ? event.accountId : undefined;
if (accountId && accountId !== UNKNOWN_KEY) {
void this.router.navigate([], {
queryParams: { accountId, accountDetailDialog: true }
@ -214,10 +220,9 @@ export class GfAllocationsPageComponent implements OnInit {
}
}
protected onSymbolChartClicked({
dataSource,
symbol
}: AssetProfileIdentifier) {
protected onSymbolChartClicked(event: PortfolioProportionChartClickEvent) {
const { dataSource, symbol } = 'symbol' in event ? event : {};
if (dataSource && symbol) {
void this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true }
@ -267,6 +272,20 @@ export class GfAllocationsPageComponent implements OnInit {
return UNKNOWN_KEY;
}
private extractValue({
valueInBaseCurrency,
valueInPercentage
}: {
valueInBaseCurrency?: PortfolioPosition['valueInBaseCurrency'];
valueInPercentage?: PortfolioPosition['valueInPercentage'];
}) {
return (
(isNumber(valueInBaseCurrency)
? valueInBaseCurrency
: valueInPercentage) ?? 0
);
}
private fetchPortfolioDetails() {
return this.dataService.fetchPortfolioDetails({
filters: this.userService.getFilters(),
@ -370,6 +389,8 @@ export class GfAllocationsPageComponent implements OnInit {
};
}
let totalValueExcludingCashPositions = 0;
for (const position of this.portfolioDetails.holdings) {
const assetProfileIdentifier = getAssetProfileIdentifier(
position.assetProfile
@ -394,111 +415,104 @@ export class GfAllocationsPageComponent implements OnInit {
: (position.valueInBaseCurrency ?? 0)
};
// Prepare analysis data by continents, countries, holdings and sectors
if (position.assetProfile.countries.length > 0) {
for (const country of position.assetProfile.countries) {
const { code, continent, weight } = country;
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
if (!isCashPosition(position.assetProfile)) {
// Prepare analysis data by continents, countries, holdings and sectors
// except for cash
totalValueExcludingCashPositions += this.extractValue(position);
if (position.assetProfile.countries.length > 0) {
for (const country of position.assetProfile.countries) {
const { code, continent, weight } = country;
const value = this.extractValue(position);
const continentData = this.continents[continent];
if (continentData) {
continentData.value += weight * value;
} else {
this.continents[continent] = {
name: translate(continent),
value: weight * value
};
}
const countryData = this.countries[code];
if (countryData) {
countryData.value += weight * value;
} else {
this.countries[code] = {
name: getCountryName({ code }),
value: weight * value
};
}
}
} else {
const value = this.extractValue(position);
const continentData = this.continents[continent];
const continentData = this.continents[UNKNOWN_KEY];
if (continentData) {
continentData.value += weight * value;
} else {
this.continents[continent] = {
name: translate(continent),
value: weight * value
};
continentData.value += value;
}
const countryData = this.countries[code];
const countryData = this.countries[UNKNOWN_KEY];
if (countryData) {
countryData.value += weight * value;
} else {
this.countries[code] = {
name: getCountryName({ code }),
value: weight * value
};
countryData.value += value;
}
}
} else {
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
const continentData = this.continents[UNKNOWN_KEY];
if (continentData) {
continentData.value += value;
}
const countryData = this.countries[UNKNOWN_KEY];
if (countryData) {
countryData.value += value;
}
}
if (position.assetProfile.holdings.length > 0) {
for (const {
allocationInPercentage,
name,
valueInBaseCurrency
} of position.assetProfile.holdings) {
const normalizedAssetName = this.normalizeAssetName(name);
const value = isNumber(valueInBaseCurrency)
? valueInBaseCurrency
: allocationInPercentage * (position.valueInPercentage ?? 0);
const holdingData = this.topHoldingsMap[normalizedAssetName];
if (holdingData) {
holdingData.value += value;
} else {
this.topHoldingsMap[normalizedAssetName] = {
name,
value
};
if (position.assetProfile.holdings.length > 0) {
for (const {
allocationInPercentage,
name,
valueInBaseCurrency
} of position.assetProfile.holdings) {
const normalizedAssetName = this.normalizeAssetName(name);
const value = isNumber(valueInBaseCurrency)
? valueInBaseCurrency
: allocationInPercentage * (position.valueInPercentage ?? 0);
const holdingData = this.topHoldingsMap[normalizedAssetName];
if (holdingData) {
holdingData.value += value;
} else {
this.topHoldingsMap[normalizedAssetName] = {
name,
value
};
}
}
}
}
if (position.assetProfile.sectors.length > 0) {
for (const sector of position.assetProfile.sectors) {
const { name, weight } = sector;
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
if (position.assetProfile.sectors.length > 0) {
for (const sector of position.assetProfile.sectors) {
const { name, weight } = sector;
const value = this.extractValue(position);
const sectorData = this.sectors[name];
if (sectorData) {
sectorData.value += weight * value;
} else {
this.sectors[name] = {
name: translate(name),
value: weight * value
};
}
}
} else {
const value = this.extractValue(position);
const sectorData = this.sectors[name];
const sectorData = this.sectors[UNKNOWN_KEY];
if (sectorData) {
sectorData.value += weight * value;
} else {
this.sectors[name] = {
name: translate(name),
value: weight * value
};
sectorData.value += value;
}
}
} else {
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
const sectorData = this.sectors[UNKNOWN_KEY];
if (sectorData) {
sectorData.value += value;
}
}
if (this.holdings[assetProfileIdentifier].assetSubClass === 'ETF') {
@ -507,10 +521,7 @@ export class GfAllocationsPageComponent implements OnInit {
const symbol = position.assetProfile.symbol;
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
const value = this.extractValue(position);
const symbolData = this.symbols[symbol];
@ -530,6 +541,17 @@ export class GfAllocationsPageComponent implements OnInit {
}
}
if (this.showValuesInPercentage()) {
// The values are percentages of the whole portfolio, but the analysis
// data does not contain the cash positions
for (const values of [this.continents, this.countries, this.sectors]) {
convertValuesToPercentagesOfTotal({
values,
total: totalValueExcludingCashPositions
});
}
}
this.markets = this.portfolioDetails.markets;
if (this.portfolioDetails.marketsAdvanced) {

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

@ -23,9 +23,11 @@
<mat-progress-bar
mode="determinate"
[title]="`${(
portfolioDetails?.summary?.filteredValueInPercentage * 100
(portfolioDetails?.summary?.filteredValueInPercentage ?? 0) * 100
).toFixed(2)}%`"
[value]="portfolioDetails?.summary?.filteredValueInPercentage * 100"
[value]="
(portfolioDetails?.summary?.filteredValueInPercentage ?? 0) * 100
"
/>
</mat-card-content>
</mat-card>
@ -236,7 +238,7 @@
>Other Markets</gf-value
>
</div>
@if (markets?.[UNKNOWN_KEY]?.valueInPercentage > 0) {
@if ((markets?.[UNKNOWN_KEY]?.valueInPercentage ?? 0) > 0) {
<div class="col-xs-12 col-md my-2">
<gf-value
i18n

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

@ -94,7 +94,7 @@ export class GfAnalysisPageComponent implements OnInit {
protected isLoadingInvestmentTimelineChart: boolean;
protected isLoadingPortfolioPrompt: boolean;
protected readonly mode = signal<GroupBy>('month');
protected readonly modeOptions: ToggleOption[] = [
protected readonly modeOptions: ToggleOption<GroupBy>[] = [
{ label: $localize`Monthly`, value: 'month' },
{ label: $localize`Yearly`, value: 'year' }
];

53
apps/client/src/app/pages/public/public-page.component.ts

@ -1,7 +1,9 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
convertValuesToPercentagesOfTotal,
getAssetProfileIdentifier,
getCountryName
getCountryName,
isCashPosition
} from '@ghostfolio/common/helper';
import {
InfoItem,
@ -34,9 +36,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { AssetClass } from '@prisma/client';
import { StatusCodes } from 'http-status-codes';
import { isNumber } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
@ -178,6 +178,8 @@ export class GfPublicPageComponent implements OnInit {
}
};
let totalValueExcludingCashPositions = 0;
for (const position of this.publicPortfolioDetails.holdings) {
const assetProfileIdentifier = getAssetProfileIdentifier(
position.assetProfile
@ -191,39 +193,40 @@ export class GfPublicPageComponent implements OnInit {
value: position.allocationInPercentage
};
if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) {
// Prepare analysis data by continents, countries, holdings and sectors except for liquidity
if (!isCashPosition(position.assetProfile)) {
// Prepare analysis data by continents, countries, holdings and sectors
// except for cash
const value = position.valueInPercentage ?? 0;
totalValueExcludingCashPositions += value;
if (position.assetProfile.countries.length > 0) {
for (const country of position.assetProfile.countries) {
const { code, continent, weight } = country;
if (this.continents[continent]?.value) {
this.continents[continent].value +=
weight * (position.valueInBaseCurrency ?? 0);
this.continents[continent].value += weight * value;
} else {
this.continents[continent] = {
name: translate(continent),
value: weight * (position.valueInBaseCurrency ?? 0)
value: weight * value
};
}
if (this.countries[code]?.value) {
this.countries[code].value +=
weight * (position.valueInBaseCurrency ?? 0);
this.countries[code].value += weight * value;
} else {
this.countries[code] = {
name: getCountryName({ code }),
value: weight * (position.valueInBaseCurrency ?? 0)
value: weight * value
};
}
}
} else {
this.continents[UNKNOWN_KEY].value +=
position.valueInBaseCurrency ?? 0;
this.continents[UNKNOWN_KEY].value += value;
this.countries[UNKNOWN_KEY].value +=
position.valueInBaseCurrency ?? 0;
this.countries[UNKNOWN_KEY].value += value;
}
if (position.assetProfile.sectors.length > 0) {
@ -231,25 +234,22 @@ export class GfPublicPageComponent implements OnInit {
const { name, weight } = sector;
if (this.sectors[name]?.value) {
this.sectors[name].value +=
weight * (position.valueInBaseCurrency ?? 0);
this.sectors[name].value += weight * value;
} else {
this.sectors[name] = {
name: translate(name),
value: weight * (position.valueInBaseCurrency ?? 0)
value: weight * value
};
}
}
} else {
this.sectors[UNKNOWN_KEY].value += position.valueInBaseCurrency ?? 0;
this.sectors[UNKNOWN_KEY].value += value;
}
}
const symbol = position.assetProfile.symbol;
const value = isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: (position.valueInPercentage ?? 0);
const value = position.valueInPercentage ?? 0;
const symbolData = this.symbols[symbol];
@ -264,5 +264,14 @@ export class GfPublicPageComponent implements OnInit {
};
}
}
// The values are percentages of the whole portfolio, but the analysis data
// does not contain the cash positions
for (const values of [this.continents, this.countries, this.sectors]) {
convertValuesToPercentagesOfTotal({
values,
total: totalValueExcludingCashPositions
});
}
}
}

4
apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.html

@ -75,13 +75,13 @@
matInput
readonly
type="text"
[(value)]="accessToken"
[value]="accessToken"
></textarea>
<div class="float-right mt-1">
<button
color="secondary"
mat-flat-button
[cdkCopyToClipboard]="accessToken"
[cdkCopyToClipboard]="accessToken ?? ''"
(click)="enableCreateAccountButton()"
>
<ion-icon class="mr-1" name="copy-outline" />

12
apps/client/tsconfig.json

@ -15,8 +15,18 @@
],
"angularCompilerOptions": {
"strictInjectionParameters": true,
// TODO: Remove these subsets once strictTemplates is enabled
"strictAttributeTypes": true,
"strictContextGenerics": true,
"strictDomEventTypes": true,
"strictDomLocalRefTypes": true,
"strictInputAccessModifiers": true,
"strictInputTypes": false,
"strictLiteralTypes": true,
"strictNullInputTypes": true,
"strictOutputEventTypes": true,
"strictSafeNavigationTypes": true,
// TODO: Enable stricter rules for this project
"strictInputAccessModifiers": false,
"strictTemplates": false
},
"compilerOptions": {

1
libs/common/src/lib/config.ts

@ -277,6 +277,7 @@ export const HTTP_RESPONSE_MESSAGE_IMPERSONATION_UNRESOLVED =
export const MAX_TOP_HOLDINGS = 50;
export const MCP_ENDPOINT = '/mcp';
export const MCP_MAX_ACCOUNTS = 50;
export const MCP_MAX_ACTIVITIES = 100;
export const MCP_REALM = 'Ghostfolio';

24
libs/common/src/lib/helper.ts

@ -232,6 +232,22 @@ export function capitalize(aString: string) {
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
}
export function convertValuesToPercentagesOfTotal({
total,
values
}: {
total: number;
values: { [key: string]: { value: number } };
}) {
if (!total) {
return;
}
for (const item of Object.values(values)) {
item.value = item.value / total;
}
}
export function downloadAsFile({
content,
contentType = 'text/plain',
@ -553,6 +569,14 @@ export function isAccountExcluded(account?: { tags?: { id: string }[] }) {
);
}
export function isCashPosition({
assetSubClass
}: {
assetSubClass?: AssetSubClass;
} = {}) {
return assetSubClass === AssetSubClass.CASH;
}
export function isCurrency(aCurrency: string) {
if (!aCurrency) {
return false;

1
libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts

@ -17,7 +17,6 @@ export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 {
| 'dateOfFirstActivity'
| 'markets'
| 'netPerformancePercentWithCurrencyEffect'
| 'valueInBaseCurrency'
| 'valueInPercentage'
>[];
latestActivities: (Pick<

6
libs/common/src/lib/types/toggle-option.type.ts

@ -1,8 +1,8 @@
interface BaseToggleOption {
value: string;
interface BaseToggleOption<T extends string = string> {
value: T;
}
export type ToggleOption = BaseToggleOption &
export type ToggleOption<T extends string = string> = BaseToggleOption<T> &
(
| { iconName: string; label?: never; title: string }
| { iconName?: never; label: string; title?: never }

2
libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html

@ -1,7 +1,7 @@
<gf-dialog-header
position="center"
[deviceType]="data.deviceType"
[title]="assetProfile?.name ?? assetProfile?.symbol"
[title]="assetProfile.name ?? assetProfile.symbol ?? ''"
(closeButtonClicked)="onClose()"
/>

8
libs/ui/src/lib/line-chart/line-chart.component.ts

@ -4,7 +4,11 @@ import {
getValueAxisOptions,
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import {
DEFAULT_COLOR_SCHEME,
primaryColorRgb,
secondaryColorRgb
} from '@ghostfolio/common/config';
import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types';
@ -52,7 +56,7 @@ export class GfLineChartComponent
{
@Input() benchmarkDataItems: LineChartItem[] = [];
@Input() benchmarkLabel = '';
@Input() colorScheme: ColorScheme;
@Input() colorScheme: ColorScheme = DEFAULT_COLOR_SCHEME;
@Input() currency: string;
@Input() historicalDataItems: LineChartItem[];
@Input() isAnimated = false;

1
libs/ui/src/lib/portfolio-proportion-chart/index.ts

@ -1 +1,2 @@
export * from './interfaces/interfaces';
export * from './portfolio-proportion-chart.component';

6
libs/ui/src/lib/services/data.service.ts

@ -788,12 +788,6 @@ export class DataService {
holding.assetProfile.assetSubClassLabel = translate(
holding.assetProfile.assetSubClass
);
holding.valueInBaseCurrency = isNumber(
holding.valueInBaseCurrency
)
? holding.valueInBaseCurrency
: holding.valueInPercentage;
}
}

2
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

@ -82,7 +82,7 @@ export class GfSymbolAutocompleteComponent
@Input() public defaultLookupItems: LookupItem[] = [];
@Input() public isLoading = false;
@Input() private includeIndices = false;
@Input() public includeIndices = false;
public readonly control = new FormControl();
public lookupItems: (LookupItem & { assetSubClassString: string })[] = [];

11
libs/ui/src/lib/toggle/toggle.component.ts

@ -18,14 +18,15 @@ import { IonIcon } from '@ionic/angular/standalone';
styleUrls: ['./toggle.component.scss'],
templateUrl: './toggle.component.html'
})
export class GfToggleComponent {
public readonly defaultValue = input.required<string>();
export class GfToggleComponent<T extends string = string> {
public readonly defaultValue = input.required<T>();
public readonly isDisabled = input<boolean>(false);
public readonly isLoading = input<boolean>(false);
public readonly options = input<ToggleOption[]>([]);
public readonly options = input<ToggleOption<T>[]>([]);
protected readonly optionFormControl = new FormControl<string | null>(null);
protected readonly valueChange = output<Pick<ToggleOption, 'value'>>();
public readonly valueChange = output<Pick<ToggleOption<T>, 'value'>>();
protected readonly optionFormControl = new FormControl<T | null>(null);
public constructor() {
effect(() => {

8
package-lock.json

@ -102,7 +102,7 @@
"svgmap": "2.21.0",
"tablemark": "4.1.0",
"twitter-api-v2": "1.29.1",
"undici": "8.5.0",
"undici": "8.10.0",
"uuid": "14.0.2",
"yahoo-finance2": "4.0.2",
"zod": "4.4.3",
@ -34889,9 +34889,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz",
"integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==",
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"

2
package.json

@ -146,7 +146,7 @@
"svgmap": "2.21.0",
"tablemark": "4.1.0",
"twitter-api-v2": "1.29.1",
"undici": "8.5.0",
"undici": "8.10.0",
"uuid": "14.0.2",
"yahoo-finance2": "4.0.2",
"zod": "4.4.3",

Loading…
Cancel
Save