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] 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