mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 19:48:59 +03:00
* migrate web to eslint 10 flat config ESLint 10 dropped `.eslintrc` support, so `.eslintrc.cjs` is replaced with `eslint.config.js` and the lint scripts no longer pass `--ext` or `--ignore-path`. typescript-eslint moves to 8, react-hooks to 7, and react-refresh to 0.5, and the unused jest and vitest-globals plugins are removed. Lint behaves as it did before: catch variables aren't checked, unused disable directives aren't reported, and rules newly added to the recommended sets are off until the code passes them. typescript-eslint 8 flags constants used only in `typeof`, so those are now exported, or replaced with a union type where the export would trip react-refresh. * fix lint findings from the eslint 10 recommended rules Remove the rule overrides from the flat config migration and fix what they were hiding. Unused catch bindings are dropped, 20 disable directives that suppressed nothing are removed (react-hooks 5.2 and 7.1.1 report identical exhaustive-deps findings with inline config ignored), dead initial values are dropped, short-circuit calls become if statements or optional calls, rethrown errors pass `cause`, and the disabled "No recordings" tooltip in `ReviewTimeline` is removed along with its memo and the `getRecordingAvailability` prop. The 3 react-refresh warnings for files that export contexts or classes are left for a later refactor.
546 lines
17 KiB
TypeScript
546 lines
17 KiB
TypeScript
import { fromUnixTime, intervalToDuration, formatDuration } from "date-fns";
|
|
import { enUS, Locale } from "date-fns/locale";
|
|
import { formatInTimeZone } from "date-fns-tz";
|
|
import i18n from "@/utils/i18n";
|
|
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());
|
|
|
|
const getDateTimeYesterday = (dateTime: Date): Date => {
|
|
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
|
|
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
|
|
};
|
|
|
|
const getNowYesterday = (): Date => {
|
|
return getDateTimeYesterday(new Date());
|
|
};
|
|
|
|
export const getNowYesterdayInLong = (): number => {
|
|
return dateToLong(getNowYesterday());
|
|
};
|
|
|
|
/**
|
|
* This function takes in a Unix timestamp, configuration options for date/time display, and an optional strftime format string,
|
|
* and returns a formatted date/time string.
|
|
*
|
|
* If the Unix timestamp is not provided, it returns "Invalid time".
|
|
*
|
|
* The configuration options determine how the date and time are formatted.
|
|
* The `timezone` option allows you to specify a specific timezone for the output, otherwise the user's browser timezone will be used.
|
|
* The `use12hour` option allows you to display time in a 12-hour format if true, and 24-hour format if false.
|
|
* The `dateStyle` and `timeStyle` options allow you to specify pre-defined formats for displaying the date and time.
|
|
*
|
|
* @param unixTimestamp The Unix timestamp to format
|
|
* @param config An object containing the configuration options for date/time display
|
|
* @returns The formatted date/time string, or "Invalid time" if the Unix timestamp is not provided or invalid.
|
|
*/
|
|
|
|
// only used as a fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
|
const formatMap: {
|
|
[k: string]: {
|
|
date: {
|
|
year: "numeric" | "2-digit";
|
|
month: "long" | "short" | "2-digit";
|
|
day: "numeric" | "2-digit";
|
|
};
|
|
time: {
|
|
hour: "numeric";
|
|
minute: "numeric";
|
|
second?: "numeric";
|
|
timeZoneName?: "short" | "long";
|
|
};
|
|
};
|
|
} = {
|
|
full: {
|
|
date: { year: "numeric", month: "long", day: "numeric" },
|
|
time: {
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
second: "numeric",
|
|
timeZoneName: "long",
|
|
},
|
|
},
|
|
long: {
|
|
date: { year: "numeric", month: "long", day: "numeric" },
|
|
time: {
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
second: "numeric",
|
|
timeZoneName: "long",
|
|
},
|
|
},
|
|
medium: {
|
|
date: { year: "numeric", month: "short", day: "numeric" },
|
|
time: { hour: "numeric", minute: "numeric", second: "numeric" },
|
|
},
|
|
short: {
|
|
date: { year: "2-digit", month: "2-digit", day: "2-digit" },
|
|
time: { hour: "numeric", minute: "numeric" },
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Attempts to get the system's time zone using Intl.DateTimeFormat. If that fails (for instance, in environments
|
|
* where Intl is not fully supported), it calculates the UTC offset for the current system time and returns
|
|
* it in a string format.
|
|
*
|
|
* Keeping the Intl.DateTimeFormat for now, as this is the recommended way to get the time zone.
|
|
* https://stackoverflow.com/a/34602679
|
|
*
|
|
* Intl.DateTimeFormat function as of April 2023, works in 95.03% of the browsers used globally
|
|
* https://caniuse.com/mdn-javascript_builtins_intl_datetimeformat_resolvedoptions_computed_timezone
|
|
*
|
|
* @returns {string} The resolved time zone or a calculated UTC offset.
|
|
* The returned string will either be a named time zone (e.g., "America/Los_Angeles"), or it will follow
|
|
* the format "UTC±HH:MM".
|
|
*/
|
|
const getResolvedTimeZone = () => {
|
|
try {
|
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
} catch {
|
|
const offsetMinutes = new Date().getTimezoneOffset();
|
|
return `UTC${offsetMinutes < 0 ? "+" : "-"}${Math.abs(offsetMinutes / 60)
|
|
.toString()
|
|
.padStart(2, "0")}:${Math.abs(offsetMinutes % 60)
|
|
.toString()
|
|
.padStart(2, "0")}`;
|
|
}
|
|
};
|
|
|
|
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, 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
|
|
* fallback method to create a formatted date/time string in such cases.
|
|
*
|
|
* @param {number} unixTimestamp - The Unix timestamp to be formatted.
|
|
* @param {DateTimeStyle} config - User configuration object.
|
|
* @returns {string} A formatted date/time string.
|
|
*
|
|
* @throws {Error} If the given unixTimestamp is not a valid number, the function will return 'Invalid time'.
|
|
*/
|
|
export const formatUnixTimestampToDateTime = (
|
|
unixTimestamp: number,
|
|
config: DateTimeStyle = {},
|
|
): string => {
|
|
const { timezone, time_format, date_style, time_style, date_format, locale } =
|
|
config;
|
|
|
|
// 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);
|
|
|
|
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) =>
|
|
i18n.t(`time.${match.toLowerCase()}`, { ns: "common" }).toUpperCase(),
|
|
);
|
|
}
|
|
return formatted;
|
|
}
|
|
|
|
// DateTime format options
|
|
const options: Intl.DateTimeFormatOptions = {
|
|
dateStyle: date_style,
|
|
timeStyle: time_style,
|
|
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(localeCode, options);
|
|
let formattedDateTime = formatter.format(date);
|
|
|
|
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
|
|
if (!containsTime) {
|
|
const dateOptions = {
|
|
...formatMap[date_style ?? ""]?.date,
|
|
timeZone: options.timeZone,
|
|
hour12: options.hour12,
|
|
};
|
|
const timeOptions = {
|
|
...formatMap[time_style ?? ""]?.time,
|
|
timeZone: options.timeZone,
|
|
hour12: options.hour12,
|
|
};
|
|
|
|
let fallbackFormatted = `${date.toLocaleDateString(
|
|
localeCode,
|
|
dateOptions,
|
|
)} ${date.toLocaleTimeString(localeCode, timeOptions)}`;
|
|
// Uppercase AM/PM in fallback
|
|
if (options.hour12) {
|
|
fallbackFormatted = fallbackFormatted.replace(/am|pm/gi, (match) =>
|
|
i18n.t(`time.${match.toLowerCase()}`, { ns: "common" }).toUpperCase(),
|
|
);
|
|
}
|
|
return fallbackFormatted;
|
|
}
|
|
|
|
return formattedDateTime;
|
|
} catch {
|
|
return "Invalid time";
|
|
}
|
|
};
|
|
|
|
/**
|
|
* This function takes in start and end time in unix timestamp,
|
|
* and returns the duration between start and end time in hours, minutes and seconds.
|
|
* If end time is not provided, it returns 'In Progress'
|
|
* @param start_time: number - Unix timestamp for start time
|
|
* @param end_time: number|null - Unix timestamp for end time
|
|
* @param abbreviated: boolean - Whether to use abbreviated forms (h, m, s) instead of full words
|
|
* @returns string - duration or 'In Progress' if end time is not provided
|
|
*/
|
|
export const getDurationFromTimestamps = (
|
|
start_time: number,
|
|
end_time: number | null,
|
|
abbreviated: boolean = false,
|
|
): string => {
|
|
if (isNaN(start_time)) {
|
|
return i18n.t("time.invalidStartTime", { ns: "common" });
|
|
}
|
|
let duration = i18n.t("time.inProgress", { ns: "common" });
|
|
if (end_time !== null) {
|
|
if (isNaN(end_time)) {
|
|
return i18n.t("time.invalidEndTime", { ns: "common" });
|
|
}
|
|
const start = fromUnixTime(start_time);
|
|
const end = fromUnixTime(end_time);
|
|
const durationObj = intervalToDuration({ start, end });
|
|
|
|
// Build duration string using i18n keys or abbreviations
|
|
const parts: string[] = [];
|
|
if (durationObj.hours) {
|
|
const count = durationObj.hours;
|
|
if (abbreviated) {
|
|
parts.push(`${count}h`);
|
|
} else {
|
|
const key = count === 1 ? "hour_one" : "hour_other";
|
|
parts.push(i18n.t(`time.${key}`, { time: count, ns: "common" }));
|
|
}
|
|
}
|
|
if (durationObj.minutes) {
|
|
const count = durationObj.minutes;
|
|
if (abbreviated) {
|
|
parts.push(`${count}m`);
|
|
} else {
|
|
const key = count === 1 ? "minute_one" : "minute_other";
|
|
parts.push(i18n.t(`time.${key}`, { time: count, ns: "common" }));
|
|
}
|
|
}
|
|
if (durationObj.seconds) {
|
|
const count = durationObj.seconds;
|
|
if (abbreviated) {
|
|
parts.push(`${count}s`);
|
|
} else {
|
|
const key = count === 1 ? "second_one" : "second_other";
|
|
parts.push(i18n.t(`time.${key}`, { time: count, ns: "common" }));
|
|
}
|
|
}
|
|
|
|
duration = parts.join(" ");
|
|
}
|
|
return duration;
|
|
};
|
|
|
|
/**
|
|
*
|
|
* @param seconds - number of seconds to convert into hours, minutes and seconds
|
|
* @param locale - the date-fns locale to use for formatting
|
|
* @returns string - formatted duration in hours, minutes and seconds
|
|
*/
|
|
export const formatSecondsToDuration = (
|
|
seconds: number,
|
|
locale?: Locale,
|
|
): string => {
|
|
if (isNaN(seconds) || seconds < 0) {
|
|
return "Invalid duration";
|
|
}
|
|
|
|
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
|
|
return formatDuration(duration, {
|
|
format: ["hours", "minutes", "seconds"],
|
|
delimiter: ", ",
|
|
locale: locale ?? enUS,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Adapted from https://stackoverflow.com/a/29268535 this takes a timezone string and
|
|
* returns the offset of that timezone from UTC in minutes.
|
|
* @param timezone string representation of the timezone the user is requesting
|
|
* @returns number of minutes offset from UTC
|
|
*/
|
|
export const getUTCOffset = (date: Date, timezone?: string | null): number => {
|
|
// ui.timezone comes back as null until the user sets one
|
|
const resolvedTimezone = timezone || getResolvedTimeZone();
|
|
|
|
// If timezone is in UTC±HH:MM format, parse it to get offset
|
|
const utcOffsetMatch = resolvedTimezone.match(/^UTC([+-])(\d{2}):(\d{2})$/);
|
|
if (utcOffsetMatch) {
|
|
const hours = parseInt(utcOffsetMatch[2], 10);
|
|
const minutes = parseInt(utcOffsetMatch[3], 10);
|
|
return (utcOffsetMatch[1] === "+" ? 1 : -1) * (hours * 60 + minutes);
|
|
}
|
|
|
|
// Otherwise, calculate offset using provided timezone
|
|
const utcDate = new Date(date.getTime());
|
|
// locale of en-CA is required for proper locale format
|
|
let iso = utcDate
|
|
.toLocaleString("en-CA", { timeZone: resolvedTimezone, hour12: false })
|
|
.replace(", ", "T");
|
|
iso += `.${utcDate.getMilliseconds().toString().padStart(3, "0")}`;
|
|
let target = new Date(`${iso}Z`);
|
|
|
|
// safari doesn't like the default format
|
|
if (isNaN(target.getTime())) {
|
|
iso = iso.replace("T", " ").split(".")[0];
|
|
target = new Date(`${iso}+000`);
|
|
}
|
|
|
|
return Math.round(
|
|
(target.getTime() - utcDate.getTime() - date.getTimezoneOffset()) /
|
|
60 /
|
|
1000,
|
|
);
|
|
};
|
|
|
|
export function getRangeForTimestamp(timestamp: number) {
|
|
const date = new Date(timestamp * 1000);
|
|
date.setMinutes(0, 0, 0);
|
|
const start = date.getTime() / 1000;
|
|
date.setHours(date.getHours() + 1);
|
|
|
|
// ensure not to go past current time
|
|
return { start, end: endOfHourOrCurrentTime(date.getTime() / 1000) };
|
|
}
|
|
|
|
export function endOfHourOrCurrentTime(timestamp: number) {
|
|
const now = new Date();
|
|
now.setMilliseconds(0);
|
|
return Math.min(timestamp, now.getTime() / 1000);
|
|
}
|
|
|
|
export function getBeginningOfDayTimestamp(date: Date) {
|
|
date.setHours(0, 0, 0, 0);
|
|
return date.getTime() / 1000;
|
|
}
|
|
|
|
export function getEndOfDayTimestamp(date: Date) {
|
|
date.setHours(23, 59, 59, 999);
|
|
return date.getTime() / 1000;
|
|
}
|
|
|
|
export function isCurrentHour(timestamp: number) {
|
|
const now = new Date();
|
|
now.setUTCMinutes(0, 0, 0);
|
|
|
|
return timestamp > now.getTime() / 1000;
|
|
}
|
|
|
|
// a just-ended hour has no mp4 yet but may still have cached frames, so it
|
|
// stays eligible for the frame-based players
|
|
export function isCurrentOrPreviousHour(timestamp: number) {
|
|
const previousHour = new Date();
|
|
previousHour.setUTCMinutes(0, 0, 0);
|
|
previousHour.setUTCHours(previousHour.getUTCHours() - 1);
|
|
|
|
return timestamp > previousHour.getTime() / 1000;
|
|
}
|
|
|
|
export const convertLocalDateToTimestamp = (dateString: string): number => {
|
|
// Ensure the date string is in the correct format (8 digits)
|
|
if (!/^\d{8}$/.test(dateString)) {
|
|
return 0;
|
|
}
|
|
|
|
// Determine the local date format
|
|
const format = new Intl.DateTimeFormat()
|
|
.formatToParts(new Date())
|
|
.reduce((acc, part) => {
|
|
if (part.type === "day") acc.push("D");
|
|
if (part.type === "month") acc.push("M");
|
|
if (part.type === "year") acc.push("Y");
|
|
return acc;
|
|
}, [] as string[])
|
|
.join("");
|
|
|
|
let day: string, month: string, year: string;
|
|
|
|
// Parse the date string according to the detected format
|
|
switch (format) {
|
|
case "DMY":
|
|
[day, month, year] = [
|
|
dateString.slice(0, 2),
|
|
dateString.slice(2, 4),
|
|
dateString.slice(4),
|
|
];
|
|
break;
|
|
case "MDY":
|
|
[month, day, year] = [
|
|
dateString.slice(0, 2),
|
|
dateString.slice(2, 4),
|
|
dateString.slice(4),
|
|
];
|
|
break;
|
|
case "YMD":
|
|
[year, month, day] = [
|
|
dateString.slice(0, 2),
|
|
dateString.slice(2, 4),
|
|
dateString.slice(4),
|
|
];
|
|
break;
|
|
default:
|
|
return 0;
|
|
}
|
|
|
|
// Create a Date object based on the local timezone
|
|
const localDate = new Date(`${year}-${month}-${day}T00:00:00`);
|
|
|
|
// Check if the date is valid
|
|
if (isNaN(localDate.getTime())) {
|
|
return 0;
|
|
}
|
|
|
|
// Convert local date to UTC timestamp
|
|
const timestamp = localDate.getTime();
|
|
|
|
return timestamp;
|
|
};
|
|
|
|
export function getIntlDateFormat() {
|
|
return new Intl.DateTimeFormat()
|
|
.formatToParts(new Date())
|
|
.reduce((acc, part) => {
|
|
if (part.type === "day") acc.push("DD");
|
|
if (part.type === "month") acc.push("MM");
|
|
if (part.type === "year") acc.push("YYYY");
|
|
return acc;
|
|
}, [] as string[])
|
|
.join("");
|
|
}
|
|
|
|
export function formatDateToLocaleString(daysOffset: number = 0): string {
|
|
const date = new Date();
|
|
date.setDate(date.getDate() + daysOffset);
|
|
|
|
return new Intl.DateTimeFormat(window.navigator.language, {
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
})
|
|
.format(date)
|
|
.replace(/[^\d]/g, "");
|
|
}
|
|
|
|
export function to24Hour(
|
|
time: string,
|
|
time_format: "12hour" | "24hour" | "browser" = "24hour",
|
|
): string {
|
|
const is24HourFormat = time_format === "24hour";
|
|
|
|
if (is24HourFormat) return time;
|
|
|
|
const [timePart, ampm] = time.split(/([AP]M)/i);
|
|
|
|
if (!timePart || !ampm) {
|
|
throw new Error(`Invalid time format: ${time}`);
|
|
}
|
|
|
|
let hours = Number(timePart.split(":")[0]);
|
|
const minutes = Number(timePart.split(":")[1]);
|
|
|
|
if (ampm.toUpperCase() === "PM" && hours !== 12) hours += 12;
|
|
if (ampm.toUpperCase() === "AM" && hours === 12) hours = 0;
|
|
|
|
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
export function isValidTimeRange(
|
|
rangeString: string,
|
|
time_format?: "12hour" | "24hour" | "browser",
|
|
): boolean {
|
|
const range = rangeString.split(",");
|
|
if (range.length !== 2) {
|
|
return false;
|
|
}
|
|
|
|
const is24HourFormat = time_format === "24hour";
|
|
|
|
const toMinutes = (time: string): number => {
|
|
const [h, m] = to24Hour(time, time_format).split(":").map(Number);
|
|
return h * 60 + m;
|
|
};
|
|
|
|
const isValidTime = (time: string): boolean => {
|
|
if (is24HourFormat) {
|
|
return /^(?:([01]\d|2[0-3]):([0-5]\d)|24:00)$/.test(time);
|
|
} else {
|
|
return /^(0?[1-9]|1[0-2]):[0-5][0-9](A|P)M$/i.test(time);
|
|
}
|
|
};
|
|
|
|
const [startTime, endTime] = range.map((t) => t.trim());
|
|
|
|
return (
|
|
isValidTime(startTime) &&
|
|
isValidTime(endTime) &&
|
|
toMinutes(startTime) < toMinutes(endTime)
|
|
);
|
|
}
|
|
|
|
export function convertTo12Hour(time: string) {
|
|
const [hours, minutes] = time.split(":");
|
|
const hour = parseInt(hours, 10);
|
|
const ampm = hour >= 12 ? "PM" : "AM";
|
|
const hour12 = hour % 12 || 12;
|
|
return `${hour12}:${minutes} ${ampm}`;
|
|
}
|