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