From b6e0e5698a52c2d5d7b38515584254d43c519215 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 22 Apr 2025 16:50:21 -0500 Subject: [PATCH] Proper i18n date/time handling (#17858) * install date-fns-tz * add date locale hook * refactor formatUnixTimestampToDateTime Use date-fns style instead of using strftime. This requires changing the i18n keys to the way date-fns represents dates (eg: "MMM d, h:mm:ss aaa" instead of "%b %-d, %H:%M" * refactor calendar to use new hook * fix useFormattedTimestamp to use new formatUnixTimestampToDateTime date_format * change i18n keys to new format * fix timeline * fix review * fix explore * fix metrics * fix notifications * fix face library * clean up --- web/package-lock.json | 10 +++ web/package.json | 1 + web/public/locales/en/common.json | 34 +++++--- .../components/button/DownloadVideoButton.tsx | 17 +++- web/src/components/card/ReviewCard.tsx | 4 +- .../components/card/SearchThumbnailFooter.tsx | 4 +- .../filter/CalendarFilterButton.tsx | 4 +- web/src/components/graph/LineGraph.tsx | 34 ++++++-- web/src/components/graph/SystemGraph.tsx | 19 +++- web/src/components/menu/LiveContextMenu.tsx | 16 +++- .../overlay/detail/ObjectLifecycle.tsx | 2 +- .../overlay/detail/ReviewDetailDialog.tsx | 8 +- .../overlay/detail/SearchDetailDialog.tsx | 8 +- .../player/PreviewThumbnailPlayer.tsx | 4 +- .../components/timeline/segment-metadata.tsx | 66 ++++++++------ web/src/components/ui/calendar.tsx | 55 +----------- web/src/hooks/use-date-locale.ts | 64 ++++++++++++++ web/src/hooks/use-date-utils.ts | 27 ++++-- web/src/hooks/use-draggable-element.ts | 23 +++-- web/src/pages/FaceLibrary.tsx | 8 +- web/src/utils/dateUtil.ts | 86 +++++++++++++------ .../settings/NotificationsSettingsView.tsx | 14 ++- web/src/views/system/StorageMetrics.tsx | 24 ++++-- 23 files changed, 356 insertions(+), 176 deletions(-) create mode 100644 web/src/hooks/use-date-locale.ts diff --git a/web/package-lock.json b/web/package-lock.json index ebcdba519..96913785b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -38,6 +38,7 @@ "cmdk": "^1.0.0", "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", + "date-fns-tz": "^3.2.0", "embla-carousel-react": "^8.2.0", "framer-motion": "^11.5.4", "hls.js": "^1.5.20", @@ -4399,6 +4400,15 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", diff --git a/web/package.json b/web/package.json index 7bcffad79..b23bcdba8 100644 --- a/web/package.json +++ b/web/package.json @@ -44,6 +44,7 @@ "cmdk": "^1.0.0", "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", + "date-fns-tz": "^3.2.0", "embla-carousel-react": "^8.2.0", "framer-motion": "^11.5.4", "hls.js": "^1.5.20", diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index a78ba2c09..3bcb6d806 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -41,22 +41,34 @@ "second_one": "{{time}} second", "second_other": "{{time}} seconds", "formattedTimestamp": { - "12hour": "%b %-d, %I:%M:%S %p", - "24hour": "%b %-d, %H:%M:%S" + "12hour": "MMM d, h:mm:ss aaa", + "24hour": "MMM d, HH:mm:ss" }, "formattedTimestamp2": { - "12hour": "%m/%d %I:%M:%S%P", - "24hour": "%d %b %H:%M:%S" + "12hour": "MM/dd h:mm:ssa", + "24hour": "d MMM HH:mm:ss" }, - "formattedTimestampExcludeSeconds": { - "12hour": "%b %-d, %I:%M %p", - "24hour": "%b %-d, %H:%M" + "formattedTimestampHourMinute": { + "12hour": "h:mm aaa", + "24hour": "HH:mm" }, - "formattedTimestampWithYear": { - "12hour": "%b %-d %Y, %I:%M %p", - "24hour": "%b %-d %Y, %H:%M" + "formattedTimestampHourMinuteSecond": { + "12hour": "h:mm:ss aaa", + "24hour": "HH:mm:ss" }, - "formattedTimestampOnlyMonthAndDay": "%b %-d" + "formattedTimestampMonthDayHourMinute": { + "12hour": "MMM d, h:mm aaa", + "24hour": "MMM d, HH:mm" + }, + "formattedTimestampMonthDayYearHourMinute": { + "12hour": "MMM d yyyy, h:mm aaa", + "24hour": "MMM d yyyy, HH:mm" + }, + "formattedTimestampMonthDay": "MMM d", + "formattedTimestampFilename": { + "12hour": "MM-dd-yy-h-mm-ss-a", + "24hour": "MM-dd-yy-HH-mm-ss" + } }, "unit": { "speed": { diff --git a/web/src/components/button/DownloadVideoButton.tsx b/web/src/components/button/DownloadVideoButton.tsx index 094cf9308..607458af4 100644 --- a/web/src/components/button/DownloadVideoButton.tsx +++ b/web/src/components/button/DownloadVideoButton.tsx @@ -4,6 +4,10 @@ import { FaDownload } from "react-icons/fa"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { useDateLocale } from "@/hooks/use-date-locale"; +import { useMemo } from "react"; type DownloadVideoButtonProps = { source: string; @@ -19,10 +23,17 @@ export function DownloadVideoButton({ className, }: DownloadVideoButtonProps) { const { t } = useTranslation(["components/input"]); + const { data: config } = useSWR("config"); + const locale = useDateLocale(); + + const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour"; + const format = useMemo(() => { + return t(`time.formattedTimestampFilename.${timeFormat}`, { ns: "common" }); + }, [t, timeFormat]); + const formattedDate = formatUnixTimestampToDateTime(startTime, { - strftime_fmt: "%D-%T", - time_style: "medium", - date_style: "medium", + date_format: format, + locale, }); const filename = `${camera}_${formattedDate}.mp4`; diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx index c183f5e71..291201369 100644 --- a/web/src/components/card/ReviewCard.tsx +++ b/web/src/components/card/ReviewCard.tsx @@ -52,7 +52,9 @@ export default function ReviewCard({ const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); const formattedDate = useFormattedTimestamp( event.start_time, - config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p", + config?.ui.time_format == "24hour" + ? t("time.formattedTimestampHourMinute.24hour", { ns: "common" }) + : t("time.formattedTimestampHourMinute.12hour", { ns: "common" }), config?.ui.timezone, ); const isSelected = useMemo( diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx index 1cf607aa0..c86e9c3c6 100644 --- a/web/src/components/card/SearchThumbnailFooter.tsx +++ b/web/src/components/card/SearchThumbnailFooter.tsx @@ -32,8 +32,8 @@ export default function SearchThumbnailFooter({ const formattedDate = useFormattedTimestamp( searchResult.start_time, config?.ui.time_format == "24hour" - ? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" }) - : t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }), + ? t("time.formattedTimestampMonthDayHourMinute.24hour", { ns: "common" }) + : t("time.formattedTimestampMonthDayHourMinute.12hour", { ns: "common" }), config?.ui.timezone, ); diff --git a/web/src/components/filter/CalendarFilterButton.tsx b/web/src/components/filter/CalendarFilterButton.tsx index af9d99851..f16368d63 100644 --- a/web/src/components/filter/CalendarFilterButton.tsx +++ b/web/src/components/filter/CalendarFilterButton.tsx @@ -32,7 +32,7 @@ export default function CalendarFilterButton({ const [open, setOpen] = useState(false); const selectedDate = useFormattedTimestamp( day == undefined ? 0 : day?.getTime() / 1000 + 1, - t("time.formattedTimestampOnlyMonthAndDay", { ns: "common" }), + t("time.formattedTimestampMonthDay", { ns: "common" }), ); const trigger = ( @@ -103,7 +103,7 @@ export function CalendarRangeFilterButton({ const selectedDate = useFormattedRange( range?.from == undefined ? 0 : range.from.getTime() / 1000 + 1, range?.to == undefined ? 0 : range.to.getTime() / 1000 - 1, - t("time.formattedTimestampOnlyMonthAndDay", { ns: "common" }), + t("time.formattedTimestampMonthDay", { ns: "common" }), ); const trigger = ( diff --git a/web/src/components/graph/LineGraph.tsx b/web/src/components/graph/LineGraph.tsx index ef55c9343..76c9c2ce3 100644 --- a/web/src/components/graph/LineGraph.tsx +++ b/web/src/components/graph/LineGraph.tsx @@ -1,4 +1,5 @@ import { useTheme } from "@/context/theme-provider"; +import { useDateLocale } from "@/hooks/use-date-locale"; import { FrigateConfig } from "@/types/frigateConfig"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { useCallback, useEffect, useMemo } from "react"; @@ -24,7 +25,7 @@ export function CameraLineGraph({ updateTimes, data, }: CameraLineGraphProps) { - const { t } = useTranslation(["views/system"]); + const { t } = useTranslation(["views/system", "common"]); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); @@ -43,18 +44,27 @@ export function CameraLineGraph({ const { theme, systemTheme } = useTheme(); + const locale = useDateLocale(); + + const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour"; + const format = useMemo(() => { + return t(`time.formattedTimestampHourMinute.${timeFormat}`, { + ns: "common", + }); + }, [t, timeFormat]); + const formatTime = useCallback( (val: unknown) => { return formatUnixTimestampToDateTime( updateTimes[Math.round(val as number)], { timezone: config?.ui.timezone, - strftime_fmt: - config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p", + date_format: format, + locale, }, ); }, - [config, updateTimes], + [config?.ui.timezone, format, locale, updateTimes], ); const options = useMemo(() => { @@ -170,18 +180,28 @@ export function EventsPerSecondsLineGraph({ [data], ); + const locale = useDateLocale(); + const { t } = useTranslation(["common"]); + + const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour"; + const format = useMemo(() => { + return t(`time.formattedTimestampHourMinute.${timeFormat}`, { + ns: "common", + }); + }, [t, timeFormat]); + const formatTime = useCallback( (val: unknown) => { return formatUnixTimestampToDateTime( updateTimes[Math.round(val as number) - 1], { timezone: config?.ui.timezone, - strftime_fmt: - config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p", + date_format: format, + locale, }, ); }, - [config, updateTimes], + [config?.ui.timezone, format, locale, updateTimes], ); const options = useMemo(() => { diff --git a/web/src/components/graph/SystemGraph.tsx b/web/src/components/graph/SystemGraph.tsx index aaf838763..b77231aa7 100644 --- a/web/src/components/graph/SystemGraph.tsx +++ b/web/src/components/graph/SystemGraph.tsx @@ -1,10 +1,12 @@ import { useTheme } from "@/context/theme-provider"; +import { useDateLocale } from "@/hooks/use-date-locale"; import { FrigateConfig } from "@/types/frigateConfig"; import { Threshold } from "@/types/graph"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { useCallback, useEffect, useMemo } from "react"; import Chart from "react-apexcharts"; import { isMobileOnly } from "react-device-detect"; +import { useTranslation } from "react-i18next"; import useSWR from "swr"; type ThresholdBarGraphProps = { @@ -45,6 +47,16 @@ export function ThresholdBarGraph({ const { theme, systemTheme } = useTheme(); + const locale = useDateLocale(); + const { t } = useTranslation(["common"]); + + const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour"; + const format = useMemo(() => { + return t(`time.formattedTimestampHourMinute.${timeFormat}`, { + ns: "common", + }); + }, [t, timeFormat]); + const formatTime = useCallback( (val: unknown) => { const dateIndex = Math.round(val as number); @@ -53,17 +65,16 @@ export function ThresholdBarGraph({ if (dateIndex < 0) { timeOffset = 5 * Math.abs(dateIndex); } - return formatUnixTimestampToDateTime( updateTimes[Math.max(1, dateIndex) - 1] - timeOffset, { timezone: config?.ui.timezone, - strftime_fmt: - config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p", + date_format: format, + locale, }, ); }, - [config, updateTimes], + [config?.ui.timezone, format, locale, updateTimes], ); const options = useMemo(() => { diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx index 5d5bf5028..cfd5fc4b0 100644 --- a/web/src/components/menu/LiveContextMenu.tsx +++ b/web/src/components/menu/LiveContextMenu.tsx @@ -45,6 +45,7 @@ import { useNotificationSuspend, } from "@/api/ws"; import { useTranslation } from "react-i18next"; +import { useDateLocale } from "@/hooks/use-date-locale"; type LiveContextMenuProps = { className?: string; @@ -235,18 +236,25 @@ export default function LiveContextMenu({ } }; + const locale = useDateLocale(); + const formatSuspendedUntil = (timestamp: string) => { // Some languages require a change in word order if (timestamp === "0") return t("time.untilForRestart", { ns: "common" }); - const time = formatUnixTimestampToDateTime(Number.parseInt(timestamp), { + const time = formatUnixTimestampToDateTime(parseInt(timestamp), { time_style: "medium", date_style: "medium", timezone: config?.ui.timezone, - strftime_fmt: + date_format: config?.ui.time_format == "24hour" - ? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" }) - : t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }), + ? t("time.formattedTimestampMonthDayHourMinute.24hour", { + ns: "common", + }) + : t("time.formattedTimestampMonthDayHourMinute.12hour", { + ns: "common", + }), + locale: locale, }); return t("time.untilForTime", { ns: "common", time }); }; diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx index 3934b4113..6b711b65f 100644 --- a/web/src/components/overlay/detail/ObjectLifecycle.tsx +++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx @@ -578,7 +578,7 @@ export default function ObjectLifecycle({
{formatUnixTimestampToDateTime(item.timestamp, { timezone: config.ui.timezone, - strftime_fmt: + date_format: config.ui.time_format == "24hour" ? t("time.formattedTimestamp2.24hour", { ns: "common", diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx index 5c952ffce..23666acb4 100644 --- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -96,8 +96,12 @@ export default function ReviewDetailDialog({ const formattedDate = useFormattedTimestamp( review?.start_time ?? 0, config?.ui.time_format == "24hour" - ? t("time.formattedTimestampWithYear.24hour", { ns: "common" }) - : t("time.formattedTimestampWithYear.12hour", { ns: "common" }), + ? t("time.formattedTimestampMonthDayYearHourMinute.24hour", { + ns: "common", + }) + : t("time.formattedTimestampMonthDayYearHourMinute.12hour", { + ns: "common", + }), config?.ui.timezone, ); diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index c238f3e53..2938bf3a2 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -320,8 +320,12 @@ function ObjectDetailsTab({ const formattedDate = useFormattedTimestamp( search?.start_time ?? 0, config?.ui.time_format == "24hour" - ? t("time.formattedTimestampWithYear.24hour", { ns: "common" }) - : t("time.formattedTimestampWithYear.12hour", { ns: "common" }), + ? t("time.formattedTimestampMonthDayYearHourMinute.24hour", { + ns: "common", + }) + : t("time.formattedTimestampMonthDayYearHourMinute.12hour", { + ns: "common", + }), config?.ui.timezone, ); diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index d7c0709eb..da7fac0e2 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -170,8 +170,8 @@ export default function PreviewThumbnailPlayer({ const formattedDate = useFormattedTimestamp( review.start_time, config?.ui.time_format == "24hour" - ? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" }) - : t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }), + ? t("time.formattedTimestampMonthDayHourMinute.24hour", { ns: "common" }) + : t("time.formattedTimestampMonthDayHourMinute.12hour", { ns: "common" }), config?.ui?.timezone, ); diff --git a/web/src/components/timeline/segment-metadata.tsx b/web/src/components/timeline/segment-metadata.tsx index 9d29c73ad..70e44b40b 100644 --- a/web/src/components/timeline/segment-metadata.tsx +++ b/web/src/components/timeline/segment-metadata.tsx @@ -1,6 +1,7 @@ +import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { FrigateConfig } from "@/types/frigateConfig"; -import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import useSWR from "swr"; type MinimapSegmentProps = { @@ -34,6 +35,24 @@ export function MinimapBounds({ dense, }: MinimapSegmentProps) { const { data: config } = useSWR("config"); + const { t } = useTranslation(["common"]); + const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour"; + + const formatKey = dense + ? `time.formattedTimestampHourMinute.${timeFormat}` + : `time.formattedTimestampMonthDayHourMinute.${timeFormat}`; + + const formattedStartTime = useFormattedTimestamp( + alignedMinimapStartTime, + t(formatKey), + config?.ui.timezone, + ); + + const formattedEndTime = useFormattedTimestamp( + alignedMinimapEndTime, + t(formatKey), + config?.ui.timezone, + ); return ( <> @@ -42,23 +61,13 @@ export function MinimapBounds({ className="pointer-events-none absolute inset-0 -bottom-7 z-20 flex w-full select-none scroll-mt-8 items-center justify-center text-center text-[10px] font-medium text-primary" ref={firstMinimapSegmentRef} > - {formatUnixTimestampToDateTime(alignedMinimapStartTime, { - timezone: config?.ui.timezone, - strftime_fmt: !dense - ? `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}` - : `${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`, - })} + {formattedStartTime}
)} {isLastSegmentInMinimap && (
- {formatUnixTimestampToDateTime(alignedMinimapEndTime, { - timezone: config?.ui.timezone, - strftime_fmt: !dense - ? `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}` - : `${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`, - })} + {formattedEndTime}
)} @@ -92,27 +101,28 @@ export function Timestamp({ timestampSpread, segmentKey, }: TimestampSegmentProps) { + const { t } = useTranslation(["common"]); const { data: config } = useSWR("config"); - const formattedTimestamp = useMemo(() => { - if ( - !( - timestamp.getMinutes() % timestampSpread === 0 && - timestamp.getSeconds() === 0 - ) - ) { - return undefined; - } + const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour"; + const format = t(`time.formattedTimestampHourMinute.${timeFormat}`); - return formatUnixTimestampToDateTime(timestamp.getTime() / 1000, { - timezone: config?.ui.timezone, - strftime_fmt: config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p", - }); - }, [config, timestamp, timestampSpread]); + const formattedTimestamp = useFormattedTimestamp( + timestamp.getTime() / 1000, + format, + config?.ui.timezone, + ); + + const shouldDisplay = useMemo(() => { + return ( + timestamp.getMinutes() % timestampSpread === 0 && + timestamp.getSeconds() === 0 + ); + }, [timestamp, timestampSpread]); return (
- {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && ( + {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && shouldDisplay && (
; -// Map of locale codes to dynamic import functions -const localeMap: Record Promise> = { - "zh-CN": () => import("date-fns/locale/zh-CN").then((module) => module.zhCN), - es: () => import("date-fns/locale/es").then((module) => module.es), - hi: () => import("date-fns/locale/hi").then((module) => module.hi), - fr: () => import("date-fns/locale/fr").then((module) => module.fr), - ar: () => import("date-fns/locale/ar").then((module) => module.ar), - pt: () => import("date-fns/locale/pt").then((module) => module.pt), - ru: () => import("date-fns/locale/ru").then((module) => module.ru), - de: () => import("date-fns/locale/de").then((module) => module.de), - ja: () => import("date-fns/locale/ja").then((module) => module.ja), - tr: () => import("date-fns/locale/tr").then((module) => module.tr), - it: () => import("date-fns/locale/it").then((module) => module.it), - nl: () => import("date-fns/locale/nl").then((module) => module.nl), - sv: () => import("date-fns/locale/sv").then((module) => module.sv), - cs: () => import("date-fns/locale/cs").then((module) => module.cs), - nb: () => import("date-fns/locale/nb").then((module) => module.nb), - ko: () => import("date-fns/locale/ko").then((module) => module.ko), - vi: () => import("date-fns/locale/vi").then((module) => module.vi), - fa: () => import("date-fns/locale/fa-IR").then((module) => module.faIR), - pl: () => import("date-fns/locale/pl").then((module) => module.pl), - uk: () => import("date-fns/locale/uk").then((module) => module.uk), - he: () => import("date-fns/locale/he").then((module) => module.he), - el: () => import("date-fns/locale/el").then((module) => module.el), - ro: () => import("date-fns/locale/ro").then((module) => module.ro), - hu: () => import("date-fns/locale/hu").then((module) => module.hu), - fi: () => import("date-fns/locale/fi").then((module) => module.fi), - da: () => import("date-fns/locale/da").then((module) => module.da), - sk: () => import("date-fns/locale/sk").then((module) => module.sk), -}; - function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { - const [locale, setLocale] = useState(enUS); - - useEffect(() => { - const loadLocale = async () => { - if (i18n.language === "en") { - setLocale(enUS); - return; - } - - const localeLoader = localeMap[i18n.language]; - if (localeLoader) { - const loadedLocale = await localeLoader(); - setLocale(loadedLocale); - } else { - setLocale(enUS); - } - }; - loadLocale(); - }, [i18n.language]); + const locale = useDateLocale(); return ( Promise> = { + "zh-CN": () => import("date-fns/locale/zh-CN").then((module) => module.zhCN), + es: () => import("date-fns/locale/es").then((module) => module.es), + hi: () => import("date-fns/locale/hi").then((module) => module.hi), + fr: () => import("date-fns/locale/fr").then((module) => module.fr), + ar: () => import("date-fns/locale/ar").then((module) => module.ar), + pt: () => import("date-fns/locale/pt").then((module) => module.pt), + ru: () => import("date-fns/locale/ru").then((module) => module.ru), + de: () => import("date-fns/locale/de").then((module) => module.de), + ja: () => import("date-fns/locale/ja").then((module) => module.ja), + tr: () => import("date-fns/locale/tr").then((module) => module.tr), + it: () => import("date-fns/locale/it").then((module) => module.it), + nl: () => import("date-fns/locale/nl").then((module) => module.nl), + sv: () => import("date-fns/locale/sv").then((module) => module.sv), + cs: () => import("date-fns/locale/cs").then((module) => module.cs), + nb: () => import("date-fns/locale/nb").then((module) => module.nb), + ko: () => import("date-fns/locale/ko").then((module) => module.ko), + vi: () => import("date-fns/locale/vi").then((module) => module.vi), + fa: () => import("date-fns/locale/fa-IR").then((module) => module.faIR), + pl: () => import("date-fns/locale/pl").then((module) => module.pl), + uk: () => import("date-fns/locale/uk").then((module) => module.uk), + he: () => import("date-fns/locale/he").then((module) => module.he), + el: () => import("date-fns/locale/el").then((module) => module.el), + ro: () => import("date-fns/locale/ro").then((module) => module.ro), + hu: () => import("date-fns/locale/hu").then((module) => module.hu), + fi: () => import("date-fns/locale/fi").then((module) => module.fi), + da: () => import("date-fns/locale/da").then((module) => module.da), + sk: () => import("date-fns/locale/sk").then((module) => module.sk), +}; + +export function useDateLocale(): Locale { + const { i18n } = useTranslation(); + const [locale, setLocale] = useState(enUS); + + useEffect(() => { + const loadLocale = async () => { + if (i18n.language === "en") { + setLocale(enUS); + return; + } + + const localeLoader = localeMap[i18n.language]; + if (localeLoader) { + try { + const loadedLocale = await localeLoader(); + setLocale(loadedLocale); + } catch (error) { + setLocale(enUS); + } + } else { + setLocale(enUS); + } + }; + + loadLocale(); + }, [i18n.language]); + + return locale; +} diff --git a/web/src/hooks/use-date-utils.ts b/web/src/hooks/use-date-utils.ts index 4fa666cbd..5fa8dd585 100644 --- a/web/src/hooks/use-date-utils.ts +++ b/web/src/hooks/use-date-utils.ts @@ -1,33 +1,42 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { useMemo } from "react"; +import { useDateLocale } from "@/hooks/use-date-locale"; export function useFormattedTimestamp( timestamp: number, format: string, timezone?: string, ) { + const locale = useDateLocale(); + const formattedTimestamp = useMemo(() => { return formatUnixTimestampToDateTime(timestamp, { timezone, - strftime_fmt: format, + date_format: format, + locale, }); - }, [format, timestamp, timezone]); + }, [format, timestamp, timezone, locale]); return formattedTimestamp; } export function useFormattedRange(start: number, end: number, format: string) { + const locale = useDateLocale(); + const formattedStart = useMemo(() => { return formatUnixTimestampToDateTime(start, { - strftime_fmt: format, + date_format: format, + locale, }); - }, [format, start]); + }, [format, start, locale]); + const formattedEnd = useMemo(() => { return formatUnixTimestampToDateTime(end, { - strftime_fmt: format, + date_format: format, + locale, }); - }, [format, end]); + }, [format, end, locale]); return `${formattedStart} - ${formattedEnd}`; } @@ -44,7 +53,7 @@ export function useTimezone(config: FrigateConfig | undefined) { }, [config]); } -function use24HourTime(config: FrigateConfig | undefined) { +export function use24HourTime(config: FrigateConfig | undefined) { const localeUses24HourTime = useMemo( () => new Intl.DateTimeFormat(undefined, { @@ -60,8 +69,8 @@ function use24HourTime(config: FrigateConfig | undefined) { return false; } - if (config.ui.time_format != "browser") { - return config.ui.time_format == "24hour"; + if (config.ui.time_format !== "browser") { + return config.ui.time_format === "24hour"; } return localeUses24HourTime; diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index ddc8851b6..bff6a1c70 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -3,6 +3,8 @@ import { useTimelineUtils } from "./use-timeline-utils"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import { useDateLocale } from "./use-date-locale"; +import { useTranslation } from "react-i18next"; type DraggableElementProps = { contentRef: React.RefObject; @@ -162,17 +164,28 @@ function useDraggableElement({ [segmentDuration, timelineStartAligned, segmentHeight], ); + const { t } = useTranslation(["common"]); + const locale = useDateLocale(); + + const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour"; + const format = useMemo(() => { + const formatKey = `time.${ + segmentDuration < 60 && !dense + ? "formattedTimestampHourMinuteSecond" + : "formattedTimestampHourMinute" + }.${timeFormat}`; + return t(formatKey); + }, [t, timeFormat, segmentDuration, dense]); + const getFormattedTimestamp = useCallback( (segmentStartTime: number) => { return formatUnixTimestampToDateTime(segmentStartTime, { timezone: config?.ui.timezone, - strftime_fmt: - config?.ui.time_format == "24hour" - ? `%H:%M${segmentDuration < 60 && !dense ? ":%S" : ""}` - : `%I:%M${segmentDuration < 60 && !dense ? ":%S" : ""} %p`, + date_format: format, + locale, }); }, - [config, dense, segmentDuration], + [config?.ui.timezone, format, locale], ); const updateDraggableElementPosition = useCallback( diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index c3070542c..2909a2ea9 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -531,8 +531,12 @@ function TrainingGrid({ const formattedDate = useFormattedTimestamp( selectedEvent?.start_time ?? 0, config?.ui.time_format == "24hour" - ? t("time.formattedTimestampWithYear.24hour", { ns: "common" }) - : t("time.formattedTimestampWithYear.12hour", { ns: "common" }), + ? t("time.formattedTimestampMonthDayYearHourMinute.24hour", { + ns: "common", + }) + : t("time.formattedTimestampMonthDayYearHourMinute.12hour", { + ns: "common", + }), config?.ui.timezone, ); diff --git a/web/src/utils/dateUtil.ts b/web/src/utils/dateUtil.ts index 8509155e1..d974521f8 100644 --- a/web/src/utils/dateUtil.ts +++ b/web/src/utils/dateUtil.ts @@ -1,5 +1,6 @@ -import strftime from "strftime"; import { fromUnixTime, intervalToDuration, formatDuration } from "date-fns"; +import { Locale } from "date-fns/locale"; +import { formatInTimeZone } from "date-fns-tz"; export const longToDate = (long: number): Date => new Date(long * 1000); export const epochToLong = (date: number): number => date / 1000; export const dateToLong = (date: Date): number => epochToLong(date.getTime()); @@ -108,11 +109,19 @@ const getResolvedTimeZone = () => { } }; +type DateTimeStyle = { + timezone?: string; + time_format?: "browser" | "12hour" | "24hour"; + date_style?: "full" | "long" | "medium" | "short"; + time_style?: "full" | "long" | "medium" | "short"; + date_format?: string; + locale?: string | Locale; +}; /** * Formats a Unix timestamp into a human-readable date/time string. * * The format of the output string is determined by a configuration object passed as an argument, which - * may specify a time zone, 12- or 24-hour time, and various stylistic options for the date and time. + * may specify a time zone, 12- or 24-hour time, various stylistic options for the date and time, and a locale. * If these options are not specified, the function will use system defaults or sensible fallbacks. * * The function is robust to environments where the Intl API is not fully supported, and includes a @@ -126,53 +135,71 @@ const getResolvedTimeZone = () => { */ export const formatUnixTimestampToDateTime = ( unixTimestamp: number, - config: { - timezone?: string; - time_format?: "browser" | "12hour" | "24hour"; - date_style?: "full" | "long" | "medium" | "short"; - time_style?: "full" | "long" | "medium" | "short"; - strftime_fmt?: string; - }, + config: DateTimeStyle = {}, ): string => { - const { timezone, time_format, date_style, time_style, strftime_fmt } = + const { timezone, time_format, date_style, time_style, date_format, locale } = config; - const locale = window.navigator?.language || "en-US"; + + // Determine the locale to use + let localeCode: string; + let dateFnsLocale: Locale | undefined; + if (typeof locale === "string") { + localeCode = locale; + } else if (locale && "code" in locale) { + localeCode = (locale as Locale).code || "en-US"; + dateFnsLocale = locale as Locale; + } else { + localeCode = window.navigator?.language || "en-US"; + } + if (isNaN(unixTimestamp)) { return "Invalid time"; } try { const date = new Date(unixTimestamp * 1000); - const resolvedTimeZone = getResolvedTimeZone(); - // use strftime_fmt if defined in config - if (strftime_fmt) { - const offset = getUTCOffset(date, timezone || resolvedTimeZone); - const strftime_locale = strftime.timezone(offset); - return strftime_locale(strftime_fmt, date); + if (date_format) { + const resolvedTimeZone = timezone || getResolvedTimeZone(); + let formatted = formatInTimeZone(date, resolvedTimeZone, date_format, { + locale: dateFnsLocale, + }); + // Uppercase AM/PM for 12-hour formats + if (date_format.includes("a") || date_format.includes("aaa")) { + formatted = formatted.replace(/am|pm/gi, (match) => + match.toUpperCase(), + ); + } + return formatted; } // DateTime format options const options: Intl.DateTimeFormatOptions = { dateStyle: date_style, timeStyle: time_style, - hour12: time_format !== "browser" ? time_format == "12hour" : undefined, + hour12: time_format !== "browser" ? time_format === "12hour" : undefined, }; // Only set timeZone option when resolvedTimeZone does not match UTC±HH:MM format, or when timezone is set in config + const resolvedTimeZone = getResolvedTimeZone(); const isUTCOffsetFormat = /^UTC[+-]\d{2}:\d{2}$/.test(resolvedTimeZone); if (timezone || !isUTCOffsetFormat) { options.timeZone = timezone || resolvedTimeZone; } - const formatter = new Intl.DateTimeFormat(locale, options); - const formattedDateTime = formatter.format(date); + const formatter = new Intl.DateTimeFormat(localeCode, options); + let formattedDateTime = formatter.format(date); - // Regex to check for existence of time. This is needed because dateStyle/timeStyle is not always supported. + if (options.hour12) { + formattedDateTime = formattedDateTime.replace(/am|pm/gi, (match) => + match.toUpperCase(), + ); + } + + // Regex to check for existence of time const containsTime = /\d{1,2}:\d{1,2}/.test(formattedDateTime); - // fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat - // This works even tough the timezone is undefined, it will use the runtime's default time zone + // fallback if the browser does not support dateStyle/timeStyle if (!containsTime) { const dateOptions = { ...formatMap[date_style ?? ""]?.date, @@ -185,10 +212,17 @@ export const formatUnixTimestampToDateTime = ( hour12: options.hour12, }; - return `${date.toLocaleDateString( - locale, + let fallbackFormatted = `${date.toLocaleDateString( + localeCode, dateOptions, - )} ${date.toLocaleTimeString(locale, timeOptions)}`; + )} ${date.toLocaleTimeString(localeCode, timeOptions)}`; + // Uppercase AM/PM in fallback + if (options.hour12) { + fallbackFormatted = fallbackFormatted.replace(/am|pm/gi, (match) => + match.toUpperCase(), + ); + } + return fallbackFormatted; } return formattedDateTime; diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index b30af4336..07274c8ec 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -44,6 +44,7 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Trans, useTranslation } from "react-i18next"; +import { useDateLocale } from "@/hooks/use-date-locale"; const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js"; @@ -645,6 +646,8 @@ export function CameraNotificationSwitch({ sendNotificationSuspend(0); }; + const locale = useDateLocale(); + const formatSuspendedUntil = (timestamp: string) => { // Some languages require a change in word order if (timestamp === "0") return t("time.untilForRestart", { ns: "common" }); @@ -653,10 +656,15 @@ export function CameraNotificationSwitch({ time_style: "medium", date_style: "medium", timezone: config?.ui.timezone, - strftime_fmt: + date_format: config?.ui.time_format == "24hour" - ? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" }) - : t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }), + ? t("time.formattedTimestampMonthDayHourMinute.24hour", { + ns: "common", + }) + : t("time.formattedTimestampMonthDayHourMinute.12hour", { + ns: "common", + }), + locale: locale, }); return t("time.untilForTime", { ns: "common", time }); }; diff --git a/web/src/views/system/StorageMetrics.tsx b/web/src/views/system/StorageMetrics.tsx index 4e7ff646d..2f41f2c92 100644 --- a/web/src/views/system/StorageMetrics.tsx +++ b/web/src/views/system/StorageMetrics.tsx @@ -10,9 +10,8 @@ import { import useSWR from "swr"; import { CiCircleAlert } from "react-icons/ci"; import { FrigateConfig } from "@/types/frigateConfig"; -import { useTimezone } from "@/hooks/use-date-utils"; +import { useFormattedTimestamp, useTimezone } from "@/hooks/use-date-utils"; import { RecordingsSummary } from "@/types/review"; -import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { useTranslation } from "react-i18next"; type CameraStorage = { @@ -70,6 +69,19 @@ export default function StorageMetrics({ : null; }, [recordingsSummary]); + const timeFormat = config?.ui.time_format === "24hour" ? "24hour" : "12hour"; + const format = useMemo(() => { + return t(`time.formattedTimestampMonthDayYearHourMinute.${timeFormat}`, { + ns: "common", + }); + }, [t, timeFormat]); + + const formattedEarliestDate = useFormattedTimestamp( + earliestDate || 0, + format, + timezone, + ); + if (!cameraStorage || !stats || !totalStorage || !config) { return; } @@ -114,13 +126,7 @@ export default function StorageMetrics({ {t("storage.recordings.earliestRecording")} {" "} - {formatUnixTimestampToDateTime(earliestDate, { - timezone: timezone, - strftime_fmt: - config.ui.time_format === "24hour" - ? "%d %b %Y, %H:%M" - : "%B %d, %Y, %I:%M %p", - })} + {formattedEarliestDate}
)}