mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
16 changed files with 346 additions and 3 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,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(); |
||||
|
}); |
||||
|
}); |
||||
@ -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 {} |
||||
|
} |
||||
Loading…
Reference in new issue