Browse Source

Merge 9bb1afe4ff into c2cb508f22

pull/8237/merge
Mister_Ideas 1 day ago
committed by GitHub
parent
commit
25af63e995
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      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. 39
      apps/client/src/app/services/user/user.service.ts
  14. 95
      libs/common/src/lib/date-range.helper.spec.ts
  15. 65
      libs/common/src/lib/date-range.helper.ts
  16. 11
      libs/ui/src/lib/services/data.service.ts

2
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

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';
@ -44,6 +45,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">

39
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<UserStoreState> {
() => 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<UserStoreState> {
);
}
this.restoreStoredDateRange(user);
this.setState({ user }, UserStoreActions.GetUser);
if (
@ -194,6 +199,38 @@ export class UserService extends ObservableStore<UserStoreState> {
);
}
/**
* 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;

95
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<string, string>();
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();
});
});

65
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<Storage, 'getItem'>) {
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<Storage, 'setItem'>
) {
try {
(storage ?? globalThis.localStorage)?.setItem(
STORED_DATE_RANGE_KEY,
dateRange
);
} catch {}
}

11
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<User>('/api/v1/user/setting', aData);
return this.http.put<User>('/api/v1/user/setting', aData).pipe(
tap(() => {
if (aData.dateRange) {
saveStoredDateRange(aData.dateRange);
}
})
);
}
public redeemCoupon(couponCode: string) {

Loading…
Cancel
Save