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] 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; +}