2021-03-05 16:56:51 -08:00
|
|
|
// 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";
|
2021-03-17 11:10:39 -07:00
|
|
|
import { useTheme } from "@material-ui/core/styles";
|
2021-03-05 16:56:51 -08:00
|
|
|
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";
|
2021-03-16 13:22:36 -07:00
|
|
|
import Collapse from "@material-ui/core/Collapse";
|
2021-03-05 16:56:51 -08:00
|
|
|
|
|
|
|
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
|
2021-03-16 13:22:36 -07:00
|
|
|
label="Time"
|
2021-03-05 16:56:51 -08:00
|
|
|
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>
|
2021-03-17 11:10:39 -07:00
|
|
|
<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" />}
|
|
|
|
/>
|
2021-03-05 16:56:51 -08:00
|
|
|
<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>
|
2021-03-16 13:22:36 -07:00
|
|
|
<Collapse in={days.endType === "other-day"}>
|
2021-03-17 11:10:39 -07:00
|
|
|
<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" />
|
|
|
|
)}
|
|
|
|
/>
|
2021-03-16 13:22:36 -07:00
|
|
|
</Collapse>
|
2021-03-05 16:56:51 -08:00
|
|
|
<MyTimePicker
|
|
|
|
value={endTime}
|
|
|
|
onChange={(newValue) => {
|
|
|
|
if (newValue === null || isFinite((newValue as Date).getTime())) {
|
|
|
|
setEndTime(newValue);
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
disabled={days.allowed === null}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</Card>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
export default TimerangeSelector;
|