Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/nowcasting-app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
264 changes: 158 additions & 106 deletions apps/nowcasting-app/components/helpers/data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<true> // 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<true>);
.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<true> // 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<true> // 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<true> // 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<true>
);
// 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<true>);

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<true>
);
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<true> // 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<true> // 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<true> // 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<true>
);

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<true> // 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<true>
);

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<true> // Mock without explicit UTC or timezone
DateTime.fromISO("2025-06-01T11:59:59+01:00", { setZone: true }) as DateTime<true> // 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<true> // 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<true> // 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.
});
51 changes: 33 additions & 18 deletions apps/nowcasting-app/components/helpers/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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;
Expand Down
Loading