From 9d36074bbcffbda5aedae844501ccf34bd08d2cf Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:00:35 +0200 Subject: [PATCH 1/3] Feature/add date range selector to home and portfolio pages --- CHANGELOG.md | 4 + .../date-range-selector.component.html | 8 ++ .../date-range-selector.component.scss | 11 ++ .../date-range-selector.component.ts | 106 ++++++++++++++++++ .../home-holdings/home-holdings.component.ts | 2 + .../home-holdings/home-holdings.html | 1 + .../home-overview/home-overview.component.ts | 2 + .../home-overview/home-overview.html | 1 + .../activities/activities-page.component.ts | 2 + .../portfolio/activities/activities-page.html | 1 + .../analysis/analysis-page.component.ts | 2 + .../portfolio/analysis/analysis-page.html | 1 + libs/common/src/lib/date-range.helper.spec.ts | 42 +++++++ libs/common/src/lib/date-range.helper.ts | 33 ++++++ 14 files changed, 216 insertions(+) create mode 100644 apps/client/src/app/components/date-range-selector/date-range-selector.component.html create mode 100644 apps/client/src/app/components/date-range-selector/date-range-selector.component.scss create mode 100644 apps/client/src/app/components/date-range-selector/date-range-selector.component.ts create mode 100644 libs/common/src/lib/date-range.helper.spec.ts create mode 100644 libs/common/src/lib/date-range.helper.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e35621f0b..40114ded92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added a date range selector to the home and the portfolio pages + ### Changed - Improved the usability of the activities page by eliminating the reload on canceling the import activities dialog 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 c7b880efce..d43e4482ee 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'; @@ -41,6 +42,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 64789ddc7d..e999d9c1bf 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/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..5798fba7b4 --- /dev/null +++ b/libs/common/src/lib/date-range.helper.spec.ts @@ -0,0 +1,42 @@ +import { getAvailableDateRanges } 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']); + }); +}); 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..a8085856be --- /dev/null +++ b/libs/common/src/lib/date-range.helper.ts @@ -0,0 +1,33 @@ +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; +} From 8a5379912e797544d634a1462e60d4f1fe6df98e Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:46:39 +0200 Subject: [PATCH 2/3] Feature/persist the selected date range on the device --- CHANGELOG.md | 1 + .../src/app/services/user/user.service.ts | 39 ++++++++++++- libs/common/src/lib/date-range.helper.spec.ts | 55 ++++++++++++++++++- libs/common/src/lib/date-range.helper.ts | 32 +++++++++++ libs/ui/src/lib/services/data.service.ts | 11 +++- 5 files changed, 134 insertions(+), 4 deletions(-) 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) { From 9bb1afe4ff4c8c52ae4c1ec890dc7cdf6386413e Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:43:47 +0200 Subject: [PATCH 3/3] Move the changelog entry to the unreleased section --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd450a39e1..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 @@ -36,8 +38,6 @@ 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 - Added a _Storybook_ story for the file drop area component ### Changed