From 92ecc294de31e2f752b97cc6d0d27b2afc2ec130 Mon Sep 17 00:00:00 2001
From: mister-ideas <36502095+mister-ideas@users.noreply.github.com>
Date: Mon, 19 Oct 2026 19:40:06 +0200
Subject: [PATCH] Feature/color changed values instead of glowing and add a
live indicator
---
CHANGELOG.md | 2 +-
.../components/header/header.component.html | 1 +
.../app/components/header/header.component.ts | 2 +
.../home-holdings/home-holdings.component.ts | 1 +
.../home-overview/home-overview.component.ts | 2 +
.../live-indicator.component.scss | 44 ++++++++++
.../live-indicator.component.ts | 67 +++++++++++++++
.../pages/accounts/accounts-page.component.ts | 1 +
.../analysis/analysis-page.component.ts | 1 +
.../auto-refresh/auto-refresh.service.spec.ts | 34 +++++++-
.../auto-refresh/auto-refresh.service.ts | 42 +++++++--
apps/client/src/styles.scss | 85 ++++---------------
.../accounts-table.component.html | 1 -
.../accounts-table.component.ts | 2 -
.../holdings-table.component.html | 1 -
.../holdings-table.component.ts | 2 -
16 files changed, 205 insertions(+), 83 deletions(-)
create mode 100644 apps/client/src/app/components/live-indicator/live-indicator.component.scss
create mode 100644 apps/client/src/app/components/live-indicator/live-indicator.component.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a12754c4f..6d622252c3 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 and table rows
+- 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
### Changed
diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html
index 88ab9f09fc..eb7a578f4a 100644
--- a/apps/client/src/app/components/header/header.component.html
+++ b/apps/client/src/app/components/header/header.component.html
@@ -12,6 +12,7 @@
+
-
{
this.holdings = holdings;
+ this.autoRefreshService.markUpdated();
this.changeDetectorRef.markForCheck();
});
}
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 f0ccce9075..2092c2d641 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
@@ -168,6 +168,8 @@ export class GfHomeOverviewComponent implements OnInit {
.subscribe(({ errors, performance }) => {
this.errors.set(errors ?? []);
this.performance.set(performance);
+
+ this.autoRefreshService.markUpdated();
});
this.autoRefreshService.refresh$
diff --git a/apps/client/src/app/components/live-indicator/live-indicator.component.scss b/apps/client/src/app/components/live-indicator/live-indicator.component.scss
new file mode 100644
index 0000000000..fbd3e05a9c
--- /dev/null
+++ b/apps/client/src/app/components/live-indicator/live-indicator.component.scss
@@ -0,0 +1,44 @@
+:host {
+ display: inline-flex;
+}
+
+.dot {
+ background-color: var(--bs-success, #198754);
+ border-radius: 50%;
+ display: inline-block;
+ height: 0.5rem;
+ opacity: 0.65;
+ position: relative;
+ width: 0.5rem;
+
+ &::after {
+ border: 2px solid var(--bs-success, #198754);
+ border-radius: 50%;
+ content: '';
+ inset: -2px;
+ opacity: 0;
+ position: absolute;
+ }
+
+ &.pulse::after {
+ animation: gf-live-pulse 1.2s ease-out;
+ }
+}
+
+@keyframes gf-live-pulse {
+ from {
+ opacity: 0.8;
+ transform: scale(1);
+ }
+
+ to {
+ opacity: 0;
+ transform: scale(2.6);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .dot.pulse::after {
+ animation: none;
+ }
+}
diff --git a/apps/client/src/app/components/live-indicator/live-indicator.component.ts b/apps/client/src/app/components/live-indicator/live-indicator.component.ts
new file mode 100644
index 0000000000..08a69e6c57
--- /dev/null
+++ b/apps/client/src/app/components/live-indicator/live-indicator.component.ts
@@ -0,0 +1,67 @@
+import { AutoRefreshService } from '@ghostfolio/client/services/auto-refresh/auto-refresh.service';
+
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ effect,
+ ElementRef,
+ inject,
+ viewChild
+} from '@angular/core';
+
+/**
+ * A small dot which pulses each time the numbers of the page have been
+ * refreshed in the background. The title shows the time of the last refresh.
+ */
+@Component({
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ selector: 'gf-live-indicator',
+ styleUrls: ['./live-indicator.component.scss'],
+ template: `
+ @if (lastUpdated()) {
+
+
+
+ }
+ `
+})
+export class GfLiveIndicatorComponent {
+ protected readonly lastUpdated = inject(AutoRefreshService).lastUpdated;
+
+ protected readonly title = computed(() => {
+ const lastUpdated = this.lastUpdated();
+
+ return lastUpdated
+ ? $localize`Live` +
+ ' ยท ' +
+ $localize`Updated` +
+ ' ' +
+ lastUpdated.toLocaleTimeString()
+ : '';
+ });
+
+ private readonly dot = viewChild>('dot');
+
+ public constructor() {
+ effect(() => {
+ this.lastUpdated();
+
+ const element = this.dot()?.nativeElement;
+
+ if (element) {
+ element.classList.remove('pulse');
+
+ // Force a reflow to restart the animation
+ void element.offsetWidth;
+
+ element.classList.add('pulse');
+ }
+ });
+ }
+}
diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts
index 2fba8a91b0..85b0a87210 100644
--- a/apps/client/src/app/pages/accounts/accounts-page.component.ts
+++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts
@@ -103,6 +103,7 @@ export class GfAccountsPageComponent implements OnInit {
)
.subscribe((response) => {
this.updateAccounts(response);
+ this.autoRefreshService.markUpdated();
});
}
diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
index d3e179a7f3..f8e86339b4 100644
--- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
+++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
@@ -196,6 +196,7 @@ export class GfAnalysisPageComponent implements OnInit {
this.updateTopAndBottomHoldings(holdings);
+ this.autoRefreshService.markUpdated();
this.changeDetectorRef.markForCheck();
});
}
diff --git a/apps/client/src/app/services/auto-refresh/auto-refresh.service.spec.ts b/apps/client/src/app/services/auto-refresh/auto-refresh.service.spec.ts
index da83179492..dbd6652663 100644
--- a/apps/client/src/app/services/auto-refresh/auto-refresh.service.spec.ts
+++ b/apps/client/src/app/services/auto-refresh/auto-refresh.service.spec.ts
@@ -1,6 +1,8 @@
import {
AUTO_REFRESH_INTERVAL_MS,
- AutoRefreshService
+ AUTO_REFRESH_INTERVAL_STORAGE_KEY,
+ AutoRefreshService,
+ getAutoRefreshInterval
} from './auto-refresh.service';
describe('AutoRefreshService', () => {
@@ -54,3 +56,33 @@ describe('AutoRefreshService', () => {
subscription.unsubscribe();
});
});
+
+describe('getAutoRefreshInterval', () => {
+ const storageWith = (value: string | null) => ({
+ getItem: (key: string) =>
+ key === AUTO_REFRESH_INTERVAL_STORAGE_KEY ? value : null
+ });
+
+ it('returns the default without an override', () => {
+ expect(getAutoRefreshInterval(storageWith(null))).toBe(
+ AUTO_REFRESH_INTERVAL_MS
+ );
+ });
+
+ it('returns a valid override', () => {
+ expect(getAutoRefreshInterval(storageWith('1000'))).toBe(1000);
+ expect(getAutoRefreshInterval(storageWith('30000'))).toBe(30_000);
+ });
+
+ it('ignores an override outside of 1 second to 1 minute', () => {
+ expect(getAutoRefreshInterval(storageWith('200'))).toBe(
+ AUTO_REFRESH_INTERVAL_MS
+ );
+ expect(getAutoRefreshInterval(storageWith('600000'))).toBe(
+ AUTO_REFRESH_INTERVAL_MS
+ );
+ expect(getAutoRefreshInterval(storageWith('abc'))).toBe(
+ AUTO_REFRESH_INTERVAL_MS
+ );
+ });
+});
diff --git a/apps/client/src/app/services/auto-refresh/auto-refresh.service.ts b/apps/client/src/app/services/auto-refresh/auto-refresh.service.ts
index 3905e3879e..14e03fec8b 100644
--- a/apps/client/src/app/services/auto-refresh/auto-refresh.service.ts
+++ b/apps/client/src/app/services/auto-refresh/auto-refresh.service.ts
@@ -1,4 +1,4 @@
-import { Service } from '@angular/core';
+import { Service, signal } from '@angular/core';
import { EMPTY, fromEvent, Observable, timer } from 'rxjs';
import {
distinctUntilChanged,
@@ -8,7 +8,30 @@ import {
switchMap
} from 'rxjs/operators';
-export const AUTO_REFRESH_INTERVAL_MS = 15_000;
+export const AUTO_REFRESH_INTERVAL_MS = 5000;
+export const AUTO_REFRESH_INTERVAL_STORAGE_KEY = 'gf-auto-refresh-ms';
+
+/**
+ * Returns the refresh interval of this device: the stored override (between
+ * 1 second and 1 minute) or the default.
+ */
+export function getAutoRefreshInterval(
+ storage?: Pick
+): number {
+ try {
+ const value = Number(
+ (storage ?? globalThis.localStorage)?.getItem(
+ AUTO_REFRESH_INTERVAL_STORAGE_KEY
+ )
+ );
+
+ return Number.isFinite(value) && value >= 1000 && value <= 60_000
+ ? value
+ : AUTO_REFRESH_INTERVAL_MS;
+ } catch {
+ return AUTO_REFRESH_INTERVAL_MS;
+ }
+}
/**
* Emits at a fixed interval while the page is visible so that pages can
@@ -17,6 +40,7 @@ export const AUTO_REFRESH_INTERVAL_MS = 15_000;
*/
@Service()
export class AutoRefreshService {
+ public readonly lastUpdated = signal(null);
public readonly refresh$: Observable;
public constructor() {
@@ -31,15 +55,21 @@ export class AutoRefreshService {
return EMPTY;
}
- const dueTime = isFirstActivation ? AUTO_REFRESH_INTERVAL_MS : 0;
+ const interval = getAutoRefreshInterval();
+ const dueTime = isFirstActivation ? interval : 0;
isFirstActivation = false;
- return timer(dueTime, AUTO_REFRESH_INTERVAL_MS).pipe(
- map(() => undefined)
- );
+ return timer(dueTime, interval).pipe(map(() => undefined));
}),
share()
);
}
+
+ /**
+ * To be called by a page after it has silently refreshed its numbers.
+ */
+ public markUpdated() {
+ this.lastUpdated.set(new Date());
+ }
}
diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss
index d74e58115f..6561cd7a44 100644
--- a/apps/client/src/styles.scss
+++ b/apps/client/src/styles.scss
@@ -503,86 +503,33 @@ ngx-skeleton-loader {
}
}
-// Highlight of a changed value, e.g. a table row after a background refresh
-@keyframes gf-flash-fade {
- from {
- opacity: 1;
+// Highlight of a changed value: the number briefly takes the colour of the
+// change (green up, red down) and fades back to its normal colour
+@keyframes gf-flash-up {
+ 0%,
+ 35% {
+ color: var(--bs-success, #198754);
}
-
- 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 {
+ 0%,
+ 35% {
+ color: var(--bs-danger, #dc3545);
}
}
-@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);
- }
+.gf-flash-up {
+ animation: gf-flash-up 1.8s ease-out;
}
-// 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;
+.gf-flash-down {
+ animation: gf-flash-down 1.8s ease-out;
}
@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;
+ .gf-flash-up {
+ animation: none;
}
}
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 997041fa48..5d8d432960 100644
--- a/libs/ui/src/lib/accounts-table/accounts-table.component.html
+++ b/libs/ui/src/lib/accounts-table/accounts-table.component.html
@@ -375,7 +375,6 @@
*matRowDef="let row; columns: displayedColumns()"
mat-row
[class.cursor-pointer]="hasPermissionToOpenDetails()"
- [gfFlashOnChange]="row.valueInBaseCurrency"
(click)="onOpenAccountDetailDialog(row.id)"
>
|