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
12 changes: 12 additions & 0 deletions apps/nowcasting-app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,15 @@
#NEXT_PUBLIC_API_PREFIX='http://localhost:8000/v0'
#NEXT_PUBLIC_4H_VIEW='true'
#NEXT_PUBLIC_DEV_MODE='true'

# History Window Configuration
# Controls how far back in time the UI fetches forecast and actual data

# Mode: "rolling" (relative to now) or "fixed" (fixed to midnight N days ago)
NEXT_PUBLIC_HISTORY_START_MODE=rolling

# Offset in hours from current time (for rolling) or days to look back (for fixed)
# Examples:
# - 48 hours = 2 days (default)
# - 72 hours = 3 days
NEXT_PUBLIC_HISTORY_OFFSET_HOURS=48
80 changes: 67 additions & 13 deletions apps/nowcasting-app/components/helpers/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,36 +436,90 @@ export const getOldestTimestampFromForecastValues = (forecastValues: ForecastDat
};

/**
* Calculates the earliest forecast timestamp based on the default behavior of the Quartz Solar API.
* Gets the configurable history start mode from environment variables.
* @returns "rolling" or "fixed"
*/
export const getHistoryStartMode = (): "rolling" | "fixed" => {
const mode = process.env.NEXT_PUBLIC_HISTORY_START_MODE || "rolling";
if (mode !== "rolling" && mode !== "fixed") {
console.warn(`Invalid NEXT_PUBLIC_HISTORY_START_MODE: "${mode}". Defaulting to "rolling".`);
return "rolling";
}
return mode;
};

/**
* Gets the configurable history offset in hours from environment variables.
* @returns offset in hours (default: 48)
*/
export const getHistoryOffsetHours = (): number => {
const offsetStr = process.env.NEXT_PUBLIC_HISTORY_OFFSET_HOURS;
const offset = offsetStr ? parseInt(offsetStr, 10) : 48;

if (isNaN(offset) || offset < 0) {
console.warn(`Invalid NEXT_PUBLIC_HISTORY_OFFSET_HOURS: "${offsetStr}". Defaulting to 48.`);
return 48;
}

return offset;
};

/**
* Calculates the earliest forecast timestamp based on configurable mode.
*
* This function determines the history window start time based on environment configuration.
* It supports two modes:
* - "rolling": offset hours back from now, rounded to 6-hour intervals in local time
* - "fixed": midnight UTC N days ago (where N = offset hours / 24)
*
* 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.
* Environment Variables:
* - NEXT_PUBLIC_HISTORY_START_MODE: "rolling" (default) or "fixed"
* - NEXT_PUBLIC_HISTORY_OFFSET_HOURS: number of hours (default: 48)
*
* Key Features:
* - Handles time zones correctly by rounding in the user's local timezone first.
* - Ensures accurate rounding during Daylight Saving Time (DST) changes.
* - Configurable via environment variables
* - Handles time zones correctly by rounding in the user's local timezone first
* - Ensures accurate rounding during Daylight Saving Time (DST) changes
* - Maintains backward compatibility (defaults to 48 hours rolling)
*
* @returns {string} The earliest forecast timestamp in UTC as an ISO-8601 string.
*
* @example
* // Assuming the current time is 2025-12-07T14:45:00Z:
* // Rolling mode (default): 48 hours back with 6-hour rounding
* // NEXT_PUBLIC_HISTORY_START_MODE=rolling
* // NEXT_PUBLIC_HISTORY_OFFSET_HOURS=48
* const result = getEarliestForecastTimestamp();
* console.log(result); // Output: "2025-12-28T12:00:00.000Z" (if now is 2025-12-30T14:45:00Z)
*
* @example
* // Fixed mode: 2 days ago at midnight
* // NEXT_PUBLIC_HISTORY_START_MODE=fixed
* // NEXT_PUBLIC_HISTORY_OFFSET_HOURS=48
* const result = getEarliestForecastTimestamp();
* console.log(result); // Output: "2025-12-05T12:00:00.000Z"
* console.log(result); // Output: "2025-12-28T00:00:00.000Z" (if now is 2025-12-30T14:45:00Z)
*/

export const getEarliestForecastTimestamp = (): string => {
const mode = getHistoryStartMode();
const offsetHours = getHistoryOffsetHours();

// Get the current time in the user's local timezone
// NB: if the user is not UK-based, this will not be the same as the Quartz API's UTC-based behavior,
// 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 });
if (mode === "fixed") {
// Fixed mode: midnight UTC N days ago
const daysAgo = Math.floor(offsetHours / 24);
const fixedDate = now.minus({ days: daysAgo }).startOf("day");
return fixedDate.toUTC().toISO();
}

// Rolling mode (default): offset hours back, rounded to 6-hour intervals
const hoursAgoLocal = now.minus({ hours: offsetHours });

// 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
const roundedDownLocal = hoursAgoLocal.startOf("hour").minus({
hours: hoursAgoLocal.hour % 6 // Rounds down to the last multiple of 6
});

// Convert the rounded timestamp back to UTC
Expand Down
125 changes: 125 additions & 0 deletions apps/nowcasting-app/components/helpers/historyConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { getEarliestForecastTimestamp, getHistoryStartMode, getHistoryOffsetHours } from "./data";
import { DateTime } from "luxon";

describe("History Window Configuration", () => {
const originalEnv = process.env;

beforeEach(() => {
// Reset process.env before each test
jest.resetModules();
process.env = { ...originalEnv };
});

afterAll(() => {
process.env = originalEnv;
});

describe("getHistoryStartMode", () => {
it("should default to 'rolling' when env var is not set", () => {
delete process.env.NEXT_PUBLIC_HISTORY_START_MODE;
expect(getHistoryStartMode()).toBe("rolling");
});

it("should return 'rolling' when explicitly set", () => {
process.env.NEXT_PUBLIC_HISTORY_START_MODE = "rolling";
expect(getHistoryStartMode()).toBe("rolling");
});

it("should return 'fixed' when explicitly set", () => {
process.env.NEXT_PUBLIC_HISTORY_START_MODE = "fixed";
expect(getHistoryStartMode()).toBe("fixed");
});

it("should default to 'rolling' for invalid values", () => {
process.env.NEXT_PUBLIC_HISTORY_START_MODE = "invalid";
expect(getHistoryStartMode()).toBe("rolling");
});
});

describe("getHistoryOffsetHours", () => {
it("should default to 48 when env var is not set", () => {
delete process.env.NEXT_PUBLIC_HISTORY_OFFSET_HOURS;
expect(getHistoryOffsetHours()).toBe(48);
});

it("should return custom offset when set", () => {
process.env.NEXT_PUBLIC_HISTORY_OFFSET_HOURS = "72";
expect(getHistoryOffsetHours()).toBe(72);
});

it("should default to 48 for invalid values", () => {
process.env.NEXT_PUBLIC_HISTORY_OFFSET_HOURS = "not-a-number";
expect(getHistoryOffsetHours()).toBe(48);
});

it("should default to 48 for negative values", () => {
process.env.NEXT_PUBLIC_HISTORY_OFFSET_HOURS = "-10";
expect(getHistoryOffsetHours()).toBe(48);
});
});

describe("getEarliestForecastTimestamp", () => {
it("should return a valid ISO timestamp", () => {
const result = getEarliestForecastTimestamp();
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});

it("should use rolling mode by default", () => {
delete process.env.NEXT_PUBLIC_HISTORY_START_MODE;
delete process.env.NEXT_PUBLIC_HISTORY_OFFSET_HOURS;

const result = getEarliestForecastTimestamp();
const resultDate = DateTime.fromISO(result);
const now = DateTime.now();

// Should be approximately 48 hours ago
const hoursDiff = now.diff(resultDate, "hours").hours;
expect(hoursDiff).toBeGreaterThan(46);
expect(hoursDiff).toBeLessThan(50);
});

it("should use fixed mode when configured", () => {
process.env.NEXT_PUBLIC_HISTORY_START_MODE = "fixed";
process.env.NEXT_PUBLIC_HISTORY_OFFSET_HOURS = "48";

const result = getEarliestForecastTimestamp();
const resultDate = DateTime.fromISO(result);

// Should be at midnight (00:00:00)
expect(resultDate.hour).toBe(0);
expect(resultDate.minute).toBe(0);
expect(resultDate.second).toBe(0);

// Should be approximately 2 days ago
const now = DateTime.now();
const daysDiff = now.diff(resultDate, "days").days;
expect(daysDiff).toBeGreaterThan(1.9);
expect(daysDiff).toBeLessThan(2.1);
});

it("should respect custom offset hours in rolling mode", () => {
process.env.NEXT_PUBLIC_HISTORY_START_MODE = "rolling";
process.env.NEXT_PUBLIC_HISTORY_OFFSET_HOURS = "72";

const result = getEarliestForecastTimestamp();
const resultDate = DateTime.fromISO(result);
const now = DateTime.now();

// Should be approximately 72 hours ago
const hoursDiff = now.diff(resultDate, "hours").hours;
expect(hoursDiff).toBeGreaterThan(70);
expect(hoursDiff).toBeLessThan(74);
});

it("should round to 6-hour intervals in rolling mode", () => {
process.env.NEXT_PUBLIC_HISTORY_START_MODE = "rolling";

const result = getEarliestForecastTimestamp();
const resultDate = DateTime.fromISO(result);

// Hour should be divisible by 6 (0, 6, 12, or 18)
expect(resultDate.hour % 6).toBe(0);
expect(resultDate.minute).toBe(0);
});
});
});
11 changes: 11 additions & 0 deletions apps/quartz-app/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# History Window Configuration
# Controls how far back in time the UI fetches forecast and actual data

# Mode: "rolling" (relative to now) or "fixed" (fixed to midnight N days ago)
NEXT_PUBLIC_HISTORY_START_MODE=rolling

# Offset in hours from current time (for rolling) or days to look back (for fixed)
# Examples:
# - 48 hours = 2 days (default)
# - 72 hours = 3 days
NEXT_PUBLIC_HISTORY_OFFSET_HOURS=48
3 changes: 3 additions & 0 deletions apps/quartz-app/src/data/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { QueryFunction, useQuery } from "@tanstack/react-query";
import { components, operations, paths } from "../types/schema";
import client from "./apiClient";
import { getHistoryStartISO } from "../helpers/historyWindow";

// paths
export const GET_REGIONS = "/{source}/regions";
Expand Down Expand Up @@ -56,6 +57,7 @@ export const getGenerationQuery = (
query: {
...sharedQueryParams,
resample_minutes: 15,
start_datetime_utc: getHistoryStartISO() as any,
},
},
// Add bearer token to headers
Expand Down Expand Up @@ -90,6 +92,7 @@ export const getForecastQuery = (
forecast_horizon,
forecast_horizon_minutes:
forecast_horizon === "horizon" ? forecast_horizon_minutes : null,
start_datetime_utc: getHistoryStartISO() as any,
},
},
// Add bearer token to headers
Expand Down
76 changes: 76 additions & 0 deletions apps/quartz-app/src/helpers/historyWindow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
getHistoryStart,
getHistoryStartISO,
getHistoryStartMode,
getHistoryOffsetHours,
} from "./historyWindow";
import { DateTime } from "luxon";

describe("History Window Configuration", () => {
const originalEnv = process.env;

beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
});

afterAll(() => {
process.env = originalEnv;
});

describe("getHistoryStartMode", () => {
it("should default to 'rolling' when env var is not set", () => {
delete process.env.NEXT_PUBLIC_HISTORY_START_MODE;
expect(getHistoryStartMode()).toBe("rolling");
});

it("should return 'fixed' when explicitly set", () => {
process.env.NEXT_PUBLIC_HISTORY_START_MODE = "fixed";
expect(getHistoryStartMode()).toBe("fixed");
});
});

describe("getHistoryOffsetHours", () => {
it("should default to 48 when env var is not set", () => {
delete process.env.NEXT_PUBLIC_HISTORY_OFFSET_HOURS;
expect(getHistoryOffsetHours()).toBe(48);
});

it("should return 72 when set", () => {
process.env.NEXT_PUBLIC_HISTORY_OFFSET_HOURS = "72";
expect(getHistoryOffsetHours()).toBe(72);
});
});

describe("getHistoryStart", () => {
it("should return rolling mode by default", () => {
delete process.env.NEXT_PUBLIC_HISTORY_START_MODE;
delete process.env.NEXT_PUBLIC_HISTORY_OFFSET_HOURS;

const result = getHistoryStart();
const now = DateTime.now().toUTC();

const hoursDiff = now.diff(result, "hours").hours;
expect(hoursDiff).toBeGreaterThan(47);
expect(hoursDiff).toBeLessThan(49);
});

it("should use fixed mode when configured", () => {
process.env.NEXT_PUBLIC_HISTORY_START_MODE = "fixed";
process.env.NEXT_PUBLIC_HISTORY_OFFSET_HOURS = "48";

const result = getHistoryStart();

expect(result.hour).toBe(0);
expect(result.minute).toBe(0);
expect(result.second).toBe(0);
});
});

describe("getHistoryStartISO", () => {
it("should return valid ISO string", () => {
const result = getHistoryStartISO();
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
});
});
Loading
Loading