Browse Source

Fix the time axis of the overview chart after changing the date range and draw the day from samples

pull/8267/head
mister-ideas 3 days ago
parent
commit
73085e710b
  1. 49
      apps/client/src/app/components/home-overview/home-overview.component.ts
  2. 50
      apps/client/src/app/components/home-overview/intraday-samples.spec.ts
  3. 83
      apps/client/src/app/components/home-overview/intraday-samples.ts
  4. 2
      libs/common/src/lib/chart-helper.ts
  5. 22
      libs/ui/src/lib/line-chart/line-chart.component.ts

49
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);

50
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);
});
});

83
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() };
})
];
}

2
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'

22
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

Loading…
Cancel
Save