From 1417edf0a5e5dc46fc9d8c549ebf15483426d2ed Mon Sep 17 00:00:00 2001 From: Takaaki Abe Date: Thu, 22 Oct 2026 15:38:33 +0900 Subject: [PATCH] feat(status-timeline): compute bar days in a time zone (#2944) * feat(status-timeline): find a calendar day's window in a time zone * refactor(status-timeline): pass a bar's day window instead of its date * feat(status-timeline): lay out days and their status in a time zone * feat(status-page): fold bars and uptime into days of a time zone --- .../router/statusPage.utils.timezone.test.ts | 96 +++++++++++ packages/api/src/router/statusPage.utils.ts | 137 +++++++-------- packages/services/package.json | 1 + .../services/src/frozen-uptime/get-history.ts | 5 +- .../__tests__/day-window.test.ts | 46 +++++ .../status-timeline/__tests__/events.test.ts | 33 ++-- .../__tests__/time-zone.test.ts | 157 ++++++++++++++++++ .../src/status-timeline/day-window.ts | 29 ++++ .../services/src/status-timeline/events.ts | 62 +++---- .../services/src/status-timeline/index.ts | 1 + .../services/src/status-timeline/uptime.ts | 15 +- pnpm-lock.yaml | 3 + 12 files changed, 452 insertions(+), 133 deletions(-) create mode 100644 packages/api/src/router/statusPage.utils.timezone.test.ts create mode 100644 packages/services/src/status-timeline/__tests__/day-window.test.ts create mode 100644 packages/services/src/status-timeline/__tests__/time-zone.test.ts create mode 100644 packages/services/src/status-timeline/day-window.ts diff --git a/packages/api/src/router/statusPage.utils.timezone.test.ts b/packages/api/src/router/statusPage.utils.timezone.test.ts new file mode 100644 index 000000000..59c5a9e6a --- /dev/null +++ b/packages/api/src/router/statusPage.utils.timezone.test.ts @@ -0,0 +1,96 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { + type Event, + getUptime, + setDataByType, + type StatusData, +} from "./statusPage.utils"; + +function bucket(date: string, ok = 100): StatusData { + return { + day: `${date}T00:00:00.000Z`, + count: ok, + ok, + degraded: 0, + error: 0, + monitorId: "1", + }; +} + +function incident(from: string, to: string): Event { + return { + id: 1, + name: "incident", + from: new Date(from), + to: new Date(to), + type: "incident", + status: "error", + }; +} + +describe("setDataByType in a time zone", () => { + // 01:00-01:30 in Tokyo on 2026-10-16, still 2026-10-15 in UTC + const early = incident( + "2026-10-15T16:00:00.000Z", + "2026-10-15T16:30:00.000Z", + ); + + it("colors the bar of the zone's date", () => { + const [tokyo] = setDataByType({ + events: [early], + data: [bucket("2026-10-16")], + cardType: "requests", + barType: "dominant", + timeZone: "Asia/Tokyo", + }); + expect(tokyo.bar[0].status).toBe("error"); + + const [utc] = setDataByType({ + events: [early], + data: [bucket("2026-10-16")], + cardType: "requests", + barType: "dominant", + }); + expect(utc.bar[0].status).toBe("success"); + }); + + it("measures downtime against the 25 hours of the day summer time ends", () => { + // 23:30-23:45 in Berlin on 2026-10-25, the day summer time ends + const late = incident( + "2026-10-25T22:30:00.000Z", + "2026-10-25T22:45:00.000Z", + ); + const [day] = setDataByType({ + events: [late], + data: [bucket("2026-10-25")], + cardType: "requests", + barType: "absolute", + timeZone: "Europe/Berlin", + }); + const error = day.bar.find((segment) => segment.status === "error"); + expect(error?.height).toBeCloseTo((15 / (25 * 60)) * 100); + }); +}); + +describe("getUptime in a time zone", () => { + // 00:00-02:24 in Tokyo on 2026-10-14: 10% of that day, still 2026-10-13 in UTC + const morning = incident( + "2026-10-13T15:00:00.000Z", + "2026-10-13T17:24:00.000Z", + ); + + it("counts downtime inside the zone's days", () => { + const uptime = (timeZone?: string) => + getUptime({ + data: [bucket("2026-10-14")], + events: [morning], + barType: "absolute", + cardType: "duration", + timeZone, + }); + expect(uptime("Asia/Tokyo")).toBe("90%"); + expect(uptime()).toBe("100%"); + }); +}); diff --git a/packages/api/src/router/statusPage.utils.ts b/packages/api/src/router/statusPage.utils.ts index b43b144b7..dc709bb10 100644 --- a/packages/api/src/router/statusPage.utils.ts +++ b/packages/api/src/router/statusPage.utils.ts @@ -8,14 +8,15 @@ import { import { clipToCoverage, type CoverageSegment, + type DayWindow, downtimeIntervals, type Event, - MS_PER_DAY, probeDowntimeIntervals, type StatusData, type UptimeWindow, type WeightedInterval, dayCoverage, + dayWindow, durationDowntimeMs, floorPct, getHighestPriorityStatus, @@ -91,14 +92,9 @@ function formatNumber(num: number): string { return num.toString(); } -// Helper to check if date is today -function isToday(date: Date): boolean { - const today = new Date(); - return ( - date.getUTCDate() === today.getUTCDate() && - date.getUTCMonth() === today.getUTCMonth() && - date.getUTCFullYear() === today.getUTCFullYear() - ); +function isToday({ start, end }: DayWindow): boolean { + const now = Date.now(); + return start <= now && now < end; } // Helper to format duration from minutes @@ -111,52 +107,44 @@ function formatDuration(minutes: number): string { } // Helper to calculate total minutes in a day (handles today vs past days) -function getTotalMinutesInDay(date: Date): number { - const now = new Date(); - const startOfDay = new Date(date); - startOfDay.setUTCHours(0, 0, 0, 0); - - if (isToday(date)) { - const minutesElapsed = Math.floor( - (now.getTime() - startOfDay.getTime()) / MILLISECONDS_PER_MINUTE, - ); - return minutesElapsed; - } - return 24 * 60; +function getTotalMinutesInDay(day: DayWindow): number { + const until = isToday(day) ? Date.now() : day.end; + return Math.floor((until - day.start) / MILLISECONDS_PER_MINUTE); } // Helper to calculate duration in minutes for a specific event type -function calculateEventDurationMinutes(events: Event[], date: Date): number { - const totalDuration = getTotalEventsDurationMs(events, date); +function calculateEventDurationMinutes( + events: Event[], + day: DayWindow, +): number { + const totalDuration = getTotalEventsDurationMs(events, day); return Math.round(totalDuration / MILLISECONDS_PER_MINUTE); } // Helper to calculate maintenance duration in minutes for a specific day function getMaintenanceDurationMinutes( maintenances: Event[], - date: Date, + day: DayWindow, ): number { - return calculateEventDurationMinutes(maintenances, date); + return calculateEventDurationMinutes(maintenances, day); } // Helper to get adjusted total minutes accounting for maintenance function getAdjustedTotalMinutesInDay( - date: Date, + day: DayWindow, maintenances: Event[], ): number { - const totalMinutes = getTotalMinutesInDay(date); - const maintenanceMinutes = getMaintenanceDurationMinutes(maintenances, date); + const totalMinutes = getTotalMinutesInDay(day); + const maintenanceMinutes = getMaintenanceDurationMinutes(maintenances, day); return Math.max(0, totalMinutes - maintenanceMinutes); } -function getTotalEventsDurationMs(events: Event[], date: Date): number { +function getTotalEventsDurationMs(events: Event[], day: DayWindow): number { if (events.length === 0) return 0; - const startOfDay = new Date(date); - startOfDay.setUTCHours(0, 0, 0, 0); - - const endOfDay = new Date(date); - endOfDay.setUTCHours(23, 59, 59, 999); + const { start: startOfDay, end } = day; + // the overlap math below treats `endOfDay` as an inclusive bound + const endOfDay = end - 1; const total = events.reduce((acc, curr) => { if (!curr.from) return acc; @@ -165,23 +153,19 @@ function getTotalEventsDurationMs(events: Event[], date: Date): number { const eventEnd = curr.to ? new Date(curr.to) : new Date(); // Only count events that overlap with this date - if ( - eventEnd.getTime() < startOfDay.getTime() || - eventStart.getTime() > endOfDay.getTime() - ) { + if (eventEnd.getTime() < startOfDay || eventStart.getTime() > endOfDay) { return acc; } // Calculate the overlapping duration within the date boundaries - const overlapStart = Math.max(eventStart.getTime(), startOfDay.getTime()); - const overlapEnd = Math.min(eventEnd.getTime(), endOfDay.getTime()); + const overlapStart = Math.max(eventStart.getTime(), startOfDay); + const overlapEnd = Math.min(eventEnd.getTime(), endOfDay); const duration = overlapEnd - overlapStart; return acc + Math.max(0, duration); }, 0); - // Cap at 24 hours per day - return Math.min(total, MS_PER_DAY); + return Math.min(total, end - startOfDay); } export function setDataByType({ @@ -189,18 +173,21 @@ export function setDataByType({ data, cardType, barType, + timeZone = "UTC", }: { events: Event[]; data: StatusData[]; cardType: "requests" | "duration" | "dominant" | "manual"; barType: "absolute" | "dominant" | "manual"; + /** IANA zone whose calendar days the bars stand for */ + timeZone?: string; }): UptimeData[] { // Helper functions moved inside to share inputs and avoid parameter passing function createEventSegments( incidents: Event[], reports: Event[], maintenances: Event[], - date: Date, + day: DayWindow, ): Array<{ status: "info" | "degraded" | "error"; count: number }> { // impact reports contribute per-interval slices to each color bucket, so a // 1h major_outage inside a 24h report only paints 1h red; legacy reports @@ -216,7 +203,7 @@ export function setDataByType({ const color = impactToStatusType(iv.impact); if (color === "success") continue; const slice = { ...report, from: iv.from, to: iv.to }; - if (!isDateWithinEvent(date, slice)) continue; + if (!isDateWithinEvent(day, slice)) continue; (color === "error" ? errorSlices : degradedSlices).push(slice); } } @@ -234,34 +221,36 @@ export function setDataByType({ .filter(({ events }) => events.length > 0) .map(({ status, events }) => ({ status, - count: getTotalEventsDurationMs(events, date), + count: getTotalEventsDurationMs(events, day), })); } function createErrorOnlyBarData( errorSegmentCount: number, + dayMs: number, ): UptimeData["bar"] { return [ { status: "success" as const, - height: ((MS_PER_DAY - errorSegmentCount) / MS_PER_DAY) * 100, + height: ((dayMs - errorSegmentCount) / dayMs) * 100, }, { status: "error" as const, - height: (errorSegmentCount / MS_PER_DAY) * 100, + height: (errorSegmentCount / dayMs) * 100, }, ]; } function createProportionalBarData( segments: Array<{ status: "info" | "degraded" | "error"; count: number }>, + dayMs: number, ): UptimeData["bar"] { // Downtime keeps its true proportion of the day; maintenance/reports are // highlight events that fill the remaining space (no uptime shown). const errorMs = segments .filter((segment) => segment.status === "error") .reduce((sum, segment) => sum + segment.count, 0); - const errorHeight = (Math.min(errorMs, MS_PER_DAY) / MS_PER_DAY) * 100; + const errorHeight = (Math.min(errorMs, dayMs) / dayMs) * 100; const remainingHeight = Math.max(0, 100 - errorHeight); const highlightSegments = segments.filter( @@ -375,8 +364,11 @@ export function setDataByType({ } // Helper to calculate duration in minutes for a specific event type - function calculateEventDurationMinutes(events: Event[], date: Date): number { - const totalDuration = getTotalEventsDurationMs(events, date); + function calculateEventDurationMinutes( + events: Event[], + day: DayWindow, + ): number { + const totalDuration = getTotalEventsDurationMs(events, day); return Math.round(totalDuration / MILLISECONDS_PER_MINUTE); } @@ -384,7 +376,7 @@ export function setDataByType({ function createDurationCardEntry( status: "error" | "degraded" | "info" | "success", events: Event[], - date: Date, + day: DayWindow, durationMap: Map, maintenances: Event[] = [], ): { @@ -397,10 +389,7 @@ export function setDataByType({ durationMap.forEach((minutes) => (totalEventMinutes += minutes)); // Use adjusted total minutes accounting for maintenance - const totalMinutesInDay = getAdjustedTotalMinutesInDay( - date, - maintenances, - ); + const totalMinutesInDay = getAdjustedTotalMinutesInDay(day, maintenances); const successMinutes = Math.max(totalMinutesInDay - totalEventMinutes, 0); if (successMinutes === 0) return null; @@ -411,7 +400,7 @@ export function setDataByType({ } // For error, degraded, info - calculate from events - const minutes = calculateEventDurationMinutes(events, date); + const minutes = calculateEventDurationMinutes(events, day); durationMap.set(status, minutes); if (minutes === 0) return null; @@ -422,10 +411,11 @@ export function setDataByType({ } return data.map((dayData) => { - const date = new Date(dayData.day); + const day = dayWindow(dayData.day, timeZone); + const dayMs = day.end - day.start; // Find events for this day - const dayEvents = events.filter((event) => isDateWithinEvent(date, event)); + const dayEvents = events.filter((event) => isDateWithinEvent(day, event)); // Determine status override based on events const incidents = dayEvents.filter((e) => e.type === "incident"); @@ -438,7 +428,7 @@ export function setDataByType({ // worst impact color across the day's reports; "success" (operational all // day) means reports don't color the day const reportsDayStatus = reports.length - ? getWorstVariant(reports.map((e) => reportEventDayStatus(e, date))) + ? getWorstVariant(reports.map((e) => reportEventDayStatus(e, day))) : undefined; const activeReportsDayStatus = reportsDayStatus === "success" ? undefined : reportsDayStatus; @@ -463,7 +453,7 @@ export function setDataByType({ incidents, reports, maintenances, - date, + day, ); // Special case: if only errors exist, show uptime vs downtime @@ -471,10 +461,10 @@ export function setDataByType({ eventSegments.length === 1 && eventSegments[0].status === "error" ) { - barData = createErrorOnlyBarData(eventSegments[0].count); + barData = createErrorOnlyBarData(eventSegments[0].count, dayMs); } else { // Multiple segments: show proportional distribution - barData = createProportionalBarData(eventSegments); + barData = createProportionalBarData(eventSegments, dayMs); } } else if (total === 0) { // Empty day - no data available @@ -560,7 +550,7 @@ export function setDataByType({ entry.count > 0 ) { const totalMinutes = getAdjustedTotalMinutesInDay( - date, + day, maintenances, ); const statusMinutes = Math.round( @@ -577,7 +567,7 @@ export function setDataByType({ return createDurationCardEntry( entry.status, events, - date, + day, durationMap, maintenances, ); @@ -599,7 +589,7 @@ export function setDataByType({ const manualCardStatus = activeReportsDayStatus ?? (hasMaintenances ? "info" : undefined); const dayImpacts = reports - .map((e) => reportEventDayImpact(e, date)) + .map((e) => reportEventDayImpact(e, day)) .filter((i): i is PageComponentImpact => i !== null); const worstDayImpact = dayImpacts.length > 0 ? worstImpact(dayImpacts) : null; @@ -659,7 +649,7 @@ export function setDataByType({ ...reports.map((e) => ({ ...e, status: - reportEventDayStatus(e, date) === "error" + reportEventDayStatus(e, day) === "error" ? ("error" as const) : ("degraded" as const), })), @@ -678,6 +668,7 @@ export function getUptime({ barType, cardType, privateLocationOnly = false, + timeZone = "UTC", }: { data: StatusData[]; events: Event[]; @@ -685,18 +676,18 @@ export function getUptime({ cardType: "requests" | "duration" | "dominant" | "manual"; /** no public regions and ≥1 private location: include probe errors in duration uptime */ privateLocationOnly?: boolean; + /** IANA zone whose calendar days `data` is keyed by */ + timeZone?: string; }): string { if (barType === "manual" || cardType === "duration") { // Clamp event durations to the data lookback window to avoid // events outside the window producing negative uptime values. - const timestamps = data.map((d) => new Date(d.day).getTime()); - const { segments: coverage, totalMs: total } = dayCoverage(timestamps); + const days = data.map((d) => dayWindow(d.day, timeZone)); + const { segments: coverage, totalMs: total } = dayCoverage(days); if (total === 0) return "100%"; - const windowEndDate = new Date(Math.max(...timestamps)); - windowEndDate.setUTCHours(23, 59, 59, 999); const window: UptimeWindow = { - start: Math.min(...timestamps), - end: windowEndDate.getTime(), + start: Math.min(...days.map((d) => d.start)), + end: Math.max(...days.map((d) => d.end)) - 1, now: Date.now(), }; @@ -711,7 +702,7 @@ export function getUptime({ // of the incident covering the same outage and nearly double it (#2481). const eventIntervals = downtimeIntervals(events, window, false); const probeIntervals = privateLocationOnly - ? probeDowntimeIntervals(data, window) + ? probeDowntimeIntervals(data, window, timeZone) : []; const allIntervals = [...eventIntervals, ...probeIntervals]; duration = mergedDowntimeMs( diff --git a/packages/services/package.json b/packages/services/package.json index 85583de2c..90a2141a3 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -160,6 +160,7 @@ "tsc": "tsc --noEmit" }, "dependencies": { + "@date-fns/tz": "catalog:", "@openstatus/assertions": "workspace:*", "@openstatus/db": "workspace:*", "@openstatus/importers": "workspace:*", diff --git a/packages/services/src/frozen-uptime/get-history.ts b/packages/services/src/frozen-uptime/get-history.ts index 7584a0c17..24be46e6e 100644 --- a/packages/services/src/frozen-uptime/get-history.ts +++ b/packages/services/src/frozen-uptime/get-history.ts @@ -8,6 +8,7 @@ import { defaultTb, getReadDb, type ServiceContext } from "../context"; import { ForbiddenError, NotFoundError } from "../errors"; import { dayCoverage, + dayWindow, durationDowntimeMs, type Event, floorPct, @@ -104,8 +105,8 @@ function durationMonth( // downtime is clipped to the checked days (a paused stretch can't exceed // the denominator) and the in-progress day is clamped to elapsed time so // 2h down on the 2nd isn't diluted by the rest of today - const dayStarts = withChecks.map((d) => Date.parse(`${d.day}T00:00:00.000Z`)); - const { segments, totalMs } = dayCoverage(dayStarts, nowMs); + const windows = withChecks.map((d) => dayWindow(d.day)); + const { segments, totalMs } = dayCoverage(windows, nowMs); if (segments.length === 0 || totalMs <= 0) return null; const window = { start: segments[0].start, diff --git a/packages/services/src/status-timeline/__tests__/day-window.test.ts b/packages/services/src/status-timeline/__tests__/day-window.test.ts new file mode 100644 index 000000000..53cc213f8 --- /dev/null +++ b/packages/services/src/status-timeline/__tests__/day-window.test.ts @@ -0,0 +1,46 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { dayWindow } from "../day-window"; + +const ms = (iso: string) => new Date(iso).getTime(); + +describe("dayWindow", () => { + test("runs from local midnight to the next local midnight", () => { + expect(dayWindow("2026-10-16")).toEqual({ + start: ms("2026-10-16T00:00:00.000Z"), + end: ms("2026-10-17T00:00:00.000Z"), + }); + expect(dayWindow("2026-10-16T00:00:00.000Z", "Asia/Tokyo")).toEqual({ + start: ms("2026-10-15T15:00:00.000Z"), + end: ms("2026-10-16T15:00:00.000Z"), + }); + }); + + test("handles offsets that are not whole hours", () => { + expect(dayWindow("2026-10-16", "Asia/Kolkata").start).toBe( + ms("2026-10-15T18:30:00.000Z"), + ); + expect(dayWindow("2026-10-16", "America/St_Johns").start).toBe( + ms("2026-10-16T02:30:00.000Z"), + ); + }); + + test("follows the offset change on the days summer time starts and ends", () => { + // Berlin leaves summer time on 2026-10-25 and enters it on 2026-03-29 + const fallBack = dayWindow("2026-10-25", "Europe/Berlin"); + expect(fallBack).toEqual({ + start: ms("2026-10-24T22:00:00.000Z"), + end: ms("2026-10-25T23:00:00.000Z"), + }); + const springForward = dayWindow("2026-03-29", "Europe/Berlin"); + expect(springForward.end - springForward.start).toBe(23 * 3_600_000); + }); + + test("starts at the first wall-clock time when midnight is skipped", () => { + // Santiago jumps from 00:00 to 01:00 on 2026-09-06 + expect(dayWindow("2026-09-06", "America/Santiago").start).toBe( + ms("2026-09-06T04:00:00.000Z"), + ); + }); +}); diff --git a/packages/services/src/status-timeline/__tests__/events.test.ts b/packages/services/src/status-timeline/__tests__/events.test.ts index c7814f115..b91797aa5 100644 --- a/packages/services/src/status-timeline/__tests__/events.test.ts +++ b/packages/services/src/status-timeline/__tests__/events.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, vi } from "vitest"; +import { dayWindow } from "../day-window"; import { activeReportStatus, type Event, @@ -226,20 +227,20 @@ describe("isDateWithinEvent", () => { const event = makeEvent({ from: day("2026-01-10"), to: day("2026-01-12") }); test("a day inside the window matches, including both boundaries", () => { - expect(isDateWithinEvent(day("2026-01-11"), event)).toBe(true); - expect(isDateWithinEvent(day("2026-01-10"), event)).toBe(true); - expect(isDateWithinEvent(day("2026-01-12"), event)).toBe(true); + expect(isDateWithinEvent(dayWindow("2026-01-11"), event)).toBe(true); + expect(isDateWithinEvent(dayWindow("2026-01-10"), event)).toBe(true); + expect(isDateWithinEvent(dayWindow("2026-01-12"), event)).toBe(true); }); test("days outside the window do not match", () => { - expect(isDateWithinEvent(day("2026-01-09"), event)).toBe(false); - expect(isDateWithinEvent(day("2026-01-13"), event)).toBe(false); + expect(isDateWithinEvent(dayWindow("2026-01-09"), event)).toBe(false); + expect(isDateWithinEvent(dayWindow("2026-01-13"), event)).toBe(false); }); test("an open-ended event runs up to now", () => { const open = makeEvent({ from: day("2020-01-01"), to: null }); - expect(isDateWithinEvent(day("2021-01-01"), open)).toBe(true); - expect(isDateWithinEvent(day("2019-01-01"), open)).toBe(false); + expect(isDateWithinEvent(dayWindow("2021-01-01"), open)).toBe(true); + expect(isDateWithinEvent(dayWindow("2019-01-01"), open)).toBe(false); }); }); @@ -335,14 +336,16 @@ describe("reportEventDayImpact", () => { }, ], }); - expect(reportEventDayImpact(event, day("2026-01-11"))).toBe("major_outage"); + expect(reportEventDayImpact(event, dayWindow("2026-01-11"))).toBe( + "major_outage", + ); }); test("null when the event has no impact rows (legacy)", () => { expect( reportEventDayImpact( makeEvent({ impactIntervals: undefined }), - day("2026-01-11"), + dayWindow("2026-01-11"), ), ).toBeNull(); }); @@ -357,7 +360,9 @@ describe("reportEventDayImpact", () => { }, ], }); - expect(reportEventDayImpact(event, day("2026-01-20"))).toBe("operational"); + expect(reportEventDayImpact(event, dayWindow("2026-01-20"))).toBe( + "operational", + ); }); }); @@ -366,7 +371,7 @@ describe("reportEventDayStatus", () => { expect( reportEventDayStatus( makeEvent({ impactIntervals: undefined }), - day("2026-01-11"), + dayWindow("2026-01-11"), ), ).toBe("degraded"); }); @@ -381,7 +386,7 @@ describe("reportEventDayStatus", () => { }, ], }); - expect(reportEventDayStatus(major, day("2026-01-11"))).toBe("error"); + expect(reportEventDayStatus(major, dayWindow("2026-01-11"))).toBe("error"); const noOverlap = makeEvent({ impactIntervals: [ @@ -392,7 +397,9 @@ describe("reportEventDayStatus", () => { }, ], }); - expect(reportEventDayStatus(noOverlap, day("2026-01-20"))).toBe("success"); + expect(reportEventDayStatus(noOverlap, dayWindow("2026-01-20"))).toBe( + "success", + ); }); }); diff --git a/packages/services/src/status-timeline/__tests__/time-zone.test.ts b/packages/services/src/status-timeline/__tests__/time-zone.test.ts new file mode 100644 index 000000000..cf2876db8 --- /dev/null +++ b/packages/services/src/status-timeline/__tests__/time-zone.test.ts @@ -0,0 +1,157 @@ +import { expect } from "@std/expect"; +import { afterEach, beforeEach, describe, test } from "@std/testing/bdd"; +import { FakeTime } from "@std/testing/time"; + +import { dayWindow } from "../day-window"; +import { + type Event, + fillStatusDataFor45Days, + fillStatusDataFor45DaysNoop, + isDateWithinEvent, + reportEventDayImpact, + resolveDayStatus, + type StatusData, +} from "../events"; +import { probeDowntimeIntervals } from "../uptime"; + +const at = (iso: string) => new Date(iso); +const ms = (iso: string) => at(iso).getTime(); + +function incident(from: string, to: string): Event { + return { + id: 1, + name: "incident", + from: at(from), + to: at(to), + type: "incident", + status: "error", + }; +} + +function bucket(date: string, overrides: Partial = {}): StatusData { + return { + day: `${date}T00:00:00.000Z`, + count: 1, + ok: 1, + degraded: 0, + error: 0, + monitorId: "1", + ...overrides, + }; +} + +describe("fillStatusDataFor45Days in a time zone", () => { + let time: FakeTime; + // 2026-10-16 05:00 in Tokyo, still 2026-10-15 in UTC + beforeEach(() => { + time = new FakeTime(at("2026-10-15T20:00:00.000Z")); + }); + afterEach(() => time.restore()); + + test("ends on today's date in the zone", () => { + expect(fillStatusDataFor45Days([], "1", 45, "Asia/Tokyo").at(-1)?.day).toBe( + "2026-10-16T00:00:00.000Z", + ); + expect(fillStatusDataFor45Days([], "1", 45).at(-1)?.day).toBe( + "2026-10-15T00:00:00.000Z", + ); + }); + + // the 15m endpoints return 45 days back from local midnight, so every + // past day listed is complete + test("starts 44 days before today's date in the zone", () => { + const filled = fillStatusDataFor45Days([], "1", 45, "Asia/Tokyo"); + expect(filled).toHaveLength(45); + expect(filled[0].day).toBe("2026-09-02T00:00:00.000Z"); + }); + + test("keeps the rows of the zone's dates", () => { + const filled = fillStatusDataFor45Days( + [bucket("2026-10-16", { error: 3 })], + "1", + 45, + "Asia/Tokyo", + ); + expect(filled.at(-1)?.error).toBe(3); + }); + + test("lines the synthetic days up with the zone's dates", () => { + const filled = fillStatusDataFor45DaysNoop({ + errorDays: [0], + degradedDays: [], + timeZone: "Asia/Tokyo", + }); + expect(filled.at(-1)).toMatchObject({ + day: "2026-10-16T00:00:00.000Z", + error: 1, + }); + }); +}); + +describe("matching events to a day in a time zone", () => { + // 01:00-01:30 in Tokyo on 2026-10-16, still 2026-10-15 in UTC + const early = incident( + "2026-10-15T16:00:00.000Z", + "2026-10-15T16:30:00.000Z", + ); + const tokyo = (date: string) => dayWindow(date, "Asia/Tokyo"); + + test("puts an event on the zone's date", () => { + expect(isDateWithinEvent(tokyo("2026-10-16"), early)).toBe(true); + expect(isDateWithinEvent(tokyo("2026-10-15"), early)).toBe(false); + expect(isDateWithinEvent(dayWindow("2026-10-15"), early)).toBe(true); + }); + + test("keeps the 25th hour of the day summer time ends on that day", () => { + // 23:30-23:45 in Berlin on 2026-10-25 + const late = incident( + "2026-10-25T22:30:00.000Z", + "2026-10-25T22:45:00.000Z", + ); + const berlin = (date: string) => dayWindow(date, "Europe/Berlin"); + expect(isDateWithinEvent(berlin("2026-10-25"), late)).toBe(true); + expect(isDateWithinEvent(berlin("2026-10-26"), late)).toBe(false); + }); + + test("reads report impacts on the zone's date", () => { + const report: Event = { + ...early, + type: "report", + impactIntervals: [ + { from: early.from, to: early.to, impact: "major_outage" }, + ], + }; + expect(reportEventDayImpact(report, tokyo("2026-10-16"))).toBe( + "major_outage", + ); + expect(reportEventDayImpact(report, tokyo("2026-10-15"))).toBe( + "operational", + ); + }); + + test("resolves the day status on the zone's date", () => { + expect( + resolveDayStatus(bucket("2026-10-16"), [early], "Asia/Tokyo").status, + ).toBe("down"); + expect(resolveDayStatus(bucket("2026-10-16"), [early]).status).toBe( + "operational", + ); + }); +}); + +describe("probeDowntimeIntervals in a time zone", () => { + test("spans the zone's day", () => { + const intervals = probeDowntimeIntervals( + [bucket("2026-10-16", { ok: 0, error: 1 })], + { start: 0, end: Number.MAX_SAFE_INTEGER, now: 0 }, + "Asia/Tokyo", + ); + expect(intervals).toEqual([ + { + from: ms("2026-10-15T15:00:00.000Z"), + to: ms("2026-10-16T15:00:00.000Z"), + weight: 1, + }, + ]); + }); +}); diff --git a/packages/services/src/status-timeline/day-window.ts b/packages/services/src/status-timeline/day-window.ts new file mode 100644 index 000000000..2a4ec3f33 --- /dev/null +++ b/packages/services/src/status-timeline/day-window.ts @@ -0,0 +1,29 @@ +import { TZDate } from "@date-fns/tz"; + +export const MS_PER_DAY = 86_400_000; + +/** Half-open `[start, end)` in epoch ms. */ +export type DayWindow = { start: number; end: number }; + +/** + * The window of the calendar day a bar's `day` key names, in `timeZone`. Built + * with TZDate directly: date-fns 3.6 helpers work in the machine's zone. + */ +export function dayWindow(day: string, timeZone = "UTC"): DayWindow { + const [year, month, date] = day.slice(0, 10).split("-").map(Number); + // UTC days are always 24h, so no zone lookup is needed + if (timeZone === "UTC") { + const start = Date.UTC(year, month - 1, date); + return { start, end: start + MS_PER_DAY }; + } + return { + start: new TZDate(year, month - 1, date, timeZone).getTime(), + end: new TZDate(year, month - 1, date + 1, timeZone).getTime(), + }; +} + +/** Today's date in `timeZone`, keyed as UTC midnight like `StatusData.day`. */ +export function todayIn(timeZone: string): Date { + const date = new TZDate(Date.now(), timeZone).toISOString().slice(0, 10); + return new Date(`${date}T00:00:00.000Z`); +} diff --git a/packages/services/src/status-timeline/events.ts b/packages/services/src/status-timeline/events.ts index 2519d8795..4f4471066 100644 --- a/packages/services/src/status-timeline/events.ts +++ b/packages/services/src/status-timeline/events.ts @@ -14,6 +14,8 @@ import { worstImpact, } from "@openstatus/db/src/schema"; +import { type DayWindow, dayWindow, todayIn } from "./day-window"; + export type MonitorComponentWithNonNullMonitor = PageComponentWithMonitorRelation & { type: "monitor"; @@ -46,6 +48,7 @@ export function fillStatusDataFor45Days( data: Array, monitorId: string, lookbackPeriod = 45, + timeZone = "UTC", ): Array { const result = []; const dataByDay = new Map(); @@ -57,11 +60,10 @@ export function fillStatusDataFor45Days( }); // Generate all days from today backwards - const now = new Date(); + const today = todayIn(timeZone); for (let i = 0; i < lookbackPeriod; i++) { - const date = new Date(now); + const date = new Date(today); date.setUTCDate(date.getUTCDate() - i); - date.setUTCHours(0, 0, 0, 0); // Set to start of day in UTC const dayKey = date.toISOString().split("T")[0]; // YYYY-MM-DD format const isoString = date.toISOString(); @@ -96,17 +98,19 @@ export function fillStatusDataFor45DaysNoop({ errorDays, degradedDays, lookbackPeriod = 45, + timeZone = "UTC", }: { errorDays: number[]; degradedDays: number[]; lookbackPeriod?: number; + timeZone?: string; }): Array { const issueDays = [...errorDays, ...degradedDays]; - // UTC day grid, matching fillStatusDataFor45Days, so synthetic days line up + // same day grid as fillStatusDataFor45Days, so synthetic days line up + const today = todayIn(timeZone); const data: StatusData[] = Array.from({ length: lookbackPeriod }, (_, i) => { - const date = new Date(); + const date = new Date(today); date.setUTCDate(date.getUTCDate() - i); - date.setUTCHours(0, 0, 0, 0); return { day: date.toISOString(), count: 1, @@ -116,7 +120,7 @@ export function fillStatusDataFor45DaysNoop({ monitorId: "1", }; }); - return fillStatusDataFor45Days(data, "1", lookbackPeriod); + return fillStatusDataFor45Days(data, "1", lookbackPeriod, timeZone); } export type ImpactInterval = { @@ -456,21 +460,13 @@ export function getHighestPriorityStatus( // worst report impact for one day; null = legacy event (no impact rows) export function reportEventDayImpact( event: Event, - date: Date, + { start, end }: DayWindow, ): PageComponentImpact | null { if (!event.impactIntervals) return null; - const startOfDay = new Date(date); - startOfDay.setUTCHours(0, 0, 0, 0); - const endOfDay = new Date(date); - endOfDay.setUTCHours(23, 59, 59, 999); - const overlapping = event.impactIntervals.filter((iv) => { - const end = iv.to ?? new Date(); - return ( - iv.from.getTime() <= endOfDay.getTime() && - end.getTime() >= startOfDay.getTime() - ); + const ivEnd = iv.to ?? new Date(); + return iv.from.getTime() < end && ivEnd.getTime() >= start; }); return worstImpact(overlapping.map((iv) => iv.impact)); } @@ -478,27 +474,20 @@ export function reportEventDayImpact( // worst projected report color for one day; legacy events stay orange export function reportEventDayStatus( event: Event, - date: Date, + day: DayWindow, ): "success" | "degraded" | "error" { - const impact = reportEventDayImpact(event, date); + const impact = reportEventDayImpact(event, day); return impact === null ? "degraded" : impactToStatusType(impact); } -// Helper to check if date is within event range -export function isDateWithinEvent(date: Date, event: Event): boolean { - const startOfDay = new Date(date); - startOfDay.setUTCHours(0, 0, 0, 0); - - const endOfDay = new Date(date); - endOfDay.setUTCHours(23, 59, 59, 999); - +export function isDateWithinEvent( + { start, end }: DayWindow, + event: Event, +): boolean { const eventStart = new Date(event.from); const eventEnd = event.to ? new Date(event.to) : new Date(); - return ( - eventStart.getTime() <= endOfDay.getTime() && - eventEnd.getTime() >= startOfDay.getTime() - ); + return eventStart.getTime() < end && eventEnd.getTime() >= start; } export type DayStatus = @@ -513,13 +502,14 @@ export type DayStatus = export function resolveDayStatus( bucket: StatusData, events: Event[], + timeZone = "UTC", ): { status: DayStatus; impact?: PageComponentImpact } { - const date = new Date(bucket.day); - const dayEvents = events.filter((e) => isDateWithinEvent(date, e)); + const day = dayWindow(bucket.day, timeZone); + const dayEvents = events.filter((e) => isDateWithinEvent(day, e)); const reports = dayEvents.filter((e) => e.type === "report"); const dayImpacts = reports - .map((e) => reportEventDayImpact(e, date)) + .map((e) => reportEventDayImpact(e, day)) .filter((i): i is PageComponentImpact => i !== null); const impact = dayImpacts.length > 0 ? worstImpact(dayImpacts) : undefined; @@ -528,7 +518,7 @@ export function resolveDayStatus( } const reportsDayStatus = reports.length - ? getWorstVariant(reports.map((e) => reportEventDayStatus(e, date))) + ? getWorstVariant(reports.map((e) => reportEventDayStatus(e, day))) : undefined; if (reportsDayStatus && reportsDayStatus !== "success") { return { diff --git a/packages/services/src/status-timeline/index.ts b/packages/services/src/status-timeline/index.ts index 46a245c6e..d1f35f16f 100644 --- a/packages/services/src/status-timeline/index.ts +++ b/packages/services/src/status-timeline/index.ts @@ -1,3 +1,4 @@ +export * from "./day-window"; export * from "./downtime"; export * from "./events"; export * from "./uptime"; diff --git a/packages/services/src/status-timeline/uptime.ts b/packages/services/src/status-timeline/uptime.ts index 9e62d8726..0d84755b3 100644 --- a/packages/services/src/status-timeline/uptime.ts +++ b/packages/services/src/status-timeline/uptime.ts @@ -3,11 +3,10 @@ import { impactUptimeWeight, } from "@openstatus/db/src/schema"; +import { type DayWindow, dayWindow } from "./day-window"; import { type WeightedInterval, mergedDowntimeMs } from "./downtime"; import type { Event, StatusData } from "./events"; -export const MS_PER_DAY = 86_400_000; - export type CheckCounts = { ok: number; degraded: number; error: number }; /** Downtime is clamped to [start, end]; `now` closes still-open events. */ @@ -22,16 +21,14 @@ export type CoverageSegment = { start: number; end: number }; * base. `clampEndMs` cuts the in-progress day to elapsed time. */ export function dayCoverage( - dayStartsMs: number[], + days: DayWindow[], clampEndMs?: number, ): { segments: CoverageSegment[]; totalMs: number } { let totalMs = 0; const segments: CoverageSegment[] = []; - for (const start of dayStartsMs) { + for (const { start, end: dayEnd } of days) { const end = - clampEndMs === undefined - ? start + MS_PER_DAY - : Math.min(start + MS_PER_DAY, clampEndMs); + clampEndMs === undefined ? dayEnd : Math.min(dayEnd, clampEndMs); if (end <= start) continue; totalMs += end - start; segments.push({ start, end }); @@ -160,12 +157,12 @@ export function reportsOnlyDowntimeMs( export function probeDowntimeIntervals( data: StatusData[], window: UptimeWindow, + timeZone = "UTC", ): WeightedInterval[] { const intervals: WeightedInterval[] = []; for (const item of data) { - const dayStart = new Date(item.day).getTime(); - const dayEnd = dayStart + MS_PER_DAY; + const { start: dayStart, end: dayEnd } = dayWindow(item.day, timeZone); // Clamp to window const start = Math.max(dayStart, window.start); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f8e09754..13a24b963 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2962,6 +2962,9 @@ importers: packages/services: dependencies: + '@date-fns/tz': + specifier: 'catalog:' + version: 1.5.0 '@openstatus/assertions': specifier: workspace:* version: link:../assertions -- 2.54.0