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() {