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
This commit is contained in:
Josh Hawkins
2025-04-22 15:50:21 -06:00
committed by GitHub
parent 645c84bc1a
commit b6e0e5698a
23 changed files with 356 additions and 176 deletions
@@ -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<FrigateConfig>("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`;
+3 -1
View File
@@ -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(
@@ -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,
);
@@ -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 = (
+27 -7
View File
@@ -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<FrigateConfig>("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(() => {
+15 -4
View File
@@ -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(() => {
+12 -4
View File
@@ -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 });
};
@@ -578,7 +578,7 @@ export default function ObjectLifecycle({
<div className="text-sm text-primary-variant">
{formatUnixTimestampToDateTime(item.timestamp, {
timezone: config.ui.timezone,
strftime_fmt:
date_format:
config.ui.time_format == "24hour"
? t("time.formattedTimestamp2.24hour", {
ns: "common",
@@ -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,
);
@@ -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,
);
@@ -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,
);
@@ -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<FrigateConfig>("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}
</div>
)}
{isLastSegmentInMinimap && (
<div className="pointer-events-none absolute inset-0 -top-3 z-20 flex w-full select-none items-center justify-center text-center text-[10px] font-medium text-primary">
{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}
</div>
)}
</>
@@ -92,27 +101,28 @@ export function Timestamp({
timestampSpread,
segmentKey,
}: TimestampSegmentProps) {
const { t } = useTranslation(["common"]);
const { data: config } = useSWR<FrigateConfig>("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 (
<div className="absolute left-[15px] z-10 h-[8px]">
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && shouldDisplay && (
<div
key={`${segmentKey}_timestamp`}
className="pointer-events-none select-none text-[8px] text-neutral_variant dark:text-neutral"
+2 -53
View File
@@ -1,69 +1,18 @@
import { useState, useEffect } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { Locale, enUS } from "date-fns/locale";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import i18n from "@/utils/i18n";
import { useDateLocale } from "@/hooks/use-date-locale";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
// Map of locale codes to dynamic import functions
const localeMap: Record<string, () => Promise<Locale>> = {
"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<Locale>(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 (
<DayPicker