Add timezone offset to fix bad display

This commit is contained in:
Owen
2026-07-16 16:09:35 -04:00
parent a48ef77ee5
commit a88b79e066
5 changed files with 82 additions and 29 deletions
+49 -18
View File
@@ -8,26 +8,42 @@ const STATUS_HISTORY_CACHE_TTL = 60; // seconds
function statusHistoryCacheKey( function statusHistoryCacheKey(
entityType: string, entityType: string,
entityId: number, entityId: number,
days: number days: number,
tzOffsetMinutes: number
): string { ): 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( export async function getCachedStatusHistory(
entityType: string, entityType: string,
entityId: number, entityId: number,
days: number days: number,
tzOffsetMinutes: number = 0
): Promise<StatusHistoryResponse> { ): Promise<StatusHistoryResponse> {
const cacheKey = statusHistoryCacheKey(entityType, entityId, days); const cacheKey = statusHistoryCacheKey(
entityType,
entityId,
days,
tzOffsetMinutes
);
const cached = await cache.get<StatusHistoryResponse>(cacheKey); const cached = await cache.get<StatusHistoryResponse>(cacheKey);
if (cached !== undefined) { if (cached !== undefined) {
return cached; return cached;
} }
// Anchor to UTC midnight so the query window aligns with stable calendar days // Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query
const utcToday = new Date(); // window aligns with stable calendar days for the requesting client
utcToday.setUTCHours(0, 0, 0, 0); const todayMidnightSec = localMidnightSec(tzOffsetMinutes);
const todayMidnightSec = Math.floor(utcToday.getTime() / 1000);
const startSec = todayMidnightSec - days * 86400; const startSec = todayMidnightSec - days * 86400;
const events = await logsDb const events = await logsDb
@@ -63,7 +79,8 @@ export async function getCachedStatusHistory(
const { buckets, totalDowntime } = computeBuckets( const { buckets, totalDowntime } = computeBuckets(
events, events,
days, days,
priorStatus priorStatus,
tzOffsetMinutes
); );
const totalWindow = days * 86400; const totalWindow = days * 86400;
const overallUptime = const overallUptime =
@@ -99,11 +116,19 @@ export const statusHistoryQuerySchema = z
days: z days: z
.string() .string()
.optional() .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( .pipe(
z.object({ 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; id: number;
}[], }[],
days: number, days: number,
priorStatus: string | null = null priorStatus: string | null = null,
tzOffsetMinutes: number = 0
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { ): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
// Anchor bucket boundaries to UTC midnight so dates are stable calendar days // Anchor bucket boundaries to local midnight (UTC when tzOffsetMinutes is
// and don't drift as the cache expires and is recomputed // 0) so dates are stable calendar days for the requesting client and
const utcToday = new Date(); // don't drift as the cache expires and is recomputed
utcToday.setUTCHours(0, 0, 0, 0); const todayMidnightSec = localMidnightSec(tzOffsetMinutes);
const todayMidnightSec = Math.floor(utcToday.getTime() / 1000);
const buckets: StatusHistoryDayBucket[] = []; const buckets: StatusHistoryDayBucket[] = [];
let totalDowntime = 0; let totalDowntime = 0;
@@ -237,7 +262,13 @@ export function computeBuckets(
) )
: 100; : 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; const hasAnyData = currentStatus !== null || dayEvents.length > 0;
@@ -55,9 +55,14 @@ export async function getHealthCheckStatusHistory(
const entityType = "health_check"; const entityType = "health_check";
const entityId = parsedParams.data.healthCheckId; 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<StatusHistoryResponse>(res, { return response<StatusHistoryResponse>(res, {
data, data,
+7 -2
View File
@@ -42,9 +42,14 @@ export async function getResourceStatusHistory(
const entityType = "resource"; const entityType = "resource";
const entityId = parsedParams.data.resourceId; 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<StatusHistoryResponse>(res, { return response<StatusHistoryResponse>(res, {
data, data,
+7 -2
View File
@@ -42,9 +42,14 @@ export async function getSiteStatusHistory(
const entityType = "site"; const entityType = "site";
const entityId = parsedParams.data.siteId; 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<StatusHistoryResponse>(res, { return response<StatusHistoryResponse>(res, {
data, data,
+12 -5
View File
@@ -650,9 +650,13 @@ export const orgQueries = {
queryOptions({ queryOptions({
queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const, queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse> AxiosResponse<StatusHistoryResponse>
>(`/site/${siteId}/status-history?days=${days}`, { signal }); >(
`/site/${siteId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`,
{ signal }
);
return res.data.data; return res.data.data;
} }
}), }),
@@ -667,11 +671,13 @@ export const orgQueries = {
queryOptions({ queryOptions({
queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const, queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse> AxiosResponse<StatusHistoryResponse>
>(`/resource/${resourceId}/status-history?days=${days}`, { >(
signal `/resource/${resourceId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`,
}); { signal }
);
return res.data.data; return res.data.data;
} }
}), }),
@@ -693,10 +699,11 @@ export const orgQueries = {
days days
] as const, ] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse> AxiosResponse<StatusHistoryResponse>
>( >(
`/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}`, `/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`,
{ signal } { signal }
); );
return res.data.data; return res.data.data;