Browse Source

Merge bccbca05d2 into c2cb508f22

pull/8267/merge
Mister_Ideas 1 day ago
committed by GitHub
parent
commit
00f787d42f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      CHANGELOG.md
  2. 57
      apps/client/src/app/components/home-overview/home-overview.component.ts
  3. 5
      apps/client/src/app/components/home-overview/home-overview.html
  4. 141
      libs/common/src/lib/chart-helper.spec.ts
  5. 262
      libs/common/src/lib/chart-helper.ts
  6. 36
      libs/ui/src/lib/line-chart/line-chart.component.ts

3
CHANGELOG.md

@ -9,10 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a time axis to the performance chart on the overview page which only contains the trading days, spans the whole current year and uses hours, days, months or years as unit according to the selected date range
- Added a link to the holdings tab in the holdings table on the overview tab of the home page (experimental) - Added a link to the holdings tab in the holdings table on the overview tab of the home page (experimental)
### Changed ### Changed
- Hid the performance chart on the overview page if it has less than three data points
- Reduced the performance chart on the overview page to one point per week above 400 trading days
- Improved the market price in the benchmark detail dialog by adding the currency and reducing the precision on mobile - 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 footer component
- Improved the style of the close button in the dialog header component - Improved the style of the close button in the dialog header component

57
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -2,6 +2,7 @@ import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/p
import { LayoutService } from '@ghostfolio/client/core/layout.service'; import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getTradingDayItems } from '@ghostfolio/common/chart-helper';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
@ -11,7 +12,8 @@ import {
import { isCashPosition } from '@ghostfolio/common/helper'; import { isCashPosition } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
LineChartItem, HistoricalDataItem,
NullableLineChartItem,
PortfolioPerformance, PortfolioPerformance,
PortfolioPosition, PortfolioPosition,
User User
@ -19,6 +21,7 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { DateRange } from '@ghostfolio/common/types';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -41,6 +44,8 @@ import { addIcons } from 'ionicons';
import { arrowForwardOutline } from 'ionicons/icons'; import { arrowForwardOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
const MINIMUM_CHART_ITEMS = 3;
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
@ -59,7 +64,20 @@ import { DeviceDetectorService } from 'ngx-device-detector';
export class GfHomeOverviewComponent implements OnInit { export class GfHomeOverviewComponent implements OnInit {
protected readonly errors = signal<AssetProfileIdentifier[]>([]); protected readonly errors = signal<AssetProfileIdentifier[]>([]);
protected readonly hasImpersonationId = signal(false); protected readonly hasImpersonationId = signal(false);
protected readonly historicalDataItems = signal<LineChartItem[] | null>(null); protected readonly defaultDateRange = DEFAULT_DATE_RANGE;
protected readonly historicalDataItems = signal<
NullableLineChartItem[] | null
>(null);
// A line through only two points (e.g. the close of the previous day and
// now) is drawn as a misleading slope across the whole width
protected readonly hasEnoughChartData = computed(() => {
return (
(this.historicalDataItems() ?? []).filter(({ value }) => {
return value !== null;
}).length >= MINIMUM_CHART_ITEMS
);
});
protected readonly holdings = signal<PortfolioPosition[] | undefined>( protected readonly holdings = signal<PortfolioPosition[] | undefined>(
undefined undefined
); );
@ -171,6 +189,30 @@ export class GfHomeOverviewComponent implements OnInit {
} }
} }
private setChartItems({
chart,
dateRange
}: {
chart?: HistoricalDataItem[];
dateRange?: DateRange;
}) {
// Only the trading days, and the remaining ones of the current year
this.historicalDataItems.set(
getTradingDayItems({
dateRange,
items:
chart?.map(
({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return {
date,
value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
};
}
) ?? []
})
);
}
private update() { private update() {
const userSettings = this.user()?.settings; const userSettings = this.user()?.settings;
@ -210,16 +252,7 @@ export class GfHomeOverviewComponent implements OnInit {
this.errors.set(errors ?? []); this.errors.set(errors ?? []);
this.performance.set(performance); this.performance.set(performance);
this.historicalDataItems.set( this.setChartItems({ chart, dateRange: userSettings?.dateRange });
chart?.map(
({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return {
date,
value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
};
}
) ?? null
);
this.precision.set(2); this.precision.set(2);

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

@ -72,14 +72,15 @@
unit="%" unit="%"
[class.pe-3]="deviceType() === 'mobile'" [class.pe-3]="deviceType() === 'mobile'"
[colorScheme]="user()?.settings?.colorScheme" [colorScheme]="user()?.settings?.colorScheme"
[hidden]="historicalDataItems()?.length === 0" [dateRange]="user()?.settings?.dateRange ?? defaultDateRange"
[hidden]="!hasEnoughChartData()"
[historicalDataItems]="historicalDataItems()" [historicalDataItems]="historicalDataItems()"
[isAnimated]="user()?.settings?.dateRange === '1d' ? false : true" [isAnimated]="user()?.settings?.dateRange === '1d' ? false : true"
[label]="performanceLabel" [label]="performanceLabel"
[locale]="locale()" [locale]="locale()"
[showGradient]="true" [showGradient]="true"
[showLoader]="false" [showLoader]="false"
[showXAxis]="false" [showXAxis]="true"
[showYAxis]="false" [showYAxis]="false"
/> />
</div> </div>

141
libs/common/src/lib/chart-helper.spec.ts

@ -0,0 +1,141 @@
import { addDays, format } from 'date-fns';
import {
getTimeAxisUnit,
getTimeSeriesTickCallback,
getTradingDayItems
} from './chart-helper';
import { DATE_FORMAT } from './helper';
describe('getTimeAxisUnit', () => {
it.each([
['1d', 'hour'],
['wtd', 'day'],
['mtd', 'day'],
['ytd', 'month'],
['1y', false],
['2025', 'month'],
['5y', false],
['max', false],
[undefined, 'year']
])('uses the unit of %s', (dateRange, unit) => {
expect(getTimeAxisUnit(dateRange)).toBe(unit);
});
});
describe('getTradingDayItems', () => {
// Thursday
const now = new Date(2026, 11, 24);
it('skips the weekends', () => {
const items = getTradingDayItems({
dateRange: '1y',
now,
items: [
{ date: '2026-12-18', value: 1 },
{ date: '2026-12-19', value: 1 },
{ date: '2026-12-20', value: 1 },
{ date: '2026-12-21', value: 2 }
]
});
expect(items.map(({ date }) => date)).toEqual(['2026-12-18', '2026-12-21']);
});
it('adds the remaining trading days of the year', () => {
const items = getTradingDayItems({
dateRange: 'ytd',
now,
items: [{ date: '2026-12-24', value: 1 }]
});
expect(items).toEqual([
{ date: '2026-12-24', value: 1 },
{ date: '2026-12-25', value: null },
{ date: '2026-12-28', value: null },
{ date: '2026-12-29', value: null },
{ date: '2026-12-30', value: null },
{ date: '2026-12-31', value: null }
]);
});
});
describe('getTimeSeriesTickCallback', () => {
it('labels the first point of each hour', () => {
const ticks = [
new Date(2026, 9, 22, 9, 0),
new Date(2026, 9, 22, 9, 30),
new Date(2026, 9, 22, 10, 0),
new Date(2026, 9, 22, 10, 30)
].map((date) => {
return { value: date.getTime() };
});
const callback = getTimeSeriesTickCallback('hour');
expect(
ticks.map(({ value }, index) => {
return callback(value, index, ticks);
})
).toEqual(['09:00', '', '10:00', '']);
});
it('labels the start of each trading day, also after a post-market past midnight', () => {
const ticks = [
new Date(2026, 9, 22, 21, 0),
new Date(2026, 9, 22, 23, 0),
new Date(2026, 9, 23, 1, 0),
new Date(2026, 9, 23, 9, 0)
].map((date) => {
return { value: date.getTime() };
});
const callback = getTimeSeriesTickCallback('day');
expect(
ticks.map(({ value }, index) => {
return callback(value, index, ticks);
})
).toEqual(['Thu 22', '', '', 'Fri 23']);
});
it('thins out the labels', () => {
const ticks = Array.from({ length: 20 }, (_, index) => {
return { value: new Date(2026, 0, 1 + index).getTime() };
});
const callback = getTimeSeriesTickCallback('day', 5);
const labels = ticks
.map(({ value }, index) => {
return callback(value, index, ticks);
})
.filter(Boolean);
expect(labels.length).toBeLessThanOrEqual(5);
});
});
describe('getTradingDayItems with many items', () => {
it('keeps one point per week (the last trading day)', () => {
const items = Array.from({ length: 28 }, (_, index) => {
return {
date: format(addDays(new Date(2026, 0, 5), index), DATE_FORMAT),
value: index
};
});
const result = getTradingDayItems({ dateRange: '1y', items, maxItems: 10 });
expect(
result.map(({ date }) => {
return date;
})
).toEqual([
'2026-01-05',
'2026-01-09',
'2026-01-16',
'2026-01-23',
'2026-01-30'
]);
});
});

262
libs/common/src/lib/chart-helper.ts

@ -12,8 +12,9 @@ import type {
TooltipPosition TooltipPosition
} from 'chart.js'; } from 'chart.js';
import type { AnnotationOptions } from 'chartjs-plugin-annotation'; import type { AnnotationOptions } from 'chartjs-plugin-annotation';
import { format } from 'date-fns'; import { addDays, format, startOfWeek } from 'date-fns';
import { secondaryColorRgb } from './config';
import { import {
DATE_FORMAT, DATE_FORMAT,
DATE_FORMAT_MONTHLY, DATE_FORMAT_MONTHLY,
@ -21,9 +22,10 @@ import {
getBackgroundColor, getBackgroundColor,
getDateFormatString, getDateFormatString,
getLocale, getLocale,
getTextColor getTextColor,
parseDate
} from './helper'; } from './helper';
import { ColorScheme, GroupBy } from './types'; import { ColorScheme, DateRange, GroupBy } from './types';
export function formatGroupedDate({ export function formatGroupedDate({
date, date,
@ -41,6 +43,21 @@ export function formatGroupedDate({
return format(date, DATE_FORMAT); return format(date, DATE_FORMAT);
} }
// Colors for the lines of the compared benchmarks, the portfolio uses the
// primary color
const BENCHMARK_COLORS = [
`rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
'rgb(255, 159, 64)',
'rgb(75, 192, 112)',
'rgb(153, 102, 255)',
'rgb(255, 99, 132)',
'rgb(201, 203, 207)'
];
export function getBenchmarkColor(index: number) {
return BENCHMARK_COLORS[index % BENCHMARK_COLORS.length];
}
export function getChartBorderColor(colorScheme?: ColorScheme) { export function getChartBorderColor(colorScheme?: ColorScheme) {
return `rgba(${getTextColor(colorScheme)}, 0.1)`; return `rgba(${getTextColor(colorScheme)}, 0.1)`;
} }
@ -60,17 +77,221 @@ export function getChartElementsOptions(
}; };
} }
/**
* Returns the unit of the ticks of the time axis, so that the labels fit the
* length of the date range: days up to a month, months up to a year and years
* above
*/
/**
* Returns the daily items of the trading days (Monday to Friday) and, for a
* date range with a fixed end (the current year), the remaining trading days
* without a value, so that the chart shows the whole period
*/
function getWeekKey(aDate?: Date) {
return aDate
? format(startOfWeek(aDate, { weekStartsOn: 1 }), DATE_FORMAT)
: '';
}
export function getTradingDayItems<T extends { date: string }>({
dateRange,
items,
maxItems = 400,
now = new Date()
}: {
dateRange?: DateRange;
items: T[];
/** Above, one point per week (the last trading day) is kept */
maxItems?: number;
now?: Date;
}): (T | { date: string; value: null })[] {
const isWeekend = (aDate: Date) => {
return aDate.getDay() === 0 || aDate.getDay() === 6;
};
const result: (T | { date: string; value: null })[] = items.filter(
({ date }) => {
const parsedDate = parseDate(date);
return !parsedDate || !isWeekend(parsedDate);
}
);
if (result.length > maxItems) {
const weekly = result.filter(({ date }, index) => {
const next = result[index + 1];
return (
index === 0 ||
!next ||
getWeekKey(parseDate(date)) !== getWeekKey(parseDate(next.date))
);
});
result.splice(0, result.length, ...weekly);
}
let end: Date | undefined;
let start: Date | undefined;
if (dateRange === 'ytd') {
end = new Date(now.getFullYear(), 11, 31);
start = new Date(now.getFullYear(), 0, 1);
} else if (/^\d{4}$/.test(dateRange ?? '')) {
end = new Date(Number(dateRange), 11, 31);
start = new Date(Number(dateRange), 0, 1);
}
const firstDate = result[0] ? parseDate(result[0].date) : undefined;
if (start && firstDate && firstDate < start) {
// The baseline (the close of the previous year) is shown at the start
result[0] = { ...result[0], date: format(start, DATE_FORMAT) };
}
const lastDate = items.at(-1)?.date;
const last = lastDate ? parseDate(lastDate) : undefined;
if (end && last) {
for (
let date = addDays(last, 1);
date.getTime() <= end.getTime();
date = addDays(date, 1)
) {
if (!isWeekend(date)) {
result.push({ date: format(date, DATE_FORMAT), value: null });
}
}
}
return result;
}
export function getTimeAxisUnit(
aDateRange?: DateRange
): 'day' | 'hour' | 'month' | 'year' | false {
switch (aDateRange) {
case undefined:
return 'year';
case '1d':
return 'hour';
case 'mtd':
case 'wtd':
return 'day';
case '1y':
case '5y':
case 'max':
// Chosen by the chart according to the length of the period
return false;
default:
// 'ytd' and single years
return 'month';
}
}
const TIME_SERIES_LABEL_FORMATS = {
day: 'EEE d',
hour: 'HH:mm',
month: 'MMM',
year: 'yyyy'
} as const;
/**
* Returns the labels of an axis with equidistant points (without the closed
* markets): a label at the first point of a period (hour, day, month or
* year), thinned out to at most a few labels
*/
export function getTimeSeriesTickCallback(
aUnit: 'day' | 'hour' | 'month' | 'year' | false,
aMaxLabels = 7
) {
let cache: { key: string; labels: Map<number, string> } | undefined;
return (
_value: number | string,
index: number,
ticks: { value: number }[]
) => {
const key = `${ticks.length}-${ticks[0]?.value}-${ticks.at(-1)?.value}`;
if (cache?.key !== key) {
const span = (ticks.at(-1)?.value ?? 0) - (ticks[0]?.value ?? 0);
const unit =
aUnit ||
(span > 3 * 365 * 24 * 60 * 60 * 1000
? 'year'
: span > 45 * 24 * 60 * 60 * 1000
? 'month'
: 'day');
const getPeriod = (aTime: number) => {
const date = new Date(aTime);
switch (unit) {
case 'hour':
return `${date.toDateString()} ${date.getHours()}`;
case 'month':
return `${date.getFullYear()}-${date.getMonth()}`;
default:
return `${date.getFullYear()}`;
}
};
// The first point of each period
const starts: number[] = [];
ticks.forEach(({ value }, tickIndex) => {
const previous = ticks[tickIndex - 1]?.value;
if (
tickIndex === 0 ||
(unit === 'day'
? // A trading day starts after the closed market (the post-market
// can last after midnight)
value - previous > 2 * 60 * 60 * 1000
: getPeriod(value) !== getPeriod(previous))
) {
starts.push(tickIndex);
}
});
const step = Math.max(1, Math.ceil(starts.length / aMaxLabels));
const labels = new Map<number, string>();
starts.forEach((tickIndex, startIndex) => {
if (startIndex % step === 0) {
labels.set(
tickIndex,
format(ticks[tickIndex].value, TIME_SERIES_LABEL_FORMATS[unit])
);
}
});
cache = { key, labels };
}
return cache.labels.get(index) ?? '';
};
}
export function getTimeAxisOptions({ export function getTimeAxisOptions({
borderWidth = 1, borderWidth = 1,
colorScheme, colorScheme,
display = true, display = true,
locale = getLocale() locale = getLocale(),
type = 'time',
unit = 'year',
withTime = false
}: { }: {
borderWidth?: number; borderWidth?: number;
colorScheme?: ColorScheme; colorScheme?: ColorScheme;
display?: boolean; display?: boolean;
locale?: string; locale?: string;
}): ScaleOptions<'time'> { type?: 'time' | 'timeseries';
unit?: 'day' | 'hour' | 'month' | 'year' | false;
/** Whether the points are intraday (the tooltip shows the time) */
withTime?: boolean;
}): ScaleOptions<'time' | 'timeseries'> {
return { return {
display, display,
border: { border: {
@ -80,11 +301,36 @@ export function getTimeAxisOptions({
grid: { grid: {
display: false display: false
}, },
ticks:
type === 'timeseries'
? {
// A label at the first point of an hour, day, month or year
autoSkip: false,
callback: getTimeSeriesTickCallback(unit),
maxRotation: 0,
source: 'data'
}
: {
autoSkip: true,
maxRotation: 0,
maxTicksLimit: 6
},
time: { time: {
tooltipFormat: getDateFormatString(locale), tooltipFormat: withTime
unit: 'year' ? unit === 'hour'
? 'HH:mm'
: `${getDateFormatString(locale)} HH:mm`
: getDateFormatString(locale),
displayFormats: {
day: 'EEE d',
hour: 'HH:mm',
month: unit === 'month' ? 'MMM' : 'MMM yy',
week: 'd MMM',
year: 'yyyy'
},
unit
}, },
type: 'time' type
}; };
} }

36
libs/ui/src/lib/line-chart/line-chart.component.ts

@ -1,16 +1,14 @@
import { import {
getChartBorderColor, getChartBorderColor,
getTimeAxisOptions, getTimeAxisOptions,
getTimeAxisUnit,
getValueAxisOptions, getValueAxisOptions,
getVerticalHoverLinePlugin getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper'; import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper';
import { import { NullableLineChartItem } from '@ghostfolio/common/interfaces';
LineChartItem, import { ColorScheme, DateRange } from '@ghostfolio/common/types';
NullableLineChartItem
} from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types';
import { import {
AfterViewInit, AfterViewInit,
@ -34,6 +32,7 @@ import {
LineElement, LineElement,
PointElement, PointElement,
TimeScale, TimeScale,
TimeSeriesScale,
Tooltip, Tooltip,
type TooltipOptions type TooltipOptions
} from 'chart.js'; } from 'chart.js';
@ -60,7 +59,8 @@ export class GfLineChartComponent
@Input() benchmarkLabel = ''; @Input() benchmarkLabel = '';
@Input() colorScheme?: ColorScheme; @Input() colorScheme?: ColorScheme;
@Input() currency?: string; @Input() currency?: string;
@Input() historicalDataItems: LineChartItem[] | null; @Input() dateRange?: DateRange;
@Input() historicalDataItems: NullableLineChartItem[] | null;
@Input() isAnimated = false; @Input() isAnimated = false;
@Input() label: string; @Input() label: string;
@Input() locale = getLocale(); @Input() locale = getLocale();
@ -90,6 +90,7 @@ export class GfLineChartComponent
PointElement, PointElement,
LinearScale, LinearScale,
TimeScale, TimeScale,
TimeSeriesScale,
Tooltip Tooltip
); );
@ -140,7 +141,7 @@ export class GfLineChartComponent
this.isLoading = true; this.isLoading = true;
const benchmarkPrices: (number | null)[] = []; const benchmarkPrices: (number | null)[] = [];
const labels: string[] = []; const labels: string[] = [];
const marketPrices: number[] = []; const marketPrices: (number | null)[] = [];
this.historicalDataItems?.forEach((historicalDataItem, index) => { this.historicalDataItems?.forEach((historicalDataItem, index) => {
benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value ?? null); benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value ?? null);
@ -207,6 +208,10 @@ export class GfLineChartComponent
? animations ? animations
: undefined; : undefined;
// The date range can change while the chart stays alive
this.chart.options.scales ??= {};
this.chart.options.scales['x'] = this.getTimeAxisConfiguration();
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart<'line'>(this.chartCanvas.nativeElement, { this.chart = new Chart<'line'>(this.chartCanvas.nativeElement, {
@ -233,11 +238,7 @@ export class GfLineChartComponent
} }
}, },
scales: { scales: {
x: getTimeAxisOptions({ x: this.getTimeAxisConfiguration(),
colorScheme: this.colorScheme,
display: this.showXAxis,
locale: this.locale
}),
y: { y: {
...getValueAxisOptions({ ...getValueAxisOptions({
colorScheme: this.colorScheme, colorScheme: this.colorScheme,
@ -287,6 +288,17 @@ export class GfLineChartComponent
this.isLoading = false; this.isLoading = false;
} }
private getTimeAxisConfiguration() {
return getTimeAxisOptions({
colorScheme: this.colorScheme,
display: this.showXAxis,
locale: this.locale,
// Equidistant points: the closed markets (nights, weekends) are skipped
type: this.dateRange ? 'timeseries' : 'time',
unit: getTimeAxisUnit(this.dateRange)
});
}
private getAnimationConfigurationForAxis({ private getAnimationConfigurationForAxis({
axis, axis,
labels labels

Loading…
Cancel
Save