fix(status-page): use operator-configured timezone on public pages

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.
This commit is contained in:
gorkem-bwl committed 2026-05-01 22:28:12 -04:00
1 parent 5b25666054
commit 1458cf4e4a
6 files changed
+43 -17

No files matched your search

+2 -1
View File
@@ -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 || [],
@@ -126,6 +126,7 @@ const StatusPageView = () => {
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
paintBody
>
{themedRenderer}
@@ -149,6 +150,7 @@ const StatusPageView = () => {
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
transparent
>
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
@@ -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<StatusPageThemeContextValue | null>(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<ResolvedMode>(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;
@@ -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<HeatCellKind, "empty"> => {
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)}
</Typography>
</Stack>
);
@@ -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
)}
</Typography>
</Stack>
+20 -2
View File
@@ -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);
}