diff --git a/apps/nowcasting-app/.env.example b/apps/nowcasting-app/.env.example index 27a64fe5..61a2696c 100644 --- a/apps/nowcasting-app/.env.example +++ b/apps/nowcasting-app/.env.example @@ -2,3 +2,6 @@ #NEXT_PUBLIC_API_PREFIX='http://localhost:8000/v0' #NEXT_PUBLIC_4H_VIEW='true' #NEXT_PUBLIC_DEV_MODE='true' + +#NEXT_PUBLIC_HISTORY_START_TYPE = 'fixed' | 'rolling' +#NEXT_PUBLIC_HISTORY_START_OFFSET_HOURS = 48 or 72 diff --git a/apps/nowcasting-app/components/helpers/data.test.ts b/apps/nowcasting-app/components/helpers/data.test.ts index 2b12ae04..582ab751 100644 --- a/apps/nowcasting-app/components/helpers/data.test.ts +++ b/apps/nowcasting-app/components/helpers/data.test.ts @@ -521,154 +521,206 @@ describe("getEarliestForecastTimestamp", () => { beforeAll(() => { jest.useFakeTimers(); Settings.defaultZone = "utc"; // Enforce UTC for all DateTime operations during tests + // Ensure environment variables do not affect deterministic tests + // Save and clear any existing values so tests are deterministic + (global as any).__orig_HISTORY_START_TYPE = process.env.NEXT_PUBLIC_HISTORY_START_TYPE; + (global as any).__orig_HISTORY_START_OFFSET_HOURS = + process.env.NEXT_PUBLIC_HISTORY_START_OFFSET_HOURS; + delete process.env.NEXT_PUBLIC_HISTORY_START_TYPE; + delete process.env.NEXT_PUBLIC_HISTORY_START_OFFSET_HOURS; }); afterAll(() => { jest.useRealTimers(); Settings.defaultZone = "system"; // Reset to system defaults after tests + // Restore any original env vars + if ((global as any).__orig_HISTORY_START_TYPE !== undefined) { + process.env.NEXT_PUBLIC_HISTORY_START_TYPE = (global as any).__orig_HISTORY_START_TYPE; + } else { + delete process.env.NEXT_PUBLIC_HISTORY_START_TYPE; + } + if ((global as any).__orig_HISTORY_START_OFFSET_HOURS !== undefined) { + process.env.NEXT_PUBLIC_HISTORY_START_OFFSET_HOURS = ( + global as any + ).__orig_HISTORY_START_OFFSET_HOURS; + } else { + delete process.env.NEXT_PUBLIC_HISTORY_START_OFFSET_HOURS; + } + }); + + // Ensure no test leaks env vars to subsequent tests + afterEach(() => { + delete process.env.NEXT_PUBLIC_HISTORY_START_TYPE; + delete process.env.NEXT_PUBLIC_HISTORY_START_OFFSET_HOURS; }); describe("General Behaviour", () => { - it("calculates two days prior, rounded to the nearest 6-hour interval in UTC+0", () => { + beforeEach(() => { + // Reset environment variables before each test + delete process.env.NEXT_PUBLIC_HISTORY_START_TYPE; + delete process.env.NEXT_PUBLIC_HISTORY_START_OFFSET_HOURS; + }); + + it("uses rolling mode with 48 hours by default", () => { jest.setSystemTime(new Date("2025-12-07T14:45:00Z").getTime()); const result = getEarliestForecastTimestamp(); - - // Two days back from 14:45 UTC == 2025-12-05 14:45 UTC - // Nearest 6-hour tick = 2025-12-05 12:00 UTC + // 48 hours back from 14:45 UTC == 2025-12-05 14:45 UTC + // Rounded to nearest 6-hour interval = 2025-12-05 12:00 UTC expect(result).toBe("2025-12-05T12:00:00.000Z"); }); - it("correctly rounds down 2 days back for local timezone (e.g. UTC+2)", () => { - jest - .spyOn(DateTime, "now") - .mockReturnValue( - DateTime.fromISO("2025-12-07T14:45:00+02:00", { setZone: true }) as DateTime - ); + it("supports fixed mode starting at midnight", () => { + jest.setSystemTime(new Date("2025-12-07T14:45:00Z").getTime()); + process.env.NEXT_PUBLIC_HISTORY_START_TYPE = "fixed"; + process.env.NEXT_PUBLIC_HISTORY_START_OFFSET_HOURS = "48"; const result = getEarliestForecastTimestamp(); - - // Local time UTC+2 => 2025-12-05 14:45 (local) => 12:00 local rounded 6 hr tick => 10:00 UTC - expect(result).toBe("2025-12-05T10:00:00.000Z"); + // Two days back at midnight UTC + expect(result).toBe("2025-12-05T00:00:00.000Z"); }); - }); - it("returns a 6-hour aligned UTC timestamp", () => { - // Mock the current time to a specific UTC date. - jest.spyOn(DateTime, "now").mockReturnValue( - DateTime.fromISO("2025-12-07T14:45:00.000Z").toUTC() as DateTime // Mock current time in UTC - ); - - const result = getEarliestForecastTimestamp(); + it("supports rolling mode with custom offset", () => { + jest.setSystemTime(new Date("2025-12-07T14:45:00Z").getTime()); + process.env.NEXT_PUBLIC_HISTORY_START_TYPE = "rolling"; + process.env.NEXT_PUBLIC_HISTORY_START_OFFSET_HOURS = "72"; - // Two days before 2025-12-07T14:45:00Z is 2025-12-05T14:45:00Z - // Rounded down to the nearest 6-hour boundary --> 2025-12-05T12:00:00Z - expect(result).toBe("2025-12-05T12:00:00.000Z"); + const result = getEarliestForecastTimestamp(); + // 72 hours back from 14:45 UTC == 2025-12-04 14:45 UTC + // Rounded to nearest 6-hour interval = 2025-12-04 12:00 UTC + expect(result).toBe("2025-12-04T12:00:00.000Z"); + }); }); - it("returns correctly rounded 6-hour boundary for a time just before midnight UTC", () => { + it("correctly rounds down 2 days back for local timezone (e.g. UTC+2)", () => { jest .spyOn(DateTime, "now") - .mockReturnValue(DateTime.fromISO("2025-12-07T23:59:59.000Z").toUTC() as DateTime); + .mockReturnValue( + DateTime.fromISO("2025-12-07T14:45:00+02:00", { setZone: true }) as DateTime + ); const result = getEarliestForecastTimestamp(); - // Two days before is 2025-12-05T23:59:59Z --> Rounded down: 2025-12-05T18:00:00Z - expect(result).toBe("2025-12-05T18:00:00.000Z"); - }); - - it("handles time zones with positive offset correctly", () => { - // Mock the current time in a timezone with +05:30 offset (e.g., India Standard Time). - jest.spyOn(DateTime, "now").mockReturnValue( - DateTime.fromISO("2025-12-07T14:45:00+05:30", { setZone: true }) as DateTime // Mock current time in IST - ); - const result = getEarliestForecastTimestamp(); - // Two days before in local time: 2025-12-05T14:45:00+05:30 - // Rounded down: 2025-12-05T12:00:00Z - // Converted to UTC: 2025-12-05T06:30:00Z - expect(result).toBe("2025-12-05T06:30:00.000Z"); + // Local time UTC+2 => 2025-12-05 14:45 (local) => 12:00 local rounded 6 hr tick => 10:00 UTC + expect(result).toBe("2025-12-05T10:00:00.000Z"); }); +}); - it("handles time zones with negative offset correctly", () => { - // Mock the current time in a timezone with -05:00 offset (e.g., Eastern Standard Time). - jest.spyOn(DateTime, "now").mockReturnValue( - DateTime.fromISO("2025-12-07T14:45:00-05:00", { setZone: true }) as DateTime // Mock current time in EST - ); +it("returns a 6-hour aligned UTC timestamp", () => { + // Mock the current time to a specific UTC date. + jest.spyOn(DateTime, "now").mockReturnValue( + DateTime.fromISO("2025-12-07T14:45:00.000Z").toUTC() as DateTime // Mock current time in UTC + ); - const result = getEarliestForecastTimestamp(); - // Two days before in local time: 2025-12-05T14:45:00-05:00 - // Rounded down: 2025-12-05T12:00:00Z - // Converted to UTC: 2025-12-05T17:00:00Z - expect(result).toBe("2025-12-05T17:00:00.000Z"); - }); + const result = getEarliestForecastTimestamp(); - it("handles Daylight Saving Time transitions (spring forward)", () => { - // Mock the current time to just after a spring-forward DST change to BST. - jest - .spyOn(DateTime, "now") - .mockReturnValue( - DateTime.fromISO("2025-03-30T03:30:00+01:00", { setZone: true }) as DateTime - ); + // Two days before 2025-12-07T14:45:00Z is 2025-12-05T14:45:00Z + // Rounded down to the nearest 6-hour boundary --> 2025-12-05T12:00:00Z + expect(result).toBe("2025-12-05T12:00:00.000Z"); +}); - const result = getEarliestForecastTimestamp(); - // Two days before in local time: 2025-03-28T03:30:00+02:00 - // Rounded down: 2025-03-28T00:00:00Z - // Converted to UTC: 2025-03-27T22:00:00Z - expect(result).toBe("2025-03-27T23:00:00.000Z"); - }); +it("returns correctly rounded 6-hour boundary for a time just before midnight UTC", () => { + jest + .spyOn(DateTime, "now") + .mockReturnValue(DateTime.fromISO("2025-12-07T23:59:59.000Z").toUTC() as DateTime); - it("handles Daylight Saving Time transitions (fall back)", () => { - // Mock the current time to just after a fall-back DST change back to GMT. - jest - .spyOn(DateTime, "now") - .mockReturnValue( - DateTime.fromISO("2025-10-26T02:30:00+00:00", { setZone: true }) as DateTime - ); + const result = getEarliestForecastTimestamp(); + // Two days before is 2025-12-05T23:59:59Z --> Rounded down: 2025-12-05T18:00:00Z + expect(result).toBe("2025-12-05T18:00:00.000Z"); +}); - const result = getEarliestForecastTimestamp(); - // Two days before in local time: 2025-10-24T01:30:00+02:00 - // Rounded down: 2025-10-24T00:00:00Z - // Converted to UTC: 2025-10-23T22:00:00Z - expect(result).toBe("2025-10-24T00:00:00.000Z"); - }); +it("handles time zones with positive offset correctly", () => { + // Mock the current time in a timezone with +05:30 offset (e.g., India Standard Time). + jest.spyOn(DateTime, "now").mockReturnValue( + DateTime.fromISO("2025-12-07T14:45:00+05:30", { setZone: true }) as DateTime // Mock current time in IST + ); + + const result = getEarliestForecastTimestamp(); + // Two days before in local time: 2025-12-05T14:45:00+05:30 + // Rounded down: 2025-12-05T12:00:00Z + // Converted to UTC: 2025-12-05T06:30:00Z + expect(result).toBe("2025-12-05T06:30:00.000Z"); +}); - describe("Handle before and after noon in BST", () => { - it("handles before noon in BST", () => { - // Mock the current time to just before noon in BST. - jest.spyOn(DateTime, "now").mockReturnValue( - DateTime.fromISO("2025-06-01T11:59:59+01:00", { setZone: true }) as DateTime // Mock BST - ); +it("handles time zones with negative offset correctly", () => { + // Mock the current time in a timezone with -05:00 offset (e.g., Eastern Standard Time). + jest.spyOn(DateTime, "now").mockReturnValue( + DateTime.fromISO("2025-12-07T14:45:00-05:00", { setZone: true }) as DateTime // Mock current time in EST + ); + + const result = getEarliestForecastTimestamp(); + // Two days before in local time: 2025-12-05T14:45:00-05:00 + // Rounded down: 2025-12-05T12:00:00Z + // Converted to UTC: 2025-12-05T17:00:00Z + expect(result).toBe("2025-12-05T17:00:00.000Z"); +}); - const result = getEarliestForecastTimestamp(); - // Two days before in local time: 2025-05-30T11:30:00+01:00 - // Rounded down: 2025-05-30T06:00:00Z - expect(result).toBe("2025-05-30T05:00:00.000Z"); - }); +it("handles Daylight Saving Time transitions (spring forward)", () => { + // Mock the current time to just after a spring-forward DST change to BST. + jest + .spyOn(DateTime, "now") + .mockReturnValue( + DateTime.fromISO("2025-03-30T03:30:00+01:00", { setZone: true }) as DateTime + ); - it("handles after noon in BST", () => { - // Mock the current time to just after noon in BST. - jest.spyOn(DateTime, "now").mockReturnValue( - DateTime.fromISO("2025-06-01T12:00:00+01:00", { setZone: true }) as DateTime // Mock BST - ); + const result = getEarliestForecastTimestamp(); + // Two days before in local time: 2025-03-28T03:30:00+02:00 + // Rounded down: 2025-03-28T00:00:00Z + // Converted to UTC: 2025-03-27T22:00:00Z + expect(result).toBe("2025-03-27T23:00:00.000Z"); +}); - const result = getEarliestForecastTimestamp(); - // Two days before in local time: 2025-05-30T12:30:00+01:00 - // Rounded down: 2025-05-30T06:00:00Z - expect(result).toBe("2025-05-30T11:00:00.000Z"); - }); - }); +it("handles Daylight Saving Time transitions (fall back)", () => { + // Mock the current time to just after a fall-back DST change back to GMT. + jest + .spyOn(DateTime, "now") + .mockReturnValue( + DateTime.fromISO("2025-10-26T02:30:00+00:00", { setZone: true }) as DateTime + ); - it("returns correctly aligned UTC values with no timezone (system default)", () => { - // Restore default zone to simulate system timezone behavior. - Settings.defaultZone = "system"; + const result = getEarliestForecastTimestamp(); + // Two days before in local time: 2025-10-24T01:30:00+02:00 + // Rounded down: 2025-10-24T00:00:00Z + // Converted to UTC: 2025-10-23T22:00:00Z + expect(result).toBe("2025-10-24T00:00:00.000Z"); +}); - // Mock the current time in the system default zone. +describe("Handle before and after noon in BST", () => { + it("handles before noon in BST", () => { + // Mock the current time to just before noon in BST. jest.spyOn(DateTime, "now").mockReturnValue( - DateTime.fromISO("2025-12-07T14:45:00") as DateTime // Mock without explicit UTC or timezone + DateTime.fromISO("2025-06-01T11:59:59+01:00", { setZone: true }) as DateTime // Mock BST ); const result = getEarliestForecastTimestamp(); + // Two days before in local time: 2025-05-30T11:30:00+01:00 + // Rounded down: 2025-05-30T06:00:00Z + expect(result).toBe("2025-05-30T05:00:00.000Z"); + }); + + it("handles after noon in BST", () => { + // Mock the current time to just after noon in BST. + jest.spyOn(DateTime, "now").mockReturnValue( + DateTime.fromISO("2025-06-01T12:00:00+01:00", { setZone: true }) as DateTime // Mock BST + ); - // The result depends on the system timezone but must align to a 6-hour boundary. - console.log(result); // Log for visual validation in non-UTC systems. + const result = getEarliestForecastTimestamp(); + // Two days before in local time: 2025-05-30T12:30:00+01:00 + // Rounded down: 2025-05-30T06:00:00Z + expect(result).toBe("2025-05-30T11:00:00.000Z"); }); }); + +it("returns correctly aligned UTC values with no timezone (system default)", () => { + // Restore default zone to simulate system timezone behavior. + Settings.defaultZone = "system"; + + // Mock the current time in the system default zone. + jest.spyOn(DateTime, "now").mockReturnValue( + DateTime.fromISO("2025-12-07T14:45:00") as DateTime // Mock without explicit UTC or timezone + ); + + const result = getEarliestForecastTimestamp(); + + // The result depends on the system timezone but must align to a 6-hour boundary. + console.log(result); // Log for visual validation in non-UTC systems. +}); diff --git a/apps/nowcasting-app/components/helpers/data.ts b/apps/nowcasting-app/components/helpers/data.ts index 38aa5e64..eff99969 100644 --- a/apps/nowcasting-app/components/helpers/data.ts +++ b/apps/nowcasting-app/components/helpers/data.ts @@ -437,21 +437,28 @@ export const getOldestTimestampFromForecastValues = (forecastValues: ForecastDat /** * Calculates the earliest forecast timestamp based on the default behavior of the Quartz Solar API. + * Gets the history start time based on environment variables * - * This function determines the timestamp two days prior to the current time, rounds it down - * to the nearest 6-hour interval (e.g., 00:00, 06:00, 12:00, 18:00) in local time, and finally - * converts the result back to UTC as an ISO-8601 string. + * NEXT_PUBLIC_HISTORY_START_TYPE: 'fixed' | 'rolling' + * NEXT_PUBLIC_HISTORY_START_OFFSET_HOURS: number (e.g. 48, 72) * + * This function determines start time,based on: + * + * 1. Type: 'fixed' (start from midnight) or 'rolling' (offset from now) + * 2. Offset: Number of hours to look back (default: 48) + * + * For both modes, the result is rounded down to the nearest 6-hour interval + * to match the API's data granularity * Key Features: * - Handles time zones correctly by rounding in the user's local timezone first. * - Ensures accurate rounding during Daylight Saving Time (DST) changes. * - * @returns {string} The earliest forecast timestamp in UTC as an ISO-8601 string. + * @returns {string} The history start timestamp in UTC as an ISO-8601 string * * @example - * // Assuming the current time is 2025-12-07T14:45:00Z: - * const result = getEarliestForecastTimestamp(); - * console.log(result); // Output: "2025-12-05T12:00:00.000Z" + * // With NEXT_PUBLIC_HISTORY_START_TYPE='fixed' and NEXT_PUBLIC_HISTORY_START_OFFSET_HOURS='48' + * // If current time is 2025-12-07T14:45:00Z + * console.log(result); // Output: "2025-12-05T00:00:00.000Z" */ export const getEarliestForecastTimestamp = (): string => { @@ -460,18 +467,26 @@ export const getEarliestForecastTimestamp = (): string => { // so they might see slightly different data around the rounding times. const now = DateTime.now(); // Defaults to the user's system timezone - // Two days ago in local time - const twoDaysAgoLocal = now.minus({ days: 2 }); - - // Round down to the nearest 6-hour interval in the user's local timezone - const roundedDownLocal = twoDaysAgoLocal.startOf("hour").minus({ - hours: twoDaysAgoLocal.hour % 6 // Rounds down to the last multiple of 6 - }); - - // Convert the rounded timestamp back to UTC - const roundedDownUtc = roundedDownLocal.toUTC(); + const startType = process.env.NEXT_PUBLIC_HISTORY_START_TYPE || "rolling"; + const offsetHours = parseInt(process.env.NEXT_PUBLIC_HISTORY_START_OFFSET_HOURS || "48", 10); - return roundedDownUtc.toISO(); // Return as an ISO-8601 UTC string + if (startType === "fixed") { + // For fixed mode, start from previous midnight + const startTime = now.minus({ days: Math.ceil(offsetHours / 24) }).startOf("day"); + // Round down to nearest 6-hour interval + const roundedTime = startTime.minus({ + hours: startTime.hour % 6 + }); + return roundedTime.toUTC().toISO(); + } else { + // For rolling mode, go back by offset hours + const startTime = now.minus({ hours: offsetHours }); + // Round down to nearest 6-hour interval + const roundedTime = startTime.startOf("hour").minus({ + hours: startTime.hour % 6 + }); + return roundedTime.toUTC().toISO(); + } }; const MILLISECONDS_PER_MINUTE = 1000 * 60;