Browse Source

Merge branch 'main' into task/improve-search-log-output-for-unsupported-queries

pull/7480/head
Thomas Kaul 1 month ago
committed by GitHub
parent
commit
5074db0c12
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 23
      CHANGELOG.md
  2. 7
      apps/api/src/app/admin/admin.service.ts
  3. 4
      apps/api/src/app/health/health.service.ts
  4. 14
      apps/api/src/app/portfolio/portfolio.service.ts
  5. 4
      apps/api/src/app/subscription/subscription.controller.ts
  6. 6
      apps/api/src/services/benchmark/benchmark.service.ts
  7. 70
      apps/api/src/services/property/property.service.ts
  8. 1
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  9. 1
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  10. 1
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  11. 4
      apps/client/src/locales/messages.de.xlf
  12. 11
      apps/client/src/styles.scss
  13. 15
      libs/ui/src/lib/activities-table/activities-table.component.html
  14. 9
      libs/ui/src/lib/activities-table/activities-table.component.scss
  15. 25
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  16. 12
      libs/ui/src/lib/activities-table/activities-table.component.ts
  17. 7
      libs/ui/src/lib/fear-and-greed-index/fear-and-greed-index.component.html

23
CHANGELOG.md

@ -9,8 +9,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- 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
- Added the activity count to the delete menu item of the activities table
- Added the activity count to the deletion confirmation dialog of the activities table
- Improved the style of the type filter in the activities table component (experimental)
- Improved the search functionality by trimming the query - Improved the search functionality by trimming the query
- Improved the log output in the search functionality of the _Yahoo Finance_ service for unsupported queries - Improved the log output in the search functionality of the _Yahoo Finance_ service for unsupported queries
- 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 ## 3.37.0 - 2026-07-30

7
apps/api/src/app/admin/admin.service.ts

@ -107,8 +107,11 @@ export class AdminService {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const currency = getCurrencyFromSymbol(symbol); const currency = getCurrencyFromSymbol(symbol);
const customCurrencies =
await this.propertyService.getByKey<string[]>(PROPERTY_CURRENCIES); const customCurrencies = await this.propertyService.getByKey<string[]>(
PROPERTY_CURRENCIES,
{ skipCache: true }
);
if (customCurrencies.includes(currency)) { if (customCurrencies.includes(currency)) {
const updatedCustomCurrencies = customCurrencies.filter( const updatedCustomCurrencies = customCurrencies.filter(

4
apps/api/src/app/health/health.service.ts

@ -26,7 +26,9 @@ export class HealthService {
public async isDatabaseHealthy() { public async isDatabaseHealthy() {
try { try {
await this.propertyService.getByKey(PROPERTY_CURRENCIES); await this.propertyService.getByKey(PROPERTY_CURRENCIES, {
skipCache: true
});
return true; return true;
} catch { } catch {

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

@ -1126,6 +1126,8 @@ export class PortfolioService {
withSummary: true withSummary: true
}); });
const hasOpenHoldings = Object.keys(holdings).length > 0;
const marketsAdvancedTotalInBaseCurrency = getSum( const marketsAdvancedTotalInBaseCurrency = getSum(
Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => { Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency); return new Big(valueInBaseCurrency);
@ -1185,8 +1187,7 @@ export class PortfolioService {
id: 'rule.currencyClusterRisk.category', id: 'rule.currencyClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new CurrencyClusterRiskBaseCurrencyCurrentInvestment( new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
@ -1212,8 +1213,7 @@ export class PortfolioService {
id: 'rule.assetClassClusterRisk.category', id: 'rule.assetClassClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new AssetClassClusterRiskEquity( new AssetClassClusterRiskEquity(
@ -1266,8 +1266,7 @@ export class PortfolioService {
id: 'rule.economicMarketClusterRisk.category', id: 'rule.economicMarketClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new EconomicMarketClusterRiskDevelopedMarkets( new EconomicMarketClusterRiskDevelopedMarkets(
@ -1295,8 +1294,7 @@ export class PortfolioService {
id: 'rule.regionalMarketClusterRisk.category', id: 'rule.regionalMarketClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new RegionalMarketClusterRiskAsiaPacific( new RegionalMarketClusterRiskAsiaPacific(

4
apps/api/src/app/subscription/subscription.controller.ts

@ -54,7 +54,9 @@ export class SubscriptionController {
} }
let coupons = let coupons =
(await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS)) ?? []; (await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS, {
skipCache: true
})) ?? [];
const coupon = coupons.find((currentCoupon) => { const coupon = coupons.find((currentCoupon) => {
return currentCoupon.code === couponCode; return currentCoupon.code === couponCode;

6
apps/api/src/services/benchmark/benchmark.service.ts

@ -159,7 +159,8 @@ export class BenchmarkService {
let benchmarks = let benchmarks =
(await this.propertyService.getByKey<BenchmarkProperty[]>( (await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? []; )) ?? [];
benchmarks.push({ symbolProfileId: assetProfile.id }); benchmarks.push({ symbolProfileId: assetProfile.id });
@ -196,7 +197,8 @@ export class BenchmarkService {
let benchmarks = let benchmarks =
(await this.propertyService.getByKey<BenchmarkProperty[]>( (await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? []; )) ?? [];
benchmarks = benchmarks.filter(({ symbolProfileId }) => { benchmarks = benchmarks.filter(({ symbolProfileId }) => {

70
apps/api/src/services/property/property.service.ts

@ -6,27 +6,39 @@ import {
import { PropertyKey } from '@ghostfolio/common/types'; import { PropertyKey } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; 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'; import { PropertyValue } from './interfaces/interfaces';
@Injectable() @Injectable()
export class PropertyService { export class PropertyService {
private static readonly CACHE_TTL = ms('1 minute');
private cachedProperties: Promise<Property[]>;
private cachedPropertiesExpiresAt: Date;
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async delete({ key }: { key: PropertyKey }) { public async delete({ key }: { key: PropertyKey }) {
return this.prismaService.property.delete({ const property = await this.prismaService.property.delete({
where: { key } where: { key }
}); });
this.invalidateCache();
return property;
} }
public async get() { public async get({ skipCache = false } = {}) {
const response: { const response: {
[key: string]: PropertyValue; [key: string]: PropertyValue;
} = { } = {
[PROPERTY_CURRENCIES]: [] [PROPERTY_CURRENCIES]: []
}; };
const properties = await this.prismaService.property.findMany(); const properties = await this.getProperties({ skipCache });
for (const property of properties) { for (const property of properties) {
let value = property.value; let value = property.value;
@ -41,8 +53,11 @@ export class PropertyService {
return response; return response;
} }
public async getByKey<TValue extends PropertyValue>(aKey: PropertyKey) { public async getByKey<TValue extends PropertyValue>(
const properties = await this.get(); aKey: PropertyKey,
{ skipCache = false } = {}
) {
const properties = await this.get({ skipCache });
return properties[aKey] as TValue; return properties[aKey] as TValue;
} }
@ -53,10 +68,53 @@ export class PropertyService {
} }
public async put({ key, value }: { key: PropertyKey; value: string }) { public async put({ key, value }: { key: PropertyKey; value: string }) {
return this.prismaService.property.upsert({ const property = await this.prismaService.property.upsert({
create: { key, value }, create: { key, value },
update: { value }, update: { value },
where: { key } 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;
} }
} }

1
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -33,6 +33,7 @@
<mat-tab-group <mat-tab-group
animationDuration="0ms" animationDuration="0ms"
class="mb-4" class="mb-4"
[disablePagination]="true"
[mat-stretch-tabs]="false" [mat-stretch-tabs]="false"
> >
<mat-tab> <mat-tab>

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

@ -105,6 +105,7 @@
<mat-tab-group <mat-tab-group
animationDuration="0ms" animationDuration="0ms"
[disablePagination]="true"
[dynamicHeight]="true" [dynamicHeight]="true"
[mat-stretch-tabs]="false" [mat-stretch-tabs]="false"
> >

1
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -39,6 +39,7 @@
<mat-tab-group <mat-tab-group
animationDuration="0ms" animationDuration="0ms"
class="mb-4" class="mb-4"
[disablePagination]="true"
[mat-stretch-tabs]="false" [mat-stretch-tabs]="false"
> >
<mat-tab> <mat-tab>

4
apps/client/src/locales/messages.de.xlf

@ -351,7 +351,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4467323362722952678" datatype="html"> <trans-unit id="4467323362722952678" datatype="html">
<source>Unknown</source> <source>Unknown</source>
<target state="new">Unknown</target> <target state="translated">Unbekannt</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">88</context> <context context-type="linenumber">88</context>
@ -1767,7 +1767,7 @@
</trans-unit> </trans-unit>
<trans-unit id="659291745893337969" datatype="html"> <trans-unit id="659291745893337969" datatype="html">
<source>Invalid API key</source> <source>Invalid API key</source>
<target state="new">Invalid API key</target> <target state="translated">Ungültiger API-Schlüssel</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context>
<context context-type="linenumber">112</context> <context context-type="linenumber">112</context>

11
apps/client/src/styles.scss

@ -411,6 +411,17 @@ ngx-skeleton-loader {
.mdc-dialog__content { .mdc-dialog__content {
--mat-dialog-supporting-text-color: rgba(var(--dark-primary-text)); --mat-dialog-supporting-text-color: rgba(var(--dark-primary-text));
} }
@media (max-width: 575.98px) {
// Tabs fill the available width on mobile
.mat-mdc-tab-group {
.mat-mdc-tab {
flex-grow: 1;
min-width: unset;
padding: 0 0.5rem;
}
}
}
} }
.mat-mdc-fab, .mat-mdc-fab,

15
libs/ui/src/lib/activities-table/activities-table.component.html

@ -1,4 +1,6 @@
<div class="d-flex justify-content-end justify-content-lg-between"> <div
class="align-items-center d-flex justify-content-end justify-content-lg-between"
>
<div class="d-none d-lg-flex"> <div class="d-none d-lg-flex">
@if (hasPermissionToFilterByType) { @if (hasPermissionToFilterByType) {
<mat-form-field appearance="outline" class="without-hint"> <mat-form-field appearance="outline" class="without-hint">
@ -82,14 +84,19 @@
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
[disabled]=" [disabled]="!canDeleteActivities()"
dataSource()?.data.length === 0 || !hasPermissionToDeleteActivity
"
(click)="onDeleteActivities()" (click)="onDeleteActivities()"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" /> <ion-icon class="mr-2" name="trash-outline" />
@if (canDeleteActivities()) {
<span i18n
>Delete {{ totalItems > 1 ? totalItems : '' }}
{totalItems, plural, =1 {Activity} other {Activities}}</span
>
} @else {
<span i18n>Delete Activities</span> <span i18n>Delete Activities</span>
}
</span> </span>
</button> </button>
</mat-menu> </mat-menu>

9
libs/ui/src/lib/activities-table/activities-table.component.scss

@ -1,3 +1,12 @@
@use '@angular/material' as mat;
:host { :host {
display: block; display: block;
@include mat.form-field-overrides(
(
container-height: 2rem,
container-vertical-padding: 0.33rem
)
);
} }

25
libs/ui/src/lib/activities-table/activities-table.component.stories.ts

@ -494,3 +494,28 @@ export const Actions: Story = {
totalItems: activities.length 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
}
};

12
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -282,6 +282,13 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
); );
} }
public canDeleteActivities() {
return (
(this.dataSource()?.data.length ?? 0) > 0 &&
this.hasPermissionToDeleteActivity
);
}
public isExcludedFromAnalysis(activity: Activity) { public isExcludedFromAnalysis(activity: Activity) {
return ( return (
(activity.account && isAccountExcluded(activity.account)) ?? (activity.account && isAccountExcluded(activity.account)) ??
@ -314,7 +321,10 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
this.activitiesDeleted.emit(); this.activitiesDeleted.emit();
}, },
confirmType: ConfirmationDialogType.Warn, confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete these activities?` title:
this.totalItems === 1
? $localize`Do you really want to delete this activity?`
: $localize`Do you really want to delete these ${this.totalItems}:count: activities?`
}); });
} }

7
libs/ui/src/lib/fear-and-greed-index/fear-and-greed-index.component.html

@ -12,10 +12,11 @@
<div> <div>
<div class="h4 mb-0"> <div class="h4 mb-0">
<span class="mr-2">{{ fearAndGreedIndexText() }}</span> <span class="mr-2">{{ fearAndGreedIndexText() }}</span>
@let value = fearAndGreedIndex() | number: '1.0-0';
<small class="text-muted" <small class="text-muted"
><strong>{{ ><span [class.font-weight-bold]="value">{{
(fearAndGreedIndex() | number: '1.0-0') ?? placeholder value ?? placeholder
}}</strong }}</span
>/100</small >/100</small
> >
</div> </div>

Loading…
Cancel
Save