diff --git a/CHANGELOG.md b/CHANGELOG.md index 297677b966..636406cf92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,12 @@ 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 - Added a link to the holdings tab in the holdings table on the overview tab of the home page (experimental) ### Changed +- Persisted the selected date range on the device and restored it on page load - Improved the market price in the benchmark detail dialog by adding the currency and reducing the precision on mobile - Improved the style of the close button in the dialog footer component - Improved the style of the close button in the dialog header component diff --git a/apps/client/src/app/components/date-range-selector/date-range-selector.component.html b/apps/client/src/app/components/date-range-selector/date-range-selector.component.html new file mode 100644 index 0000000000..c58b5496a9 --- /dev/null +++ b/apps/client/src/app/components/date-range-selector/date-range-selector.component.html @@ -0,0 +1,8 @@ +@if (isVisible()) { + +} diff --git a/apps/client/src/app/components/date-range-selector/date-range-selector.component.scss b/apps/client/src/app/components/date-range-selector/date-range-selector.component.scss new file mode 100644 index 0000000000..1d6a487eaa --- /dev/null +++ b/apps/client/src/app/components/date-range-selector/date-range-selector.component.scss @@ -0,0 +1,11 @@ +:host { + display: block; + margin-bottom: 1rem; + max-width: 100%; + overflow-x: auto; + + gf-toggle { + display: flex; + justify-content: center; + } +} diff --git a/apps/client/src/app/components/date-range-selector/date-range-selector.component.ts b/apps/client/src/app/components/date-range-selector/date-range-selector.component.ts new file mode 100644 index 0000000000..e4301ea20b --- /dev/null +++ b/apps/client/src/app/components/date-range-selector/date-range-selector.component.ts @@ -0,0 +1,106 @@ +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { getAvailableDateRanges } from '@ghostfolio/common/date-range.helper'; +import { User } from '@ghostfolio/common/interfaces'; +import { DateRange, ToggleOption } from '@ghostfolio/common/types'; +import { DataService } from '@ghostfolio/ui/services'; +import { GfToggleComponent } from '@ghostfolio/ui/toggle'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + inject, + signal +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [GfToggleComponent], + selector: 'gf-date-range-selector', + styleUrls: ['./date-range-selector.component.scss'], + templateUrl: './date-range-selector.component.html' +}) +export class GfDateRangeSelectorComponent { + protected readonly isLoading = signal(false); + protected readonly user = signal(null); + + // The date range is fixed to `max` in the zen mode + protected readonly isVisible = computed(() => { + return !!this.user() && this.user()?.settings?.viewMode !== 'ZEN'; + }); + + protected readonly options = computed[]>(() => { + const dateRanges = getAvailableDateRanges({ + dateOfFirstActivity: this.user()?.dateOfFirstActivity + }); + + // Keep a range which is not part of the toggle, e.g. a past year selected + // in the assistant, visible as selected option + const selectedDateRange = this.user()?.settings?.dateRange; + + if (selectedDateRange && !dateRanges.includes(selectedDateRange)) { + dateRanges.splice(dateRanges.length - 1, 0, selectedDateRange); + } + + return dateRanges.map((value) => { + return { value, label: this.getLabel(value) }; + }); + }); + + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly userService = inject(UserService); + + public constructor() { + this.userService.stateChanged + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((state) => { + if (state?.user) { + this.user.set(state.user); + } + }); + } + + protected onValueChange({ value }: { value: DateRange }) { + this.isLoading.set(true); + + this.dataService + .putUserSetting({ dateRange: value }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + complete: () => { + this.userService + .get(true) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + complete: () => this.isLoading.set(false) + }); + }, + error: () => this.isLoading.set(false) + }); + } + + private getLabel(dateRange: DateRange) { + switch (dateRange) { + case '1d': + return $localize`Today`; + case 'wtd': + return $localize`WTD`; + case 'mtd': + return $localize`MTD`; + case 'ytd': + return $localize`YTD`; + case '1y': + return $localize`1Y`; + case '5y': + return $localize`5Y`; + case 'max': + return $localize`Max`; + default: + // A past year, e.g. 2025 + return dateRange; + } + } +} diff --git a/apps/client/src/app/components/home-holdings/home-holdings.component.ts b/apps/client/src/app/components/home-holdings/home-holdings.component.ts index 333a24d5d6..a3ff57501a 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.component.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.component.ts @@ -1,3 +1,4 @@ +import { GfDateRangeSelectorComponent } from '@ghostfolio/client/components/date-range-selector/date-range-selector.component'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_CURRENCY, @@ -43,6 +44,7 @@ import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + GfDateRangeSelectorComponent, GfHoldingsTableComponent, GfToggleComponent, GfTreemapChartComponent, diff --git a/apps/client/src/app/components/home-holdings/home-holdings.html b/apps/client/src/app/components/home-holdings/home-holdings.html index 7c533b2835..448285d867 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.html +++ b/apps/client/src/app/components/home-holdings/home-holdings.html @@ -2,6 +2,7 @@

Holdings

+
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 5e6601886c..9aef710cbc 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 @@ -1,3 +1,4 @@ +import { GfDateRangeSelectorComponent } from '@ghostfolio/client/components/date-range-selector/date-range-selector.component'; import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.component'; import { LayoutService } from '@ghostfolio/client/core/layout.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; @@ -44,6 +45,7 @@ import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + GfDateRangeSelectorComponent, GfHoldingsTableComponent, GfLineChartComponent, GfPortfolioPerformanceComponent, diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html index 0f9e1bcfae..93bc1c50af 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -66,6 +66,7 @@ } @else {
+

Activities

+

Analysis

+ @if (user?.settings?.isExperimentalFeatures) {
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 new file mode 100644 index 0000000000..88644cde13 --- /dev/null +++ b/libs/common/src/lib/date-range.helper.spec.ts @@ -0,0 +1,95 @@ +import { + getAvailableDateRanges, + getStoredDateRange, + saveStoredDateRange, + STORED_DATE_RANGE_KEY +} from './date-range.helper'; + +describe('getAvailableDateRanges', () => { + const referenceDate = new Date('2026-10-19T12:00:00.000Z'); + + it('returns the short ranges without any activity', () => { + expect(getAvailableDateRanges({ referenceDate })).toEqual([ + '1d', + 'wtd', + 'mtd', + 'ytd', + 'max' + ]); + }); + + it('returns the short ranges for less than one year of data', () => { + expect( + getAvailableDateRanges({ + referenceDate, + dateOfFirstActivity: new Date('2026-01-05T00:00:00.000Z') + }) + ).toEqual(['1d', 'wtd', 'mtd', 'ytd', 'max']); + }); + + it('adds 1y after one year of data', () => { + expect( + getAvailableDateRanges({ + referenceDate, + dateOfFirstActivity: new Date('2025-10-18T00:00:00.000Z') + }) + ).toEqual(['1d', 'wtd', 'mtd', 'ytd', '1y', 'max']); + }); + + it('adds 1y and 5y after five years of data', () => { + expect( + getAvailableDateRanges({ + referenceDate, + dateOfFirstActivity: '2021-10-18T00:00:00.000Z' + }) + ).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 new file mode 100644 index 0000000000..7f7d61a648 --- /dev/null +++ b/libs/common/src/lib/date-range.helper.ts @@ -0,0 +1,65 @@ +import { DateRange } from '@ghostfolio/common/types'; + +import { differenceInYears } from 'date-fns'; + +/** + * Returns the date ranges which are meaningful for a user depending on the + * date of the first activity (e.g. `1y` requires at least one year of data). + */ +export function getAvailableDateRanges({ + dateOfFirstActivity, + referenceDate = new Date() +}: { + dateOfFirstActivity?: Date | string | null; + referenceDate?: Date; +} = {}): DateRange[] { + const yearsOfData = dateOfFirstActivity + ? differenceInYears(referenceDate, new Date(dateOfFirstActivity)) + : 0; + + const dateRanges: DateRange[] = ['1d', 'wtd', 'mtd', 'ytd']; + + if (yearsOfData >= 1) { + dateRanges.push('1y'); + } + + if (yearsOfData >= 5) { + dateRanges.push('5y'); + } + + dateRanges.push('max'); + + 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 6292f8770a..ea24912716 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, @@ -88,7 +89,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 { @@ -963,7 +964,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) {