moonfire-nvr/ui/src/List/TimerangeSelector.tsx

345 lines
9.8 KiB
TypeScript
Raw Normal View History

// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import { Stream } from "../types";
import StaticDatePicker from "@material-ui/lab/StaticDatePicker";
import React, { useEffect } from "react";
import { zonedTimeToUtc } from "date-fns-tz";
import { addDays, addMilliseconds, differenceInMilliseconds } from "date-fns";
import startOfDay from "date-fns/startOfDay";
import Card from "@material-ui/core/Card";
import { useTheme } from "@material-ui/core/styles";
import TextField from "@material-ui/core/TextField";
import FormControlLabel from "@material-ui/core/FormControlLabel";
import FormLabel from "@material-ui/core/FormLabel";
import Radio from "@material-ui/core/Radio";
import RadioGroup from "@material-ui/core/RadioGroup";
import TimePicker, { TimePickerProps } from "@material-ui/lab/TimePicker";
import Collapse from "@material-ui/core/Collapse";
interface Props {
selectedStreams: Set<Stream>;
timeZoneName: string;
range90k: [number, number] | null;
setRange90k: (range: [number, number] | null) => void;
}
const MyTimePicker = (
props: Pick<TimePickerProps, "value" | "onChange" | "disabled">
) => (
<TimePicker
label="Time"
views={["hours", "minutes", "seconds"]}
renderInput={(params) => <TextField size="small" {...params} />}
inputFormat="HH:mm:ss"
mask="__:__:__"
ampm={false}
{...props}
/>
);
/**
* Combines the date-part of <tt>dayMillis</tt> and the time part of
* <tt>time</tt>. If <tt>time</tt> is null, assume it reaches the end of the
* day.
*/
const combine = (dayMillis: number, time: Date | null) => {
const start = new Date(dayMillis);
if (time === null) {
return addDays(start, 1);
}
return addMilliseconds(
start,
differenceInMilliseconds(time, startOfDay(time))
);
};
/**
* Allowed days to select (ones with video).
*
* These are stored in a funny format: number of milliseconds since epoch of
* the start of the given day in the browser's time zone. This is because
* (a) Date objects are always in the local time zone and date-fn rolls with
* that, and (b) Date objects don't work well in a set. Javascript's
* "same-value-zero algorithm" means that two different Date objects never
* compare the same.
*/
type AllowedDays = {
minMillis: number;
maxMillis: number;
allMillis: Set<number>;
};
type EndDayType = "same-day" | "other-day";
type DaysState = {
allowed: AllowedDays | null;
/** [start, end] in same format as described for <tt>AllowedDays</tt>. */
rangeMillis: [number, number] | null;
endType: EndDayType;
};
type DaysOpUpdateSelectedStreams = {
op: "update-selected-streams";
selectedStreams: Set<Stream>;
};
type DaysOpSetStartDay = {
op: "set-start-day";
newStartDate: Date | null;
};
type DaysOpSetEndDay = {
op: "set-end-day";
newEndDate: Date;
};
type DaysOpSetEndDayType = {
op: "set-end-type";
newEndType: EndDayType;
};
type DaysOp =
| DaysOpUpdateSelectedStreams
| DaysOpSetStartDay
| DaysOpSetEndDay
| DaysOpSetEndDayType;
/**
* Computes an <tt>AllowedDays</tt> from the given streams.
* Returns null if there are no allowed days.
*/
function computeAllowedDayInfo(
selectedStreams: Set<Stream>
): AllowedDays | null {
let minMillis = null;
let maxMillis = null;
let allMillis = new Set<number>();
for (const s of selectedStreams) {
for (const d in s.days) {
const t = new Date(d + "T00:00:00").getTime();
if (minMillis === null || t < minMillis) {
minMillis = t;
}
if (maxMillis === null || t > maxMillis) {
maxMillis = t;
}
allMillis.add(t);
}
}
if (minMillis === null || maxMillis === null) {
return null;
}
return {
minMillis,
maxMillis,
allMillis,
};
}
const toMillis = (d: Date) => startOfDay(d).getTime();
function daysStateReducer(old: DaysState, op: DaysOp): DaysState {
let state = { ...old };
function updateStart(newStart: number) {
if (
state.rangeMillis === null ||
state.endType === "same-day" ||
state.rangeMillis[1] < newStart
) {
state.rangeMillis = [newStart, newStart];
} else {
state.rangeMillis[0] = newStart;
}
}
switch (op.op) {
case "update-selected-streams":
state.allowed = computeAllowedDayInfo(op.selectedStreams);
if (state.allowed === null) {
state.rangeMillis = null;
} else if (state.rangeMillis === null) {
state.rangeMillis = [state.allowed.maxMillis, state.allowed.maxMillis];
} else {
if (state.rangeMillis[0] < state.allowed.minMillis) {
updateStart(state.allowed.minMillis);
}
if (state.rangeMillis[1] > state.allowed.maxMillis) {
state.rangeMillis[1] = state.allowed.maxMillis;
}
}
break;
case "set-start-day":
if (op.newStartDate === null) {
state.rangeMillis = null;
} else {
const millis = toMillis(op.newStartDate);
if (state.allowed === null || state.allowed.minMillis > millis) {
console.error("Invalid start day selection ", op.newStartDate);
} else {
updateStart(millis);
}
}
break;
case "set-end-day":
const millis = toMillis(op.newEndDate);
if (
state.rangeMillis === null ||
state.allowed === null ||
state.allowed.maxMillis < millis
) {
console.error("Invalid end day selection ", op.newEndDate);
} else {
state.rangeMillis[1] = millis;
}
break;
case "set-end-type":
state.endType = op.newEndType;
if (state.endType === "same-day" && state.rangeMillis !== null) {
state.rangeMillis[1] = state.rangeMillis[0];
}
break;
}
return state;
}
const TimerangeSelector = ({
selectedStreams,
timeZoneName,
range90k,
setRange90k,
}: Props) => {
const theme = useTheme();
const [days, updateDays] = React.useReducer(daysStateReducer, {
allowed: null,
rangeMillis: null,
endType: "same-day",
});
const [startTime, setStartTime] = React.useState<any>(
new Date("1970-01-01T00:00:00")
);
const [endTime, setEndTime] = React.useState<any>(null);
useEffect(
() => updateDays({ op: "update-selected-streams", selectedStreams }),
[selectedStreams]
);
const shouldDisableDate = (date: Date | null) => {
return (
days.allowed === null ||
!days.allowed.allMillis.has(startOfDay(date!).getTime())
);
};
// Update range90k to reflect the selected options.
useEffect(() => {
if (days.rangeMillis === null) {
setRange90k(null);
return;
}
const start = combine(days.rangeMillis[0], startTime);
const end = combine(days.rangeMillis[1], endTime);
setRange90k([
zonedTimeToUtc(start, timeZoneName).getTime() * 90,
zonedTimeToUtc(end, timeZoneName).getTime() * 90,
]);
}, [days, startTime, endTime, timeZoneName, setRange90k]);
const today = new Date();
let startDate = null;
let endDate = null;
if (days.rangeMillis !== null) {
startDate = new Date(days.rangeMillis[0]);
endDate = new Date(days.rangeMillis[1]);
}
return (
<Card sx={{ padding: theme.spacing(1) }}>
<div>
<FormLabel component="legend">From</FormLabel>
<StaticDatePicker
displayStaticWrapperAs="desktop"
value={startDate}
shouldDisableDate={shouldDisableDate}
maxDate={
days.allowed === null ? today : new Date(days.allowed.maxMillis)
}
minDate={
days.allowed === null ? today : new Date(days.allowed.minMillis)
}
onChange={(d: Date | null) => {
updateDays({ op: "set-start-day", newStartDate: d });
}}
renderInput={(params) => <TextField {...params} variant="outlined" />}
/>
<MyTimePicker
value={startTime}
onChange={(newValue) => {
if (newValue === null || isFinite((newValue as Date).getTime())) {
setStartTime(newValue);
}
}}
disabled={days.allowed === null}
/>
</div>
<div>
<FormLabel component="legend">To</FormLabel>
<RadioGroup
row
value={days.endType}
onChange={(e) => {
updateDays({
op: "set-end-type",
newEndType: e.target.value as EndDayType,
});
}}
>
<FormControlLabel
value="same-day"
control={<Radio size="small" />}
label="Same day"
/>
<FormControlLabel
value="other-day"
control={<Radio size="small" />}
label="Other day"
/>
</RadioGroup>
<Collapse in={days.endType === "other-day"}>
<StaticDatePicker
displayStaticWrapperAs="desktop"
value={endDate}
shouldDisableDate={(d: Date | null) =>
days.endType !== "other-day" || shouldDisableDate(d)
}
maxDate={
startDate === null ? today : new Date(days.allowed!.maxMillis)
}
minDate={startDate === null ? today : startDate}
onChange={(d: Date | null) => {
updateDays({ op: "set-end-day", newEndDate: d! });
}}
renderInput={(params) => (
<TextField {...params} variant="outlined" />
)}
/>
</Collapse>
<MyTimePicker
value={endTime}
onChange={(newValue) => {
if (newValue === null || isFinite((newValue as Date).getTime())) {
setEndTime(newValue);
}
}}
disabled={days.allowed === null}
/>
</div>
</Card>
);
};
export default TimerangeSelector;