diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 7c5b5c370..d11ea88ef 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -8,26 +8,42 @@ const STATUS_HISTORY_CACHE_TTL = 60; // seconds function statusHistoryCacheKey( entityType: string, entityId: number, - days: number + days: number, + tzOffsetMinutes: number ): string { - return `statusHistory:${entityType}:${entityId}:${days}`; + return `statusHistory:${entityType}:${entityId}:${days}:${tzOffsetMinutes}`; +} + +// Returns the epoch seconds of the most recent local-calendar-day midnight, +// where "local" is defined by tzOffsetMinutes (minutes to ADD to UTC to get +// local time, e.g. Australia/Sydney standard time is 600). Defaults to 0 +// (UTC) so callers that don't pass a timezone keep the original behavior. +function localMidnightSec(tzOffsetMinutes: number): number { + const localNow = new Date(Date.now() + tzOffsetMinutes * 60_000); + localNow.setUTCHours(0, 0, 0, 0); + return Math.floor(localNow.getTime() / 1000) - tzOffsetMinutes * 60; } export async function getCachedStatusHistory( entityType: string, entityId: number, - days: number + days: number, + tzOffsetMinutes: number = 0 ): Promise { - const cacheKey = statusHistoryCacheKey(entityType, entityId, days); + const cacheKey = statusHistoryCacheKey( + entityType, + entityId, + days, + tzOffsetMinutes + ); const cached = await cache.get(cacheKey); if (cached !== undefined) { return cached; } - // Anchor to UTC midnight so the query window aligns with stable calendar days - const utcToday = new Date(); - utcToday.setUTCHours(0, 0, 0, 0); - const todayMidnightSec = Math.floor(utcToday.getTime() / 1000); + // Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query + // window aligns with stable calendar days for the requesting client + const todayMidnightSec = localMidnightSec(tzOffsetMinutes); const startSec = todayMidnightSec - days * 86400; const events = await logsDb @@ -63,7 +79,8 @@ export async function getCachedStatusHistory( const { buckets, totalDowntime } = computeBuckets( events, days, - priorStatus + priorStatus, + tzOffsetMinutes ); const totalWindow = days * 86400; const overallUptime = @@ -99,11 +116,19 @@ export const statusHistoryQuerySchema = z days: z .string() .optional() - .transform((v) => (v ? parseInt(v, 10) : 90)) + .transform((v) => (v ? parseInt(v, 10) : 90)), + // Minutes to add to UTC to get the requesting client's local time + // (e.g. Australia/Sydney standard time is 600). Optional and + // defaults to 0 (UTC) so older clients keep the prior behavior. + tzOffsetMinutes: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 0)) }) .pipe( z.object({ - days: z.number().int().min(1).max(365) + days: z.number().int().min(1).max(365), + tzOffsetMinutes: z.number().int().min(-720).max(840) }) ); @@ -133,15 +158,15 @@ export function computeBuckets( id: number; }[], days: number, - priorStatus: string | null = null + priorStatus: string | null = null, + tzOffsetMinutes: number = 0 ): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { const nowSec = Math.floor(Date.now() / 1000); - // Anchor bucket boundaries to UTC midnight so dates are stable calendar days - // and don't drift as the cache expires and is recomputed - const utcToday = new Date(); - utcToday.setUTCHours(0, 0, 0, 0); - const todayMidnightSec = Math.floor(utcToday.getTime() / 1000); + // Anchor bucket boundaries to local midnight (UTC when tzOffsetMinutes is + // 0) so dates are stable calendar days for the requesting client and + // don't drift as the cache expires and is recomputed + const todayMidnightSec = localMidnightSec(tzOffsetMinutes); const buckets: StatusHistoryDayBucket[] = []; let totalDowntime = 0; @@ -237,7 +262,13 @@ export function computeBuckets( ) : 100; - const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10); + // Shift by the client's offset before formatting so the label reflects + // their local calendar date rather than the UTC date of dayStartSec + const dateStr = new Date( + (dayStartSec + tzOffsetMinutes * 60) * 1000 + ) + .toISOString() + .slice(0, 10); const hasAnyData = currentStatus !== null || dayEvents.length > 0; diff --git a/server/private/routers/healthChecks/getStatusHistory.ts b/server/private/routers/healthChecks/getStatusHistory.ts index 51a59e2e1..484870886 100644 --- a/server/private/routers/healthChecks/getStatusHistory.ts +++ b/server/private/routers/healthChecks/getStatusHistory.ts @@ -55,9 +55,14 @@ export async function getHealthCheckStatusHistory( const entityType = "health_check"; const entityId = parsedParams.data.healthCheckId; - const { days } = parsedQuery.data; + const { days, tzOffsetMinutes } = parsedQuery.data; - const data = await getCachedStatusHistory(entityType, entityId, days); + const data = await getCachedStatusHistory( + entityType, + entityId, + days, + tzOffsetMinutes + ); return response(res, { data, diff --git a/server/routers/resource/getStatusHistory.ts b/server/routers/resource/getStatusHistory.ts index c3dcf6c88..19e11b78f 100644 --- a/server/routers/resource/getStatusHistory.ts +++ b/server/routers/resource/getStatusHistory.ts @@ -42,9 +42,14 @@ export async function getResourceStatusHistory( const entityType = "resource"; const entityId = parsedParams.data.resourceId; - const { days } = parsedQuery.data; + const { days, tzOffsetMinutes } = parsedQuery.data; - const data = await getCachedStatusHistory(entityType, entityId, days); + const data = await getCachedStatusHistory( + entityType, + entityId, + days, + tzOffsetMinutes + ); return response(res, { data, diff --git a/server/routers/site/getStatusHistory.ts b/server/routers/site/getStatusHistory.ts index 26f1dbbd2..d281961e9 100644 --- a/server/routers/site/getStatusHistory.ts +++ b/server/routers/site/getStatusHistory.ts @@ -42,9 +42,14 @@ export async function getSiteStatusHistory( const entityType = "site"; const entityId = parsedParams.data.siteId; - const { days } = parsedQuery.data; + const { days, tzOffsetMinutes } = parsedQuery.data; - const data = await getCachedStatusHistory(entityType, entityId, days); + const data = await getCachedStatusHistory( + entityType, + entityId, + days, + tzOffsetMinutes + ); return response(res, { data, diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 8fdb1c531..3194e5651 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -650,9 +650,13 @@ export const orgQueries = { queryOptions({ queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const, queryFn: async ({ signal, meta }) => { + const tzOffsetMinutes = -new Date().getTimezoneOffset(); const res = await meta!.api.get< AxiosResponse - >(`/site/${siteId}/status-history?days=${days}`, { signal }); + >( + `/site/${siteId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`, + { signal } + ); return res.data.data; } }), @@ -667,11 +671,13 @@ export const orgQueries = { queryOptions({ queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const, queryFn: async ({ signal, meta }) => { + const tzOffsetMinutes = -new Date().getTimezoneOffset(); const res = await meta!.api.get< AxiosResponse - >(`/resource/${resourceId}/status-history?days=${days}`, { - signal - }); + >( + `/resource/${resourceId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`, + { signal } + ); return res.data.data; } }), @@ -693,10 +699,11 @@ export const orgQueries = { days ] as const, queryFn: async ({ signal, meta }) => { + const tzOffsetMinutes = -new Date().getTimezoneOffset(); const res = await meta!.api.get< AxiosResponse >( - `/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}`, + `/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`, { signal } ); return res.data.data;