diff --git a/CHANGELOG.md b/CHANGELOG.md index 1865c53b4b..d64b985ef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added a periodic refresh of the numbers on the home, analysis and accounts pages while the page is visible, briefly coloring changed values green or red, with a live indicator in the header - Added a link to the holdings tab in the holdings table on the overview tab of the home page (experimental) ### Changed +- Colored the changes of the performance in green or red only while the market of a holding is open (in gray while it is closed) and highlighted a change in percent together with its absolute change - Improved the market price in the benchmark detail dialog by adding the currency and reducing the precision on mobile - Improved the style of the close button in the dialog footer component - Improved the style of the close button in the dialog header component @@ -36,7 +38,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added a periodic refresh of the numbers on the home, analysis and accounts pages while the page is visible, briefly coloring changed values green or red, with a live indicator in the header - Added a _Storybook_ story for the file drop area component ### Changed diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index a8a3fc6740..d963570b4e 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -679,9 +679,24 @@ export class PortfolioService { }; }); - const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( - assetProfileIdentifiers - ); + const [symbolProfiles, quotes] = await Promise.all([ + this.symbolProfileService.getSymbolProfiles(assetProfileIdentifiers), + // The quotes are cached, they only provide the state of the markets + this.dataProviderService + .getQuotes({ + items: positions + .filter(({ quantity }) => { + return !quantity.eq(0); + }) + .map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }), + subscriptionType: user.subscription?.type + }) + .catch(() => { + return {} as Awaited>; + }) + ]); const cashSymbolProfiles = this.getCashSymbolProfiles(cashDetails); symbolProfiles.push(...cashSymbolProfiles); @@ -764,6 +779,9 @@ export class PortfolioService { markets, marketsAdvanced, marketPrice, + marketState: + quotes[getAssetProfileIdentifier({ dataSource, symbol })] + ?.marketState, tags, allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 @@ -1223,11 +1241,32 @@ export class PortfolioService { subscriptionType: user.subscription?.type }); - const { errors, hasErrors, historicalData } = + const { errors, hasErrors, historicalData, positions } = await portfolioCalculator.getSnapshot(); const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); + // The quotes are cached, they only provide the state of the markets + const isMarketOpen = await this.dataProviderService + .getQuotes({ + items: positions + .filter(({ quantity }) => { + return !quantity.eq(0); + }) + .map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }), + subscriptionType: user.subscription?.type + }) + .then((quotes) => { + return Object.values(quotes).some(({ marketState }) => { + return marketState === 'open'; + }); + }) + .catch(() => { + return undefined; + }); + const { chart: chartOfDateRange } = await portfolioCalculator.getPerformance({ end: endDate, @@ -1271,6 +1310,7 @@ export class PortfolioService { chart, errors, hasErrors, + isMarketOpen, dateOfFirstActivity: parseDate(historicalData[0]?.date), performance: { dividendInBaseCurrency, diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index 9cbd9c5892..06e99f40d4 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -66,6 +66,7 @@ export class GfHomeOverviewComponent implements OnInit { undefined ); protected readonly isLoadingPerformance = signal(true); + protected readonly isMarketOpen = signal(undefined); protected readonly locale = computed(() => { return this.user()?.settings?.locale ?? DEFAULT_LOCALE; @@ -177,8 +178,9 @@ export class GfHomeOverviewComponent implements OnInit { }), takeUntilDestroyed(this.destroyRef) ) - .subscribe(({ errors, performance }) => { + .subscribe(({ errors, isMarketOpen, performance }) => { this.errors.set(errors ?? []); + this.isMarketOpen.set(isMarketOpen); this.performance.set(performance); this.autoRefreshService.markUpdated(); @@ -248,8 +250,9 @@ export class GfHomeOverviewComponent implements OnInit { range: userSettings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ chart, errors, performance }) => { + .subscribe(({ chart, errors, isMarketOpen, performance }) => { this.errors.set(errors ?? []); + this.isMarketOpen.set(isMarketOpen); this.performance.set(performance); this.historicalDataItems.set( diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html index 0f9e1bcfae..8aae32ce7b 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -87,6 +87,7 @@
+ (); public readonly isLoading = input(false); + public readonly isMarketOpen = input(); public readonly locale = input(getLocale()); public readonly performance = input.required(); public readonly precision = input.required({ diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 08dad5343c..2d2c8cc149 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -359,6 +359,8 @@ class="justify-content-end" position="end" [colorizeSign]="true" + [flashValue]="holding.netPerformanceWithCurrencyEffect" + [isMarketOpen]="holding.marketState === 'open'" [isPercent]="true" [locale]="user?.settings?.locale" [value]="holding.netPerformancePercentWithCurrencyEffect" @@ -412,6 +414,8 @@ class="justify-content-end" position="end" [colorizeSign]="true" + [flashValue]="holding.netPerformanceWithCurrencyEffect" + [isMarketOpen]="holding.marketState === 'open'" [isPercent]="true" [locale]="user?.settings?.locale" [value]="holding.netPerformancePercentWithCurrencyEffect" diff --git a/libs/common/src/lib/interfaces/portfolio-position.interface.ts b/libs/common/src/lib/interfaces/portfolio-position.interface.ts index dcb150d963..80997679ac 100644 --- a/libs/common/src/lib/interfaces/portfolio-position.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-position.interface.ts @@ -1,4 +1,4 @@ -import { Market, MarketAdvanced } from '@ghostfolio/common/types'; +import { Market, MarketAdvanced, MarketState } from '@ghostfolio/common/types'; import type { Tag } from '@ghostfolio/prisma/browser'; import { EnhancedAssetProfile } from './enhanced-asset-profile.interface'; @@ -33,6 +33,7 @@ export interface PortfolioPosition { marketChange?: number; marketChangePercent?: number; marketPrice: number; + marketState?: MarketState; markets?: { [key in Market]: number }; marketsAdvanced?: { [key in MarketAdvanced]: number }; netPerformance: number; diff --git a/libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts index 8177bd533c..0217e5bbc0 100644 --- a/libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts @@ -5,5 +5,9 @@ import { ResponseError } from './errors.interface'; export interface PortfolioPerformanceResponse extends ResponseError { chart?: HistoricalDataItem[]; dateOfFirstActivity: Date; + + /** Whether a market (including the pre-market and the post-market) of a holding is open */ + isMarketOpen?: boolean; + performance: PortfolioPerformance; } diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.html b/libs/ui/src/lib/holdings-table/holdings-table.component.html index 8cbce910bb..967a824c10 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.html +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.html @@ -173,6 +173,7 @@ [colorizeSign]="true" [isCurrency]="true" [isLoading]="isLoading()" + [isMarketOpen]="element.marketState === 'open'" [locale]="locale()" [value]="element.netPerformanceWithCurrencyEffect" /> @@ -200,9 +201,12 @@ [class.text-nowrap]="mode() === 'simple'" >
+ @if (isNumber || value === null) { -
+
@if (colorizeSign && !useAbsoluteValue && value !== null) { @if (+value > 0) { -
+
+
+ + +
} @if (+value < 0) { -
-
+
+ - +
} }
0) { + return 'text-success'; + } + + return this.value < 0 ? 'text-danger' : ''; + } + private get hasPrecision() { const precision = this.precision(); return precision !== undefined && precision >= 0; @@ -102,7 +135,8 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy { * has changed, e.g. after a background refresh. */ private flashOnChange(changes?: SimpleChanges) { - const change = changes?.['value']; + const hasFlashValue = this.flashValue !== undefined; + const change = changes?.[hasFlashValue ? 'flashValue' : 'value']; if (!change || change.firstChange || this.isLoading()) { return; @@ -110,9 +144,10 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy { const direction = getValueChangeDirection({ currentValue: change.currentValue, - digits: - ((this.hasPrecision ? this.precision() : 2) ?? 2) + - (this.isPercent ? 2 : 0), + digits: hasFlashValue + ? 2 + : ((this.hasPrecision ? this.precision() : 2) ?? 2) + + (this.isPercent ? 2 : 0), previousValue: change.previousValue });