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