Browse Source

Feature/color the changes while the market is open and highlight the change in percent with the absolute change

pull/8249/head
mister-ideas 1 day ago
parent
commit
c92e146499
  1. 3
      CHANGELOG.md
  2. 48
      apps/api/src/app/portfolio/portfolio.service.ts
  3. 7
      apps/client/src/app/components/home-overview/home-overview.component.ts
  4. 1
      apps/client/src/app/components/home-overview/home-overview.html
  5. 4
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html
  6. 1
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  7. 4
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  8. 3
      libs/common/src/lib/interfaces/portfolio-position.interface.ts
  9. 4
      libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts
  10. 4
      libs/ui/src/lib/holdings-table/holdings-table.component.html
  11. 19
      libs/ui/src/lib/value/value.component.html
  12. 43
      libs/ui/src/lib/value/value.component.ts

3
CHANGELOG.md

@ -9,10 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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) - Added a link to the holdings tab in the holdings table on the overview tab of the home page (experimental)
### Changed ### 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 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 footer component
- Improved the style of the close button in the dialog header 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
- 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 - Added a _Storybook_ story for the file drop area component
### Changed ### Changed

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

@ -679,9 +679,24 @@ export class PortfolioService {
}; };
}); });
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const [symbolProfiles, quotes] = await Promise.all([
assetProfileIdentifiers 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<ReturnType<DataProviderService['getQuotes']>>;
})
]);
const cashSymbolProfiles = this.getCashSymbolProfiles(cashDetails); const cashSymbolProfiles = this.getCashSymbolProfiles(cashDetails);
symbolProfiles.push(...cashSymbolProfiles); symbolProfiles.push(...cashSymbolProfiles);
@ -764,6 +779,9 @@ export class PortfolioService {
markets, markets,
marketsAdvanced, marketsAdvanced,
marketPrice, marketPrice,
marketState:
quotes[getAssetProfileIdentifier({ dataSource, symbol })]
?.marketState,
tags, tags,
allocationInPercentage: filteredValueInBaseCurrency.eq(0) allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0 ? 0
@ -1223,11 +1241,32 @@ export class PortfolioService {
subscriptionType: user.subscription?.type subscriptionType: user.subscription?.type
}); });
const { errors, hasErrors, historicalData } = const { errors, hasErrors, historicalData, positions } =
await portfolioCalculator.getSnapshot(); await portfolioCalculator.getSnapshot();
const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); 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 } = const { chart: chartOfDateRange } =
await portfolioCalculator.getPerformance({ await portfolioCalculator.getPerformance({
end: endDate, end: endDate,
@ -1271,6 +1310,7 @@ export class PortfolioService {
chart, chart,
errors, errors,
hasErrors, hasErrors,
isMarketOpen,
dateOfFirstActivity: parseDate(historicalData[0]?.date), dateOfFirstActivity: parseDate(historicalData[0]?.date),
performance: { performance: {
dividendInBaseCurrency, dividendInBaseCurrency,

7
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -66,6 +66,7 @@ export class GfHomeOverviewComponent implements OnInit {
undefined undefined
); );
protected readonly isLoadingPerformance = signal(true); protected readonly isLoadingPerformance = signal(true);
protected readonly isMarketOpen = signal<boolean | undefined>(undefined);
protected readonly locale = computed(() => { protected readonly locale = computed(() => {
return this.user()?.settings?.locale ?? DEFAULT_LOCALE; return this.user()?.settings?.locale ?? DEFAULT_LOCALE;
@ -177,8 +178,9 @@ export class GfHomeOverviewComponent implements OnInit {
}), }),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(({ errors, performance }) => { .subscribe(({ errors, isMarketOpen, performance }) => {
this.errors.set(errors ?? []); this.errors.set(errors ?? []);
this.isMarketOpen.set(isMarketOpen);
this.performance.set(performance); this.performance.set(performance);
this.autoRefreshService.markUpdated(); this.autoRefreshService.markUpdated();
@ -248,8 +250,9 @@ export class GfHomeOverviewComponent implements OnInit {
range: userSettings?.dateRange ?? DEFAULT_DATE_RANGE range: userSettings?.dateRange ?? DEFAULT_DATE_RANGE
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ chart, errors, performance }) => { .subscribe(({ chart, errors, isMarketOpen, performance }) => {
this.errors.set(errors ?? []); this.errors.set(errors ?? []);
this.isMarketOpen.set(isMarketOpen);
this.performance.set(performance); this.performance.set(performance);
this.historicalDataItems.set( this.historicalDataItems.set(

1
apps/client/src/app/components/home-overview/home-overview.html

@ -87,6 +87,7 @@
<gf-portfolio-performance <gf-portfolio-performance
[errors]="errors()" [errors]="errors()"
[isLoading]="isLoadingPerformance()" [isLoading]="isLoadingPerformance()"
[isMarketOpen]="isMarketOpen()"
[locale]="locale()" [locale]="locale()"
[performance]="performance()" [performance]="performance()"
[precision]="precision()" [precision]="precision()"

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

@ -42,14 +42,18 @@
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
[isLoading]="isLoading()" [isLoading]="isLoading()"
[isMarketOpen]="isMarketOpen()"
[locale]="locale()" [locale]="locale()"
[value]="performance()?.netPerformanceWithCurrencyEffect" [value]="performance()?.netPerformanceWithCurrencyEffect"
/> />
</div> </div>
<div class="col"> <div class="col">
<!-- Highlighted together with the absolute change -->
<gf-value <gf-value
[colorizeSign]="true" [colorizeSign]="true"
[flashValue]="performance()?.netPerformanceWithCurrencyEffect"
[isLoading]="isLoading()" [isLoading]="isLoading()"
[isMarketOpen]="isMarketOpen()"
[isPercent]="true" [isPercent]="true"
[locale]="locale()" [locale]="locale()"
[value]="performance()?.netPerformancePercentageWithCurrencyEffect" [value]="performance()?.netPerformancePercentageWithCurrencyEffect"

1
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts

@ -43,6 +43,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
export class GfPortfolioPerformanceComponent { export class GfPortfolioPerformanceComponent {
public readonly errors = input<ResponseError['errors']>(); public readonly errors = input<ResponseError['errors']>();
public readonly isLoading = input<boolean>(false); public readonly isLoading = input<boolean>(false);
public readonly isMarketOpen = input<boolean>();
public readonly locale = input<string>(getLocale()); public readonly locale = input<string>(getLocale());
public readonly performance = input.required<PortfolioPerformance | null>(); public readonly performance = input.required<PortfolioPerformance | null>();
public readonly precision = input.required<number, number>({ public readonly precision = input.required<number, number>({

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

@ -359,6 +359,8 @@
class="justify-content-end" class="justify-content-end"
position="end" position="end"
[colorizeSign]="true" [colorizeSign]="true"
[flashValue]="holding.netPerformanceWithCurrencyEffect"
[isMarketOpen]="holding.marketState === 'open'"
[isPercent]="true" [isPercent]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]="holding.netPerformancePercentWithCurrencyEffect" [value]="holding.netPerformancePercentWithCurrencyEffect"
@ -412,6 +414,8 @@
class="justify-content-end" class="justify-content-end"
position="end" position="end"
[colorizeSign]="true" [colorizeSign]="true"
[flashValue]="holding.netPerformanceWithCurrencyEffect"
[isMarketOpen]="holding.marketState === 'open'"
[isPercent]="true" [isPercent]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]="holding.netPerformancePercentWithCurrencyEffect" [value]="holding.netPerformancePercentWithCurrencyEffect"

3
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 type { Tag } from '@ghostfolio/prisma/browser';
import { EnhancedAssetProfile } from './enhanced-asset-profile.interface'; import { EnhancedAssetProfile } from './enhanced-asset-profile.interface';
@ -33,6 +33,7 @@ export interface PortfolioPosition {
marketChange?: number; marketChange?: number;
marketChangePercent?: number; marketChangePercent?: number;
marketPrice: number; marketPrice: number;
marketState?: MarketState;
markets?: { [key in Market]: number }; markets?: { [key in Market]: number };
marketsAdvanced?: { [key in MarketAdvanced]: number }; marketsAdvanced?: { [key in MarketAdvanced]: number };
netPerformance: number; netPerformance: number;

4
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 { export interface PortfolioPerformanceResponse extends ResponseError {
chart?: HistoricalDataItem[]; chart?: HistoricalDataItem[];
dateOfFirstActivity: Date; dateOfFirstActivity: Date;
/** Whether a market (including the pre-market and the post-market) of a holding is open */
isMarketOpen?: boolean;
performance: PortfolioPerformance; performance: PortfolioPerformance;
} }

4
libs/ui/src/lib/holdings-table/holdings-table.component.html

@ -173,6 +173,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
[isLoading]="isLoading()" [isLoading]="isLoading()"
[isMarketOpen]="element.marketState === 'open'"
[locale]="locale()" [locale]="locale()"
[value]="element.netPerformanceWithCurrencyEffect" [value]="element.netPerformanceWithCurrencyEffect"
/> />
@ -200,9 +201,12 @@
[class.text-nowrap]="mode() === 'simple'" [class.text-nowrap]="mode() === 'simple'"
> >
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<!-- Highlighted together with the absolute change -->
<gf-value <gf-value
[colorizeSign]="true" [colorizeSign]="true"
[flashValue]="element.netPerformanceWithCurrencyEffect"
[isLoading]="isLoading()" [isLoading]="isLoading()"
[isMarketOpen]="element.marketState === 'open'"
[isPercent]="true" [isPercent]="true"
[locale]="locale()" [locale]="locale()"
[value]="element.netPerformancePercentWithCurrencyEffect" [value]="element.netPerformancePercentWithCurrencyEffect"

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

@ -42,13 +42,26 @@
[class.justify-content-end]="position === 'end'" [class.justify-content-end]="position === 'end'"
> >
@if (isNumber || value === null) { @if (isNumber || value === null) {
<div class="align-items-center d-flex gf-value-line"> <div
class="align-items-center d-flex gf-value-line"
[ngClass]="trendColorClass"
>
@if (colorizeSign && !useAbsoluteValue && value !== null) { @if (colorizeSign && !useAbsoluteValue && value !== null) {
@if (+value > 0) { @if (+value > 0) {
<div class="me-1 text-success">+</div> <div
class="me-1"
[class.text-success]="isMarketOpen === undefined"
>
+
</div>
} }
@if (+value < 0) { @if (+value < 0) {
<div class="me-1 text-danger">-</div> <div
class="me-1"
[class.text-danger]="isMarketOpen === undefined"
>
-
</div>
} }
} }
<div <div

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

@ -40,10 +40,23 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() colorizeSign = false; @Input() colorizeSign = false;
@Input() deviceType: string; @Input() deviceType: string;
@Input() enableCopyToClipboardButton = false; @Input() enableCopyToClipboardButton = false;
/**
* Number which triggers the highlight of a change instead of the value,
* e.g. the absolute change for a change in percent which is displayed
* with fewer digits
*/
@Input() flashValue?: number | null;
@Input() icon = ''; @Input() icon = '';
@Input() isAbsolute = false; @Input() isAbsolute = false;
@Input() isCurrency = false; @Input() isCurrency = false;
@Input() isDate = false; @Input() isDate = false;
/**
* Colors the whole figure (with `colorizeSign`) in green or red while the
* market is open and in grey while it is closed
*/
@Input() isMarketOpen?: boolean;
@Input() isPercent = false; @Input() isPercent = false;
@Input() locale?: string; @Input() locale?: string;
@Input() position = ''; @Input() position = '';
@ -92,6 +105,26 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
}; };
}); });
protected get trendColorClass() {
if (
!this.colorizeSign ||
this.isMarketOpen === undefined ||
!isNumber(this.value)
) {
return '';
}
if (!this.isMarketOpen) {
return 'text-body-secondary';
}
if (this.value > 0) {
return 'text-success';
}
return this.value < 0 ? 'text-danger' : '';
}
private get hasPrecision() { private get hasPrecision() {
const precision = this.precision(); const precision = this.precision();
return precision !== undefined && precision >= 0; return precision !== undefined && precision >= 0;
@ -102,7 +135,8 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
* has changed, e.g. after a background refresh. * has changed, e.g. after a background refresh.
*/ */
private flashOnChange(changes?: SimpleChanges) { 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()) { if (!change || change.firstChange || this.isLoading()) {
return; return;
@ -110,9 +144,10 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
const direction = getValueChangeDirection({ const direction = getValueChangeDirection({
currentValue: change.currentValue, currentValue: change.currentValue,
digits: digits: hasFlashValue
((this.hasPrecision ? this.precision() : 2) ?? 2) + ? 2
(this.isPercent ? 2 : 0), : ((this.hasPrecision ? this.precision() : 2) ?? 2) +
(this.isPercent ? 2 : 0),
previousValue: change.previousValue previousValue: change.previousValue
}); });

Loading…
Cancel
Save