From 1458cf4e4abaa4c35503bc9439944a49f64e7fba Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 1 May 2026 22:28:12 -0400 Subject: [PATCH] fix(status-page): use operator-configured timezone on public pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anonymous visitors to a public status page have no persisted Redux state, so ThemedHeatmap and ThemedHistogram tooltips always rendered timestamps in the hardcoded "America/Toronto" default — regardless of the timezone the operator configured. Even on the same domain, ThemedHeatmap and ThemedHistogram had no code path to consume the operator-configured statusPage.timezone returned by the API. - Add SYSTEM_TIMEZONE module constant and a small resolveTimezone() helper to TimeUtils.ts. formatDateWithTz's timezone parameter is now optional and defaults to the system tz, so callers don't need to rebuild the prop -> browser -> UTC chain inline. - Add a `timezone` field to StatusPageThemeContextValue so any themed component on the public page can read the page-wide tz without prop drilling (parallels how `theme` and `mode` are already exposed). - Wire statusPage.timezone into StatusPageThemeProvider from Pages/StatusPage/Status/index.tsx (both the public route and the admin preview). - ThemedHeatmap and ThemedHistogram now read tz from the context instead of useSelector(state.ui.timezone) — that selector was unreliable for anonymous visitors and removing it cuts a Redux subscription on the public page. - Replace the inline `data?.timezone || Intl.DateTimeFormat()...` expression in useStatusPageForm.ts with resolveTimezone(). Closes #3628. --- client/src/Hooks/useStatusPageForm.ts | 3 ++- client/src/Pages/StatusPage/Status/index.tsx | 2 ++ .../Status/themes/StatusPageThemeProvider.tsx | 15 +++++++++++-- .../Status/themes/shared/ThemedHeatmap.tsx | 11 +++------- .../Status/themes/shared/ThemedHistogram.tsx | 7 +++--- client/src/Utils/TimeUtils.ts | 22 +++++++++++++++++-- 6 files changed, 43 insertions(+), 17 deletions(-) diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts index 8bec86f9b..681a3da6c 100644 --- a/client/src/Hooks/useStatusPageForm.ts +++ b/client/src/Hooks/useStatusPageForm.ts @@ -6,6 +6,7 @@ import { DEFAULT_STATUS_PAGE_THEME_MODE, } from "@/Types/StatusPage"; import type { Monitor } from "@/Types/Monitor"; +import { resolveTimezone } from "@/Utils/TimeUtils"; interface UseStatusPageFormOptions { data?: StatusPage | null; @@ -30,7 +31,7 @@ export const useStatusPageForm = ({ const defaults: StatusPageFormData = { companyName: data?.companyName || "", url: data?.url || generateDefaultUrl(), - timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, + timezone: resolveTimezone(data?.timezone), type: data?.type || ["uptime"], color: data?.color || "#13715B", monitors: data?.monitors || [], diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 59ba4f21b..9c4001fae 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -126,6 +126,7 @@ const StatusPageView = () => { {themedRenderer} @@ -149,6 +150,7 @@ const StatusPageView = () => { {themedRenderer} diff --git a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx index 2b10b0d9a..f79991c3b 100644 --- a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx +++ b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx @@ -13,6 +13,7 @@ import { type StatusPageTheme, type StatusPageThemeMode, } from "@/Types/StatusPage"; +import { resolveTimezone } from "@/Utils/TimeUtils"; import { themeTokens, type StatusPageThemeTokens } from "./tokens"; type ResolvedMode = "light" | "dark"; @@ -21,6 +22,7 @@ interface StatusPageThemeContextValue { theme: StatusPageTheme; mode: ResolvedMode; tokens: StatusPageThemeTokens; + timezone: string; } const StatusPageThemeContext = createContext(null); @@ -33,6 +35,7 @@ const resolveSystemMode = (): ResolvedMode => { interface Props { theme?: StatusPageTheme; themeMode?: StatusPageThemeMode; + timezone?: string; /** * When true, paint the document body with the theme background so it * covers beyond the provider's wrapper (useful on the public route @@ -54,12 +57,14 @@ interface Props { export const StatusPageThemeProvider = ({ theme, themeMode, + timezone, paintBody = false, transparent = false, children, }: Props) => { const resolvedTheme = resolveStatusPageTheme(theme); const resolvedThemeMode = resolveStatusPageThemeMode(themeMode); + const resolvedTimezone = resolveTimezone(timezone); const [systemMode, setSystemMode] = useState(resolveSystemMode); @@ -107,8 +112,13 @@ export const StatusPageThemeProvider = ({ const tokens = themeTokens[resolvedTheme][resolvedMode]; const value = useMemo( - () => ({ theme: resolvedTheme, mode: resolvedMode, tokens }), - [resolvedTheme, resolvedMode, tokens] + () => ({ + theme: resolvedTheme, + mode: resolvedMode, + tokens, + timezone: resolvedTimezone, + }), + [resolvedTheme, resolvedMode, tokens, resolvedTimezone] ); return ( @@ -136,6 +146,7 @@ export const useStatusPageTheme = (): StatusPageThemeContextValue => { theme: DEFAULT_STATUS_PAGE_THEME, mode: resolveSystemMode(), tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()], + timezone: resolveTimezone(), }; } return ctx; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx index 58ae86d44..fe56155c3 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -4,11 +4,10 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; -import type { RootState } from "@/store"; import type { CheckSnapshot } from "@/Types/Check"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; +import { useStatusPageTheme } from "../StatusPageThemeProvider"; const CELLS = MAX_RECENT_CHECKS; @@ -30,7 +29,7 @@ const classify = (check: CheckSnapshot): Exclude => { export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { const { t } = useTranslation(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const { timezone } = useStatusPageTheme(); const source = checks.slice(-CELLS); const padded: (CheckSnapshot | null)[] = [ @@ -68,11 +67,7 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { variant="caption" sx={{ opacity: 0.8 }} > - {formatDateWithTz( - check.createdAt, - "ddd, MMMM D, YYYY, HH:mm A", - uiTimezone - )} + {formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)} ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index 8408a22ef..9d06a99ac 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -5,11 +5,10 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; -import type { RootState } from "@/store"; import type { CheckSnapshot } from "@/Types/Check"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; +import { useStatusPageTheme } from "../StatusPageThemeProvider"; const CELLS = MAX_RECENT_CHECKS; const MIN_HEIGHT_PCT = 6; @@ -34,7 +33,7 @@ export const ThemedHistogram = ({ statsGap = 1, }: Props) => { const { t } = useTranslation(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const { timezone } = useStatusPageTheme(); const { padded, max, avg, peak } = useMemo(() => { const source = checks.slice(-CELLS); @@ -83,7 +82,7 @@ export const ThemedHistogram = ({ {formatDateWithTz( check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", - uiTimezone + timezone )} diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index cffdd5440..5b63de87e 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -15,12 +15,30 @@ export const MS_PER_HOUR = 60 * MS_PER_MINUTE; export const MS_PER_DAY = 24 * MS_PER_HOUR; export const MS_PER_WEEK = MS_PER_DAY * 7; -export const formatDateWithTz = (timestamp: string, format: string, timezone: string) => { +const resolveSystemTimezone = (): string => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +}; + +export const SYSTEM_TIMEZONE = resolveSystemTimezone(); + +export const resolveTimezone = (preferred?: string | null): string => + preferred?.trim() || SYSTEM_TIMEZONE; + +export const formatDateWithTz = ( + timestamp: string, + format: string, + timezone?: string +) => { if (!timestamp) { return "Unknown time"; } + const tz = resolveTimezone(timezone); try { - return dayjs(timestamp).tz(timezone).format(format); + return dayjs(timestamp).tz(tz).format(format); } catch { return dayjs(timestamp).utc().format(format); }