From 3485ef087727d42ca5cee24a6792c96dabc117bc Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 16:48:34 +0200 Subject: [PATCH] fix: localize widget chart formatting --- .../src/speedtest-tracker/recent-results.tsx | 73 +++++++++++-------- packages/widgets/src/umami/umami-content.tsx | 2 +- .../src/umami/umami-events-content.tsx | 5 +- packages/widgets/src/umami/umami-top-list.tsx | 6 +- packages/widgets/src/umami/umami-utils.ts | 6 +- 5 files changed, 52 insertions(+), 40 deletions(-) diff --git a/packages/widgets/src/speedtest-tracker/recent-results.tsx b/packages/widgets/src/speedtest-tracker/recent-results.tsx index 5d2328563..174a4c890 100644 --- a/packages/widgets/src/speedtest-tracker/recent-results.tsx +++ b/packages/widgets/src/speedtest-tracker/recent-results.tsx @@ -8,7 +8,7 @@ import { ReferenceLine, XAxis } from "recharts"; import { useRequiredBoard } from "@homarr/boards/context"; import type { SpeedtestTrackerResult } from "@homarr/integrations/types"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import { SectionLabel } from "./section-label"; @@ -40,17 +40,17 @@ function buildXAxisTicks(data: { ts: number }[]): XAxisTicks { return { midnightTs: foundMidnight, - xTicks: Array.from(tickSet).sort((tsA, tsB) => tsA - tsB), + xTicks: Array.from(tickSet).toSorted((tsA, tsB) => tsA - tsB), topDateTicks: topTicks, }; } -function makeXTickRenderer(midnightTs: number | null) { +function makeXTickRenderer(midnightTs: number | null, locale: string) { return ({ x, y, payload }: { x: number | string; y: number | string; payload: { value: number } }) => { const date = new Date(payload.value); - const timeStr = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + const timeStr = date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }); const isMidnight = midnightTs !== null && payload.value === midnightTs; - const dateStr = isMidnight ? date.toLocaleDateString([], { month: "short", day: "numeric" }) : null; + const dateStr = isMidnight ? date.toLocaleDateString(locale, { month: "short", day: "numeric" }) : null; return ( @@ -67,23 +67,29 @@ function makeXTickRenderer(midnightTs: number | null) { }; } -function renderTopDateTick(props: { - x: number | string; - y: number | string; - payload: { value: number }; - index: number; -}) { - const { x, y, payload, index } = props; - const date = new Date(payload.value); - const dateStr = date.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" }); - const anchor = index === 0 ? "start" : "middle"; - return ( - - - {dateStr} - - - ); +function makeTopDateTickRenderer(locale: string) { + return ({ + x, + y, + payload, + index, + }: { + x: number | string; + y: number | string; + payload: { value: number }; + index: number; + }) => { + const date = new Date(payload.value); + const dateStr = date.toLocaleDateString(locale, { weekday: "short", month: "short", day: "numeric" }); + const anchor = index === 0 ? "start" : "middle"; + return ( + + + {dateStr} + + + ); + }; } function buildYAxisConfig(maxVal: number, stepOptions: { threshold: number; step: number }[]) { @@ -94,10 +100,10 @@ function buildYAxisConfig(maxVal: number, stepOptions: { threshold: number; step return { ticks, domain: [0, roundedMax] as [number, number] }; } -function formatTooltipDate(label: number | string | undefined): string { +function formatTooltipDate(label: number | string | undefined, locale: string): string { const timestamp = typeof label === "number" ? label : Number(label); if (Number.isNaN(timestamp)) return ""; - return new Date(timestamp).toLocaleString([], { + return new Date(timestamp).toLocaleString(locale, { month: "short", day: "numeric", hour: "2-digit", @@ -157,11 +163,12 @@ function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResul const board = useRequiredBoard(); const theme = useMantineTheme(); const t = useScopedI18n("widget.speedtestTracker"); + const locale = useCurrentIntlLocale(); const data = useMemo( () => - [...results] - .sort((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime()) + results + .toSorted((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime()) .filter((result) => (result.download_bits ?? 0) > 0) .map((result) => ({ ts: result.created_at.getTime(), @@ -184,7 +191,8 @@ function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResul ); const { midnightTs, xTicks, topDateTicks } = useMemo(() => buildXAxisTicks(data), [data]); - const renderXTick = useMemo(() => makeXTickRenderer(midnightTs), [midnightTs]); + const renderXTick = useMemo(() => makeXTickRenderer(midnightTs, locale), [locale, midnightTs]); + const renderTopDateTick = useMemo(() => makeTopDateTickRenderer(locale), [locale]); if (data.length === 0) return null; @@ -230,7 +238,7 @@ function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResul if (!active || !payload?.length) return null; return ( `${val} Mbps`} /> @@ -269,11 +277,12 @@ function PingHistoryChart({ results, height }: { results: SpeedtestTrackerResult const board = useRequiredBoard(); const theme = useMantineTheme(); const t = useScopedI18n("widget.speedtestTracker"); + const locale = useCurrentIntlLocale(); const data = useMemo( () => - [...results] - .sort((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime()) + results + .toSorted((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime()) .map((result) => result.ping === null ? null @@ -298,7 +307,7 @@ function PingHistoryChart({ results, height }: { results: SpeedtestTrackerResult ); const { midnightTs, xTicks } = useMemo(() => buildXAxisTicks(data), [data]); - const renderXTick = useMemo(() => makeXTickRenderer(midnightTs), [midnightTs]); + const renderXTick = useMemo(() => makeXTickRenderer(midnightTs, locale), [locale, midnightTs]); if (data.length === 0) return null; @@ -341,7 +350,7 @@ function PingHistoryChart({ results, height }: { results: SpeedtestTrackerResult if (!active || !payload?.length) return null; return ( `${val} ms`} /> diff --git a/packages/widgets/src/umami/umami-content.tsx b/packages/widgets/src/umami/umami-content.tsx index 510d5749c..9eb6d8cdf 100644 --- a/packages/widgets/src/umami/umami-content.tsx +++ b/packages/widgets/src/umami/umami-content.tsx @@ -91,7 +91,7 @@ export function UmamiContent({ const { visitorStats } = firstResult; const chartData = visitorStats.dataPoints.map((point: UmamiVisitorStats["dataPoints"][number]) => ({ - label: formatXLabel(point.timestamp, timeFrame), + label: formatXLabel(point.timestamp, timeFrame, locale), visitors: point.visitors, ...(point.events !== undefined ? { events: point.events } : {}), })); diff --git a/packages/widgets/src/umami/umami-events-content.tsx b/packages/widgets/src/umami/umami-events-content.tsx index e503df97a..a1af745e2 100644 --- a/packages/widgets/src/umami/umami-events-content.tsx +++ b/packages/widgets/src/umami/umami-events-content.tsx @@ -5,7 +5,7 @@ import { Box, Group, Stack, Text, useMantineColorScheme } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import type { UmamiEventSeries } from "@homarr/integrations/types"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import { EVENT_COLORS, formatXLabel, umamiQueryOptions } from "./umami-utils"; @@ -27,6 +27,7 @@ export function UmamiEventsContent({ showXAxis, }: UmamiEventsContentProps) { const t = useScopedI18n("widget.umami"); + const locale = useCurrentIntlLocale(); const { colorScheme } = useMantineColorScheme(); const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057"; @@ -59,7 +60,7 @@ export function UmamiEventsContent({ ); const chartData = allTimestamps.map((timestamp) => { - const row: Record = { label: formatXLabel(timestamp, timeFrame) }; + const row: Record = { label: formatXLabel(timestamp, timeFrame, locale) }; for (const serie of series) { row[serie.eventName] = byEvent.get(serie.eventName)?.get(timestamp) ?? 0; } diff --git a/packages/widgets/src/umami/umami-top-list.tsx b/packages/widgets/src/umami/umami-top-list.tsx index 3ce4b8730..3d17d257a 100644 --- a/packages/widgets/src/umami/umami-top-list.tsx +++ b/packages/widgets/src/umami/umami-top-list.tsx @@ -4,7 +4,7 @@ import { Group, ScrollArea, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import type { UmamiMetricItem } from "@homarr/integrations/types"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import { umamiQueryOptions } from "./umami-utils"; @@ -48,6 +48,8 @@ function UmamiTopList({ heading: string; emptyLabel: string; }) { + const locale = useCurrentIntlLocale(); + return ( @@ -64,7 +66,7 @@ function UmamiTopList({ {item.x || emptyLabel} - {item.y.toLocaleString()} + {item.y.toLocaleString(locale)} ))} diff --git a/packages/widgets/src/umami/umami-utils.ts b/packages/widgets/src/umami/umami-utils.ts index bca9d247b..dd64a6e64 100644 --- a/packages/widgets/src/umami/umami-utils.ts +++ b/packages/widgets/src/umami/umami-utils.ts @@ -6,17 +6,17 @@ export const umamiQueryOptions = { refetchInterval: 60_000 } as const; export const EVENT_COLORS = ["blue.5", "orange.5", "green.5", "red.5", "violet.5", "teal.5", "yellow.5", "pink.5"]; -export function formatXLabel(timestamp: string, timeFrame: string): string { +export function formatXLabel(timestamp: string, timeFrame: string, locale: string): string { const date = new Date(timestamp); switch (timeFrame) { case "today": case "24h": - return `${date.getHours().toString().padStart(2, "0")}:00`; + return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }); case "7d": case "30d": case "month": case "lastMonth": - return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return date.toLocaleDateString(locale, { month: "short", day: "numeric" }); default: return timestamp; }