mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
31 changed files with 989 additions and 92 deletions
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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()) { |
||||
|
<span |
||||
|
class="align-items-center d-flex" |
||||
|
role="status" |
||||
|
[attr.aria-label]="title()" |
||||
|
[title]="title()" |
||||
|
> |
||||
|
<span #dot class="dot"></span> |
||||
|
</span> |
||||
|
} |
||||
|
` |
||||
|
}) |
||||
|
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<ElementRef<HTMLElement>>('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'); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,88 @@ |
|||||
|
import { |
||||
|
AUTO_REFRESH_INTERVAL_MS, |
||||
|
AUTO_REFRESH_INTERVAL_STORAGE_KEY, |
||||
|
AutoRefreshService, |
||||
|
getAutoRefreshInterval |
||||
|
} from './auto-refresh.service'; |
||||
|
|
||||
|
describe('AutoRefreshService', () => { |
||||
|
const setVisibility = (state: 'hidden' | 'visible') => { |
||||
|
Object.defineProperty(document, 'visibilityState', { |
||||
|
configurable: true, |
||||
|
get: () => state |
||||
|
}); |
||||
|
document.dispatchEvent(new Event('visibilitychange')); |
||||
|
}; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
jest.useFakeTimers(); |
||||
|
setVisibility('visible'); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
jest.useRealTimers(); |
||||
|
}); |
||||
|
|
||||
|
it('emits at the interval while the page is visible', () => { |
||||
|
const service = new AutoRefreshService(); |
||||
|
const next = jest.fn(); |
||||
|
const subscription = service.refresh$.subscribe(next); |
||||
|
|
||||
|
jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS - 1); |
||||
|
expect(next).not.toHaveBeenCalled(); |
||||
|
|
||||
|
jest.advanceTimersByTime(1); |
||||
|
expect(next).toHaveBeenCalledTimes(1); |
||||
|
|
||||
|
jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS * 2); |
||||
|
expect(next).toHaveBeenCalledTimes(3); |
||||
|
|
||||
|
subscription.unsubscribe(); |
||||
|
}); |
||||
|
|
||||
|
it('pauses while hidden and refreshes immediately when visible again', () => { |
||||
|
const service = new AutoRefreshService(); |
||||
|
const next = jest.fn(); |
||||
|
const subscription = service.refresh$.subscribe(next); |
||||
|
|
||||
|
setVisibility('hidden'); |
||||
|
jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS * 3); |
||||
|
expect(next).not.toHaveBeenCalled(); |
||||
|
|
||||
|
setVisibility('visible'); |
||||
|
jest.advanceTimersByTime(0); |
||||
|
expect(next).toHaveBeenCalledTimes(1); |
||||
|
|
||||
|
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 |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,75 @@ |
|||||
|
import { Service, signal } from '@angular/core'; |
||||
|
import { EMPTY, fromEvent, Observable, timer } from 'rxjs'; |
||||
|
import { |
||||
|
distinctUntilChanged, |
||||
|
map, |
||||
|
share, |
||||
|
startWith, |
||||
|
switchMap |
||||
|
} from 'rxjs/operators'; |
||||
|
|
||||
|
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<Storage, 'getItem'> |
||||
|
): 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 |
||||
|
* refresh their numbers silently. The timer stops while the tab is hidden |
||||
|
* and fires once immediately when the tab becomes visible again. |
||||
|
*/ |
||||
|
@Service() |
||||
|
export class AutoRefreshService { |
||||
|
public readonly lastUpdated = signal<Date | null>(null); |
||||
|
public readonly refresh$: Observable<void>; |
||||
|
|
||||
|
public constructor() { |
||||
|
let isFirstActivation = true; |
||||
|
|
||||
|
this.refresh$ = fromEvent(document, 'visibilitychange').pipe( |
||||
|
startWith(undefined), |
||||
|
map(() => document.visibilityState === 'visible'), |
||||
|
distinctUntilChanged(), |
||||
|
switchMap((isVisible) => { |
||||
|
if (!isVisible) { |
||||
|
return EMPTY; |
||||
|
} |
||||
|
|
||||
|
const interval = getAutoRefreshInterval(); |
||||
|
const dueTime = isFirstActivation ? interval : 0; |
||||
|
|
||||
|
isFirstActivation = false; |
||||
|
|
||||
|
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()); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
}); |
||||
|
}); |
||||
@ -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 } |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
export * from './flash-on-change.directive'; |
||||
@ -0,0 +1,51 @@ |
|||||
|
import { getValueChangeDirection } from './value-change.helper'; |
||||
|
|
||||
|
describe('getValueChangeDirection', () => { |
||||
|
it('detects an increase', () => { |
||||
|
expect( |
||||
|
getValueChangeDirection({ |
||||
|
currentValue: 101, |
||||
|
digits: 2, |
||||
|
previousValue: 100 |
||||
|
}) |
||||
|
).toBe('up'); |
||||
|
}); |
||||
|
|
||||
|
it('detects a decrease', () => { |
||||
|
expect( |
||||
|
getValueChangeDirection({ |
||||
|
currentValue: 99.5, |
||||
|
digits: 2, |
||||
|
previousValue: 100 |
||||
|
}) |
||||
|
).toBe('down'); |
||||
|
}); |
||||
|
|
||||
|
it('ignores changes below the displayed precision', () => { |
||||
|
expect( |
||||
|
getValueChangeDirection({ |
||||
|
currentValue: 100.001, |
||||
|
digits: 2, |
||||
|
previousValue: 100 |
||||
|
}) |
||||
|
).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('ignores values which are not finite numbers', () => { |
||||
|
expect( |
||||
|
getValueChangeDirection({ |
||||
|
currentValue: NaN, |
||||
|
digits: 2, |
||||
|
previousValue: 100 |
||||
|
}) |
||||
|
).toBeUndefined(); |
||||
|
|
||||
|
expect( |
||||
|
getValueChangeDirection({ |
||||
|
currentValue: '101', |
||||
|
digits: 2, |
||||
|
previousValue: 100 |
||||
|
}) |
||||
|
).toBeUndefined(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,32 @@ |
|||||
|
/** |
||||
|
* Returns the direction in which a displayed number has changed, or |
||||
|
* `undefined` if it is unchanged at the displayed precision. |
||||
|
*/ |
||||
|
export function getValueChangeDirection({ |
||||
|
currentValue, |
||||
|
digits, |
||||
|
previousValue |
||||
|
}: { |
||||
|
currentValue: unknown; |
||||
|
digits: number; |
||||
|
previousValue: unknown; |
||||
|
}): 'down' | 'up' | undefined { |
||||
|
if ( |
||||
|
typeof currentValue !== 'number' || |
||||
|
typeof previousValue !== 'number' || |
||||
|
!Number.isFinite(currentValue) || |
||||
|
!Number.isFinite(previousValue) |
||||
|
) { |
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
const factor = 10 ** digits; |
||||
|
const difference = |
||||
|
Math.round(currentValue * factor) - Math.round(previousValue * factor); |
||||
|
|
||||
|
if (difference === 0) { |
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
return difference > 0 ? 'up' : 'down'; |
||||
|
} |
||||
Loading…
Reference in new issue