diff --git a/CHANGELOG.md b/CHANGELOG.md index 40114ded92..c5c39600a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a date range selector to the home and the portfolio pages +- Persisted the selected date range on the device and restored it on page load ### Changed diff --git a/apps/client/src/app/services/user/user.service.ts b/apps/client/src/app/services/user/user.service.ts index 2fc6d399a6..7a0c0a60b0 100644 --- a/apps/client/src/app/services/user/user.service.ts +++ b/apps/client/src/app/services/user/user.service.ts @@ -1,4 +1,5 @@ import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; +import { getStoredDateRange } from '@ghostfolio/common/date-range.helper'; import { Filter, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -9,7 +10,7 @@ import { MatDialog } from '@angular/material/dialog'; import { ObservableStore } from '@codewithdan/observable-store'; import { parseISO } from 'date-fns'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { Observable, of } from 'rxjs'; +import { EMPTY, Observable, of } from 'rxjs'; import { throwError } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; @@ -24,6 +25,8 @@ export class UserService extends ObservableStore { () => this.deviceDetectorService.deviceInfo().deviceType ); + private hasRestoredDateRange = false; + private readonly destroyRef = inject(DestroyRef); private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly dialog = inject(MatDialog); @@ -161,6 +164,8 @@ export class UserService extends ObservableStore { ); } + this.restoreStoredDateRange(user); + this.setState({ user }, UserStoreActions.GetUser); if ( @@ -194,6 +199,38 @@ export class UserService extends ObservableStore { ); } + /** + * Restores the date range selected last on this device once per page load, + * e.g. if the stored user setting has been changed from another device. + */ + private restoreStoredDateRange(user: User) { + if (this.hasRestoredDateRange || !user.settings) { + return; + } + + this.hasRestoredDateRange = true; + + const storedDateRange = getStoredDateRange(); + + if ( + !storedDateRange || + storedDateRange === user.settings.dateRange || + user.settings.viewMode === 'ZEN' + ) { + return; + } + + user.settings.dateRange = storedDateRange; + + this.http + .put('/api/v1/user/setting', { dateRange: storedDateRange }) + .pipe( + catchError(() => EMPTY), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); + } + private handleError(error: any) { if (error.error instanceof Error) { const errMessage = error.error.message; diff --git a/libs/common/src/lib/date-range.helper.spec.ts b/libs/common/src/lib/date-range.helper.spec.ts index 5798fba7b4..88644cde13 100644 --- a/libs/common/src/lib/date-range.helper.spec.ts +++ b/libs/common/src/lib/date-range.helper.spec.ts @@ -1,4 +1,9 @@ -import { getAvailableDateRanges } from './date-range.helper'; +import { + getAvailableDateRanges, + getStoredDateRange, + saveStoredDateRange, + STORED_DATE_RANGE_KEY +} from './date-range.helper'; describe('getAvailableDateRanges', () => { const referenceDate = new Date('2026-10-19T12:00:00.000Z'); @@ -40,3 +45,51 @@ describe('getAvailableDateRanges', () => { ).toEqual(['1d', 'wtd', 'mtd', 'ytd', '1y', '5y', 'max']); }); }); + +describe('stored date range', () => { + const createStorage = (initial?: string) => { + const items = new Map(); + + if (initial !== undefined) { + items.set(STORED_DATE_RANGE_KEY, initial); + } + + return { + getItem: (key: string) => items.get(key) ?? null, + setItem: (key: string, value: string) => { + items.set(key, value); + } + }; + }; + + it('returns undefined without a stored value', () => { + expect(getStoredDateRange(createStorage())).toBeUndefined(); + }); + + it('returns a valid stored value', () => { + expect(getStoredDateRange(createStorage('ytd'))).toBe('ytd'); + expect(getStoredDateRange(createStorage('2025'))).toBe('2025'); + }); + + it('ignores an invalid stored value', () => { + expect(getStoredDateRange(createStorage('forever'))).toBeUndefined(); + }); + + it('round-trips a saved value', () => { + const storage = createStorage(); + + saveStoredDateRange('mtd', storage); + + expect(getStoredDateRange(storage)).toBe('mtd'); + }); + + it('does not throw if the storage is not available', () => { + expect( + getStoredDateRange({ + getItem: () => { + throw new Error('denied'); + } + }) + ).toBeUndefined(); + }); +}); diff --git a/libs/common/src/lib/date-range.helper.ts b/libs/common/src/lib/date-range.helper.ts index a8085856be..7f7d61a648 100644 --- a/libs/common/src/lib/date-range.helper.ts +++ b/libs/common/src/lib/date-range.helper.ts @@ -31,3 +31,35 @@ export function getAvailableDateRanges({ return dateRanges; } + +export const STORED_DATE_RANGE_KEY = 'gf-date-range'; + +/** + * Returns the date range which has been selected last on this device + * (e.g. `ytd` or a year like `2025`), if any. + */ +export function getStoredDateRange(storage?: Pick) { + try { + const value = (storage ?? globalThis.localStorage)?.getItem( + STORED_DATE_RANGE_KEY + ); + + return value && /^(1d|wtd|mtd|ytd|1y|5y|max|\d{4})$/.test(value) + ? (value as DateRange) + : undefined; + } catch { + return undefined; + } +} + +export function saveStoredDateRange( + dateRange: DateRange, + storage?: Pick +) { + try { + (storage ?? globalThis.localStorage)?.setItem( + STORED_DATE_RANGE_KEY, + dateRange + ); + } catch {} +} diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index a74dabded0..cd891aa898 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -1,3 +1,4 @@ +import { saveStoredDateRange } from '@ghostfolio/common/date-range.helper'; import { CreateAccessDto, CreateAccountBalanceDto, @@ -86,7 +87,7 @@ import { utc } from '@date-fns/utc'; import { format, parseISO } from 'date-fns'; import { cloneDeep, groupBy, isNumber } from 'lodash-es'; import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { map, tap } from 'rxjs/operators'; @Service() export class DataService { @@ -958,7 +959,13 @@ export class DataService { } public putUserSetting(aData: UpdateUserSettingDto) { - return this.http.put('/api/v1/user/setting', aData); + return this.http.put('/api/v1/user/setting', aData).pipe( + tap(() => { + if (aData.dateRange) { + saveStoredDateRange(aData.dateRange); + } + }) + ); } public redeemCoupon(couponCode: string) {