diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a92f3b5dd..1a12754c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ 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, highlighting changed values +- Added a periodic refresh of the numbers on the home, analysis and accounts pages while the page is visible, highlighting changed values and table rows ### Changed diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html index 9be2be344e..4948f84ca9 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html @@ -24,6 +24,8 @@ }
diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts index 04929e03a6..4f772e4993 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts @@ -7,12 +7,14 @@ import { PortfolioPerformance, ResponseError } from '@ghostfolio/common/interfaces'; +import { GfFlashOnChangeDirective } from '@ghostfolio/ui/flash-on-change'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { ChangeDetectionStrategy, Component, + computed, effect, ElementRef, inject, @@ -28,7 +30,12 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [GfValueComponent, IonIcon, NgxSkeletonLoaderModule], + imports: [ + GfFlashOnChangeDirective, + GfValueComponent, + IonIcon, + NgxSkeletonLoaderModule + ], selector: 'gf-portfolio-performance', styleUrls: ['./portfolio-performance.component.scss'], templateUrl: './portfolio-performance.component.html' @@ -49,6 +56,21 @@ export class GfPortfolioPerformanceComponent { private readonly value = viewChild.required>('value'); + protected readonly displayedValue = computed(() => { + const performance = this.performance(); + + if (isNumber(performance?.currentValueInBaseCurrency)) { + return performance.currentValueInBaseCurrency; + } + + return this.showDetails() === false && performance + ? performance.netPerformancePercentageWithCurrencyEffect * 100 + : undefined; + }); + + private countUp?: CountUp; + private countUpTarget?: number; + private readonly notificationService = inject(NotificationService); public constructor() { @@ -56,6 +78,9 @@ export class GfPortfolioPerformanceComponent { effect(() => { if (this.isLoading()) { + this.countUp = undefined; + this.countUpTarget = undefined; + if (this.value().nativeElement) { this.value().nativeElement.innerHTML = ''; } @@ -63,30 +88,54 @@ export class GfPortfolioPerformanceComponent { const performance = this.performance(); if (isNumber(performance?.currentValueInBaseCurrency)) { - new CountUp('value', performance.currentValueInBaseCurrency, { - decimal: getNumberFormatDecimal(this.locale()), + this.showValue({ decimalPlaces: this.precision(), - duration: 1, - separator: getNumberFormatGroup(this.locale()) - }).start(); + value: performance.currentValueInBaseCurrency + }); } else if (this.showDetails() === false && performance) { - new CountUp( - 'value', - performance.netPerformancePercentageWithCurrencyEffect * 100, - { - decimal: getNumberFormatDecimal(this.locale()), - decimalPlaces: 2, - duration: 1, - separator: getNumberFormatGroup(this.locale()) - } - ).start(); + this.showValue({ + decimalPlaces: 2, + value: performance.netPerformancePercentageWithCurrencyEffect * 100 + }); } else { + this.countUp = undefined; + this.countUpTarget = undefined; this.value().nativeElement.innerHTML = '*****'; } } }); } + /** + * Counts up from zero on the initial value and rolls from the current to + * the new number on a refresh. Nothing happens if the number is unchanged. + */ + private showValue({ + decimalPlaces, + value + }: { + decimalPlaces: number; + value: number; + }) { + if (this.countUp && this.countUpTarget === value) { + return; + } + + const isUpdate = this.countUpTarget !== undefined; + + this.countUp = new CountUp('value', value, { + decimalPlaces, + decimal: getNumberFormatDecimal(this.locale()), + duration: isUpdate ? 0.6 : 1, + separator: getNumberFormatGroup(this.locale()), + startVal: isUpdate ? this.countUpTarget : 0 + }); + + this.countUp.start(); + + this.countUpTarget = value; + } + protected onShowErrors() { const errors = this.errors(); diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss index 5771ebd6de..d74e58115f 100644 --- a/apps/client/src/styles.scss +++ b/apps/client/src/styles.scss @@ -502,3 +502,87 @@ ngx-skeleton-loader { } } } + +// Highlight of a changed value, e.g. a table row after a background refresh +@keyframes gf-flash-fade { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +tr.gf-flash-down > td:not(.mat-mdc-table-sticky), +tr.gf-flash-up > td:not(.mat-mdc-table-sticky) { + position: relative; +} + +tr.gf-flash-down > td::after, +tr.gf-flash-up > td::after { + animation: gf-flash-fade 1.4s ease-out forwards; + content: ''; + inset: 0; + pointer-events: none; + position: absolute; +} + +tr.gf-flash-up > td::after { + background-color: rgba(var(--bs-success-rgb, 40, 167, 69), 0.14); +} + +tr.gf-flash-down > td::after { + background-color: rgba(var(--bs-danger-rgb, 220, 53, 69), 0.14); +} + +.gf-flash-down:not(tr), +.gf-flash-up:not(tr) { + border-radius: 0.25rem; +} + +.gf-flash-up:not(tr) { + animation: gf-flash-up-shadow 1.4s ease-out; +} + +.gf-flash-down:not(tr) { + animation: gf-flash-down-shadow 1.4s ease-out; +} + +@keyframes gf-flash-up-shadow { + from { + box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb, 40, 167, 69), 0.3); + } + + to { + box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb, 40, 167, 69), 0); + } +} + +@keyframes gf-flash-down-shadow { + from { + box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb, 220, 53, 69), 0.3); + } + + to { + box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb, 220, 53, 69), 0); + } +} + +// The highlight of a row replaces the highlight of its numbers +tr.gf-flash-down .gf-flash-down, +tr.gf-flash-down .gf-flash-up, +tr.gf-flash-up .gf-flash-down, +tr.gf-flash-up .gf-flash-up { + animation: none; + box-shadow: none; +} + +@media (prefers-reduced-motion: reduce) { + .gf-flash-down, + .gf-flash-up, + tr.gf-flash-down > td::after, + tr.gf-flash-up > td::after { + animation: none !important; + } +} diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.html b/libs/ui/src/lib/accounts-table/accounts-table.component.html index 43a6a429b4..997041fa48 100644 --- a/libs/ui/src/lib/accounts-table/accounts-table.component.html +++ b/libs/ui/src/lib/accounts-table/accounts-table.component.html @@ -20,6 +20,7 @@ matSortActive="name" matSortDirection="asc" [dataSource]="dataSource" + [trackBy]="trackByAccount" > id; + protected readonly accountDialogRouterLinks = computed(() => { const { detail, update } = internalRoutes.accounts.subRoutes; diff --git a/libs/ui/src/lib/flash-on-change/flash-on-change.directive.spec.ts b/libs/ui/src/lib/flash-on-change/flash-on-change.directive.spec.ts new file mode 100644 index 0000000000..71d495ba2b --- /dev/null +++ b/libs/ui/src/lib/flash-on-change/flash-on-change.directive.spec.ts @@ -0,0 +1,63 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { GfFlashOnChangeDirective } from './flash-on-change.directive'; + +@Component({ + imports: [GfFlashOnChangeDirective], + template: `` +}) +class TestHostComponent { + public readonly value = signal(100); +} + +describe('GfFlashOnChangeDirective', () => { + const setup = () => { + const fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + + return { + fixture, + classes: () => fixture.nativeElement.querySelector('tr').classList + }; + }; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [TestHostComponent] }); + }); + + it('does not flash initially', () => { + const { classes } = setup(); + + expect(classes().contains('gf-flash-up')).toBe(false); + expect(classes().contains('gf-flash-down')).toBe(false); + }); + + it('flashes up on an increase', () => { + const { classes, fixture } = setup(); + + fixture.componentInstance.value.set(101); + fixture.detectChanges(); + + expect(classes().contains('gf-flash-up')).toBe(true); + }); + + it('flashes down on a decrease', () => { + const { classes, fixture } = setup(); + + fixture.componentInstance.value.set(99); + fixture.detectChanges(); + + expect(classes().contains('gf-flash-down')).toBe(true); + }); + + it('does not flash if the value becomes undefined', () => { + const { classes, fixture } = setup(); + + fixture.componentInstance.value.set(undefined); + fixture.detectChanges(); + + expect(classes().contains('gf-flash-up')).toBe(false); + expect(classes().contains('gf-flash-down')).toBe(false); + }); +}); diff --git a/libs/ui/src/lib/flash-on-change/flash-on-change.directive.ts b/libs/ui/src/lib/flash-on-change/flash-on-change.directive.ts new file mode 100644 index 0000000000..befc49569b --- /dev/null +++ b/libs/ui/src/lib/flash-on-change/flash-on-change.directive.ts @@ -0,0 +1,71 @@ +import { + Directive, + effect, + ElementRef, + inject, + input, + untracked +} from '@angular/core'; + +import { getValueChangeDirection } from '../value/value-change.helper'; + +/** + * Briefly highlights the host element (e.g. a table row) in green or red if + * the bound number changes, e.g. after a background refresh. The highlight + * is styled globally by the `gf-flash-up` and `gf-flash-down` classes. + */ +@Directive({ + selector: '[gfFlashOnChange]' +}) +export class GfFlashOnChangeDirective { + public readonly gfFlashDigits = input(2); + public readonly gfFlashOnChange = input(); + + private hasValue = false; + private previousValue: number | null | undefined; + + private readonly element = inject>(ElementRef); + + public constructor() { + effect(() => { + const digits = this.gfFlashDigits(); + const value = this.gfFlashOnChange(); + + untracked(() => { + if (this.hasValue) { + const direction = getValueChangeDirection({ + digits, + currentValue: value, + previousValue: this.previousValue + }); + + if (direction) { + this.flash(direction); + } + } + + this.hasValue = true; + this.previousValue = value; + }); + }); + } + + private flash(aDirection: 'down' | 'up') { + const element = this.element.nativeElement; + + element.classList.remove('gf-flash-down', 'gf-flash-up'); + + // Force a reflow to restart the animation + void element.offsetWidth; + + element.classList.add(`gf-flash-${aDirection}`); + + element.addEventListener( + 'animationend', + () => { + element.classList.remove('gf-flash-down', 'gf-flash-up'); + }, + { once: true } + ); + } +} diff --git a/libs/ui/src/lib/flash-on-change/index.ts b/libs/ui/src/lib/flash-on-change/index.ts new file mode 100644 index 0000000000..3577482978 --- /dev/null +++ b/libs/ui/src/lib/flash-on-change/index.ts @@ -0,0 +1 @@ +export * from './flash-on-change.directive'; 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 025be8a723..55af6e5b90 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.html +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.html @@ -7,6 +7,7 @@ [dataSource]="dataSource" [matSortActive]="sortActive()" [matSortDirection]="sortDirection()" + [trackBy]="trackByHolding" > @@ -219,6 +220,7 @@ *matRowDef="let row; columns: displayedColumns()" mat-row [class.cursor-pointer]="canShowDetails(row)" + [gfFlashOnChange]="row.netPerformancePercentWithCurrencyEffect * 100" (click)=" canShowDetails(row) && onOpenHoldingDialog({ diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.ts b/libs/ui/src/lib/holdings-table/holdings-table.component.ts index 3181cc1347..fa0add4336 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.ts +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.ts @@ -27,6 +27,7 @@ import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component'; +import { GfFlashOnChangeDirective } from '../flash-on-change/flash-on-change.directive'; import { GfValueComponent } from '../value/value.component'; import { HoldingsTableItem } from './interfaces/interfaces'; @@ -34,6 +35,7 @@ import { HoldingsTableItem } from './interfaces/interfaces'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GfEntityLogoComponent, + GfFlashOnChangeDirective, GfValueComponent, MatButtonModule, MatDialogModule, @@ -95,6 +97,11 @@ export class GfHoldingsTableComponent { }); protected readonly getHoldingName = getHoldingName; + + protected readonly trackByHolding = ( + _index: number, + { assetProfile }: HoldingsTableItem + ) => `${assetProfile.dataSource}-${assetProfile.symbol}`; protected readonly isCashPosition = isCashPosition; protected readonly isLoading = computed(() => { diff --git a/libs/ui/src/lib/value/value.component.scss b/libs/ui/src/lib/value/value.component.scss index 76cd562a29..087bf31e13 100644 --- a/libs/ui/src/lib/value/value.component.scss +++ b/libs/ui/src/lib/value/value.component.scss @@ -8,38 +8,3 @@ line-height: 1; } } - -@keyframes gf-flash-up { - from { - background-color: rgba(var(--bs-success-rgb, 40, 167, 69), 0.35); - } - - to { - background-color: transparent; - } -} - -@keyframes gf-flash-down { - from { - background-color: rgba(var(--bs-danger-rgb, 220, 53, 69), 0.35); - } - - to { - background-color: transparent; - } -} - -:host(.gf-flash-up) { - animation: gf-flash-up 1.2s ease-out; -} - -:host(.gf-flash-down) { - animation: gf-flash-down 1.2s ease-out; -} - -@media (prefers-reduced-motion: reduce) { - :host(.gf-flash-down), - :host(.gf-flash-up) { - animation: none; - } -} diff --git a/libs/ui/src/lib/value/value.component.ts b/libs/ui/src/lib/value/value.component.ts index 39deaa98b0..5a50df4566 100644 --- a/libs/ui/src/lib/value/value.component.ts +++ b/libs/ui/src/lib/value/value.component.ts @@ -120,7 +120,13 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy { return; } - const element = this.elementRef.nativeElement; + // Highlight the number only, not the whole (full width) host element + const element = + this.elementRef.nativeElement.querySelector('.value'); + + if (!element) { + return; + } element.classList.remove('gf-flash-down', 'gf-flash-up'); @@ -128,6 +134,14 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy { void element.offsetWidth; element.classList.add(`gf-flash-${direction}`); + + element.addEventListener( + 'animationend', + () => { + element.classList.remove('gf-flash-down', 'gf-flash-up'); + }, + { once: true } + ); } public ngAfterViewInit() {