From 7757daca3b0171ee55d153394125693e37298db0 Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:54:00 +0200 Subject: [PATCH 1/8] Feature/hide the performance chart of the home overview if it has less than three data points --- .../components/home-overview/home-overview.component.ts | 8 ++++++++ .../src/app/components/home-overview/home-overview.html | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index c7b880efce..fc33a10d0d 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -38,6 +38,8 @@ import { MatCardModule } from '@angular/material/card'; import { Router, RouterModule } from '@angular/router'; import { DeviceDetectorService } from 'ngx-device-detector'; +const MINIMUM_CHART_ITEMS = 3; + @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ @@ -56,6 +58,12 @@ export class GfHomeOverviewComponent implements OnInit { protected readonly errors = signal([]); protected readonly hasImpersonationId = signal(false); protected readonly historicalDataItems = signal(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()?.length ?? 0) >= MINIMUM_CHART_ITEMS; + }); protected readonly holdings = signal( undefined ); diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html index 64789ddc7d..1d1110dc9e 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -72,7 +72,7 @@ unit="%" [class.pe-3]="deviceType() === 'mobile'" [colorScheme]="user()?.settings?.colorScheme" - [hidden]="historicalDataItems()?.length === 0" + [hidden]="!hasEnoughChartData()" [historicalDataItems]="historicalDataItems()" [isAnimated]="user()?.settings?.dateRange === '1d' ? false : true" [label]="performanceLabel" From 1470758e308b4db0f0b1eb5fe817cdcf7f81f532 Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:58:28 +0200 Subject: [PATCH 2/8] Feature/label the time axis of the home overview chart according to the date range --- .../home-overview/home-overview.component.ts | 1 + .../home-overview/home-overview.html | 3 +- libs/common/src/lib/chart-helper.spec.ts | 17 +++++++++ libs/common/src/lib/chart-helper.ts | 36 +++++++++++++++++-- .../lib/line-chart/line-chart.component.ts | 7 ++-- 5 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 libs/common/src/lib/chart-helper.spec.ts diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index fc33a10d0d..1abe7aeb77 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -57,6 +57,7 @@ const MINIMUM_CHART_ITEMS = 3; export class GfHomeOverviewComponent implements OnInit { protected readonly errors = signal([]); protected readonly hasImpersonationId = signal(false); + protected readonly defaultDateRange = DEFAULT_DATE_RANGE; protected readonly historicalDataItems = signal(null); // A line through only two points (e.g. the close of the previous day and diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html index 1d1110dc9e..11732af0f4 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -72,6 +72,7 @@ unit="%" [class.pe-3]="deviceType() === 'mobile'" [colorScheme]="user()?.settings?.colorScheme" + [dateRange]="user()?.settings?.dateRange ?? defaultDateRange" [hidden]="!hasEnoughChartData()" [historicalDataItems]="historicalDataItems()" [isAnimated]="user()?.settings?.dateRange === '1d' ? false : true" @@ -79,7 +80,7 @@ [locale]="locale()" [showGradient]="true" [showLoader]="false" - [showXAxis]="false" + [showXAxis]="true" [showYAxis]="false" /> diff --git a/libs/common/src/lib/chart-helper.spec.ts b/libs/common/src/lib/chart-helper.spec.ts new file mode 100644 index 0000000000..2cd77e9f96 --- /dev/null +++ b/libs/common/src/lib/chart-helper.spec.ts @@ -0,0 +1,17 @@ +import { getTimeAxisUnit } from './chart-helper'; + +describe('getTimeAxisUnit', () => { + it.each([ + ['1d', 'day'], + ['wtd', 'day'], + ['mtd', 'day'], + ['ytd', 'month'], + ['1y', 'month'], + ['2025', 'month'], + ['5y', 'year'], + ['max', 'year'], + [undefined, 'year'] + ])('uses the unit of %s', (dateRange, unit) => { + expect(getTimeAxisUnit(dateRange)).toBe(unit); + }); +}); diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts index 72b629556a..b4bc5f36c6 100644 --- a/libs/common/src/lib/chart-helper.ts +++ b/libs/common/src/lib/chart-helper.ts @@ -23,7 +23,7 @@ import { getLocale, getTextColor } from './helper'; -import { ColorScheme, GroupBy } from './types'; +import { ColorScheme, DateRange, GroupBy } from './types'; export function formatGroupedDate({ date, @@ -60,16 +60,41 @@ 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 + */ +export function getTimeAxisUnit( + aDateRange?: DateRange +): 'day' | 'month' | 'year' { + switch (aDateRange) { + case '1d': + case 'mtd': + case 'wtd': + return 'day'; + case '5y': + case 'max': + case undefined: + return 'year'; + default: + // 'ytd', '1y' and single years + return 'month'; + } +} + export function getTimeAxisOptions({ borderWidth = 1, colorScheme, display = true, - locale = getLocale() + locale = getLocale(), + unit = 'year' }: { borderWidth?: number; colorScheme?: ColorScheme; display?: boolean; locale?: string; + unit?: 'day' | 'month' | 'year'; }): ScaleOptions<'time'> { return { display, @@ -80,9 +105,14 @@ export function getTimeAxisOptions({ grid: { display: false }, + ticks: { + autoSkip: true, + maxRotation: 0, + maxTicksLimit: 6 + }, time: { tooltipFormat: getDateFormatString(locale), - unit: 'year' + unit }, type: 'time' }; diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts index d910033bc0..bda6d0a38c 100644 --- a/libs/ui/src/lib/line-chart/line-chart.component.ts +++ b/libs/ui/src/lib/line-chart/line-chart.component.ts @@ -1,6 +1,7 @@ import { getChartBorderColor, getTimeAxisOptions, + getTimeAxisUnit, getValueAxisOptions, getVerticalHoverLinePlugin } from '@ghostfolio/common/chart-helper'; @@ -10,7 +11,7 @@ import { LineChartItem, NullableLineChartItem } from '@ghostfolio/common/interfaces'; -import { ColorScheme } from '@ghostfolio/common/types'; +import { ColorScheme, DateRange } from '@ghostfolio/common/types'; import { AfterViewInit, @@ -60,6 +61,7 @@ export class GfLineChartComponent @Input() benchmarkLabel = ''; @Input() colorScheme?: ColorScheme; @Input() currency?: string; + @Input() dateRange?: DateRange; @Input() historicalDataItems: LineChartItem[] | null; @Input() isAnimated = false; @Input() label: string; @@ -236,7 +238,8 @@ export class GfLineChartComponent x: getTimeAxisOptions({ colorScheme: this.colorScheme, display: this.showXAxis, - locale: this.locale + locale: this.locale, + unit: getTimeAxisUnit(this.dateRange) }), y: { ...getValueAxisOptions({ From 0fbe3f1e2c972916ddccd91b0427318b351aa0e0 Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:59:44 +0200 Subject: [PATCH 3/8] Feature/update the changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e35621f0b..6b9073957e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added the unit (days, months or years) to the time axis of the performance chart on the overview page according to the selected date range + ### Changed +- Hid the performance chart on the overview page if it has less than three data points - Improved the usability of the activities page by eliminating the reload on canceling the import activities dialog - Improved the accessibility of the _Buy me a coffee_ button on the about page - Improved the accessibility of the entity logo component From 6b7f4093c30c757b2ecd4399e3a760e53284a64d Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:16:24 +0200 Subject: [PATCH 4/8] Feature/show the whole current period on the time axis of the overview chart --- .../home-overview/home-overview.component.ts | 5 +- libs/common/src/lib/chart-helper.spec.ts | 53 +++++++++++++- libs/common/src/lib/chart-helper.ts | 69 ++++++++++++++++++- .../lib/line-chart/line-chart.component.ts | 2 + 4 files changed, 123 insertions(+), 6 deletions(-) diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index 1abe7aeb77..cc98251232 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -63,7 +63,10 @@ export class GfHomeOverviewComponent implements OnInit { // 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()?.length ?? 0) >= MINIMUM_CHART_ITEMS; + const dateRange = this.user()?.settings?.dateRange; + const minimum = dateRange === '1d' ? 1 : MINIMUM_CHART_ITEMS; + + return (this.historicalDataItems()?.length ?? 0) >= minimum; }); protected readonly holdings = signal( undefined diff --git a/libs/common/src/lib/chart-helper.spec.ts b/libs/common/src/lib/chart-helper.spec.ts index 2cd77e9f96..efb66a36e9 100644 --- a/libs/common/src/lib/chart-helper.spec.ts +++ b/libs/common/src/lib/chart-helper.spec.ts @@ -1,8 +1,8 @@ -import { getTimeAxisUnit } from './chart-helper'; +import { getTimeAxisBounds, getTimeAxisUnit } from './chart-helper'; describe('getTimeAxisUnit', () => { it.each([ - ['1d', 'day'], + ['1d', 'hour'], ['wtd', 'day'], ['mtd', 'day'], ['ytd', 'month'], @@ -15,3 +15,52 @@ describe('getTimeAxisUnit', () => { expect(getTimeAxisUnit(dateRange)).toBe(unit); }); }); + +describe('getTimeAxisBounds', () => { + const reference = new Date(2026, 9, 21, 15, 30); + + it('spans the whole current year for ytd', () => { + const { max, min } = getTimeAxisBounds('ytd', reference); + + expect(new Date(min).toDateString()).toBe( + new Date(2026, 0, 1).toDateString() + ); + expect(new Date(max).toDateString()).toBe( + new Date(2026, 11, 31).toDateString() + ); + }); + + it('spans the whole current month for mtd', () => { + const { max, min } = getTimeAxisBounds('mtd', reference); + + expect(new Date(min).getDate()).toBe(1); + expect(new Date(max).getDate()).toBe(31); + }); + + it('spans the whole current week for wtd', () => { + const { max, min } = getTimeAxisBounds('wtd', reference); + + expect(new Date(min).getDay()).toBe(1); + expect(new Date(max).getDay()).toBe(0); + }); + + it('spans the whole current day for 1d', () => { + const { max, min } = getTimeAxisBounds('1d', reference); + + expect(new Date(min).getHours()).toBe(0); + expect(new Date(max).getHours()).toBe(23); + }); + + it('spans a past year', () => { + const { max, min } = getTimeAxisBounds('2024', reference); + + expect(new Date(min).getFullYear()).toBe(2024); + expect(new Date(max).getMonth()).toBe(11); + }); + + it('does not fix the bounds of rolling ranges', () => { + expect(getTimeAxisBounds('1y', reference)).toEqual({}); + expect(getTimeAxisBounds('5y', reference)).toEqual({}); + expect(getTimeAxisBounds('max', reference)).toEqual({}); + }); +}); diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts index b4bc5f36c6..3de8ade41e 100644 --- a/libs/common/src/lib/chart-helper.ts +++ b/libs/common/src/lib/chart-helper.ts @@ -12,7 +12,17 @@ import type { TooltipPosition } from 'chart.js'; import type { AnnotationOptions } from 'chartjs-plugin-annotation'; -import { format } from 'date-fns'; +import { + endOfDay, + endOfMonth, + endOfWeek, + endOfYear, + format, + startOfDay, + startOfMonth, + startOfWeek, + startOfYear +} from 'date-fns'; import { DATE_FORMAT, @@ -67,9 +77,10 @@ export function getChartElementsOptions( */ export function getTimeAxisUnit( aDateRange?: DateRange -): 'day' | 'month' | 'year' { +): 'day' | 'hour' | 'month' | 'year' { switch (aDateRange) { case '1d': + return 'hour'; case 'mtd': case 'wtd': return 'day'; @@ -83,18 +94,68 @@ export function getTimeAxisUnit( } } +/** + * Returns the bounds of the time axis for the date ranges which have a fixed + * end (the current week, month or year), so that the chart shows the whole + * period with the part still to come left empty. Other date ranges (rolling + * periods and `max`) end at the latest data point. + */ +export function getTimeAxisBounds( + aDateRange?: DateRange, + aReferenceDate = new Date() +): { max?: number; min?: number } { + switch (aDateRange) { + case '1d': + return { + max: endOfDay(aReferenceDate).getTime(), + min: startOfDay(aReferenceDate).getTime() + }; + case 'wtd': + return { + max: endOfWeek(aReferenceDate, { weekStartsOn: 1 }).getTime(), + min: startOfWeek(aReferenceDate, { weekStartsOn: 1 }).getTime() + }; + case 'mtd': + return { + max: endOfMonth(aReferenceDate).getTime(), + min: startOfMonth(aReferenceDate).getTime() + }; + case 'ytd': + return { + max: endOfYear(aReferenceDate).getTime(), + min: startOfYear(aReferenceDate).getTime() + }; + default: { + if (typeof aDateRange === 'string' && /^\d{4}$/.test(aDateRange)) { + const year = new Date(Number(aDateRange), 0, 1); + + return { + max: endOfYear(year).getTime(), + min: startOfYear(year).getTime() + }; + } + + return {}; + } + } +} + export function getTimeAxisOptions({ borderWidth = 1, colorScheme, display = true, locale = getLocale(), + max, + min, unit = 'year' }: { borderWidth?: number; colorScheme?: ColorScheme; display?: boolean; locale?: string; - unit?: 'day' | 'month' | 'year'; + max?: number; + min?: number; + unit?: 'day' | 'hour' | 'month' | 'year'; }): ScaleOptions<'time'> { return { display, @@ -105,6 +166,8 @@ export function getTimeAxisOptions({ grid: { display: false }, + max, + min, ticks: { autoSkip: true, maxRotation: 0, diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts index bda6d0a38c..7ace37da54 100644 --- a/libs/ui/src/lib/line-chart/line-chart.component.ts +++ b/libs/ui/src/lib/line-chart/line-chart.component.ts @@ -1,5 +1,6 @@ import { getChartBorderColor, + getTimeAxisBounds, getTimeAxisOptions, getTimeAxisUnit, getValueAxisOptions, @@ -239,6 +240,7 @@ export class GfLineChartComponent colorScheme: this.colorScheme, display: this.showXAxis, locale: this.locale, + ...getTimeAxisBounds(this.dateRange), unit: getTimeAxisUnit(this.dateRange) }), y: { From 9cd5c283b7f873b955995875261bb451d140aa0b Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:16:28 +0200 Subject: [PATCH 5/8] Feature/update the changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9073957e..159f9bd7c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added the unit (days, months or years) to the time axis of the performance chart on the overview page according to the selected date range +- Added a time axis to the performance chart on the overview page which spans the whole current day, week, month or year and uses days, months or years as unit according to the selected date range ### Changed From 73085e710b35bae3598291a8656a51aad58f9069 Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:54:06 +0200 Subject: [PATCH 6/8] Fix the time axis of the overview chart after changing the date range and draw the day from samples --- .../home-overview/home-overview.component.ts | 49 ++++++++--- .../home-overview/intraday-samples.spec.ts | 50 +++++++++++ .../home-overview/intraday-samples.ts | 83 +++++++++++++++++++ libs/common/src/lib/chart-helper.ts | 2 +- .../lib/line-chart/line-chart.component.ts | 22 +++-- 5 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 apps/client/src/app/components/home-overview/intraday-samples.spec.ts create mode 100644 apps/client/src/app/components/home-overview/intraday-samples.ts diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index cc98251232..05a70ac9c2 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -11,6 +11,7 @@ import { import { isCashPosition } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, + HistoricalDataItem, LineChartItem, PortfolioPerformance, PortfolioPosition, @@ -19,6 +20,7 @@ import { import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { hasScope, scopes } from '@ghostfolio/common/scopes'; +import { DateRange } from '@ghostfolio/common/types'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { DataService } from '@ghostfolio/ui/services'; @@ -38,6 +40,11 @@ import { MatCardModule } from '@angular/material/card'; import { Router, RouterModule } from '@angular/router'; import { DeviceDetectorService } from 'ngx-device-detector'; +import { + getIntradayChartItems, + recordIntradaySample +} from './intraday-samples'; + const MINIMUM_CHART_ITEMS = 3; @Component({ @@ -171,6 +178,37 @@ export class GfHomeOverviewComponent implements OnInit { } } + private setChartItems({ + chart, + dateRange + }: { + chart?: HistoricalDataItem[]; + dateRange?: DateRange; + }) { + const items = + chart?.map(({ date, netPerformanceInPercentageWithCurrencyEffect }) => { + return { + date, + value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100 + }; + }) ?? null; + + const latest = items?.at(-1); + + if ((dateRange ?? DEFAULT_DATE_RANGE) === '1d' && latest) { + // There is only one value per day on the server: draw the day from the + // close of the previous day to the samples recorded on this device + this.historicalDataItems.set( + getIntradayChartItems({ + baseline: items?.[0]?.value ?? 0, + samples: recordIntradaySample({ value: latest.value }) + }) + ); + } else { + this.historicalDataItems.set(items); + } + } + private update() { const userSettings = this.user()?.settings; @@ -210,16 +248,7 @@ export class GfHomeOverviewComponent implements OnInit { this.errors.set(errors ?? []); this.performance.set(performance); - this.historicalDataItems.set( - chart?.map( - ({ date, netPerformanceInPercentageWithCurrencyEffect }) => { - return { - date, - value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100 - }; - } - ) ?? null - ); + this.setChartItems({ chart, dateRange: userSettings?.dateRange }); this.precision.set(2); diff --git a/apps/client/src/app/components/home-overview/intraday-samples.spec.ts b/apps/client/src/app/components/home-overview/intraday-samples.spec.ts new file mode 100644 index 0000000000..6d3ab357c4 --- /dev/null +++ b/apps/client/src/app/components/home-overview/intraday-samples.spec.ts @@ -0,0 +1,50 @@ +import { + getIntradayChartItems, + INTRADAY_SAMPLE_INTERVAL_MS, + loadIntradaySamples, + recordIntradaySample +} from './intraday-samples'; + +describe('intraday samples', () => { + const now = new Date(2026, 9, 21, 10, 0, 0); + + beforeEach(() => { + localStorage.clear(); + }); + + it('records at most one sample per minute', () => { + recordIntradaySample({ now, value: 1 }); + recordIntradaySample({ now: new Date(now.getTime() + 5_000), value: 2 }); + + expect(loadIntradaySamples(now)).toEqual([ + { timestamp: now.getTime(), value: 2 } + ]); + + recordIntradaySample({ + now: new Date(now.getTime() + INTRADAY_SAMPLE_INTERVAL_MS), + value: 3 + }); + + expect(loadIntradaySamples(now)).toHaveLength(2); + }); + + it('removes the samples of previous days', () => { + recordIntradaySample({ now: new Date(2026, 9, 20, 15, 0), value: 1 }); + recordIntradaySample({ now, value: 2 }); + + expect(loadIntradaySamples(new Date(2026, 9, 20))).toEqual([]); + expect(loadIntradaySamples(now)).toHaveLength(1); + }); + + it('starts the chart of the day with the baseline at midnight', () => { + const items = getIntradayChartItems({ + now, + baseline: 0, + samples: [{ timestamp: now.getTime(), value: 0.5 }] + }); + + expect(items).toHaveLength(2); + expect(new Date(items[0].date).getHours()).toBe(0); + expect(items[1].value).toBe(0.5); + }); +}); diff --git a/apps/client/src/app/components/home-overview/intraday-samples.ts b/apps/client/src/app/components/home-overview/intraday-samples.ts new file mode 100644 index 0000000000..8828c746fa --- /dev/null +++ b/apps/client/src/app/components/home-overview/intraday-samples.ts @@ -0,0 +1,83 @@ +import { LineChartItem } from '@ghostfolio/common/interfaces'; + +import { format, startOfDay } from 'date-fns'; + +export const INTRADAY_SAMPLES_KEY_PREFIX = 'gf-intraday-'; +export const INTRADAY_SAMPLE_INTERVAL_MS = 60_000; + +export interface IntradaySample { + timestamp: number; + value: number; +} + +function getKey(aDate: Date) { + return `${INTRADAY_SAMPLES_KEY_PREFIX}${format(aDate, 'yyyy-MM-dd')}`; +} + +export function loadIntradaySamples(aDate = new Date()): IntradaySample[] { + try { + const samples = JSON.parse(localStorage.getItem(getKey(aDate)) ?? '[]'); + + return Array.isArray(samples) ? samples : []; + } catch { + return []; + } +} + +/** + * Stores the current value of the day (at most one sample per minute) in the + * local storage of the device, because only a single value per day is stored + * on the server. Samples of previous days are removed. + */ +export function recordIntradaySample({ + now = new Date(), + value +}: { + now?: Date; + value: number; +}): IntradaySample[] { + const samples = loadIntradaySamples(now); + const last = samples.at(-1); + + if (!last || now.getTime() - last.timestamp >= INTRADAY_SAMPLE_INTERVAL_MS) { + samples.push({ value, timestamp: now.getTime() }); + } else { + // Keep the latest value of the current minute + last.value = value; + } + + try { + for (let index = localStorage.length - 1; index >= 0; index--) { + const key = localStorage.key(index); + + if (key?.startsWith(INTRADAY_SAMPLES_KEY_PREFIX) && key !== getKey(now)) { + localStorage.removeItem(key); + } + } + + localStorage.setItem(getKey(now), JSON.stringify(samples)); + } catch {} + + return samples; +} + +/** + * Builds the chart of the current day: the baseline (the close of the previous + * day) at the start of the day followed by the recorded samples of the day. + */ +export function getIntradayChartItems({ + baseline, + now = new Date(), + samples +}: { + baseline: number; + now?: Date; + samples: IntradaySample[]; +}): LineChartItem[] { + return [ + { date: startOfDay(now).toISOString(), value: baseline }, + ...samples.map(({ timestamp, value }) => { + return { value, date: new Date(timestamp).toISOString() }; + }) + ]; +} diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts index 3de8ade41e..3e96ca381d 100644 --- a/libs/common/src/lib/chart-helper.ts +++ b/libs/common/src/lib/chart-helper.ts @@ -174,7 +174,7 @@ export function getTimeAxisOptions({ maxTicksLimit: 6 }, time: { - tooltipFormat: getDateFormatString(locale), + tooltipFormat: unit === 'hour' ? 'HH:mm' : getDateFormatString(locale), unit }, type: 'time' diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts index 7ace37da54..4ecf9426b8 100644 --- a/libs/ui/src/lib/line-chart/line-chart.component.ts +++ b/libs/ui/src/lib/line-chart/line-chart.component.ts @@ -210,6 +210,10 @@ export class GfLineChartComponent ? animations : 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(); } else { this.chart = new Chart<'line'>(this.chartCanvas.nativeElement, { @@ -236,13 +240,7 @@ export class GfLineChartComponent } }, scales: { - x: getTimeAxisOptions({ - colorScheme: this.colorScheme, - display: this.showXAxis, - locale: this.locale, - ...getTimeAxisBounds(this.dateRange), - unit: getTimeAxisUnit(this.dateRange) - }), + x: this.getTimeAxisConfiguration(), y: { ...getValueAxisOptions({ colorScheme: this.colorScheme, @@ -292,6 +290,16 @@ export class GfLineChartComponent this.isLoading = false; } + private getTimeAxisConfiguration() { + return getTimeAxisOptions({ + colorScheme: this.colorScheme, + display: this.showXAxis, + locale: this.locale, + ...getTimeAxisBounds(this.dateRange), + unit: getTimeAxisUnit(this.dateRange) + }); + } + private getAnimationConfigurationForAxis({ axis, labels From 7461a1eb316c6398c70e63b16fe7c46c569a14c6 Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:55:31 +0200 Subject: [PATCH 7/8] Feature/update the changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 159f9bd7c4..eeb316e581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added a time axis to the performance chart on the overview page which spans the whole current day, week, month or year and uses days, months or years as unit according to the selected date range +- Added a time axis to the performance chart on the overview page, drawing the current day from samples recorded on the device, which spans the whole current day, week, month or year and uses days, months or years as unit according to the selected date range ### Changed From bccbca05d2f3da566b0974b763cc4b08f1414190 Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:49:34 +0200 Subject: [PATCH 8/8] Feature/draw the performance chart of the overview page on the trading days and remove the samples of the day --- CHANGELOG.md | 8 +- .../home-overview/home-overview.component.ts | 58 ++-- .../home-overview/intraday-samples.spec.ts | 50 --- .../home-overview/intraday-samples.ts | 83 ----- libs/common/src/lib/chart-helper.spec.ts | 143 +++++++-- libs/common/src/lib/chart-helper.ts | 295 +++++++++++++----- .../lib/line-chart/line-chart.component.ts | 15 +- 7 files changed, 368 insertions(+), 284 deletions(-) delete mode 100644 apps/client/src/app/components/home-overview/intraday-samples.spec.ts delete mode 100644 apps/client/src/app/components/home-overview/intraday-samples.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 926bd28651..10950709cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added a time axis to the performance chart on the overview page, drawing the current day from samples recorded on the device, which spans the whole current day, week, month or year and uses days, months or years as unit according to the selected date range - -### Changed - -- Hid the performance chart on the overview page if it has less than three data points +- 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) ### 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 style of the close button in the dialog footer component - Improved the style of the close button in the dialog header component diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index 8f73a10281..bd78988456 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/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 { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { getTradingDayItems } from '@ghostfolio/common/chart-helper'; import { DEFAULT_CURRENCY, DEFAULT_DATE_RANGE, @@ -12,7 +13,7 @@ import { isCashPosition } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, HistoricalDataItem, - LineChartItem, + NullableLineChartItem, PortfolioPerformance, PortfolioPosition, User @@ -43,11 +44,6 @@ import { addIcons } from 'ionicons'; import { arrowForwardOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { - getIntradayChartItems, - recordIntradaySample -} from './intraday-samples'; - const MINIMUM_CHART_ITEMS = 3; @Component({ @@ -69,15 +65,18 @@ export class GfHomeOverviewComponent implements OnInit { protected readonly errors = signal([]); protected readonly hasImpersonationId = signal(false); protected readonly defaultDateRange = DEFAULT_DATE_RANGE; - protected readonly historicalDataItems = signal(null); + 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(() => { - const dateRange = this.user()?.settings?.dateRange; - const minimum = dateRange === '1d' ? 1 : MINIMUM_CHART_ITEMS; - - return (this.historicalDataItems()?.length ?? 0) >= minimum; + return ( + (this.historicalDataItems() ?? []).filter(({ value }) => { + return value !== null; + }).length >= MINIMUM_CHART_ITEMS + ); }); protected readonly holdings = signal( undefined @@ -197,28 +196,21 @@ export class GfHomeOverviewComponent implements OnInit { chart?: HistoricalDataItem[]; dateRange?: DateRange; }) { - const items = - chart?.map(({ date, netPerformanceInPercentageWithCurrencyEffect }) => { - return { - date, - value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100 - }; - }) ?? null; - - const latest = items?.at(-1); - - if ((dateRange ?? DEFAULT_DATE_RANGE) === '1d' && latest) { - // There is only one value per day on the server: draw the day from the - // close of the previous day to the samples recorded on this device - this.historicalDataItems.set( - getIntradayChartItems({ - baseline: items?.[0]?.value ?? 0, - samples: recordIntradaySample({ value: latest.value }) - }) - ); - } else { - this.historicalDataItems.set(items); - } + // 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() { diff --git a/apps/client/src/app/components/home-overview/intraday-samples.spec.ts b/apps/client/src/app/components/home-overview/intraday-samples.spec.ts deleted file mode 100644 index 6d3ab357c4..0000000000 --- a/apps/client/src/app/components/home-overview/intraday-samples.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - getIntradayChartItems, - INTRADAY_SAMPLE_INTERVAL_MS, - loadIntradaySamples, - recordIntradaySample -} from './intraday-samples'; - -describe('intraday samples', () => { - const now = new Date(2026, 9, 21, 10, 0, 0); - - beforeEach(() => { - localStorage.clear(); - }); - - it('records at most one sample per minute', () => { - recordIntradaySample({ now, value: 1 }); - recordIntradaySample({ now: new Date(now.getTime() + 5_000), value: 2 }); - - expect(loadIntradaySamples(now)).toEqual([ - { timestamp: now.getTime(), value: 2 } - ]); - - recordIntradaySample({ - now: new Date(now.getTime() + INTRADAY_SAMPLE_INTERVAL_MS), - value: 3 - }); - - expect(loadIntradaySamples(now)).toHaveLength(2); - }); - - it('removes the samples of previous days', () => { - recordIntradaySample({ now: new Date(2026, 9, 20, 15, 0), value: 1 }); - recordIntradaySample({ now, value: 2 }); - - expect(loadIntradaySamples(new Date(2026, 9, 20))).toEqual([]); - expect(loadIntradaySamples(now)).toHaveLength(1); - }); - - it('starts the chart of the day with the baseline at midnight', () => { - const items = getIntradayChartItems({ - now, - baseline: 0, - samples: [{ timestamp: now.getTime(), value: 0.5 }] - }); - - expect(items).toHaveLength(2); - expect(new Date(items[0].date).getHours()).toBe(0); - expect(items[1].value).toBe(0.5); - }); -}); diff --git a/apps/client/src/app/components/home-overview/intraday-samples.ts b/apps/client/src/app/components/home-overview/intraday-samples.ts deleted file mode 100644 index 8828c746fa..0000000000 --- a/apps/client/src/app/components/home-overview/intraday-samples.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { LineChartItem } from '@ghostfolio/common/interfaces'; - -import { format, startOfDay } from 'date-fns'; - -export const INTRADAY_SAMPLES_KEY_PREFIX = 'gf-intraday-'; -export const INTRADAY_SAMPLE_INTERVAL_MS = 60_000; - -export interface IntradaySample { - timestamp: number; - value: number; -} - -function getKey(aDate: Date) { - return `${INTRADAY_SAMPLES_KEY_PREFIX}${format(aDate, 'yyyy-MM-dd')}`; -} - -export function loadIntradaySamples(aDate = new Date()): IntradaySample[] { - try { - const samples = JSON.parse(localStorage.getItem(getKey(aDate)) ?? '[]'); - - return Array.isArray(samples) ? samples : []; - } catch { - return []; - } -} - -/** - * Stores the current value of the day (at most one sample per minute) in the - * local storage of the device, because only a single value per day is stored - * on the server. Samples of previous days are removed. - */ -export function recordIntradaySample({ - now = new Date(), - value -}: { - now?: Date; - value: number; -}): IntradaySample[] { - const samples = loadIntradaySamples(now); - const last = samples.at(-1); - - if (!last || now.getTime() - last.timestamp >= INTRADAY_SAMPLE_INTERVAL_MS) { - samples.push({ value, timestamp: now.getTime() }); - } else { - // Keep the latest value of the current minute - last.value = value; - } - - try { - for (let index = localStorage.length - 1; index >= 0; index--) { - const key = localStorage.key(index); - - if (key?.startsWith(INTRADAY_SAMPLES_KEY_PREFIX) && key !== getKey(now)) { - localStorage.removeItem(key); - } - } - - localStorage.setItem(getKey(now), JSON.stringify(samples)); - } catch {} - - return samples; -} - -/** - * Builds the chart of the current day: the baseline (the close of the previous - * day) at the start of the day followed by the recorded samples of the day. - */ -export function getIntradayChartItems({ - baseline, - now = new Date(), - samples -}: { - baseline: number; - now?: Date; - samples: IntradaySample[]; -}): LineChartItem[] { - return [ - { date: startOfDay(now).toISOString(), value: baseline }, - ...samples.map(({ timestamp, value }) => { - return { value, date: new Date(timestamp).toISOString() }; - }) - ]; -} diff --git a/libs/common/src/lib/chart-helper.spec.ts b/libs/common/src/lib/chart-helper.spec.ts index efb66a36e9..8b35f0f493 100644 --- a/libs/common/src/lib/chart-helper.spec.ts +++ b/libs/common/src/lib/chart-helper.spec.ts @@ -1,4 +1,11 @@ -import { getTimeAxisBounds, getTimeAxisUnit } from './chart-helper'; +import { addDays, format } from 'date-fns'; + +import { + getTimeAxisUnit, + getTimeSeriesTickCallback, + getTradingDayItems +} from './chart-helper'; +import { DATE_FORMAT } from './helper'; describe('getTimeAxisUnit', () => { it.each([ @@ -6,61 +13,129 @@ describe('getTimeAxisUnit', () => { ['wtd', 'day'], ['mtd', 'day'], ['ytd', 'month'], - ['1y', 'month'], + ['1y', false], ['2025', 'month'], - ['5y', 'year'], - ['max', 'year'], + ['5y', false], + ['max', false], [undefined, 'year'] ])('uses the unit of %s', (dateRange, unit) => { expect(getTimeAxisUnit(dateRange)).toBe(unit); }); }); -describe('getTimeAxisBounds', () => { - const reference = new Date(2026, 9, 21, 15, 30); +describe('getTradingDayItems', () => { + // Thursday + const now = new Date(2026, 11, 24); - it('spans the whole current year for ytd', () => { - const { max, min } = getTimeAxisBounds('ytd', reference); + 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(new Date(min).toDateString()).toBe( - new Date(2026, 0, 1).toDateString() - ); - expect(new Date(max).toDateString()).toBe( - new Date(2026, 11, 31).toDateString() - ); + expect(items.map(({ date }) => date)).toEqual(['2026-12-18', '2026-12-21']); }); - it('spans the whole current month for mtd', () => { - const { max, min } = getTimeAxisBounds('mtd', reference); + it('adds the remaining trading days of the year', () => { + const items = getTradingDayItems({ + dateRange: 'ytd', + now, + items: [{ date: '2026-12-24', value: 1 }] + }); - expect(new Date(min).getDate()).toBe(1); - expect(new Date(max).getDate()).toBe(31); + 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 } + ]); }); +}); - it('spans the whole current week for wtd', () => { - const { max, min } = getTimeAxisBounds('wtd', reference); +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() }; + }); - expect(new Date(min).getDay()).toBe(1); - expect(new Date(max).getDay()).toBe(0); + const callback = getTimeSeriesTickCallback('hour'); + + expect( + ticks.map(({ value }, index) => { + return callback(value, index, ticks); + }) + ).toEqual(['09:00', '', '10:00', '']); }); - it('spans the whole current day for 1d', () => { - const { max, min } = getTimeAxisBounds('1d', reference); + 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(new Date(min).getHours()).toBe(0); - expect(new Date(max).getHours()).toBe(23); + expect( + ticks.map(({ value }, index) => { + return callback(value, index, ticks); + }) + ).toEqual(['Thu 22', '', '', 'Fri 23']); }); - it('spans a past year', () => { - const { max, min } = getTimeAxisBounds('2024', reference); + it('thins out the labels', () => { + const ticks = Array.from({ length: 20 }, (_, index) => { + return { value: new Date(2026, 0, 1 + index).getTime() }; + }); - expect(new Date(min).getFullYear()).toBe(2024); - expect(new Date(max).getMonth()).toBe(11); + 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 }); - it('does not fix the bounds of rolling ranges', () => { - expect(getTimeAxisBounds('1y', reference)).toEqual({}); - expect(getTimeAxisBounds('5y', reference)).toEqual({}); - expect(getTimeAxisBounds('max', reference)).toEqual({}); + expect( + result.map(({ date }) => { + return date; + }) + ).toEqual([ + '2026-01-05', + '2026-01-09', + '2026-01-16', + '2026-01-23', + '2026-01-30' + ]); }); }); diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts index 3e96ca381d..c75b7d135d 100644 --- a/libs/common/src/lib/chart-helper.ts +++ b/libs/common/src/lib/chart-helper.ts @@ -12,18 +12,9 @@ import type { TooltipPosition } from 'chart.js'; import type { AnnotationOptions } from 'chartjs-plugin-annotation'; -import { - endOfDay, - endOfMonth, - endOfWeek, - endOfYear, - format, - startOfDay, - startOfMonth, - startOfWeek, - startOfYear -} from 'date-fns'; +import { addDays, format, startOfWeek } from 'date-fns'; +import { secondaryColorRgb } from './config'; import { DATE_FORMAT, DATE_FORMAT_MONTHLY, @@ -31,7 +22,8 @@ import { getBackgroundColor, getDateFormatString, getLocale, - getTextColor + getTextColor, + parseDate } from './helper'; import { ColorScheme, DateRange, GroupBy } from './types'; @@ -51,6 +43,21 @@ export function formatGroupedDate({ 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) { return `rgba(${getTextColor(colorScheme)}, 0.1)`; } @@ -75,69 +82,196 @@ export function getChartElementsOptions( * 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({ + 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' { +): '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': - case undefined: - return 'year'; + // Chosen by the chart according to the length of the period + return false; default: - // 'ytd', '1y' and single years + // '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 bounds of the time axis for the date ranges which have a fixed - * end (the current week, month or year), so that the chart shows the whole - * period with the part still to come left empty. Other date ranges (rolling - * periods and `max`) end at the latest data point. + * 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 getTimeAxisBounds( - aDateRange?: DateRange, - aReferenceDate = new Date() -): { max?: number; min?: number } { - switch (aDateRange) { - case '1d': - return { - max: endOfDay(aReferenceDate).getTime(), - min: startOfDay(aReferenceDate).getTime() - }; - case 'wtd': - return { - max: endOfWeek(aReferenceDate, { weekStartsOn: 1 }).getTime(), - min: startOfWeek(aReferenceDate, { weekStartsOn: 1 }).getTime() - }; - case 'mtd': - return { - max: endOfMonth(aReferenceDate).getTime(), - min: startOfMonth(aReferenceDate).getTime() - }; - case 'ytd': - return { - max: endOfYear(aReferenceDate).getTime(), - min: startOfYear(aReferenceDate).getTime() +export function getTimeSeriesTickCallback( + aUnit: 'day' | 'hour' | 'month' | 'year' | false, + aMaxLabels = 7 +) { + let cache: { key: string; labels: Map } | 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()}`; + } }; - default: { - if (typeof aDateRange === 'string' && /^\d{4}$/.test(aDateRange)) { - const year = new Date(Number(aDateRange), 0, 1); - - return { - max: endOfYear(year).getTime(), - min: startOfYear(year).getTime() - }; - } - return {}; + // 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(); + + 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({ @@ -145,18 +279,19 @@ export function getTimeAxisOptions({ colorScheme, display = true, locale = getLocale(), - max, - min, - unit = 'year' + type = 'time', + unit = 'year', + withTime = false }: { borderWidth?: number; colorScheme?: ColorScheme; display?: boolean; locale?: string; - max?: number; - min?: number; - unit?: 'day' | 'hour' | 'month' | 'year'; -}): 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 { display, border: { @@ -166,18 +301,36 @@ export function getTimeAxisOptions({ grid: { display: false }, - max, - min, - ticks: { - autoSkip: true, - maxRotation: 0, - maxTicksLimit: 6 - }, + 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: { - tooltipFormat: unit === 'hour' ? 'HH:mm' : getDateFormatString(locale), + tooltipFormat: withTime + ? 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 }; } diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts index 4ecf9426b8..b5b90ee35b 100644 --- a/libs/ui/src/lib/line-chart/line-chart.component.ts +++ b/libs/ui/src/lib/line-chart/line-chart.component.ts @@ -1,6 +1,5 @@ import { getChartBorderColor, - getTimeAxisBounds, getTimeAxisOptions, getTimeAxisUnit, getValueAxisOptions, @@ -8,10 +7,7 @@ import { } from '@ghostfolio/common/chart-helper'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper'; -import { - LineChartItem, - NullableLineChartItem -} from '@ghostfolio/common/interfaces'; +import { NullableLineChartItem } from '@ghostfolio/common/interfaces'; import { ColorScheme, DateRange } from '@ghostfolio/common/types'; import { @@ -36,6 +32,7 @@ import { LineElement, PointElement, TimeScale, + TimeSeriesScale, Tooltip, type TooltipOptions } from 'chart.js'; @@ -63,7 +60,7 @@ export class GfLineChartComponent @Input() colorScheme?: ColorScheme; @Input() currency?: string; @Input() dateRange?: DateRange; - @Input() historicalDataItems: LineChartItem[] | null; + @Input() historicalDataItems: NullableLineChartItem[] | null; @Input() isAnimated = false; @Input() label: string; @Input() locale = getLocale(); @@ -93,6 +90,7 @@ export class GfLineChartComponent PointElement, LinearScale, TimeScale, + TimeSeriesScale, Tooltip ); @@ -143,7 +141,7 @@ export class GfLineChartComponent this.isLoading = true; const benchmarkPrices: (number | null)[] = []; const labels: string[] = []; - const marketPrices: number[] = []; + const marketPrices: (number | null)[] = []; this.historicalDataItems?.forEach((historicalDataItem, index) => { benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value ?? null); @@ -295,7 +293,8 @@ export class GfLineChartComponent colorScheme: this.colorScheme, display: this.showXAxis, locale: this.locale, - ...getTimeAxisBounds(this.dateRange), + // Equidistant points: the closed markets (nights, weekends) are skipped + type: this.dateRange ? 'timeseries' : 'time', unit: getTimeAxisUnit(this.dateRange) }); }