mirror of https://github.com/ghostfolio/ghostfolio
14 changed files with 216 additions and 0 deletions
@ -0,0 +1,8 @@ |
|||||
|
@if (isVisible()) { |
||||
|
<gf-toggle |
||||
|
[defaultValue]="user()?.settings?.dateRange ?? 'ytd'" |
||||
|
[isLoading]="isLoading()" |
||||
|
[options]="options()" |
||||
|
(valueChange)="onValueChange($event)" |
||||
|
/> |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
margin-bottom: 1rem; |
||||
|
max-width: 100%; |
||||
|
overflow-x: auto; |
||||
|
|
||||
|
gf-toggle { |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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']); |
||||
|
}); |
||||
|
}); |
||||
@ -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…
Reference in new issue