Browse Source

Feature/highlight changed table rows and roll the performance number on refresh

pull/8249/head
mister-ideas 4 days ago
parent
commit
8fb22f4903
  1. 2
      CHANGELOG.md
  2. 2
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html
  3. 79
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  4. 84
      apps/client/src/styles.scss
  5. 2
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  6. 7
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  7. 63
      libs/ui/src/lib/flash-on-change/flash-on-change.directive.spec.ts
  8. 71
      libs/ui/src/lib/flash-on-change/flash-on-change.directive.ts
  9. 1
      libs/ui/src/lib/flash-on-change/index.ts
  10. 2
      libs/ui/src/lib/holdings-table/holdings-table.component.html
  11. 7
      libs/ui/src/lib/holdings-table/holdings-table.component.ts
  12. 35
      libs/ui/src/lib/value/value.component.scss
  13. 16
      libs/ui/src/lib/value/value.component.ts

2
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

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

@ -24,6 +24,8 @@
}
<div
class="display-4 fw-bold m-0 text-center value-container"
[gfFlashDigits]="precision()"
[gfFlashOnChange]="displayedValue()"
[hidden]="isLoading()"
>
<span #value id="value"></span>

79
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<ElementRef<HTMLSpanElement>>('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()),
this.showValue({
decimalPlaces: 2,
duration: 1,
separator: getNumberFormatGroup(this.locale())
}
).start();
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();

84
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;
}
}

2
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -20,6 +20,7 @@
matSortActive="name"
matSortDirection="asc"
[dataSource]="dataSource"
[trackBy]="trackByAccount"
>
<ng-container matColumnDef="status">
<th
@ -374,6 +375,7 @@
*matRowDef="let row; columns: displayedColumns()"
mat-row
[class.cursor-pointer]="hasPermissionToOpenDetails()"
[gfFlashOnChange]="row.valueInBaseCurrency"
(click)="onOpenAccountDetailDialog(row.id)"
></tr>
<tr

7
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -7,6 +7,7 @@ import {
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { AccountWithValue } from '@ghostfolio/common/types';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfFlashOnChangeDirective } from '@ghostfolio/ui/flash-on-change';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -42,6 +43,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GfEntityLogoComponent,
GfFlashOnChangeDirective,
GfValueComponent,
IonIcon,
MatButtonModule,
@ -84,6 +86,11 @@ export class GfAccountsTableComponent {
public readonly sort = viewChild.required(MatSort);
protected readonly trackByAccount = (
_index: number,
{ id }: AccountWithValue
) => id;
protected readonly accountDialogRouterLinks = computed(() => {
const { detail, update } = internalRoutes.accounts.subRoutes;

63
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: `<tr [gfFlashOnChange]="value()"></tr>`
})
class TestHostComponent {
public readonly value = signal<number | undefined>(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);
});
});

71
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<number | null | undefined>();
private hasValue = false;
private previousValue: number | null | undefined;
private readonly element = inject<ElementRef<HTMLElement>>(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 }
);
}
}

1
libs/ui/src/lib/flash-on-change/index.ts

@ -0,0 +1 @@
export * from './flash-on-change.directive';

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

@ -7,6 +7,7 @@
[dataSource]="dataSource"
[matSortActive]="sortActive()"
[matSortDirection]="sortDirection()"
[trackBy]="trackByHolding"
>
<ng-container matColumnDef="icon" [sticky]="mode() === 'default'">
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
@ -219,6 +220,7 @@
*matRowDef="let row; columns: displayedColumns()"
mat-row
[class.cursor-pointer]="canShowDetails(row)"
[gfFlashOnChange]="row.netPerformancePercentWithCurrencyEffect * 100"
(click)="
canShowDetails(row) &&
onOpenHoldingDialog({

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

35
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;
}
}

16
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<HTMLElement>('.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() {

Loading…
Cancel
Save