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:
1 parent
5b25666054
commit
1458cf4e4a
6 files changed
+43
-17
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user