Camera dashboard (#8981)

* Show camera dashboard

* Cleanup

* Cleanup

* Cleanup

* Cleanup

* fix

* Fix input color

* Show recent events on dashboard
This commit is contained in:
Nicolas Mowen 2023-12-16 07:40:00 -07:00 committed by Blake Blackshear
parent 3a33090984
commit a2c6f45454
12 changed files with 463 additions and 108 deletions

View File

@ -32,7 +32,7 @@ function App() {
<div id="pageRoot" className="overflow-x-hidden px-4 py-2 w-screen md:w-full"> <div id="pageRoot" className="overflow-x-hidden px-4 py-2 w-screen md:w-full">
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/live" element={<Live />} /> <Route path="/live/:camera?" element={<Live />} />
<Route path="/history" element={<History />} /> <Route path="/history" element={<History />} />
<Route path="/export" element={<Export />} /> <Route path="/export" element={<Export />} />
<Route path="/storage" element={<Storage />} /> <Route path="/storage" element={<Storage />} />

View File

@ -7,8 +7,9 @@ import { useResizeObserver } from "@/hooks/resize-observer";
type CameraImageProps = { type CameraImageProps = {
camera: string; camera: string;
onload?: (event: Event) => void; onload?: (event: Event) => void;
searchParams: {}; searchParams?: {};
stretch?: boolean; stretch?: boolean; // stretch to fit width
fitAspect?: number; // shrink to fit height
}; };
export default function CameraImage({ export default function CameraImage({
@ -16,13 +17,15 @@ export default function CameraImage({
onload, onload,
searchParams = "", searchParams = "",
stretch = false, stretch = false,
fitAspect,
}: CameraImageProps) { }: CameraImageProps) {
const { data: config } = useSWR("config"); const { data: config } = useSWR("config");
const apiHost = useApiHost(); const apiHost = useApiHost();
const [hasLoaded, setHasLoaded] = useState(false); const [hasLoaded, setHasLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [{ width: containerWidth }] = useResizeObserver(containerRef); const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
// Add scrollbar width (when visible) to the available observer width to eliminate screen juddering. // Add scrollbar width (when visible) to the available observer width to eliminate screen juddering.
// https://github.com/blakeblackshear/frigate/issues/1657 // https://github.com/blakeblackshear/frigate/issues/1657
@ -42,7 +45,10 @@ export default function CameraImage({
const aspectRatio = width / height; const aspectRatio = width / height;
const scaledHeight = useMemo(() => { const scaledHeight = useMemo(() => {
const scaledHeight = Math.floor(availableWidth / aspectRatio); const scaledHeight =
aspectRatio < (fitAspect ?? 0)
? Math.floor(containerHeight)
: Math.floor(availableWidth / aspectRatio);
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height); const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
if (finalHeight > 0) { if (finalHeight > 0) {
@ -79,7 +85,12 @@ export default function CameraImage({
}, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]); }, [apiHost, canvasRef, name, img, searchParams, scaledHeight, config]);
return ( return (
<div className="relative w-full" ref={containerRef}> <div
className={`relative w-full ${
fitAspect && aspectRatio < fitAspect ? "h-full flex justify-center" : ""
}`}
ref={containerRef}
>
{enabled ? ( {enabled ? (
<canvas <canvas
data-testid="cameraimage-canvas" data-testid="cameraimage-canvas"

View File

@ -32,7 +32,7 @@ export default function HistoryCard({
return ( return (
<Card <Card
className="cursor-pointer my-2 xs:mr-2 bg-secondary w-full xs:w-[48%] sm:w-[284px]" className="cursor-pointer my-2 xs:mr-2 w-full xs:w-[48%] sm:w-[284px]"
onClick={onClick} onClick={onClick}
> >
<PreviewThumbnailPlayer <PreviewThumbnailPlayer

View File

@ -0,0 +1,83 @@
import { useApiHost } from "@/api";
import { Card } from "../ui/card";
import { Event as FrigateEvent } from "@/types/event";
import { LuClock, LuStar } from "react-icons/lu";
import { useCallback } from "react";
import TimeAgo from "../dynamic/TimeAgo";
import { HiOutlineVideoCamera } from "react-icons/hi";
import { MdOutlineLocationOn } from "react-icons/md";
import axios from "axios";
type MiniEventCardProps = {
event: FrigateEvent;
onUpdate?: () => void;
};
export default function MiniEventCard({ event, onUpdate }: MiniEventCardProps) {
const baseUrl = useApiHost();
const onSave = useCallback(
async (e: Event) => {
e.stopPropagation();
let response;
if (!event.retain_indefinitely) {
response = await axios.post(`events/${event.id}/retain`);
} else {
response = await axios.delete(`events/${event.id}/retain`);
}
if (response.status === 200 && onUpdate) {
onUpdate();
}
},
[event]
);
return (
<Card className="mr-2 min-w-[260px] max-w-[320px]">
<div className="flex">
<div
className="relative rounded-l min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
style={{
backgroundImage: `url(${baseUrl}api/events/${event.id}/thumbnail.jpg)`,
}}
>
<LuStar
className="h-6 w-6 text-yellow-300 absolute top-1 right-1 cursor-pointer"
onClick={(e: Event) => onSave(e)}
fill={event.retain_indefinitely ? "currentColor" : "none"}
/>
{event.end_time ? null : (
<div className="bg-slate-300 dark:bg-slate-700 absolute bottom-0 text-center w-full uppercase text-sm rounded-bl">
In progress
</div>
)}
</div>
<div className="p-1 flex flex-col justify-between">
<div className="capitalize text-lg font-bold">
{event.label.replaceAll("_", " ")}
{event.sub_label
? `: ${event.sub_label.replaceAll("_", " ")}`
: null}
</div>
<div>
<div className="text-sm flex">
<LuClock className="h-4 w-4 mr-2 inline" />
<div className="hidden sm:inline">
<TimeAgo time={event.start_time * 1000} dense />
</div>
</div>
<div className="capitalize text-sm flex align-center mt-1 whitespace-nowrap">
<HiOutlineVideoCamera className="h-4 w-4 mr-2 inline" />
{event.camera.replaceAll("_", " ")}
</div>
{event.zones.length ? (
<div className="capitalize text-sm flex align-center">
<MdOutlineLocationOn className="w-4 h-4 mr-2 inline" />
{event.zones.join(", ").replaceAll("_", " ")}
</div>
) : null}
</div>
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,83 @@
import { FunctionComponent, useEffect, useMemo, useState } from "react";
interface IProp {
/** The time to calculate time-ago from */
time: number;
/** OPTIONAL: overwrite current time */
currentTime?: Date;
/** OPTIONAL: boolean that determines whether to show the time-ago text in dense format */
dense?: boolean;
/** OPTIONAL: set custom refresh interval in milliseconds, default 1000 (1 sec) */
refreshInterval?: number;
}
type TimeUnit = {
unit: string;
full: string;
value: number;
};
const timeAgo = ({ time, currentTime = new Date(), dense = false }: IProp): string => {
if (typeof time !== 'number' || time < 0) return 'Invalid Time Provided';
const pastTime: Date = new Date(time);
const elapsedTime: number = currentTime.getTime() - pastTime.getTime();
const timeUnits: TimeUnit[] = [
{ unit: 'yr', full: 'year', value: 31536000 },
{ unit: 'mo', full: 'month', value: 0 },
{ unit: 'd', full: 'day', value: 86400 },
{ unit: 'h', full: 'hour', value: 3600 },
{ unit: 'm', full: 'minute', value: 60 },
{ unit: 's', full: 'second', value: 1 },
];
const elapsed: number = elapsedTime / 1000;
if (elapsed < 10) {
return 'just now';
}
for (let i = 0; i < timeUnits.length; i++) {
// if months
if (i === 1) {
// Get the month and year for the time provided
const pastMonth = pastTime.getUTCMonth();
const pastYear = pastTime.getUTCFullYear();
// get current month and year
const currentMonth = currentTime.getUTCMonth();
const currentYear = currentTime.getUTCFullYear();
let monthDiff = (currentYear - pastYear) * 12 + (currentMonth - pastMonth);
// check if the time provided is the previous month but not exceeded 1 month ago.
if (currentTime.getUTCDate() < pastTime.getUTCDate()) {
monthDiff--;
}
if (monthDiff > 0) {
const unitAmount = monthDiff;
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
}
} else if (elapsed >= timeUnits[i].value) {
const unitAmount: number = Math.floor(elapsed / timeUnits[i].value);
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
}
}
return 'Invalid Time';
};
const TimeAgo: FunctionComponent<IProp> = ({ refreshInterval = 1000, ...rest }): JSX.Element => {
const [currentTime, setCurrentTime] = useState<Date>(new Date());
useEffect(() => {
const intervalId: NodeJS.Timeout = setInterval(() => {
setCurrentTime(new Date());
}, refreshInterval);
return () => clearInterval(intervalId);
}, [refreshInterval]);
const timeAgoValue = useMemo(() => timeAgo({ currentTime, ...rest }), [currentTime, rest]);
return <span>{timeAgoValue}</span>;
};
export default TimeAgo;

View File

@ -8,7 +8,7 @@ import { useCallback, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { usePersistence } from "@/context/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
const emptyObject = Object.freeze({}); const emptyObject = Object.freeze({});

View File

@ -1,79 +1,169 @@
import { useState } from "react"; import { useMemo } from "react";
import ActivityIndicator from "@/components/ui/activity-indicator"; import ActivityIndicator from "@/components/ui/activity-indicator";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { import {
Select, useAudioState,
SelectContent, useDetectState,
SelectGroup, useRecordingsState,
SelectItem, useSnapshotsState,
SelectTrigger, } from "@/api/ws";
SelectValue,
} from "@/components/ui/select";
import { useDetectState } from "@/api/ws";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { Card } from "@/components/ui/card";
import CameraImage from "@/components/camera/CameraImage";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { AiOutlinePicture } from "react-icons/ai";
import { FaWalking } from "react-icons/fa";
import { LuEar } from "react-icons/lu";
import { TbMovie } from "react-icons/tb";
import MiniEventCard from "@/components/card/MiniEventCard";
import { Event } from "@/types/event";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
export function Dashboard() { export function Dashboard() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [selectedCamera, setSelectedCamera] = useState<string | undefined>(
undefined
);
let cameras; const recentTimestamp = useMemo(() => {
if (config?.cameras) { const now = new Date();
cameras = Object.keys(config.cameras).map((name) => ( now.setMinutes(now.getMinutes() - 30);
<div key={name}> return now.getTime() / 1000;
<SelectItem value={name} onClick={() => setSelectedCamera(name)}> }, []);
{name} const { data: events, mutate: updateEvents } = useSWR<Event[]>([
</SelectItem> "events",
</div> { limit: 10, after: recentTimestamp },
)); ]);
}
const sortedCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
return ( return (
<> <>
<Heading as="h2">Dashboard</Heading>
{!config && <ActivityIndicator />} {!config && <ActivityIndicator />}
<Heading as="h2">Components testing</Heading> {config && (
<div>
<div className="flex items-center space-x-2 mt-5"> {events && events.length > 0 && (
<Select <>
value={selectedCamera} <Heading as="h4">Recent Events</Heading>
onValueChange={(val) => setSelectedCamera(val as string)} <ScrollArea>
> <div className="flex">
<SelectTrigger className="w-[180px]"> {events.map((event) => {
<SelectValue placeholder="Choose camera" /> return (
</SelectTrigger> <MiniEventCard
<SelectContent> key={event.id}
<SelectGroup>{cameras}</SelectGroup> event={event}
</SelectContent> onUpdate={() => updateEvents()}
</Select> />
</div> );
{selectedCamera && <Camera cameraName={selectedCamera} />} })}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</>
)}
<Heading as="h4">Cameras</Heading>
<div className="mt-2 grid gap-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{sortedCameras.map((camera) => {
return <Camera key={camera.name} camera={camera} />;
})}
</div>
</div>
)}
</> </>
); );
} }
function Camera({ cameraName }: { cameraName: string }) { function Camera({ camera }: { camera: CameraConfig }) {
const { payload: detectValue, send: sendDetect } = useDetectState(cameraName); const { payload: detectValue, send: sendDetect } = useDetectState(
camera.name
);
const { payload: recordValue, send: sendRecord } = useRecordingsState(
camera.name
);
const { payload: snapshotValue, send: sendSnapshot } = useSnapshotsState(
camera.name
);
const { payload: audioValue, send: sendAudio } = useAudioState(camera.name);
return ( return (
<> <>
<Heading as="h3" className="mt-5"> <Card className="">
{cameraName} <a href={`/live/${camera.name}`}>
</Heading> <AspectRatio
<div className="flex items-center space-x-2 mt-5"> ratio={16 / 9}
<Switch className="bg-black flex justify-center items-center"
id={`detect-${cameraName}`} >
checked={detectValue === "ON"} <CameraImage camera={camera.name} fitAspect={16 / 9} />
onCheckedChange={() => </AspectRatio>
sendDetect(detectValue === "ON" ? "OFF" : "ON", true) <div className="flex justify-between items-center">
} <div className="text-lg capitalize p-2">
/> {camera.name.replaceAll("_", " ")}
<Label htmlFor={`detect-${cameraName}`}>Detect</Label> </div>
</div> <div>
<Button
variant="ghost"
size="sm"
className={`${
detectValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={() => sendDetect(detectValue == "ON" ? "OFF" : "ON")}
>
<FaWalking />
</Button>
<Button
variant="ghost"
size="sm"
className={
camera.record.enabled_in_config
? recordValue == "ON"
? "text-primary"
: "text-gray-400"
: "text-red-500"
}
onClick={() =>
camera.record.enabled_in_config
? sendRecord(recordValue == "ON" ? "OFF" : "ON")
: {}
}
>
<TbMovie />
</Button>
<Button
variant="ghost"
size="sm"
className={`${
snapshotValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={() => sendSnapshot(detectValue == "ON" ? "OFF" : "ON")}
>
<AiOutlinePicture />
</Button>
{camera.audio.enabled_in_config && (
<Button
variant="ghost"
size="sm"
className={`${
audioValue == "ON" ? "text-primary" : "text-gray-400"
}`}
onClick={() => sendAudio(detectValue == "ON" ? "OFF" : "ON")}
>
<LuEar />
</Button>
)}
</div>
</div>
</a>
</Card>
</> </>
); );
} }

View File

@ -13,15 +13,27 @@ import Heading from "@/components/ui/heading";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import useSWR from "swr"; import useSWR from "swr";
function Live() { function Live() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const { camera: openedCamera } = useParams();
const [camera, setCamera] = useState<string>("Select A Camera"); const [camera, setCamera] = useState<string>(
openedCamera ?? "Select A Camera"
);
const cameraConfig = useMemo(() => { const cameraConfig = useMemo(() => {
return config?.cameras[camera]; return config?.cameras[camera];
}, [camera, config]); }, [camera, config]);
const sortedCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const restreamEnabled = useMemo(() => { const restreamEnabled = useMemo(() => {
return ( return (
config && config &&
@ -62,13 +74,13 @@ function Live() {
<DropdownMenuLabel>Select A Camera</DropdownMenuLabel> <DropdownMenuLabel>Select A Camera</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuRadioGroup value={camera} onValueChange={setCamera}> <DropdownMenuRadioGroup value={camera} onValueChange={setCamera}>
{Object.keys(config?.cameras || {}).map((item) => ( {(sortedCameras).map((item) => (
<DropdownMenuRadioItem <DropdownMenuRadioItem
className="capitalize" className="capitalize"
key={item} key={item.name}
value={item} value={item.name}
> >
{item.replaceAll("_", " ")} {item.name.replaceAll("_", " ")}
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
))} ))}
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>

View File

@ -0,0 +1,25 @@
export interface Event {
id: string;
label: string;
sub_label?: string;
camera: string;
start_time: number;
end_time?: number;
false_positive: boolean;
zones: string[];
thumbnail: string;
has_clip: boolean;
has_snapshot: boolean;
retain_indefinitely: boolean;
plus_id?: string;
model_hash?: string;
data: {
top_score: number;
score: number;
region: number[];
box: number[];
area: number;
ratio: number;
type: "object" | "audio" | "manual";
}
}

View File

@ -6,6 +6,8 @@ export interface UiConfig {
strftime_fmt?: string; strftime_fmt?: string;
live_mode?: string; live_mode?: string;
use_experimental?: boolean; use_experimental?: boolean;
dashboard: boolean;
order: number;
} }
export interface CameraConfig { export interface CameraConfig {

View File

@ -1,6 +1,5 @@
import strftime from 'strftime'; import strftime from "strftime";
import { fromUnixTime, intervalToDuration, formatDuration } from 'date-fns'; import { fromUnixTime, intervalToDuration, formatDuration } from "date-fns";
import { UiConfig } from "@/types/frigateConfig";
export const longToDate = (long: number): Date => new Date(long * 1000); export const longToDate = (long: number): Date => new Date(long * 1000);
export const epochToLong = (date: number): number => date / 1000; export const epochToLong = (date: number): number => date / 1000;
export const dateToLong = (date: Date): number => epochToLong(date.getTime()); export const dateToLong = (date: Date): number => epochToLong(date.getTime());
@ -40,23 +39,45 @@ export const getNowYesterdayInLong = (): number => {
// only used as a fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat // only used as a fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
const formatMap: { const formatMap: {
[k: string]: { [k: string]: {
date: { year: 'numeric' | '2-digit'; month: 'long' | 'short' | '2-digit'; day: 'numeric' | '2-digit' }; date: {
time: { hour: 'numeric'; minute: 'numeric'; second?: 'numeric'; timeZoneName?: 'short' | 'long' }; year: "numeric" | "2-digit";
month: "long" | "short" | "2-digit";
day: "numeric" | "2-digit";
};
time: {
hour: "numeric";
minute: "numeric";
second?: "numeric";
timeZoneName?: "short" | "long";
};
}; };
} = { } = {
full: { full: {
date: { year: 'numeric', month: 'long', day: 'numeric' }, date: { year: "numeric", month: "long", day: "numeric" },
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' }, time: {
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "long",
},
}, },
long: { long: {
date: { year: 'numeric', month: 'long', day: 'numeric' }, date: { year: "numeric", month: "long", day: "numeric" },
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' }, time: {
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "long",
},
}, },
medium: { medium: {
date: { year: 'numeric', month: 'short', day: 'numeric' }, date: { year: "numeric", month: "short", day: "numeric" },
time: { hour: 'numeric', minute: 'numeric', second: 'numeric' }, time: { hour: "numeric", minute: "numeric", second: "numeric" },
},
short: {
date: { year: "2-digit", month: "2-digit", day: "2-digit" },
time: { hour: "numeric", minute: "numeric" },
}, },
short: { date: { year: '2-digit', month: '2-digit', day: '2-digit' }, time: { hour: 'numeric', minute: 'numeric' } },
}; };
/** /**
@ -79,11 +100,11 @@ const getResolvedTimeZone = () => {
return Intl.DateTimeFormat().resolvedOptions().timeZone; return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (error) { } catch (error) {
const offsetMinutes = new Date().getTimezoneOffset(); const offsetMinutes = new Date().getTimezoneOffset();
return `UTC${offsetMinutes < 0 ? '+' : '-'}${Math.abs(offsetMinutes / 60) return `UTC${offsetMinutes < 0 ? "+" : "-"}${Math.abs(offsetMinutes / 60)
.toString() .toString()
.padStart(2, '0')}:${Math.abs(offsetMinutes % 60) .padStart(2, "0")}:${Math.abs(offsetMinutes % 60)
.toString() .toString()
.padStart(2, '0')}`; .padStart(2, "0")}`;
} }
}; };
@ -103,11 +124,21 @@ const getResolvedTimeZone = () => {
* *
* @throws {Error} If the given unixTimestamp is not a valid number, the function will return 'Invalid time'. * @throws {Error} If the given unixTimestamp is not a valid number, the function will return 'Invalid time'.
*/ */
export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiConfig): string => { export const formatUnixTimestampToDateTime = (
const { timezone, time_format, date_style, time_style, strftime_fmt } = config; unixTimestamp: number,
const locale = window.navigator?.language || 'en-US'; config: {
timezone?: string;
time_format?: "browser" | "12hour" | "24hour";
date_style?: "full" | "long" | "medium" | "short";
time_style?: "full" | "long" | "medium" | "short";
strftime_fmt?: string;
}
): string => {
const { timezone, time_format, date_style, time_style, strftime_fmt } =
config;
const locale = window.navigator?.language || "en-US";
if (isNaN(unixTimestamp)) { if (isNaN(unixTimestamp)) {
return 'Invalid time'; return "Invalid time";
} }
try { try {
@ -125,7 +156,7 @@ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiC
const options: Intl.DateTimeFormatOptions = { const options: Intl.DateTimeFormatOptions = {
dateStyle: date_style, dateStyle: date_style,
timeStyle: time_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 // Only set timeZone option when resolvedTimeZone does not match UTC±HH:MM format, or when timezone is set in config
@ -143,15 +174,26 @@ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiC
// fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat // 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 // This works even tough the timezone is undefined, it will use the runtime's default time zone
if (!containsTime) { if (!containsTime) {
const dateOptions = { ...formatMap[date_style ?? ""]?.date, timeZone: options.timeZone, hour12: options.hour12 }; const dateOptions = {
const timeOptions = { ...formatMap[time_style ?? ""]?.time, timeZone: options.timeZone, hour12: options.hour12 }; ...formatMap[date_style ?? ""]?.date,
timeZone: options.timeZone,
hour12: options.hour12,
};
const timeOptions = {
...formatMap[time_style ?? ""]?.time,
timeZone: options.timeZone,
hour12: options.hour12,
};
return `${date.toLocaleDateString(locale, dateOptions)} ${date.toLocaleTimeString(locale, timeOptions)}`; return `${date.toLocaleDateString(
locale,
dateOptions
)} ${date.toLocaleTimeString(locale, timeOptions)}`;
} }
return formattedDateTime; return formattedDateTime;
} catch (error) { } catch (error) {
return 'Invalid time'; return "Invalid time";
} }
}; };
@ -169,28 +211,31 @@ interface DurationToken {
* @param end_time: number|null - Unix timestamp for end time * @param end_time: number|null - Unix timestamp for end time
* @returns string - duration or 'In Progress' if end time is not provided * @returns string - duration or 'In Progress' if end time is not provided
*/ */
export const getDurationFromTimestamps = (start_time: number, end_time: number | null): string => { export const getDurationFromTimestamps = (
start_time: number,
end_time: number | null
): string => {
if (isNaN(start_time)) { if (isNaN(start_time)) {
return 'Invalid start time'; return "Invalid start time";
} }
let duration = 'In Progress'; let duration = "In Progress";
if (end_time !== null) { if (end_time !== null) {
if (isNaN(end_time)) { if (isNaN(end_time)) {
return 'Invalid end time'; return "Invalid end time";
} }
const start = fromUnixTime(start_time); const start = fromUnixTime(start_time);
const end = fromUnixTime(end_time); const end = fromUnixTime(end_time);
const formatDistanceLocale: DurationToken = { const formatDistanceLocale: DurationToken = {
xSeconds: '{{count}}s', xSeconds: "{{count}}s",
xMinutes: '{{count}}m', xMinutes: "{{count}}m",
xHours: '{{count}}h', xHours: "{{count}}h",
}; };
const shortEnLocale = { const shortEnLocale = {
formatDistance: (token: keyof DurationToken, count: number) => formatDistance: (token: keyof DurationToken, count: number) =>
formatDistanceLocale[token].replace('{{count}}', count.toString()), formatDistanceLocale[token].replace("{{count}}", count.toString()),
}; };
duration = formatDuration(intervalToDuration({ start, end }), { duration = formatDuration(intervalToDuration({ start, end }), {
format: ['hours', 'minutes', 'seconds'], format: ["hours", "minutes", "seconds"],
locale: shortEnLocale, locale: shortEnLocale,
}); });
} }
@ -209,14 +254,18 @@ const getUTCOffset = (date: Date, timezone: string): number => {
if (utcOffsetMatch) { if (utcOffsetMatch) {
const hours = parseInt(utcOffsetMatch[2], 10); const hours = parseInt(utcOffsetMatch[2], 10);
const minutes = parseInt(utcOffsetMatch[3], 10); const minutes = parseInt(utcOffsetMatch[3], 10);
return (utcOffsetMatch[1] === '+' ? 1 : -1) * (hours * 60 + minutes); return (utcOffsetMatch[1] === "+" ? 1 : -1) * (hours * 60 + minutes);
} }
// Otherwise, calculate offset using provided timezone // Otherwise, calculate offset using provided timezone
const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000); const utcDate = new Date(
date.getTime() - date.getTimezoneOffset() * 60 * 1000
);
// locale of en-CA is required for proper locale format // locale of en-CA is required for proper locale format
let iso = utcDate.toLocaleString('en-CA', { timeZone: timezone, hour12: false }).replace(', ', 'T'); let iso = utcDate
iso += `.${utcDate.getMilliseconds().toString().padStart(3, '0')}`; .toLocaleString("en-CA", { timeZone: timezone, hour12: false })
.replace(", ", "T");
iso += `.${utcDate.getMilliseconds().toString().padStart(3, "0")}`;
let target = new Date(`${iso}Z`); let target = new Date(`${iso}Z`);
// safari doesn't like the default format // safari doesn't like the default format

View File

@ -25,7 +25,7 @@
.theme-blue.dark { .theme-blue.dark {
--background: 222.2 84% 4.9%; --background: 222.2 84% 4.9%;
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
--card: 222.2 84% 4.9%; --card: 217.2 32.6% 17.5%;
--card-foreground: 210 40% 98%; --card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%; --popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%; --popover-foreground: 210 40% 98%;
@ -40,7 +40,7 @@
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%; --border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%; --input: 217.2 38.6% 29.5%;
--ring: 224.3 76.3% 48%; --ring: 224.3 76.3% 48%;
} }
} }