diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0acff07e..62cbee836d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the style of the tabs in the account detail dialog on mobile - Improved the style of the tabs in the holding detail dialog on mobile - Improved the style of the tabs in the asset profile dialog of the admin control panel on mobile +- Improved the style of the empty state in the _Fear & Greed Index_ component +- Improved the style of the type filter in the activities table component (experimental) +- Improved the performance of the property service by caching the properties in memory +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Equity) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Fixed Income) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment: Base Currency) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Developed Markets) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Emerging Markets) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Asia-Pacific) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Emerging Markets) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Europe) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Japan) +- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (North America) ## 3.37.0 - 2026-07-30 diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 26a4e06f47..4c608e0fd2 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -107,8 +107,11 @@ export class AdminService { await this.marketDataService.deleteMany({ dataSource, symbol }); const currency = getCurrencyFromSymbol(symbol); - const customCurrencies = - await this.propertyService.getByKey(PROPERTY_CURRENCIES); + + const customCurrencies = await this.propertyService.getByKey( + PROPERTY_CURRENCIES, + { skipCache: true } + ); if (customCurrencies.includes(currency)) { const updatedCustomCurrencies = customCurrencies.filter( diff --git a/apps/api/src/app/health/health.service.ts b/apps/api/src/app/health/health.service.ts index f08f33a1e3..42a0be61b8 100644 --- a/apps/api/src/app/health/health.service.ts +++ b/apps/api/src/app/health/health.service.ts @@ -26,7 +26,9 @@ export class HealthService { public async isDatabaseHealthy() { try { - await this.propertyService.getByKey(PROPERTY_CURRENCIES); + await this.propertyService.getByKey(PROPERTY_CURRENCIES, { + skipCache: true + }); return true; } catch { diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 6a3c1f145a..d1aff38111 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1126,6 +1126,8 @@ export class PortfolioService { withSummary: true }); + const hasOpenHoldings = Object.keys(holdings).length > 0; + const marketsAdvancedTotalInBaseCurrency = getSum( Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => { return new Big(valueInBaseCurrency); @@ -1185,26 +1187,25 @@ export class PortfolioService { id: 'rule.currencyClusterRisk.category', languageCode: userSettings.language }), - rules: - summary.activityCount > 0 - ? await this.rulesService.evaluate( - [ - new CurrencyClusterRiskBaseCurrencyCurrentInvestment( - this.exchangeRateDataService, - this.i18nService, - Object.values(holdings), - userSettings.language - ), - new CurrencyClusterRiskCurrentInvestment( - this.exchangeRateDataService, - this.i18nService, - Object.values(holdings), - userSettings.language - ) - ], - userSettings - ) - : undefined + rules: hasOpenHoldings + ? await this.rulesService.evaluate( + [ + new CurrencyClusterRiskBaseCurrencyCurrentInvestment( + this.exchangeRateDataService, + this.i18nService, + Object.values(holdings), + userSettings.language + ), + new CurrencyClusterRiskCurrentInvestment( + this.exchangeRateDataService, + this.i18nService, + Object.values(holdings), + userSettings.language + ) + ], + userSettings + ) + : undefined }, { key: 'assetClassClusterRisk', @@ -1212,26 +1213,25 @@ export class PortfolioService { id: 'rule.assetClassClusterRisk.category', languageCode: userSettings.language }), - rules: - summary.activityCount > 0 - ? await this.rulesService.evaluate( - [ - new AssetClassClusterRiskEquity( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - Object.values(holdings) - ), - new AssetClassClusterRiskFixedIncome( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - Object.values(holdings) - ) - ], - userSettings - ) - : undefined + rules: hasOpenHoldings + ? await this.rulesService.evaluate( + [ + new AssetClassClusterRiskEquity( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + Object.values(holdings) + ), + new AssetClassClusterRiskFixedIncome( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + Object.values(holdings) + ) + ], + userSettings + ) + : undefined }, { key: 'accountClusterRisk', @@ -1266,28 +1266,27 @@ export class PortfolioService { id: 'rule.economicMarketClusterRisk.category', languageCode: userSettings.language }), - rules: - summary.activityCount > 0 - ? await this.rulesService.evaluate( - [ - new EconomicMarketClusterRiskDevelopedMarkets( - this.exchangeRateDataService, - this.i18nService, - marketsTotalInBaseCurrency, - markets.developedMarkets.valueInBaseCurrency, - userSettings.language - ), - new EconomicMarketClusterRiskEmergingMarkets( - this.exchangeRateDataService, - this.i18nService, - marketsTotalInBaseCurrency, - markets.emergingMarkets.valueInBaseCurrency, - userSettings.language - ) - ], - userSettings - ) - : undefined + rules: hasOpenHoldings + ? await this.rulesService.evaluate( + [ + new EconomicMarketClusterRiskDevelopedMarkets( + this.exchangeRateDataService, + this.i18nService, + marketsTotalInBaseCurrency, + markets.developedMarkets.valueInBaseCurrency, + userSettings.language + ), + new EconomicMarketClusterRiskEmergingMarkets( + this.exchangeRateDataService, + this.i18nService, + marketsTotalInBaseCurrency, + markets.emergingMarkets.valueInBaseCurrency, + userSettings.language + ) + ], + userSettings + ) + : undefined }, { key: 'regionalMarketClusterRisk', @@ -1295,49 +1294,48 @@ export class PortfolioService { id: 'rule.regionalMarketClusterRisk.category', languageCode: userSettings.language }), - rules: - summary.activityCount > 0 - ? await this.rulesService.evaluate( - [ - new RegionalMarketClusterRiskAsiaPacific( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.asiaPacific.valueInBaseCurrency - ), - new RegionalMarketClusterRiskEmergingMarkets( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.emergingMarkets.valueInBaseCurrency - ), - new RegionalMarketClusterRiskEurope( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.europe.valueInBaseCurrency - ), - new RegionalMarketClusterRiskJapan( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.japan.valueInBaseCurrency - ), - new RegionalMarketClusterRiskNorthAmerica( - this.exchangeRateDataService, - this.i18nService, - userSettings.language, - marketsAdvancedTotalInBaseCurrency, - marketsAdvanced.northAmerica.valueInBaseCurrency - ) - ], - userSettings - ) - : undefined + rules: hasOpenHoldings + ? await this.rulesService.evaluate( + [ + new RegionalMarketClusterRiskAsiaPacific( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.asiaPacific.valueInBaseCurrency + ), + new RegionalMarketClusterRiskEmergingMarkets( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.emergingMarkets.valueInBaseCurrency + ), + new RegionalMarketClusterRiskEurope( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.europe.valueInBaseCurrency + ), + new RegionalMarketClusterRiskJapan( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.japan.valueInBaseCurrency + ), + new RegionalMarketClusterRiskNorthAmerica( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.northAmerica.valueInBaseCurrency + ) + ], + userSettings + ) + : undefined }, { key: 'fees', diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index 4018e4753e..a70fe87916 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -54,7 +54,9 @@ export class SubscriptionController { } let coupons = - (await this.propertyService.getByKey(PROPERTY_COUPONS)) ?? []; + (await this.propertyService.getByKey(PROPERTY_COUPONS, { + skipCache: true + })) ?? []; const coupon = coupons.find((currentCoupon) => { return currentCoupon.code === couponCode; diff --git a/apps/api/src/services/benchmark/benchmark.service.ts b/apps/api/src/services/benchmark/benchmark.service.ts index 17e729f9f4..993e0f0aae 100644 --- a/apps/api/src/services/benchmark/benchmark.service.ts +++ b/apps/api/src/services/benchmark/benchmark.service.ts @@ -159,7 +159,8 @@ export class BenchmarkService { let benchmarks = (await this.propertyService.getByKey( - PROPERTY_BENCHMARKS + PROPERTY_BENCHMARKS, + { skipCache: true } )) ?? []; benchmarks.push({ symbolProfileId: assetProfile.id }); @@ -196,7 +197,8 @@ export class BenchmarkService { let benchmarks = (await this.propertyService.getByKey( - PROPERTY_BENCHMARKS + PROPERTY_BENCHMARKS, + { skipCache: true } )) ?? []; benchmarks = benchmarks.filter(({ symbolProfileId }) => { diff --git a/apps/api/src/services/property/property.service.ts b/apps/api/src/services/property/property.service.ts index 80643482fa..6d8130bbc2 100644 --- a/apps/api/src/services/property/property.service.ts +++ b/apps/api/src/services/property/property.service.ts @@ -6,27 +6,39 @@ import { import { PropertyKey } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { Property } from '@prisma/client'; +import { addMilliseconds, isBefore } from 'date-fns'; +import ms from 'ms'; import { PropertyValue } from './interfaces/interfaces'; @Injectable() export class PropertyService { + private static readonly CACHE_TTL = ms('1 minute'); + + private cachedProperties: Promise; + private cachedPropertiesExpiresAt: Date; + public constructor(private readonly prismaService: PrismaService) {} public async delete({ key }: { key: PropertyKey }) { - return this.prismaService.property.delete({ + const property = await this.prismaService.property.delete({ where: { key } }); + + this.invalidateCache(); + + return property; } - public async get() { + public async get({ skipCache = false } = {}) { const response: { [key: string]: PropertyValue; } = { [PROPERTY_CURRENCIES]: [] }; - const properties = await this.prismaService.property.findMany(); + const properties = await this.getProperties({ skipCache }); for (const property of properties) { let value = property.value; @@ -41,8 +53,11 @@ export class PropertyService { return response; } - public async getByKey(aKey: PropertyKey) { - const properties = await this.get(); + public async getByKey( + aKey: PropertyKey, + { skipCache = false } = {} + ) { + const properties = await this.get({ skipCache }); return properties[aKey] as TValue; } @@ -53,10 +68,53 @@ export class PropertyService { } public async put({ key, value }: { key: PropertyKey; value: string }) { - return this.prismaService.property.upsert({ + const property = await this.prismaService.property.upsert({ create: { key, value }, update: { value }, where: { key } }); + + this.invalidateCache(); + + return property; + } + + /** + * Returns the properties from the in-memory cache, falling back to the + * database. Callers which write back a modified property must set + * skipCache to avoid basing the write on a stale read. + */ + private async getProperties({ skipCache = false } = {}) { + if (skipCache) { + return this.prismaService.property.findMany(); + } + + if ( + this.cachedProperties && + isBefore(new Date(), this.cachedPropertiesExpiresAt) + ) { + return this.cachedProperties; + } + + const properties = this.prismaService.property.findMany().catch((error) => { + if (this.cachedProperties === properties) { + this.invalidateCache(); + } + + throw error; + }); + + this.cachedProperties = properties; + this.cachedPropertiesExpiresAt = addMilliseconds( + new Date(), + PropertyService.CACHE_TTL + ); + + return this.cachedProperties; + } + + private invalidateCache() { + this.cachedProperties = undefined; + this.cachedPropertiesExpiresAt = undefined; } } diff --git a/apps/client/src/locales/messages.de.xlf b/apps/client/src/locales/messages.de.xlf index 4fe4422678..0378b10680 100644 --- a/apps/client/src/locales/messages.de.xlf +++ b/apps/client/src/locales/messages.de.xlf @@ -351,7 +351,7 @@ Unknown - Unknown + Unbekannt libs/ui/src/lib/i18n.ts 88 @@ -1767,7 +1767,7 @@ Invalid API key - Invalid API key + Ungültiger API-Schlüssel apps/client/src/app/components/admin-settings/admin-settings.component.html 112 diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index 91a67d1cb4..3d91852a0c 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -1,4 +1,6 @@ -
+
@if (hasPermissionToFilterByType) { diff --git a/libs/ui/src/lib/activities-table/activities-table.component.scss b/libs/ui/src/lib/activities-table/activities-table.component.scss index 5d4e87f30f..4e98f726e2 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.scss +++ b/libs/ui/src/lib/activities-table/activities-table.component.scss @@ -1,3 +1,12 @@ +@use '@angular/material' as mat; + :host { display: block; + + @include mat.form-field-overrides( + ( + container-height: 2rem, + container-vertical-padding: 0.33rem + ) + ); } diff --git a/libs/ui/src/lib/activities-table/activities-table.component.stories.ts b/libs/ui/src/lib/activities-table/activities-table.component.stories.ts index 57a8f18b68..057747cf14 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.stories.ts +++ b/libs/ui/src/lib/activities-table/activities-table.component.stories.ts @@ -494,3 +494,28 @@ export const Actions: Story = { totalItems: activities.length } }; + +export const Toolbar: Story = { + args: { + dataSource, + baseCurrency: 'USD', + deviceType: 'desktop', + hasActivities: true, + hasPermissionToCreateActivity: true, + hasPermissionToDeleteActivity: true, + hasPermissionToExportActivities: true, + hasPermissionToFilterByType: true, + hasPermissionToOpenDetails: false, + locale: 'en-US', + pageIndex: 0, + pageSize: 10, + showAccountColumn: true, + showActions: false, + showCheckbox: false, + showNameColumn: true, + sortColumn: 'date', + sortDirection: 'desc', + sortDisabled: false, + totalItems: activities.length + } +}; diff --git a/libs/ui/src/lib/fear-and-greed-index/fear-and-greed-index.component.html b/libs/ui/src/lib/fear-and-greed-index/fear-and-greed-index.component.html index e441c53f6b..02eed4442a 100644 --- a/libs/ui/src/lib/fear-and-greed-index/fear-and-greed-index.component.html +++ b/libs/ui/src/lib/fear-and-greed-index/fear-and-greed-index.component.html @@ -12,10 +12,11 @@
{{ fearAndGreedIndexText() }} + @let value = fearAndGreedIndex() | number: '1.0-0'; {{ - (fearAndGreedIndex() | number: '1.0-0') ?? placeholder - }}{{ + value ?? placeholder + }}/100