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

458 lines
14 KiB
TypeScript

// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2022 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
/**
* @fileoverview Selects a datetime range to view, in the NVR's timezone
*
* Renders a pair of date pickers for the date range and a radio button
* for single-day or multi-day selection (disabling or enabling the end date
* picker, respectively). These date pickers show which dates actually have
* video for the selected days and only allow selecting those days. As the
* selected video streams change, the allowed dates change, and the selected
* date range may automatically tighten.
*
* The start and end time pickers are simpler: they simply honor what was
* selected in the UI.
*
* The internal state is all held in one `DaysState` object; `daysStateReducer`
* updates it consistently for a given operation.
*
* Calls `setRange90k` with the final result. Note that not all of
* `TimerangeSelector`'s internal state changes will actually produce a new
* `range90k`, e.g.:
*
* - clicking "To other day" (multi-day selection) doesn't by itself
* change the result; it just allows subsequent UI clicks to do so.
* - selecting another stream may expand the list of possible days but doesn't
* also by itself doesn't change the time range.
*
* # Limitations
*
* This has several known problems with time zone handling, including:
*
* - doesn't correctly handle times that exist for the NVR's timezone but not in
* the browser's. Specifically, consider the case in which the browser's
* timezone changes for daylight saving but the NVR doesn't. A Javascript
* `Date` object simply can't represent times during the "spring forward"
* hour. We are currently using `date-fn`, which has the fundamental design
* flaw of assuming that all dates (even in a remote timezone) can be
* represented by `Date`.
* - doesn't allow disambiguating times during the "fall back" hour. Ideally
* we'd have support not only in the datetime library but also in the UI
* time picker component, and it doesn't exist today.
* - looks up the NVR's time zone name in the browser's time zone database,
* rather than actually transferring and using the NVR's time zone definition.
* Thus if say one has been updated for a new daylight saving transition date
* but the other doesn't, results will be weird.
*
* We hope to address these problems after the Javascript Temporal library is
* standardized.
*/
import { Stream } from "../types";
import {
StaticDatePicker,
StaticDatePickerProps,
} from "@mui/x-date-pickers/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 FormControlLabel from "@mui/material/FormControlLabel";
import FormLabel from "@mui/material/FormLabel";
import Radio from "@mui/material/Radio";
import RadioGroup from "@mui/material/RadioGroup";
import { TimePicker, TimePickerProps } from "@mui/x-date-pickers/TimePicker";
import Collapse from "@mui/material/Collapse";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import { useTheme } from "@mui/material/styles";
interface Props {
selectedStreams: Set<Stream>;
timeZoneName: string;
setRange90k: (range: [number, number] | null) => void;
}
const MyTimePicker = (
props: Pick<TimePickerProps<Date>, "value" | "onChange" | "disabled">
) => (
<TimePicker
label="Time"
views={["hours", "minutes", "seconds"]}
slotProps={{
textField: {
fullWidth: true,
size: "small",
variant: "outlined",
},
}}
ampm={false}
{...props}
/>
);
const SmallStaticDatePicker = (props: StaticDatePickerProps<Date>) => {
// The spacing defined at https://material.io/components/date-pickers#specs
// seems plenty big enough (on desktop). Not sure why material-ui wants
// to make it bigger but that doesn't work well with our layout.
// This adjustment is a fragile hack but seems to work for now.
// See: https://github.com/mui-org/material-ui/issues/27700
const DATE_SIZE = 32;
return (
<Box
sx={{
"@media (pointer: fine)": {
"& .MuiPickersLayout-root": {
minWidth: "auto", // defaults to 320px
},
"& .MuiPickersLayout-root, & .MuiPickersLayout-contentWrapper, & .MuiDateCalendar-root":
{
width: 256, // defaults to 320px
margin: 0,
},
"& .MuiPickersArrowSwitcher-spacer": {
// By default, this spacer is so big that there's not enough space
// in the row for October. Shrink it.
width: 12,
},
"& .MuiDayCalendar-weekDayLabel": {
width: DATE_SIZE,
margin: 0,
},
"& .MuiDayCalendar-slideTransition": {
minHeight: DATE_SIZE * 6,
},
"& .MuiDateCalendar-root": {
height: "auto",
},
"& .MuiDayCalendar-weekContainer": {
margin: 0,
},
"& .MuiPickersDay-dayWithMargin": {
margin: 0,
},
"& .MuiPickersDay-root": {
width: DATE_SIZE,
height: DATE_SIZE,
},
},
}}
>
<StaticDatePicker {...props} sx={{ background: "transparent" }} />
</Box>
);
};
/**
* 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
*
* 1. `Date` objects are always in the browser's time zone and `date-fn` rolls
* with that, and
* 2. `Date` objects don't work well in a `Set`. ECMAScript's [equality
* rules](https://262.ecma-international.org/7.0/#sec-abstract-equality-comparison)
* mean 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 (funny) format as described for `AllowedDays`.
*
* This gets mirrored into `range90k` in its expected format (90k units
* since epoch).
*/
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,
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 (
<Paper sx={{ padding: theme.spacing(1) }}>
<Box>
<FormLabel component="legend">From</FormLabel>
<SmallStaticDatePicker
displayStaticWrapperAs="desktop"
value={startDate}
disabled={days.allowed === null}
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 });
}}
/>
<MyTimePicker
value={startTime}
onChange={(newValue) => {
if (newValue === null || isFinite((newValue as Date).getTime())) {
setStartTime(newValue);
}
}}
disabled={days.allowed === null}
/>
</Box>
<Box>
<FormLabel sx={{ mt: 1 }} 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" color="secondary" />}
label="Same day"
/>
<FormControlLabel
value="other-day"
control={<Radio size="small" color="secondary" />}
label="Other day"
/>
</RadioGroup>
<Collapse in={days.endType === "other-day"}>
<SmallStaticDatePicker
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! });
}}
/>
</Collapse>
<MyTimePicker
value={endTime}
onChange={(newValue) => {
if (newValue === null || isFinite((newValue as Date).getTime())) {
setEndTime(newValue);
}
}}
disabled={days.allowed === null}
/>
</Box>
</Paper>
);
};
export default TimerangeSelector;