Browse Source

Feature/add date range selector to home and portfolio pages

pull/8237/head
mister-ideas 5 days ago
parent
commit
9d36074bbc
  1. 4
      CHANGELOG.md
  2. 8
      apps/client/src/app/components/date-range-selector/date-range-selector.component.html
  3. 11
      apps/client/src/app/components/date-range-selector/date-range-selector.component.scss
  4. 106
      apps/client/src/app/components/date-range-selector/date-range-selector.component.ts
  5. 2
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  6. 1
      apps/client/src/app/components/home-holdings/home-holdings.html
  7. 2
      apps/client/src/app/components/home-overview/home-overview.component.ts
  8. 1
      apps/client/src/app/components/home-overview/home-overview.html
  9. 2
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  10. 1
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  11. 2
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  12. 1
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  13. 42
      libs/common/src/lib/date-range.helper.spec.ts
  14. 33
      libs/common/src/lib/date-range.helper.ts

4
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

8
apps/client/src/app/components/date-range-selector/date-range-selector.component.html

@ -0,0 +1,8 @@
@if (isVisible()) {
<gf-toggle
[defaultValue]="user()?.settings?.dateRange ?? 'ytd'"
[isLoading]="isLoading()"
[options]="options()"
(valueChange)="onValueChange($event)"
/>
}

11
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;
}
}

106
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<User | null>(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<ToggleOption<DateRange>[]>(() => {
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;
}
}
}

2
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,

1
apps/client/src/app/components/home-holdings/home-holdings.html

@ -2,6 +2,7 @@
<div class="row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Holdings</h1>
<gf-date-range-selector />
</div>
</div>
<div class="row">

2
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,

1
apps/client/src/app/components/home-overview/home-overview.html

@ -66,6 +66,7 @@
} @else {
<div class="align-items-stretch flex-nowrap row w-100">
<div class="col-12 col-xl p-0">
<gf-date-range-selector />
<div class="chart-container mx-auto position-relative">
<gf-line-chart
class="position-absolute"

2
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -1,3 +1,4 @@
import { GfDateRangeSelectorComponent } from '@ghostfolio/client/components/date-range-selector/date-range-selector.component';
import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -43,6 +44,7 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GfActivitiesTableComponent,
GfDateRangeSelectorComponent,
GfFabComponent,
MatSnackBarModule,
RouterModule

1
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -2,6 +2,7 @@
<div class="row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Activities</h1>
<gf-date-range-selector />
<gf-activities-table
[activityTypes]="user?.activityTypes"
[baseCurrency]="user?.settings?.baseCurrency"

2
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -1,4 +1,5 @@
import { GfBenchmarkComparatorComponent } from '@ghostfolio/client/components/benchmark-comparator/benchmark-comparator.component';
import { GfDateRangeSelectorComponent } from '@ghostfolio/client/components/date-range-selector/date-range-selector.component';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_DATE_RANGE,
@ -62,6 +63,7 @@ import { forkJoin } from 'rxjs';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GfBenchmarkComparatorComponent,
GfDateRangeSelectorComponent,
GfInvestmentChartComponent,
GfPremiumIndicatorComponent,
GfToggleComponent,

1
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -1,5 +1,6 @@
<div class="container">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Analysis</h1>
<gf-date-range-selector />
@if (user?.settings?.isExperimentalFeatures) {
<div class="mb-3 row">
<div class="col-lg">

42
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']);
});
});

33
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;
}
Loading…
Cancel
Save