From 8fa4f6a6739afee82461b3aa48f47dfa6a396dc8 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:41:52 +0200 Subject: [PATCH] Bugfix/date ranges in time zones other than UTC (#8099) * Fix start date of date ranges for instances in time zones with negative UTC offset * Update changelog --- CHANGELOG.md | 1 + .../calculator/portfolio-calculator.ts | 9 +--- .../roai/portfolio-calculator.spec.ts | 44 +++++++++++++++++++ .../app/portfolio/portfolio.service.spec.ts | 8 ++-- .../src/app/portfolio/portfolio.service.ts | 10 ++++- .../common/src/lib/calculation-helper.spec.ts | 24 ++++++++++ libs/common/src/lib/calculation-helper.ts | 20 +++------ 7 files changed, 90 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5aea753b4..dc2c6193e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed the _Copy AI prompt to clipboard_ actions on the analysis page in impersonation mode to be based on the impersonated user (experimental) +- Fixed the start date of the date ranges for instances in time zones with a negative UTC offset ## 3.83.0 - 2026-10-10 diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index ca440c6d2a..d172652a83 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -64,7 +64,6 @@ import { format, isAfter, isBefore, - isEqual, isFuture, isPast, isWithinInterval, @@ -1418,8 +1417,8 @@ export abstract class PortfolioCalculator { let netPerformanceAtStartDate: number; let netPerformanceWithCurrencyEffectAtStartDate: number; - const endDate = this.toChartDate(end); - const startDate = this.toChartDate(start); + const endDate = resetHours(end); + const startDate = resetHours(start); for (const historicalDataItem of historicalData) { const date = resetHours(parseDate(historicalDataItem.date)); @@ -1914,8 +1913,4 @@ export abstract class PortfolioCalculator { await this.initialize(attempt + 1); } } - - private toChartDate(aDate: Date) { - return isEqual(aDate, endOfDay(aDate)) ? resetHours(aDate) : aDate; - } } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts index 43ff160a78..c2779c2c14 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts @@ -190,6 +190,10 @@ describe('PortfolioCalculator', () => { }); describe('get performance', () => { + afterEach(() => { + jest.useRealTimers(); + }); + it('with a calendar year date range', async () => { const snapshot = await portfolioCalculator.getSnapshot(); @@ -226,5 +230,45 @@ describe('PortfolioCalculator', () => { }) ).toEqual(['2016-12-31', '2017-12-31']); }); + + it('with a year to date date range', async () => { + const snapshot = await portfolioCalculator.getSnapshot(); + + snapshot.historicalData = [ + getHistoricalDataItem({ + date: '2016-12-30', + investment: 100, + netPerformance: 0 + }), + getHistoricalDataItem({ + date: '2016-12-31', + investment: 100, + netPerformance: 10 + }), + getHistoricalDataItem({ + date: '2017-01-01', + investment: 100, + netPerformance: 20 + }) + ]; + + // 1 January 2017, 10:00 in New York + jest.useFakeTimers().setSystemTime(new Date('2017-01-01T15:00:00.000Z')); + + const { endDate, startDate } = getIntervalFromDateRange({ + dateRange: 'ytd' + }); + + const { chart } = await portfolioCalculator.getPerformance({ + end: endDate, + start: startDate + }); + + expect( + chart.map(({ date }) => { + return date; + }) + ).toEqual(['2016-12-31', '2017-01-01']); + }); }); }); diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index f562966b06..c408f51934 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -16,7 +16,7 @@ import { TAG_ID_EXCLUDE_FROM_ANALYSIS, UNKNOWN_KEY } from '@ghostfolio/common/config'; -import { parseDate, resetHours } from '@ghostfolio/common/helper'; +import { parseDate } from '@ghostfolio/common/helper'; import { Activity, AssetProfileIdentifier, @@ -1045,7 +1045,7 @@ describe('PortfolioService', () => { expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { end: endOfDay(parseDate('2024-12-31')), - start: resetHours(parseDate('2024-06-15')) + start: parseDate('2024-06-15') }); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { end: endOfDay(parseDate('2025-06-15')), @@ -1099,11 +1099,11 @@ describe('PortfolioService', () => { expect(getPerformanceOfCalculator).toHaveBeenCalledTimes(3); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(1, { end: endOfDay(parseDate('2026-01-01')), - start: resetHours(parseDate('2025-01-01')) + start: parseDate('2025-01-01') }); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(2, { end: endOfDay(parseDate('2025-12-31')), - start: resetHours(parseDate('2025-01-01')) + start: parseDate('2025-01-01') }); expect(getPerformanceOfCalculator).toHaveBeenNthCalledWith(3, { end: endOfDay(parseDate('2026-01-01')), diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 6e9f8c045a..ab2c0de2d4 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -47,6 +47,7 @@ import { isAccountExcluded, isDraftActivity, parseDate, + resetHours, resolveUserSettings } from '@ghostfolio/common/helper'; import { @@ -559,8 +560,13 @@ export class PortfolioService { const { historicalData } = await portfolioCalculator.getSnapshot(); - const items = historicalData.filter(({ date }) => { - return !isBefore(date, startDate) && !isAfter(date, endDate); + const items = historicalData.filter((historicalDataItem) => { + const date = resetHours(parseDate(historicalDataItem.date)); + + return ( + !isBefore(date, resetHours(startDate)) && + !isAfter(date, resetHours(endDate)) + ); }); let investments: InvestmentItem[]; diff --git a/libs/common/src/lib/calculation-helper.spec.ts b/libs/common/src/lib/calculation-helper.spec.ts index 75a24edbab..7924d72544 100644 --- a/libs/common/src/lib/calculation-helper.spec.ts +++ b/libs/common/src/lib/calculation-helper.spec.ts @@ -6,9 +6,14 @@ import { getIntervalFromDateRange } from './calculation-helper'; import { DATE_FORMAT } from './helper'; +import { DateRange } from './types'; describe('CalculationHelper', () => { describe('interval from date range', () => { + afterEach(() => { + jest.useRealTimers(); + }); + it('Get interval of a calendar year', async () => { const { endDate, startDate } = getIntervalFromDateRange({ dateRange: '2024' @@ -23,6 +28,25 @@ describe('CalculationHelper', () => { // part of the interval expect(startDate.getTime()).toEqual(new Date(2024, 0, 1).getTime() - 1); }); + + it('Get interval of a date range relative to today', () => { + jest.useFakeTimers().setSystemTime(new Date(2024, 9, 10, 22, 0)); + + expect( + (['1d', 'mtd', 'wtd', 'ytd', '1y', '5y'] as DateRange[]).map( + (dateRange) => { + return getIntervalFromDateRange({ dateRange }).startDate; + } + ) + ).toEqual([ + new Date(2024, 9, 9), + new Date(2024, 8, 30), + new Date(2024, 9, 6), + new Date(2023, 11, 31), + new Date(2023, 9, 10), + new Date(2019, 9, 10) + ]); + }); }); describe('annualized performance percentage', () => { diff --git a/libs/common/src/lib/calculation-helper.ts b/libs/common/src/lib/calculation-helper.ts index 2cc1fa2d1c..6ee06af857 100644 --- a/libs/common/src/lib/calculation-helper.ts +++ b/libs/common/src/lib/calculation-helper.ts @@ -3,6 +3,7 @@ import { endOfDay, endOfYear, max, + startOfDay, startOfMonth, startOfWeek, startOfYear, @@ -12,7 +13,6 @@ import { } from 'date-fns'; import { isFinite, isNumber } from 'lodash-es'; -import { resetHours } from './helper'; import { DateRange } from './types'; export function getAnnualizedPerformancePercent({ @@ -48,31 +48,25 @@ export function getIntervalFromDateRange(params: { switch (dateRange) { case '1d': - startDate = max([startDate, subDays(resetHours(new Date()), 1)]); + startDate = max([startDate, subDays(startOfDay(new Date()), 1)]); break; case 'mtd': - startDate = max([ - startDate, - subDays(startOfMonth(resetHours(new Date())), 1) - ]); + startDate = max([startDate, subDays(startOfMonth(new Date()), 1)]); break; case 'wtd': startDate = max([ startDate, - subDays(startOfWeek(resetHours(new Date()), { weekStartsOn: 1 }), 1) + subDays(startOfWeek(new Date(), { weekStartsOn: 1 }), 1) ]); break; case 'ytd': - startDate = max([ - startDate, - subDays(startOfYear(resetHours(new Date())), 1) - ]); + startDate = max([startDate, subDays(startOfYear(new Date()), 1)]); break; case '1y': - startDate = max([startDate, subYears(resetHours(new Date()), 1)]); + startDate = max([startDate, subYears(startOfDay(new Date()), 1)]); break; case '5y': - startDate = max([startDate, subYears(resetHours(new Date()), 5)]); + startDate = max([startDate, subYears(startOfDay(new Date()), 5)]); break; case 'max': break;