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