Browse Source

Merge remote-tracking branch 'upstream/main' into bugfix/average-unit-price-line-alignment

# Conflicts:
#	CHANGELOG.md
pull/7937/head
GabeSilvaDev 2 weeks ago
parent
commit
6fbb4a0736
  1. 9
      CHANGELOG.md
  2. 96
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  3. 27
      apps/api/src/app/portfolio/current-rate.service.ts
  4. 59
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  5. 34
      apps/api/src/app/portfolio/portfolio.service.ts
  6. 96
      apps/api/src/services/data-provider/manual/manual.service.spec.ts
  7. 29
      apps/api/src/services/data-provider/manual/manual.service.ts
  8. 2
      apps/client/eslint.config.cjs
  9. 58
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  10. 31
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  11. 30
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  12. 85
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  13. 290
      eslint.config.cjs
  14. 437
      libs/common/src/lib/personal-finance-tools.ts
  15. 2
      libs/ui/eslint.config.cjs
  16. 28
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
  17. 2
      libs/ui/src/lib/value/value.component.html
  18. 13
      libs/ui/src/lib/value/value.component.ts
  19. 34
      package-lock.json
  20. 4
      package.json

9
CHANGELOG.md

@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Changed
- Extended the net performance on the analysis page to include the dividends (experimental)
- Extended the net performance in the portfolio summary to include the dividends (experimental)
- Improved the get quotes functionality of the _Manual_ service
- Migrated the _ESLint_ configuration to the flat config format without `FlatCompat`
- Upgraded `chartjs-chart-treemap` from version `4.2.0` to `4.2.2`
### Fixed ### Fixed
- Fixed an issue where holdings without a quote have been valued at the unit price of the latest activity instead of the latest market price
- Fixed the alignment of the average price in the chart of the holding detail dialog for custom asset profiles - Fixed the alignment of the average price in the chart of the holding detail dialog for custom asset profiles
## 3.74.0 - 2026-09-27 ## 3.74.0 - 2026-09-27

96
apps/api/src/app/portfolio/current-rate.service.spec.ts

@ -1,9 +1,12 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData, Order } from '@prisma/client';
import { addDays, subDays } from 'date-fns';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { DateQuery } from './interfaces/date-query.interface'; import { DateQuery } from './interfaces/date-query.interface';
@ -25,6 +28,9 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
state: 'CLOSE' state: 'CLOSE'
}); });
}, },
getLatest: () => {
return Promise.resolve<MarketData>(null);
},
getRange: ({ getRange: ({
assetProfileIdentifiers, assetProfileIdentifiers,
dateQuery dateQuery
@ -97,12 +103,19 @@ jest.mock('@ghostfolio/api/services/property/property.service', () => {
}); });
describe('CurrentRateService', () => { describe('CurrentRateService', () => {
let activitiesService: ActivitiesService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let marketDataService: MarketDataService; let marketDataService: MarketDataService;
let propertyService: PropertyService; let propertyService: PropertyService;
beforeAll(async () => { beforeAll(async () => {
activitiesService = {
getLatestActivity: () => {
return Promise.resolve<Order>(null);
}
} as unknown as ActivitiesService;
propertyService = new PropertyService(null); propertyService = new PropertyService(null);
dataProviderService = new DataProviderService( dataProviderService = new DataProviderService(
@ -117,7 +130,7 @@ describe('CurrentRateService', () => {
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);
currentRateService = new CurrentRateService( currentRateService = new CurrentRateService(
null, activitiesService,
dataProviderService, dataProviderService,
marketDataService marketDataService
); );
@ -151,4 +164,83 @@ describe('CurrentRateService', () => {
] ]
}); });
}); });
describe('getValues without a quote', () => {
const today = resetHours(new Date());
const yesterday = subDays(today, 1);
const dataGatheringItems: AssetProfileIdentifier[] = [
{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }
];
const dateQuery: DateQuery = {
gte: yesterday,
lt: addDays(today, 1)
};
beforeEach(() => {
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({});
jest.spyOn(marketDataService, 'getRangeCount').mockResolvedValue(0);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should fall back to the latest market price', async () => {
const getLatestActivity = jest
.spyOn(activitiesService, 'getLatestActivity')
.mockResolvedValue({ unitPrice: 1000 } as Order);
jest.spyOn(marketDataService, 'getLatest').mockResolvedValue({
createdAt: yesterday,
dataSource: DataSource.YAHOO,
date: yesterday,
id: '4f2b8a1e-7c3d-4e5f-9a6b-1c2d3e4f5a6b',
isCarriedForward: false,
marketPrice: 1847.839966,
state: 'CLOSE',
symbol: 'AMZN'
});
const { errors, values } = await currentRateService.getValues({
dataGatheringItems,
dateQuery
});
expect(getLatestActivity).not.toHaveBeenCalled();
expect(errors).toEqual(dataGatheringItems);
expect(values).toEqual([
{
dataSource: DataSource.YAHOO,
date: today,
marketPrice: 1847.839966,
symbol: 'AMZN'
}
]);
});
it('should fall back to the unit price of the latest activity without market data', async () => {
jest
.spyOn(activitiesService, 'getLatestActivity')
.mockResolvedValue({ unitPrice: 1000 } as Order);
jest.spyOn(marketDataService, 'getLatest').mockResolvedValue(null);
const { errors, values } = await currentRateService.getValues({
dataGatheringItems,
dateQuery
});
expect(errors).toEqual(dataGatheringItems);
expect(values).toEqual([
{
dataSource: DataSource.YAHOO,
date: today,
marketPrice: 1000,
symbol: 'AMZN'
}
]);
});
});
}); });

27
apps/api/src/app/portfolio/current-rate.service.ts

@ -132,18 +132,29 @@ export class CurrentRateService {
}); });
if (!value) { if (!value) {
// Fallback to unit price of latest activity const latestMarketData = await this.marketDataService.getLatest({
const latestActivity = dataSource,
await this.activitiesService.getLatestActivity({ symbol
dataSource, });
symbol
}); let marketPrice = latestMarketData?.marketPrice;
if (!marketPrice) {
// Fallback to unit price of latest activity
const latestActivity =
await this.activitiesService.getLatestActivity({
dataSource,
symbol
});
marketPrice = latestActivity?.unitPrice ?? 0;
}
value = { value = {
dataSource, dataSource,
marketPrice,
symbol, symbol,
date: today, date: today
marketPrice: latestActivity?.unitPrice ?? 0
}; };
response.values.push(value); response.values.push(value);

59
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -4,6 +4,7 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -33,6 +34,7 @@ describe('PortfolioService', () => {
let accountService: AccountService; let accountService: AccountService;
let activitiesService: ActivitiesService; let activitiesService: ActivitiesService;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioCalculatorFactory: PortfolioCalculatorFactory;
@ -82,6 +84,12 @@ describe('PortfolioService', () => {
null null
); );
currentRateService = new CurrentRateService(
activitiesService,
dataProviderService,
null
);
portfolioCalculatorFactory = new PortfolioCalculatorFactory( portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService, configurationService,
null, null,
@ -110,6 +118,7 @@ describe('PortfolioService', () => {
activitiesService, activitiesService,
null, null,
portfolioCalculatorFactory, portfolioCalculatorFactory,
currentRateService,
dataProviderService, dataProviderService,
exchangeRateDataService, exchangeRateDataService,
null, null,
@ -901,15 +910,19 @@ describe('PortfolioService', () => {
count: 4 count: 4
}); });
const getQuotes = jest const getValues = jest
.spyOn(dataProviderService, 'getQuotes') .spyOn(currentRateService, 'getValues')
.mockResolvedValue({ .mockResolvedValue({
'YAHOO-AAPL': { dataProviderInfos: [],
currency: 'CHF', errors: [],
dataSource: DataSource.YAHOO, values: [
marketPrice: 200, {
marketState: 'open' dataSource: DataSource.YAHOO,
} date: new Date(),
marketPrice: 200,
symbol: 'AAPL'
}
]
}); });
const summary = await getSummary({ const summary = await getSummary({
@ -921,10 +934,10 @@ describe('PortfolioService', () => {
userId: userDummyData.id userId: userDummyData.id
}); });
// The closed holding (MSFT) does not need a quote // The closed holding (MSFT) does not need a market price
expect(getQuotes).toHaveBeenCalledWith( expect(getValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
items: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }] dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }]
}) })
); );
@ -961,13 +974,17 @@ describe('PortfolioService', () => {
count: 2 count: 2
}); });
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({ jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
'YAHOO-AAPL': { dataProviderInfos: [],
currency: 'USD', errors: [],
dataSource: DataSource.YAHOO, values: [
marketPrice: 200, {
marketState: 'open' dataSource: DataSource.YAHOO,
} date: new Date(),
marketPrice: 200,
symbol: 'AAPL'
}
]
}); });
jest jest
@ -1018,7 +1035,11 @@ describe('PortfolioService', () => {
count: 2 count: 2
}); });
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({}); jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [],
errors: [],
values: []
});
const summary = await getSummary({ const summary = await getSummary({
balanceInBaseCurrency: 1000, balanceInBaseCurrency: 1000,

34
apps/api/src/app/portfolio/portfolio.service.ts

@ -94,18 +94,21 @@ import {
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
differenceInDays, differenceInDays,
endOfDay,
format, format,
isAfter, isAfter,
isBefore, isBefore,
isSameMonth, isSameMonth,
isSameYear, isSameYear,
parseISO, parseISO,
set set,
startOfDay
} from 'date-fns'; } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { CurrentRateService } from './current-rate.service';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
const Fuse = require('fuse.js'); const Fuse = require('fuse.js');
@ -125,6 +128,7 @@ export class PortfolioService {
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
@ -2136,6 +2140,7 @@ export class PortfolioService {
const { const {
currentValueInBaseCurrency, currentValueInBaseCurrency,
dividendPercentageWithCurrencyEffect,
netPerformance, netPerformance,
netPerformancePercentage, netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect, netPerformancePercentageWithCurrencyEffect,
@ -2232,6 +2237,7 @@ export class PortfolioService {
cash, cash,
currentValueInBaseCurrency, currentValueInBaseCurrency,
dateOfFirstActivity, dateOfFirstActivity,
dividendPercentageWithCurrencyEffect,
excludedAccountsAndActivities, excludedAccountsAndActivities,
netPerformance, netPerformance,
netPerformancePercentage, netPerformancePercentage,
@ -2539,11 +2545,15 @@ export class PortfolioService {
return !quantity.eq(0); return !quantity.eq(0);
}); });
const quotes = const now = new Date();
// Get the market prices of today with the same fallback as the portfolio
// calculator
const { values } =
openHoldings.length > 0 openHoldings.length > 0
? await this.dataProviderService.getQuotes({ ? await this.currentRateService.getValues({
subscriptionType, subscriptionType,
items: openHoldings.map( dataGatheringItems: openHoldings.map(
({ ({
latestActivity: { latestActivity: {
assetProfile: { dataSource, symbol } assetProfile: { dataSource, symbol }
@ -2551,16 +2561,24 @@ export class PortfolioService {
}) => { }) => {
return { dataSource, symbol }; return { dataSource, symbol };
} }
) ),
dateQuery: {
gte: startOfDay(now),
lt: endOfDay(now)
}
}) })
: {}; : { values: [] };
return getSum( return getSum(
openHoldings.map(({ latestActivity, quantity }) => { openHoldings.map(({ latestActivity, quantity }) => {
const { assetProfile, currency, unitPrice } = latestActivity; const { assetProfile, currency, unitPrice } = latestActivity;
const marketPrice = const marketPrice = values.find((value) => {
quotes[getAssetProfileIdentifier(assetProfile)]?.marketPrice; return (
getAssetProfileIdentifier(value) ===
getAssetProfileIdentifier(assetProfile)
);
})?.marketPrice;
if (!marketPrice) { if (!marketPrice) {
// Fall back to the unit price of the latest activity without a // Fall back to the unit price of the latest activity without a

96
apps/api/src/services/data-provider/manual/manual.service.spec.ts

@ -0,0 +1,96 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { EnhancedAssetProfile } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
import { ManualService } from './manual.service';
jest.mock('@ghostfolio/api/services/fetch/fetch.service', () => {
return {
FetchService: jest.fn()
};
});
describe('ManualService', () => {
let manualService: ManualService;
let prismaService: {
marketData: {
findFirst: jest.Mock;
};
};
let symbolProfileService: SymbolProfileService;
beforeEach(() => {
prismaService = {
marketData: {
findFirst: jest.fn()
}
};
symbolProfileService = new SymbolProfileService(null);
manualService = new ManualService(
null,
null,
prismaService as unknown as PrismaService,
symbolProfileService
);
});
describe('getQuotes', () => {
it('should use the latest market price of each symbol of the manual data source', async () => {
const latestMarketPrices: { [symbol: string]: number } = {
A: 13,
B: 22
};
prismaService.marketData.findFirst.mockImplementation(
({ where: { symbol } }) => {
return Promise.resolve(
latestMarketPrices[symbol]
? { symbol, marketPrice: latestMarketPrices[symbol] }
: null
);
}
);
jest.spyOn(symbolProfileService, 'getSymbolProfiles').mockResolvedValue(
['A', 'B', 'C'].map((symbol) => {
return { symbol, currency: 'USD' };
}) as EnhancedAssetProfile[]
);
const quotes = await manualService.getQuotes({
symbols: ['A', 'B', 'C']
});
expect(prismaService.marketData.findFirst).toHaveBeenCalledWith({
orderBy: { date: 'desc' },
where: { dataSource: DataSource.MANUAL, symbol: 'A' }
});
expect(quotes).toEqual({
A: {
currency: 'USD',
dataSource: DataSource.MANUAL,
marketPrice: 13,
marketState: 'delayed'
},
B: {
currency: 'USD',
dataSource: DataSource.MANUAL,
marketPrice: 22,
marketState: 'delayed'
},
// Without market data
C: {
currency: 'USD',
dataSource: DataSource.MANUAL,
marketPrice: 0,
marketState: 'delayed'
}
});
});
});
});

29
apps/api/src/services/data-provider/manual/manual.service.ts

@ -150,18 +150,21 @@ export class ManualService implements DataProviderInterface {
}) })
); );
const marketData = await this.prismaService.marketData.findMany({ // Query the latest market data per symbol because distinct loads all
distinct: ['symbol'], // the market data of the symbols into memory
orderBy: { const marketData = await Promise.all(
date: 'desc' symbols.map((symbol) => {
}, return this.prismaService.marketData.findFirst({
take: symbols.length, orderBy: {
where: { date: 'desc'
symbol: { },
in: symbols where: {
} symbol,
} dataSource: this.getName()
}); }
});
})
);
const symbolProfilesToScrape = symbolProfiles.filter( const symbolProfilesToScrape = symbolProfiles.filter(
({ scraperConfiguration }) => { ({ scraperConfiguration }) => {
@ -202,7 +205,7 @@ export class ManualService implements DataProviderInterface {
marketPrice = marketPrice =
marketPrice ?? marketPrice ??
marketData.find((marketDataItem) => { marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbol; return marketDataItem?.symbol === symbol;
})?.marketPrice ?? })?.marketPrice ??
0; 0;

2
apps/client/eslint.config.cjs

@ -1,6 +1,6 @@
const baseConfig = require('../../eslint.config.cjs'); const baseConfig = require('../../eslint.config.cjs');
const angularEslintPlugin = require('@angular-eslint/eslint-plugin'); const angularEslintPlugin = require('@angular-eslint/eslint-plugin');
const typescriptEslintPlugin = require('@typescript-eslint/eslint-plugin'); const typescriptEslintPlugin = require('typescript-eslint').plugin;
module.exports = [ module.exports = [
{ {

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

@ -98,10 +98,29 @@
/> />
</div> </div>
</div> </div>
@if (user?.settings?.isExperimentalFeatures) {
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Dividend</div>
<div class="d-flex justify-content-end">
@if (isNumber(summary?.dividendInBaseCurrency)) {
<span class="mr-1">+</span>
}
<gf-value
class="justify-content-end"
[isCurrency]="true"
[isLoading]="isLoading"
[locale]="locale"
[precision]="precision"
[unit]="displayedCurrency"
[value]="summary?.dividendInBaseCurrency"
/>
</div>
</div>
}
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Fees</div> <div class="flex-grow-1 text-truncate" i18n>Fees</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@if (summary?.fees || summary?.fees === 0) { @if (isNumber(summary?.fees)) {
<span class="mr-1">-</span> <span class="mr-1">-</span>
} }
<gf-value <gf-value
@ -128,7 +147,7 @@
[locale]="locale" [locale]="locale"
[precision]="precision" [precision]="precision"
[unit]="displayedCurrency" [unit]="displayedCurrency"
[value]="summary?.netPerformanceWithCurrencyEffect" [value]="netPerformanceWithCurrencyEffect"
/> />
</div> </div>
</div> </div>
@ -154,7 +173,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="summary?.netPerformancePercentageWithCurrencyEffect" [value]="netPerformancePercentageWithCurrencyEffect"
/> />
</div> </div>
</div> </div>
@ -367,10 +386,7 @@
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Liabilities</div> <div class="flex-grow-1 text-truncate" i18n>Liabilities</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@if ( @if (isNumber(summary?.liabilitiesInBaseCurrency)) {
summary?.liabilitiesInBaseCurrency ||
summary?.liabilitiesInBaseCurrency === 0
) {
<span class="mr-1">-</span> <span class="mr-1">-</span>
} }
<gf-value <gf-value
@ -488,18 +504,20 @@
/> />
</div> </div>
</div> </div>
<div class="flex-nowrap px-3 py-1 row"> @if (!user?.settings?.isExperimentalFeatures) {
<div class="flex-grow-1 text-truncate" i18n>Dividend</div> <div class="flex-nowrap px-3 py-1 row">
<div class="justify-content-end"> <div class="flex-grow-1 text-truncate" i18n>Dividend</div>
<gf-value <div class="justify-content-end">
class="justify-content-end" <gf-value
[isCurrency]="true" class="justify-content-end"
[isLoading]="isLoading" [isCurrency]="true"
[locale]="locale" [isLoading]="isLoading"
[precision]="precision" [locale]="locale"
[unit]="displayedCurrency" [precision]="precision"
[value]="summary?.dividendInBaseCurrency" [unit]="displayedCurrency"
/> [value]="summary?.dividendInBaseCurrency"
/>
</div>
</div> </div>
</div> }
</div> </div>

31
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts

@ -55,6 +55,7 @@ export class GfPortfolioSummaryComponent implements OnChanges {
'BUY_AND_SELL_ACTIVITIES_TOOLTIP' 'BUY_AND_SELL_ACTIVITIES_TOOLTIP'
); );
protected readonly isNumber = isNumber;
protected readonly PerformanceCalculationType = PerformanceCalculationType; protected readonly PerformanceCalculationType = PerformanceCalculationType;
protected isCashExpanded = false; protected isCashExpanded = false;
@ -136,6 +137,36 @@ export class GfPortfolioSummaryComponent implements OnChanges {
); );
} }
protected get netPerformancePercentageWithCurrencyEffect() {
if (
!this.user?.settings?.isExperimentalFeatures ||
!isNumber(this.summary?.netPerformancePercentageWithCurrencyEffect)
) {
return this.summary?.netPerformancePercentageWithCurrencyEffect;
}
// Include the dividends (total return)
return (
this.summary.netPerformancePercentageWithCurrencyEffect +
(this.summary.dividendPercentageWithCurrencyEffect ?? 0)
);
}
protected get netPerformanceWithCurrencyEffect() {
if (
!this.user?.settings?.isExperimentalFeatures ||
!isNumber(this.summary?.netPerformanceWithCurrencyEffect)
) {
return this.summary?.netPerformanceWithCurrencyEffect;
}
// Include the dividends (total return)
return (
this.summary.netPerformanceWithCurrencyEffect +
this.summary.dividendInBaseCurrency
);
}
public ngOnChanges() { public ngOnChanges() {
if (this.summary) { if (this.summary) {
if ( if (

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

@ -133,6 +133,36 @@ export class GfAnalysisPageComponent implements OnInit {
addIcons({ copyOutline, ellipsisVertical }); addIcons({ copyOutline, ellipsisVertical });
} }
protected get netPerformancePercentageWithCurrencyEffect() {
if (
!this.user?.settings?.isExperimentalFeatures ||
!isNumber(this.performance?.netPerformancePercentageWithCurrencyEffect)
) {
return this.performance?.netPerformancePercentageWithCurrencyEffect;
}
// Include the dividends (total return)
return (
this.performance.netPerformancePercentageWithCurrencyEffect +
(this.performance.dividendPercentageWithCurrencyEffect ?? 0)
);
}
protected get netPerformanceWithCurrencyEffect() {
if (
!this.user?.settings?.isExperimentalFeatures ||
!isNumber(this.performance?.netPerformanceWithCurrencyEffect)
) {
return this.performance?.netPerformanceWithCurrencyEffect;
}
// Include the dividends (total return)
return (
this.performance.netPerformanceWithCurrencyEffect +
this.performance.dividendInBaseCurrency
);
}
get savingsRate() { get savingsRate() {
if (!this.savingsRatePerMonth) { if (!this.savingsRatePerMonth) {
return undefined; return undefined;

85
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -187,6 +187,45 @@
/> />
</div> </div>
</div> </div>
@if (user?.settings?.isExperimentalFeatures) {
<div class="d-flex py-1">
<div
class="align-items-center d-flex flex-grow-1 mr-2 text-truncate"
>
<span i18n>Absolute Dividend Performance</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isCurrency]="true"
[isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="performance?.dividendInBaseCurrency"
/>
</div>
</div>
<div class="d-flex mb-3 ml-3 py-1">
<div class="flex-grow-1 mr-2 text-truncate">
<ng-container i18n>Dividend Performance</ng-container>
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isLoading]="isLoadingInvestmentChart"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="performance?.dividendPercentageWithCurrencyEffect"
/>
</div>
</div>
}
<div class="d-flex py-1"> <div class="d-flex py-1">
<div <div
class="align-items-center d-flex flex-grow-1 mr-2 text-truncate" class="align-items-center d-flex flex-grow-1 mr-2 text-truncate"
@ -247,7 +286,7 @@
[isLoading]="isLoadingInvestmentChart" [isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency" [unit]="user?.settings?.baseCurrency"
[value]="performance?.netPerformanceWithCurrencyEffect" [value]="netPerformanceWithCurrencyEffect"
/> />
</div> </div>
</div> </div>
@ -273,52 +312,10 @@
[isLoading]="isLoadingInvestmentChart" [isLoading]="isLoadingInvestmentChart"
[isPercent]="true" [isPercent]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]=" [value]="netPerformancePercentageWithCurrencyEffect"
performance?.netPerformancePercentageWithCurrencyEffect
"
/> />
</div> </div>
</div> </div>
@if (user?.settings?.isExperimentalFeatures) {
<div><hr /></div>
<div class="d-flex py-1">
<div
class="align-items-center d-flex flex-grow-1 mr-2 text-truncate"
>
<span i18n>Absolute Dividend Performance</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isCurrency]="true"
[isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency"
[value]="performance?.dividendInBaseCurrency"
/>
</div>
</div>
<div class="d-flex ml-3 py-1">
<div class="flex-grow-1 mr-2 text-truncate">
<ng-container i18n>Dividend Performance</ng-container>
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isLoading]="isLoadingInvestmentChart"
[isPercent]="true"
[locale]="user?.settings?.locale"
[value]="performance?.dividendPercentageWithCurrencyEffect"
/>
</div>
</div>
}
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>

290
eslint.config.cjs

@ -1,12 +1,7 @@
const { FlatCompat } = require('@eslint/eslintrc');
const js = require('@eslint/js');
const nxEslintPlugin = require('@nx/eslint-plugin'); const nxEslintPlugin = require('@nx/eslint-plugin');
const importPlugin = require('eslint-plugin-import');
const storybook = require('eslint-plugin-storybook'); const storybook = require('eslint-plugin-storybook');
const typescriptEslint = require('typescript-eslint');
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended
});
module.exports = [ module.exports = [
{ {
@ -35,160 +30,135 @@ module.exports = [
'no-extra-semi': 'off' 'no-extra-semi': 'off'
} }
}, },
...compat ...nxEslintPlugin.configs['flat/typescript'],
.config({ ...nxEslintPlugin.configs['flat/javascript'],
extends: ['plugin:@nx/typescript'] ...typescriptEslint.config({
}) files: ['**/*.ts'],
.map((config) => ({ plugins: { import: importPlugin },
...config, extends: [
files: ['**/*.ts', '**/*.tsx', '**/*.cts', '**/*.mts'], typescriptEslint.configs.recommendedTypeChecked,
rules: { typescriptEslint.configs.stylisticTypeChecked
...config.rules ],
} rules: {
})), '@typescript-eslint/consistent-indexed-object-style': 'off',
...compat '@typescript-eslint/dot-notation': 'off',
.config({ '@typescript-eslint/explicit-member-accessibility': [
extends: ['plugin:@nx/javascript'] 'off',
}) {
.map((config) => ({ accessibility: 'explicit'
...config, }
files: ['**/*.js', '**/*.jsx', '**/*.cjs', '**/*.mjs'], ],
rules: { '@typescript-eslint/member-ordering': 'warn',
...config.rules '@typescript-eslint/naming-convention': [
} 'off',
})), {
...compat selector: 'default',
.config({ format: ['camelCase'],
plugins: ['eslint-plugin-import', '@typescript-eslint'], leadingUnderscore: 'allow',
extends: [ trailingUnderscore: 'allow'
'plugin:@typescript-eslint/recommended-type-checked', },
'plugin:@typescript-eslint/stylistic-type-checked' {
] selector: ['variable', 'classProperty', 'typeProperty'],
}) format: ['camelCase', 'UPPER_CASE'],
.map((config) => ({ leadingUnderscore: 'allow',
...config, trailingUnderscore: 'allow'
files: ['**/*.ts'], },
rules: { {
...config.rules, selector: 'objectLiteralProperty',
'@typescript-eslint/consistent-indexed-object-style': 'off', format: null
'@typescript-eslint/dot-notation': 'off', },
'@typescript-eslint/explicit-member-accessibility': [ {
'off', selector: 'enumMember',
{ format: ['camelCase', 'UPPER_CASE', 'PascalCase']
accessibility: 'explicit' },
} {
], selector: 'typeLike',
'@typescript-eslint/member-ordering': 'warn', format: ['PascalCase']
'@typescript-eslint/naming-convention': [ }
'off', ],
{ '@typescript-eslint/no-empty-interface': 'warn',
selector: 'default', '@typescript-eslint/no-inferrable-types': [
format: ['camelCase'], 'warn',
leadingUnderscore: 'allow', {
trailingUnderscore: 'allow' ignoreParameters: true
}, }
{ ],
selector: ['variable', 'classProperty', 'typeProperty'], '@typescript-eslint/no-non-null-assertion': 'warn',
format: ['camelCase', 'UPPER_CASE'], '@typescript-eslint/no-shadow': [
leadingUnderscore: 'allow', 'warn',
trailingUnderscore: 'allow' {
}, hoist: 'all'
{ }
selector: 'objectLiteralProperty', ],
format: null '@typescript-eslint/unified-signatures': 'error',
}, '@typescript-eslint/no-loss-of-precision': 'warn',
{ '@typescript-eslint/no-var-requires': 'warn',
selector: 'enumMember', 'arrow-body-style': 'off',
format: ['camelCase', 'UPPER_CASE', 'PascalCase'] 'constructor-super': 'error',
}, eqeqeq: ['error', 'smart'],
{ 'guard-for-in': 'warn',
selector: 'typeLike', 'id-blacklist': 'off',
format: ['PascalCase'] 'id-match': 'off',
} 'import/no-deprecated': 'warn',
], 'no-bitwise': 'error',
'@typescript-eslint/no-empty-interface': 'warn', 'no-caller': 'error',
'@typescript-eslint/no-inferrable-types': [ 'no-debugger': 'error',
'warn', 'no-empty': 'off',
{ 'no-eval': 'error',
ignoreParameters: true 'no-fallthrough': 'error',
} 'no-new-wrappers': 'error',
], 'no-restricted-imports': ['error', 'rxjs/Rx'],
'@typescript-eslint/no-non-null-assertion': 'warn', 'no-undef-init': 'error',
'@typescript-eslint/no-shadow': [ 'no-underscore-dangle': 'off',
'warn', 'no-var': 'error',
{ radix: 'error',
hoist: 'all' 'no-unsafe-optional-chaining': 'warn',
} 'no-extra-boolean-cast': 'warn',
], 'no-empty-pattern': 'warn',
'@typescript-eslint/unified-signatures': 'error', 'no-useless-catch': 'warn',
'@typescript-eslint/no-loss-of-precision': 'warn', 'no-unsafe-finally': 'warn',
'@typescript-eslint/no-var-requires': 'warn', 'no-prototype-builtins': 'warn',
'arrow-body-style': 'off', 'no-async-promise-executor': 'warn',
'constructor-super': 'error', 'no-constant-condition': 'warn',
eqeqeq: ['error', 'smart'],
'guard-for-in': 'warn',
'id-blacklist': 'off',
'id-match': 'off',
'import/no-deprecated': 'warn',
'no-bitwise': 'error',
'no-caller': 'error',
'no-debugger': 'error',
'no-empty': 'off',
'no-eval': 'error',
'no-fallthrough': 'error',
'no-new-wrappers': 'error',
'no-restricted-imports': ['error', 'rxjs/Rx'],
'no-undef-init': 'error',
'no-underscore-dangle': 'off',
'no-var': 'error',
radix: 'error',
'no-unsafe-optional-chaining': 'warn',
'no-extra-boolean-cast': 'warn',
'no-empty-pattern': 'warn',
'no-useless-catch': 'warn',
'no-unsafe-finally': 'warn',
'no-prototype-builtins': 'warn',
'no-async-promise-executor': 'warn',
'no-constant-condition': 'warn',
// The following rules are part of eslint:recommended // The following rules are part of eslint:recommended
// and can be remove once solved // and can be remove once solved
'no-loss-of-precision': 'warn', 'no-loss-of-precision': 'warn',
// The following rules are part of @typescript-eslint/recommended-type-checked // The following rules are part of @typescript-eslint/recommended-type-checked
// and can be remove once solved // and can be remove once solved
'@typescript-eslint/await-thenable': 'warn', '@typescript-eslint/await-thenable': 'warn',
'@typescript-eslint/ban-ts-comment': 'warn', '@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-base-to-string': 'warn', '@typescript-eslint/no-base-to-string': 'warn',
'@typescript-eslint/no-empty-object-type': 'warn', '@typescript-eslint/no-empty-object-type': 'warn',
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-misused-promises': 'warn', '@typescript-eslint/no-misused-promises': 'warn',
'@typescript-eslint/no-redundant-type-constituents': 'warn', '@typescript-eslint/no-redundant-type-constituents': 'warn',
'@typescript-eslint/no-require-imports': 'warn', '@typescript-eslint/no-require-imports': 'warn',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn', '@typescript-eslint/no-unnecessary-type-assertion': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn', '@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/no-unsafe-enum-comparison': 'warn', '@typescript-eslint/no-unsafe-enum-comparison': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn', '@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn', '@typescript-eslint/no-unsafe-return': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn', '@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'error', 'error',
{ {
caughtErrors: 'none' caughtErrors: 'none'
} }
], ],
'@typescript-eslint/no-wrapper-object-types': 'warn', '@typescript-eslint/no-wrapper-object-types': 'warn',
'@typescript-eslint/only-throw-error': 'warn', '@typescript-eslint/only-throw-error': 'warn',
'@typescript-eslint/prefer-promise-reject-errors': 'warn', '@typescript-eslint/prefer-promise-reject-errors': 'warn',
'@typescript-eslint/require-await': 'warn', '@typescript-eslint/require-await': 'warn',
'@typescript-eslint/restrict-template-expressions': 'warn', '@typescript-eslint/restrict-template-expressions': 'warn',
'@typescript-eslint/unbound-method': 'warn', '@typescript-eslint/unbound-method': 'warn',
// The following rules are part of @typescript-eslint/stylistic-type-checked // The following rules are part of @typescript-eslint/stylistic-type-checked
// and can be remove once solved // and can be remove once solved
'@typescript-eslint/prefer-nullish-coalescing': 'warn' // TODO: Requires strictNullChecks: true '@typescript-eslint/prefer-nullish-coalescing': 'warn' // TODO: Requires strictNullChecks: true
} }
})) })
]; ];

437
libs/common/src/lib/personal-finance-tools.ts

@ -2,6 +2,21 @@ import { Product } from '@ghostfolio/common/interfaces';
export const personalFinanceTools: Product[] = ( export const personalFinanceTools: Product[] = (
[ [
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'WEALTH_MANAGEMENT'
],
key: '1fs-wealth',
name: '1fs Wealth',
origin: 'GB',
platforms: ['WEB'],
slogan: 'A single platform for all of your wealth.',
updatedAt: '2026-09-24',
url: 'https://www.1fs.co'
},
{ {
categories: [ categories: [
'FAMILY_OFFICE', 'FAMILY_OFFICE',
@ -44,7 +59,6 @@ export const personalFinanceTools: Product[] = (
url: 'https://addepar.com' url: 'https://addepar.com'
}, },
{ {
alias: 'APX',
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'], categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
hasSelfHostingAbility: true, hasSelfHostingAbility: true,
key: 'advent-portfolio-exchange', key: 'advent-portfolio-exchange',
@ -77,6 +91,22 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-09', updatedAt: '2026-07-09',
url: 'https://akkuro.com' url: 'https://akkuro.com'
}, },
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'WEALTH_MANAGEMENT'
],
hasSelfHostingAbility: false,
key: 'aleta',
name: 'Aleta',
origin: 'US',
platforms: ['ANDROID', 'IOS', 'WEB'],
slogan: 'Family Office Software for Forward-Thinking Families',
updatedAt: '2026-09-24',
url: 'https://aleta.io'
},
{ {
categories: ['ETF_TRACKING', 'STOCK_TRACKING'], categories: ['ETF_TRACKING', 'STOCK_TRACKING'],
founded: 2023, founded: 2023,
@ -174,6 +204,26 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://altruist.com' url: 'https://altruist.com'
}, },
{
categories: ['BUDGETING', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
hasFreePlan: true,
key: 'alwy',
languages: [
'Dansk',
'Deutsch',
'English',
'Nederlands',
'Norsk',
'Suomi',
'Svenska'
],
name: 'Alwy',
origin: 'SE',
platforms: ['IOS', 'WEB'],
slogan: 'Future-proof your business',
updatedAt: '2026-09-24',
url: 'https://alwy.se'
},
{ {
categories: ['INVESTMENT_RESEARCH', 'STOCK_TRACKING'], categories: ['INVESTMENT_RESEARCH', 'STOCK_TRACKING'],
founded: 2023, founded: 2023,
@ -215,6 +265,21 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://anlage.app' url: 'https://anlage.app'
}, },
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'TAX_REPORTING',
'WEALTH_MANAGEMENT'
],
key: 'arch',
name: 'Arch',
origin: 'US',
platforms: ['WEB'],
slogan: 'Private Markets. Simplified.',
updatedAt: '2026-09-24',
url: 'https://arch.com'
},
{ {
categories: ['FAMILY_OFFICE', 'WEALTH_MANAGEMENT'], categories: ['FAMILY_OFFICE', 'WEALTH_MANAGEMENT'],
founded: 2002, founded: 2002,
@ -240,6 +305,39 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-09-23', updatedAt: '2026-09-23',
url: 'https://asignal.io' url: 'https://asignal.io'
}, },
{
categories: ['FAMILY_OFFICE', 'NET_WORTH_TRACKING', 'WEALTH_MANAGEMENT'],
founded: 2021,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'asora',
name: 'Asora',
origin: 'IE',
platforms: ['ANDROID', 'IOS', 'WEB'],
pricingPerYear: '$10800',
slogan: 'Software for the modern family office',
updatedAt: '2026-09-24',
url: 'https://www.asora.com'
},
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'WEALTH_MANAGEMENT'
],
founded: 2011,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'asset-vantage',
name: 'Asset Vantage',
platforms: ['ANDROID', 'IOS', 'WEB'],
pricingPerYear: '$30000',
slogan:
'The Financial Operating System for Modern Family Offices and Their Advisors',
updatedAt: '2026-09-24',
url: 'https://www.assetvantage.com'
},
{ {
categories: ['FAMILY_OFFICE'], categories: ['FAMILY_OFFICE'],
founded: 2022, founded: 2022,
@ -253,6 +351,33 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://www.asseta.ai' url: 'https://www.asseta.ai'
}, },
{
categories: ['FAMILY_OFFICE', 'WEALTH_MANAGEMENT'],
key: 'atalaya',
languages: ['English', 'Español'],
name: 'Atalaya',
origin: 'ES',
slogan: 'Software for Family Offices',
updatedAt: '2026-09-24',
url: 'https://www.atalaya.ai'
},
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'TAX_REPORTING',
'WEALTH_MANAGEMENT'
],
hasSelfHostingAbility: false,
key: 'atlasfive',
languages: ['English'],
name: 'AtlasFive®',
origin: 'US',
slogan: 'Experience Wealth Intelligence',
updatedAt: '2026-09-24',
url: 'https://eton-solutions.com'
},
{ {
categories: ['WEALTH_MANAGEMENT'], categories: ['WEALTH_MANAGEMENT'],
founded: 2016, founded: 2016,
@ -263,6 +388,20 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://www.atominvest.co' url: 'https://www.atominvest.co'
}, },
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'WEALTH_MANAGEMENT'
],
key: 'auria',
name: 'Auria',
platforms: ['WEB'],
slogan: 'Simplify the complexities of wealth with a unified solution',
updatedAt: '2026-09-24',
url: 'https://auriaplatform.com'
},
{ {
categories: ['BUDGETING'], categories: ['BUDGETING'],
founded: 2020, founded: 2020,
@ -506,6 +645,41 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-09', updatedAt: '2026-07-09',
url: 'https://www.buxfer.com' url: 'https://www.buxfer.com'
}, },
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'WEALTH_MANAGEMENT'
],
founded: 2017,
hasSelfHostingAbility: false,
key: 'canopy',
languages: [
'Bahasa Indonesia',
'Bahasa Melayu',
'Deutsch',
'English',
'Español',
'Français',
'Italiano',
'Português',
'Tiếng Việt',
'Русский',
'العربية',
'ภาษาไทย',
'日本語',
'简体中文',
'繁體中文',
'한국어'
],
name: 'Canopy',
origin: 'SG',
platforms: ['ANDROID', 'IOS', 'WEB'],
slogan: 'All your wealth, finally understood.',
updatedAt: '2026-09-24',
url: 'https://www.canopy.cloud'
},
{ {
categories: ['NET_WORTH_TRACKING'], categories: ['NET_WORTH_TRACKING'],
hasFreePlan: true, hasFreePlan: true,
@ -557,6 +731,19 @@ export const personalFinanceTools: Product[] = (
slogan: 'Toma el control total de tus finanzas.', slogan: 'Toma el control total de tus finanzas.',
updatedAt: '2025-07-27' updatedAt: '2025-07-27'
}, },
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
founded: 2016,
hasFreePlan: false,
key: 'clockwork',
name: 'Clockwork',
origin: 'US',
platforms: ['WEB'],
slogan:
'Manage your alternative investments, and everything else, with Clockwork.',
updatedAt: '2026-09-24',
url: 'https://www.clockwork.app'
},
{ {
categories: ['WEALTH_MANAGEMENT'], categories: ['WEALTH_MANAGEMENT'],
founded: 2011, founded: 2011,
@ -882,6 +1069,22 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-08-16', updatedAt: '2026-08-16',
url: 'https://www.donkycapital.com' url: 'https://www.donkycapital.com'
}, },
{
categories: [
'FAMILY_OFFICE',
'NET_WORTH_TRACKING',
'TAX_REPORTING',
'WEALTH_MANAGEMENT'
],
founded: 2006,
key: 'elysys',
name: 'Elysys',
origin: 'MC',
slogan:
'Complete front-to-back investment accounting and portfolio management on Business Central',
updatedAt: '2026-09-24',
url: 'https://www.elysys.com'
},
{ {
categories: ['BUDGETING', 'FINANCIAL_ADVISOR', 'FINANCIAL_PLANNING'], categories: ['BUDGETING', 'FINANCIAL_ADVISOR', 'FINANCIAL_PLANNING'],
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
@ -1116,6 +1319,17 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-08-16', updatedAt: '2026-08-16',
url: 'https://extraetf.com' url: 'https://extraetf.com'
}, },
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
hasSelfHostingAbility: false,
key: 'fa-platform',
languages: ['English'],
name: 'FA Platform',
origin: 'FI',
slogan: 'Empowering Investment Management with Innovation',
updatedAt: '2026-09-24',
url: 'https://fasolutions.com'
},
{ {
categories: ['INVESTMENT_RESEARCH'], categories: ['INVESTMENT_RESEARCH'],
key: 'factorstoday', key: 'factorstoday',
@ -1442,6 +1656,20 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://www.fundmanagersoftware.com' url: 'https://www.fundmanagersoftware.com'
}, },
{
categories: ['FAMILY_OFFICE', 'TAX_REPORTING', 'WEALTH_MANAGEMENT'],
founded: 1999,
hasFreePlan: false,
hasSelfHostingAbility: true,
key: 'fundcount',
name: 'FundCount',
origin: 'BB',
platforms: ['LINUX', 'MACOS', 'WEB', 'WINDOWS'],
pricingPerYear: '$14450',
slogan: 'One source of truth for every entity, fund, and asset class.',
updatedAt: '2026-09-24',
url: 'https://fundcount.com'
},
{ {
categories: ['BUDGETING'], categories: ['BUDGETING'],
hasFreePlan: true, hasFreePlan: true,
@ -1626,6 +1854,16 @@ export const personalFinanceTools: Product[] = (
pricingPerYear: '$120', pricingPerYear: '$120',
updatedAt: '2025-08-27' updatedAt: '2025-08-27'
}, },
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
key: 'infront',
name: 'Infront',
origin: 'NO',
platforms: ['WEB'],
slogan: 'Built for Complex Wealth. Made for Family Offices',
updatedAt: '2026-09-24',
url: 'https://www.infront.co/global/en/your-industry/wealth/family-offices.html'
},
{ {
categories: ['INVESTMENT_RESEARCH'], categories: ['INVESTMENT_RESEARCH'],
hasFreePlan: true, hasFreePlan: true,
@ -1760,6 +1998,18 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-09-23', updatedAt: '2026-09-23',
url: 'https://istfin.com' url: 'https://istfin.com'
}, },
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
hasSelfHostingAbility: false,
key: 'j-ray',
languages: ['English'],
name: 'J-Ray',
origin: 'FI',
platforms: ['WEB'],
slogan: 'The Guiding Light for Complex Wealth',
updatedAt: '2026-09-24',
url: 'https://jaysolutions.com'
},
{ {
categories: ['ETF_TRACKING'], categories: ['ETF_TRACKING'],
founded: 2011, founded: 2011,
@ -1774,6 +2024,18 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://www.justetf.com' url: 'https://www.justetf.com'
}, },
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
founded: 2004,
hasSelfHostingAbility: true,
key: 'keesense',
languages: ['English', 'Français'],
name: 'KeeSense',
origin: 'MC',
slogan: 'Augmented Wealth Management',
updatedAt: '2026-09-24',
url: 'https://www.keesystem.com'
},
{ {
categories: ['CRYPTOCURRENCY', 'TAX_REPORTING'], categories: ['CRYPTOCURRENCY', 'TAX_REPORTING'],
founded: 2018, founded: 2018,
@ -2233,6 +2495,23 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://myfinancetools.io' url: 'https://myfinancetools.io'
}, },
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'NET_WORTH_TRACKING',
'WEALTH_MANAGEMENT'
],
hasSelfHostingAbility: false,
key: 'myfo',
languages: ['English'],
name: 'MYFO',
origin: 'CA',
platforms: ['WEB'],
slogan: 'The First Full Family Office Operating System',
updatedAt: '2026-09-24',
url: 'https://www.myfotech.com'
},
{ {
categories: ['CRYPTOCURRENCY', 'NET_WORTH_TRACKING', 'STOCK_TRACKING'], categories: ['CRYPTOCURRENCY', 'NET_WORTH_TRACKING', 'STOCK_TRACKING'],
hasFreePlan: true, hasFreePlan: true,
@ -2499,6 +2778,19 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://www.getpennies.com' url: 'https://www.getpennies.com'
}, },
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
founded: 2020,
hasSelfHostingAbility: false,
key: 'performativ',
languages: ['Deutsch', 'English', 'Nederlands'],
name: 'Performativ',
origin: 'DK',
platforms: ['WEB'],
slogan: 'The Operating System Powering Tomorrow’s Wealth Management',
updatedAt: '2026-09-24',
url: 'https://www.performativ.com'
},
{ {
categories: ['FAMILY_OFFICE'], categories: ['FAMILY_OFFICE'],
hasFreePlan: true, hasFreePlan: true,
@ -2552,6 +2844,22 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://www.plannix.co' url: 'https://www.plannix.co'
}, },
{
categories: [
'CRYPTOCURRENCY',
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'WEALTH_MANAGEMENT'
],
founded: 2012,
hasSelfHostingAbility: true,
key: 'pmplus',
name: 'PMplus®',
origin: 'CH',
platforms: ['WEB'],
updatedAt: '2026-09-24',
url: 'https://www.suissetechpartners.com'
},
{ {
categories: ['BUDGETING'], categories: ['BUDGETING'],
founded: 2015, founded: 2015,
@ -2582,6 +2890,17 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://www.pocketsmith.com' url: 'https://www.pocketsmith.com'
}, },
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
founded: 2017,
key: 'point',
languages: ['English'],
name: 'Point',
origin: 'GB',
slogan: 'AI-Ready Investment Data',
updatedAt: '2026-09-24',
url: 'https://pointgroup.io'
},
{ {
categories: ['FINANCIAL_PLANNING', 'WEALTH_MANAGEMENT'], categories: ['FINANCIAL_PLANNING', 'WEALTH_MANAGEMENT'],
hasFreePlan: false, hasFreePlan: false,
@ -2775,6 +3094,16 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://www.portseido.com' url: 'https://www.portseido.com'
}, },
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
key: 'private-wealth-systems',
name: 'Private Wealth Systems',
origin: 'US',
platforms: ['WEB'],
slogan: 'Portfolio Management & Reporting Software',
updatedAt: '2026-09-24',
url: 'https://privatewealthsystems.com'
},
{ {
categories: ['FINANCIAL_PLANNING'], categories: ['FINANCIAL_PLANNING'],
founded: 2021, founded: 2021,
@ -2815,6 +3144,23 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-09-23', updatedAt: '2026-09-23',
url: 'https://www.assetbook.com' url: 'https://www.assetbook.com'
}, },
{
categories: [
'FAMILY_OFFICE',
'FINANCIAL_ADVISOR',
'TAX_REPORTING',
'WEALTH_MANAGEMENT'
],
founded: 2012,
key: 'qplix',
languages: ['Deutsch', 'English'],
name: 'QPLIX',
origin: 'DE',
platforms: ['IOS', 'WEB'],
slogan: 'Your Software to Master Wealth Management.',
updatedAt: '2026-09-24',
url: 'https://www.qplix.com'
},
{ {
categories: ['NET_WORTH_TRACKING'], categories: ['NET_WORTH_TRACKING'],
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
@ -2963,6 +3309,20 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://www.segmio.com' url: 'https://www.segmio.com'
}, },
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
founded: 2019,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'sesame-one',
name: 'Sesame One',
origin: 'GB',
platforms: ['WEB'],
pricingPerYear: '£20000',
slogan: 'Spend more time building investment value, not spreadsheets',
updatedAt: '2026-09-24',
url: 'https://www.landytech.com'
},
{ {
categories: ['INVESTMENT_RESEARCH', 'STOCK_TRACKING'], categories: ['INVESTMENT_RESEARCH', 'STOCK_TRACKING'],
key: 'sharescope', key: 'sharescope',
@ -3360,6 +3720,30 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://www.sumio.app' url: 'https://www.sumio.app'
}, },
{
categories: ['FAMILY_OFFICE'],
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'sumit',
languages: ['English'],
name: 'SumIt',
platforms: ['WEB'],
slogan: 'Powerfully simple family office accounting.',
updatedAt: '2026-09-24',
url: 'https://getsumit.com'
},
{
categories: ['FAMILY_OFFICE', 'WEALTH_MANAGEMENT'],
hasSelfHostingAbility: true,
key: 'swimbird',
languages: ['English'],
name: 'Swimbird',
origin: 'SE',
platforms: ['ANDROID', 'IOS'],
slogan: 'Wealth Management Software',
updatedAt: '2026-09-24',
url: 'https://www.swimbird.com'
},
{ {
categories: ['INVESTMENT_RESEARCH'], categories: ['INVESTMENT_RESEARCH'],
key: 'tc2000', key: 'tc2000',
@ -3408,6 +3792,22 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://tiller.com' url: 'https://tiller.com'
}, },
{
categories: [
'FINANCIAL_ADVISOR',
'INVESTMENT_RESEARCH',
'WEALTH_MANAGEMENT'
],
founded: 2010,
hasFreePlan: false,
key: 'tindeco',
languages: ['English'],
name: 'Tindeco',
origin: 'CH',
slogan: 'Scalable Investing Made Simple',
updatedAt: '2026-09-24',
url: 'https://tindecofs.com'
},
{ {
categories: ['CRYPTOCURRENCY', 'DIVIDEND_TRACKING', 'STOCK_TRACKING'], categories: ['CRYPTOCURRENCY', 'DIVIDEND_TRACKING', 'STOCK_TRACKING'],
hasFreePlan: true, hasFreePlan: true,
@ -3858,6 +4258,17 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://www.wallstreetzen.com' url: 'https://www.wallstreetzen.com'
}, },
{
categories: ['FINANCIAL_ADVISOR', 'FINANCIAL_PLANNING'],
key: 'wealth-wizards',
languages: ['English'],
name: 'Wealth Wizards',
origin: 'GB',
regions: ['GB'],
slogan: 'Get new regime ready.',
updatedAt: '2026-09-24',
url: 'https://www.wealthwizards.com'
},
{ {
categories: ['FAMILY_OFFICE', 'TAX_REPORTING'], categories: ['FAMILY_OFFICE', 'TAX_REPORTING'],
founded: 2001, founded: 2001,
@ -3940,6 +4351,18 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-07-05', updatedAt: '2026-07-05',
url: 'https://www.wealthposition.com' url: 'https://www.wealthposition.com'
}, },
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
hasSelfHostingAbility: true,
key: 'wealthspectrum',
languages: ['English'],
name: 'WealthSpectrum',
origin: 'IN',
platforms: ['WEB'],
slogan: 'Powering smart investment operations',
updatedAt: '2026-09-24',
url: 'https://www.wealthspectrum.com'
},
{ {
categories: ['STOCK_TRACKING'], categories: ['STOCK_TRACKING'],
founded: 2018, founded: 2018,
@ -3970,6 +4393,18 @@ export const personalFinanceTools: Product[] = (
updatedAt: '2026-08-28', updatedAt: '2026-08-28',
url: 'https://www.wisesheets.io' url: 'https://www.wisesheets.io'
}, },
{
categories: ['FAMILY_OFFICE', 'FINANCIAL_ADVISOR', 'WEALTH_MANAGEMENT'],
founded: 2010,
hasSelfHostingAbility: true,
key: 'wize',
languages: ['English'],
name: 'WIZE',
origin: 'CH',
slogan: 'The all-in-one Wealth & Asset management IT platform',
updatedAt: '2026-09-24',
url: 'https://www.wize.net'
},
{ {
categories: [ categories: [
'FINANCIAL_ADVISOR', 'FINANCIAL_ADVISOR',

2
libs/ui/eslint.config.cjs

@ -2,7 +2,7 @@ const baseConfig = require('../../eslint.config.cjs');
const angularEslintPlugin = require('@angular-eslint/eslint-plugin'); const angularEslintPlugin = require('@angular-eslint/eslint-plugin');
const angularTemplateEslintPlugin = require('@angular-eslint/eslint-plugin-template'); const angularTemplateEslintPlugin = require('@angular-eslint/eslint-plugin-template');
const angularTemplateParser = require('@angular-eslint/template-parser'); const angularTemplateParser = require('@angular-eslint/template-parser');
const typescriptEslintPlugin = require('@typescript-eslint/eslint-plugin'); const typescriptEslintPlugin = require('typescript-eslint').plugin;
module.exports = [ module.exports = [
{ {

28
libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts

@ -1,5 +1,5 @@
import { getTooltipOptions } from '@ghostfolio/common/chart-helper'; import { getTooltipOptions } from '@ghostfolio/common/chart-helper';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { DEFAULT_COLOR_SCHEME, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getLocale, getSum, getTextColor } from '@ghostfolio/common/helper'; import { getLocale, getSum, getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
@ -60,20 +60,20 @@ const {
export class GfPortfolioProportionChartComponent export class GfPortfolioProportionChartComponent
implements AfterViewInit, OnChanges, OnDestroy implements AfterViewInit, OnChanges, OnDestroy
{ {
@Input() baseCurrency: string; @Input() baseCurrency?: string;
@Input() colorScheme: ColorScheme; @Input() colorScheme?: ColorScheme;
@Input() cursor: string; @Input() cursor: string;
@Input() data: { @Input() data: {
[symbol: string]: Pick<PortfolioPosition, 'type'> & { [symbol: string]: Pick<PortfolioPosition, 'type'> & {
dataSource?: DataSource; dataSource?: DataSource;
isClickable?: boolean; isClickable?: boolean;
name: string; name?: string | null;
value: number; value: number;
}; };
} = {}; } = {};
@Input() isInPercentage = false; @Input() isInPercentage?: boolean = false;
@Input() keys: string[] = []; @Input() keys: string[] = [];
@Input() locale = getLocale(); @Input() locale?: string = getLocale();
@Input() maxItems?: number; @Input() maxItems?: number;
@Input() showLabels = false; @Input() showLabels = false;
@ -114,6 +114,7 @@ export class GfPortfolioProportionChartComponent
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
const chartData: { const chartData: {
[symbol: string]: { [symbol: string]: {
color?: string; color?: string;
@ -122,9 +123,12 @@ export class GfPortfolioProportionChartComponent
value: Big; value: Big;
}; };
} = {}; } = {};
const textColor = getTextColor(this.colorScheme ?? DEFAULT_COLOR_SCHEME);
this.colorMap = { this.colorMap = {
[this.OTHER_KEY]: `rgba(${getTextColor(this.colorScheme)}, 0.24)`, [this.OTHER_KEY]: `rgba(${textColor}, 0.24)`,
[UNKNOWN_KEY]: `rgba(${getTextColor(this.colorScheme)}, 0.12)` [UNKNOWN_KEY]: `rgba(${textColor}, 0.12)`
}; };
if (this.keys.length > 0) { if (this.keys.length > 0) {
@ -178,7 +182,7 @@ export class GfPortfolioProportionChartComponent
); );
} else { } else {
chartData[UNKNOWN_KEY] = { chartData[UNKNOWN_KEY] = {
name: this.data[symbol].name, name: this.data[symbol].name ?? '',
subCategory: secondaryKey subCategory: secondaryKey
? { [secondaryKey]: { value: new Big(0) } } ? { [secondaryKey]: { value: new Big(0) } }
: undefined, : undefined,
@ -190,7 +194,7 @@ export class GfPortfolioProportionChartComponent
} else { } else {
Object.keys(this.data).forEach((symbol) => { Object.keys(this.data).forEach((symbol) => {
chartData[symbol] = { chartData[symbol] = {
name: this.data[symbol].name, name: this.data[symbol].name ?? '',
value: new Big(this.data[symbol].value || 0) value: new Big(this.data[symbol].value || 0)
}; };
}); });
@ -444,7 +448,7 @@ export class GfPortfolioProportionChartComponent
): Partial<TooltipOptions<'doughnut'>> { ): Partial<TooltipOptions<'doughnut'>> {
return { return {
...getTooltipOptions({ ...getTooltipOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme ?? DEFAULT_COLOR_SCHEME,
currency: this.baseCurrency, currency: this.baseCurrency,
locale: this.locale locale: this.locale
}), }),
@ -486,7 +490,7 @@ export class GfPortfolioProportionChartComponent
`${value.toLocaleString(this.locale, { `${value.toLocaleString(this.locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2
})} ${this.baseCurrency} (${percentage.toFixed(2)}%)` })} ${this.baseCurrency ?? ''} (${percentage.toFixed(2)}%)`
]; ];
} }
}, },

2
libs/ui/src/lib/value/value.component.html

@ -46,7 +46,7 @@
[class.justify-content-end]="position === 'end'" [class.justify-content-end]="position === 'end'"
> >
@if (isNumber || value === null) { @if (isNumber || value === null) {
@if (colorizeSign && !useAbsoluteValue) { @if (colorizeSign && !useAbsoluteValue && value !== null) {
@if (+value > 0) { @if (+value > 0) {
<div class="mr-1 text-success">+</div> <div class="mr-1 text-success">+</div>
} }

13
libs/ui/src/lib/value/value.component.ts

@ -42,12 +42,12 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() isCurrency = false; @Input() isCurrency = false;
@Input() isDate = false; @Input() isDate = false;
@Input() isPercent = false; @Input() isPercent = false;
@Input() locale: string; @Input() locale?: string;
@Input() position = ''; @Input() position = '';
@Input() size: 'large' | 'medium' | 'small' = 'small'; @Input() size: 'large' | 'medium' | 'small' = 'small';
@Input() subLabel = ''; @Input() subLabel?: string = '';
@Input() unit = ''; @Input() unit?: string | null = '';
@Input() value: number | string = ''; @Input() value?: number | string | null = '';
@ViewChild('labelContent', { static: false }) @ViewChild('labelContent', { static: false })
labelContent!: ElementRef<HTMLSpanElement>; labelContent!: ElementRef<HTMLSpanElement>;
@ -219,11 +219,14 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
this.isCopied = false; this.isCopied = false;
this.isNumber = false; this.isNumber = false;
this.isString = false; this.isString = false;
this.locale = this.locale || getLocale();
this.useAbsoluteValue = false; this.useAbsoluteValue = false;
if (this.copyToClipboardTimeout) { if (this.copyToClipboardTimeout) {
clearTimeout(this.copyToClipboardTimeout); clearTimeout(this.copyToClipboardTimeout);
} }
if (this.locale === undefined || this.locale === '') {
this.locale = getLocale();
}
} }
} }

34
package-lock.json

@ -59,7 +59,7 @@
"bull": "4.16.5", "bull": "4.16.5",
"chart.js": "4.5.1", "chart.js": "4.5.1",
"chartjs-adapter-date-fns": "3.0.0", "chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-treemap": "4.2.0", "chartjs-chart-treemap": "4.2.2",
"chartjs-plugin-annotation": "3.1.0", "chartjs-plugin-annotation": "3.1.0",
"chartjs-plugin-datalabels": "2.2.0", "chartjs-plugin-datalabels": "2.2.0",
"cheerio": "1.2.0", "cheerio": "1.2.0",
@ -122,7 +122,6 @@
"@angular/language-service": "22.1.2", "@angular/language-service": "22.1.2",
"@angular/localize": "22.1.2", "@angular/localize": "22.1.2",
"@angular/pwa": "22.1.4", "@angular/pwa": "22.1.4",
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.35.0", "@eslint/js": "9.35.0",
"@nestjs/schematics": "11.1.0", "@nestjs/schematics": "11.1.0",
"@nestjs/testing": "11.2.3", "@nestjs/testing": "11.2.3",
@ -177,6 +176,7 @@
"ts-node": "10.9.2", "ts-node": "10.9.2",
"tslib": "2.8.1", "tslib": "2.8.1",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"webpack-cli": "7.1.0" "webpack-cli": "7.1.0"
}, },
"engines": { "engines": {
@ -18249,9 +18249,9 @@
} }
}, },
"node_modules/chartjs-chart-treemap": { "node_modules/chartjs-chart-treemap": {
"version": "4.2.0", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/chartjs-chart-treemap/-/chartjs-chart-treemap-4.2.0.tgz", "resolved": "https://registry.npmjs.org/chartjs-chart-treemap/-/chartjs-chart-treemap-4.2.2.tgz",
"integrity": "sha512-2ghdKHbknLYEzqD2fRTbACOzEhuH/f7zI5t6dbQ3cAOS+ric4mLjll6Sxsaugy/HFU0KUftJEszYOvtay3wFSQ==", "integrity": "sha512-gmOVr5bokdhs0RsHqrnj1oQu9ybaRjdlXubzLhmRB9DsRnpY51WmyqxUd8JCCKbnFVru1yjdeOTXyqeMIFhxlg==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"chart.js": ">=3.0.0" "chart.js": ">=3.0.0"
@ -34933,6 +34933,30 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/typescript-eslint": {
"version": "8.67.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz",
"integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.67.0",
"@typescript-eslint/parser": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/uglify-js": { "node_modules/uglify-js": {
"version": "3.19.3", "version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",

4
package.json

@ -103,7 +103,7 @@
"bull": "4.16.5", "bull": "4.16.5",
"chart.js": "4.5.1", "chart.js": "4.5.1",
"chartjs-adapter-date-fns": "3.0.0", "chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-treemap": "4.2.0", "chartjs-chart-treemap": "4.2.2",
"chartjs-plugin-annotation": "3.1.0", "chartjs-plugin-annotation": "3.1.0",
"chartjs-plugin-datalabels": "2.2.0", "chartjs-plugin-datalabels": "2.2.0",
"cheerio": "1.2.0", "cheerio": "1.2.0",
@ -166,7 +166,6 @@
"@angular/language-service": "22.1.2", "@angular/language-service": "22.1.2",
"@angular/localize": "22.1.2", "@angular/localize": "22.1.2",
"@angular/pwa": "22.1.4", "@angular/pwa": "22.1.4",
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.35.0", "@eslint/js": "9.35.0",
"@nestjs/schematics": "11.1.0", "@nestjs/schematics": "11.1.0",
"@nestjs/testing": "11.2.3", "@nestjs/testing": "11.2.3",
@ -221,6 +220,7 @@
"ts-node": "10.9.2", "ts-node": "10.9.2",
"tslib": "2.8.1", "tslib": "2.8.1",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"webpack-cli": "7.1.0" "webpack-cli": "7.1.0"
}, },
"engines": { "engines": {

Loading…
Cancel
Save