From 8a6568797024d7c3a35748bad6ddba8dbfc34868 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Mon, 29 Jul 2024 22:11:13 +0200 Subject: [PATCH] Start to build relay dialog --- central_backend/src/constants.rs | 18 ++ central_backend/src/devices/device.rs | 8 +- central_frontend/package-lock.json | 124 ++++++-- central_frontend/package.json | 2 + central_frontend/src/api/ServerApi.ts | 6 + .../src/dialogs/EditDeviceRelaysDialog.tsx | 283 ++++++++++++++++++ central_frontend/src/main.tsx | 36 ++- .../src/routes/DeviceRoute/DeviceRelays.tsx | 50 ++++ .../src/routes/DeviceRoute/DeviceRoute.tsx | 12 +- .../routes/DeviceRoute/GeneralDeviceInfo.tsx | 4 +- central_frontend/src/utils/DateUtils.ts | 23 ++ 11 files changed, 518 insertions(+), 48 deletions(-) create mode 100644 central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx create mode 100644 central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx diff --git a/central_backend/src/constants.rs b/central_backend/src/constants.rs index ad6fdd7..792de2b 100644 --- a/central_backend/src/constants.rs +++ b/central_backend/src/constants.rs @@ -45,6 +45,18 @@ pub struct StaticConstraints { pub dev_name_len: SizeConstraint, /// Device description constraint pub dev_description_len: SizeConstraint, + /// Relay name constraint + pub relay_name_len: SizeConstraint, + /// Relay priority constraint + pub relay_priority: SizeConstraint, + /// Relay consumption constraint + pub relay_consumption: SizeConstraint, + /// Relay minimal uptime + pub relay_minimal_uptime: SizeConstraint, + /// Relay minimal downtime + pub relay_minimal_downtime: SizeConstraint, + /// Relay daily minimal uptime + pub relay_daily_minimal_runtime: SizeConstraint, } impl Default for StaticConstraints { @@ -52,6 +64,12 @@ impl Default for StaticConstraints { Self { dev_name_len: SizeConstraint::new(1, 50), dev_description_len: SizeConstraint::new(0, 100), + relay_name_len: SizeConstraint::new(1, 100), + relay_priority: SizeConstraint::new(0, 999999), + relay_consumption: SizeConstraint::new(0, 999999), + relay_minimal_uptime: SizeConstraint::new(0, 9999999), + relay_minimal_downtime: SizeConstraint::new(0, 9999999), + relay_daily_minimal_runtime: SizeConstraint::new(0, 3600 * 24), } } } diff --git a/central_backend/src/devices/device.rs b/central_backend/src/devices/device.rs index 03a2c34..18e2a85 100644 --- a/central_backend/src/devices/device.rs +++ b/central_backend/src/devices/device.rs @@ -67,7 +67,7 @@ pub struct Device { /// time of a device #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct DailyMinRuntime { - /// Minimum time, in seconds, that this relay should run + /// Minimum time, in seconds, that this relay should run each day pub min_runtime: usize, /// The seconds in the days (from 00:00) where the counter is reset pub reset_time: usize, @@ -87,13 +87,13 @@ pub struct DeviceRelay { name: String, /// Whether this relay can be turned on or not enabled: bool, - /// Relay priority when selecting relays to turn of / on. 0 = lowest priority + /// Relay priority when selecting relays to turn on. 0 = lowest priority priority: usize, /// Estimated consumption of the electrical equipment triggered by the relay consumption: usize, - /// Minimal time this relay shall be left on before it can be turned off + /// Minimal time this relay shall be left on before it can be turned off (in seconds) minimal_uptime: usize, - /// Minimal time this relay shall be left off before it can be turned on again + /// Minimal time this relay shall be left off before it can be turned on again (in seconds) minimal_downtime: usize, /// Optional minimal runtime requirements for this relay daily_runtime: Option, diff --git a/central_frontend/package-lock.json b/central_frontend/package-lock.json index ddad6ed..3957242 100644 --- a/central_frontend/package-lock.json +++ b/central_frontend/package-lock.json @@ -15,7 +15,9 @@ "@mdi/react": "^1.6.1", "@mui/icons-material": "^5.15.21", "@mui/material": "^5.15.21", + "@mui/x-date-pickers": "^7.11.1", "date-and-time": "^3.3.0", + "dayjs": "^1.11.12", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0" @@ -337,9 +339,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.7.tgz", - "integrity": "sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz", + "integrity": "sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -1265,12 +1267,12 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.15.20", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.20.tgz", - "integrity": "sha512-BK8F94AIqSrnaPYXf2KAOjGZJgWfvqAVQ2gVR3EryvQFtuBnG6RwodxrCvd3B48VuMy6Wsk897+lQMUxJyk+6g==", + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.5.tgz", + "integrity": "sha512-CSLg0YkpDqg0aXOxtjo3oTMd3XWMxvNb5d0v4AYVqwOltU8q6GvnZjhWyCLjGSCrcgfwm6/VDjaKLPlR14wxIA==", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.15.20", + "@mui/utils": "^5.16.5", "prop-types": "^15.8.1" }, "engines": { @@ -1291,9 +1293,9 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.14.tgz", - "integrity": "sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==", + "version": "5.16.4", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.4.tgz", + "integrity": "sha512-0+mnkf+UiAmTVB8PZFqOhqf729Yh0Cxq29/5cA3VAyDVTRIUUQ8FXQhiAhUIbijFmM72rY80ahFPXIm4WDbzcA==", "dependencies": { "@babel/runtime": "^7.23.9", "@emotion/cache": "^11.11.0", @@ -1322,15 +1324,15 @@ } }, "node_modules/@mui/system": { - "version": "5.15.20", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.20.tgz", - "integrity": "sha512-LoMq4IlAAhxzL2VNUDBTQxAb4chnBe8JvRINVNDiMtHE2PiPOoHlhOPutSxEbaL5mkECPVWSv6p8JEV+uykwIA==", + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.5.tgz", + "integrity": "sha512-uzIUGdrWddUx1HPxW4+B2o4vpgKyRxGe/8BxbfXVDPNPHX75c782TseoCnR/VyfnZJfqX87GcxDmnZEE1c031g==", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.15.20", - "@mui/styled-engine": "^5.15.14", - "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.20", + "@mui/private-theming": "^5.16.5", + "@mui/styled-engine": "^5.16.4", + "@mui/types": "^7.2.15", + "@mui/utils": "^5.16.5", "clsx": "^2.1.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -1361,9 +1363,9 @@ } }, "node_modules/@mui/types": { - "version": "7.2.14", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.14.tgz", - "integrity": "sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==", + "version": "7.2.15", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.15.tgz", + "integrity": "sha512-nbo7yPhtKJkdf9kcVOF8JZHPZTmqXjJ/tI0bdWgHg5tp9AnIN4Y7f7wm9T+0SyGYJk76+GYZ8Q5XaTYAsUHN0Q==", "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0" }, @@ -1374,14 +1376,16 @@ } }, "node_modules/@mui/utils": { - "version": "5.15.20", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.20.tgz", - "integrity": "sha512-mAbYx0sovrnpAu1zHc3MDIhPqL8RPVC5W5xcO1b7PiSCJPtckIZmBkp8hefamAvUiAV8gpfMOM6Zb+eSisbI2A==", + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.5.tgz", + "integrity": "sha512-CwhcA9y44XwK7k2joL3Y29mRUnoBt+gOZZdGyw7YihbEwEErJYBtDwbZwVgH68zAljGe/b+Kd5bzfl63Gi3R2A==", "dependencies": { "@babel/runtime": "^7.23.9", - "@types/prop-types": "^15.7.11", + "@mui/types": "^7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^18.2.0" + "react-is": "^18.3.1" }, "engines": { "node": ">=12.0.0" @@ -1400,6 +1404,71 @@ } } }, + "node_modules/@mui/x-date-pickers": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.11.1.tgz", + "integrity": "sha512-CflouzTNSv0YeOA8iiYpJMtqGlwGC8LI9EE9egDGhatR9Mn5geRDTXsm0rRG/4pMOfaRxyJc6Yzr/axBhEXM7w==", + "dependencies": { + "@babel/runtime": "^7.24.8", + "@mui/base": "^5.0.0-beta.40", + "@mui/system": "^5.16.5", + "@mui/utils": "^5.16.5", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14", + "date-fns": "^2.25.0 || ^3.2.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2211,6 +2280,11 @@ "resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-3.3.0.tgz", "integrity": "sha512-UguWfh9LkUecVrGSE0B7SpAnGRMPATmpwSoSij24/lDnwET3A641abfDBD/TdL0T+E04f8NWlbMkD9BscVvIZg==" }, + "node_modules/dayjs": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz", + "integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==" + }, "node_modules/debug": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", diff --git a/central_frontend/package.json b/central_frontend/package.json index 10295f0..4e6bd48 100644 --- a/central_frontend/package.json +++ b/central_frontend/package.json @@ -17,7 +17,9 @@ "@mdi/react": "^1.6.1", "@mui/icons-material": "^5.15.21", "@mui/material": "^5.15.21", + "@mui/x-date-pickers": "^7.11.1", "date-and-time": "^3.3.0", + "dayjs": "^1.11.12", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.24.0" diff --git a/central_frontend/src/api/ServerApi.ts b/central_frontend/src/api/ServerApi.ts index 92ce7d1..798df78 100644 --- a/central_frontend/src/api/ServerApi.ts +++ b/central_frontend/src/api/ServerApi.ts @@ -8,6 +8,12 @@ export interface ServerConfig { export interface ServerConstraint { dev_name_len: LenConstraint; dev_description_len: LenConstraint; + relay_name_len: LenConstraint; + relay_priority: LenConstraint; + relay_consumption: LenConstraint; + relay_minimal_uptime: LenConstraint; + relay_minimal_downtime: LenConstraint; + relay_daily_minimal_runtime: LenConstraint; } export interface LenConstraint { diff --git a/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx b/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx new file mode 100644 index 0000000..8639925 --- /dev/null +++ b/central_frontend/src/dialogs/EditDeviceRelaysDialog.tsx @@ -0,0 +1,283 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + Typography, +} from "@mui/material"; +import { TimePicker } from "@mui/x-date-pickers"; +import React from "react"; +import { Device, DeviceRelay } from "../api/DeviceApi"; +import { ServerApi } from "../api/ServerApi"; +import { useAlert } from "../hooks/context_providers/AlertDialogProvider"; +import { useLoadingMessage } from "../hooks/context_providers/LoadingMessageProvider"; +import { useSnackbar } from "../hooks/context_providers/SnackbarProvider"; +import { dayjsToTimeOfDay, timeOfDay } from "../utils/DateUtils"; +import { lenValid } from "../utils/StringsUtils"; +import { CheckboxInput } from "../widgets/forms/CheckboxInput"; +import { TextInput } from "../widgets/forms/TextInput"; + +export function EditDeviceRelaysDialog(p: { + onClose: () => void; + relay?: DeviceRelay; + device: Device; + onUpdated: () => void; +}): React.ReactElement { + const loadingMessage = useLoadingMessage(); + const alert = useAlert(); + const snackbar = useSnackbar(); + + const [relay, setRelay] = React.useState( + p.relay ?? { + id: "", + name: "relay", + enabled: false, + priority: 1, + consumption: 500, + minimal_downtime: 60 * 5, + minimal_uptime: 60 * 5, + depends_on: [], + conflicts_with: [], + } + ); + + const creating = !p.relay; + + const onSubmit = async () => { + try { + loadingMessage.show( + `${creating ? "Creating" : "Updating"} relay information` + ); + + // TODO + + snackbar( + `The relay have been successfully ${creating ? "created" : "updated"}!` + ); + p.onUpdated(); + } catch (e) { + console.error("Failed to update device relay information!" + e); + alert(`Failed to ${creating ? "create" : "update"} relay! ${e}`); + } finally { + loadingMessage.hide(); + } + }; + + const canSubmit = + lenValid(relay.name, ServerApi.Config.constraints.relay_name_len) && + relay.priority >= 0; + + return ( + + Edit relay information + + General info + + + + setRelay((r) => { + return { + ...r, + name: v ?? "", + }; + }) + } + size={ServerApi.Config.constraints.dev_name_len} + /> + + + + setRelay((r) => { + return { + ...r, + enabled: v, + }; + }) + } + /> + + + + setRelay((r) => { + return { + ...r, + priority: Number(v) ?? 0, + }; + }) + } + size={ServerApi.Config.constraints.relay_priority} + helperText="Relay priority when selecting relays to turn on. 0 = lowest priority" + /> + + + + setRelay((r) => { + return { + ...r, + consumption: Number(v) ?? 0, + }; + }) + } + size={ServerApi.Config.constraints.relay_consumption} + helperText="Estimated consumption of device powered by relay" + /> + + + + setRelay((r) => { + return { + ...r, + minimal_uptime: Number(v) ?? 0, + }; + }) + } + size={ServerApi.Config.constraints.relay_minimal_uptime} + helperText="Minimal time this relay shall be left on before it can be turned off (in seconds)" + /> + + + + setRelay((r) => { + return { + ...r, + minimal_downtime: Number(v) ?? 0, + }; + }) + } + size={ServerApi.Config.constraints.relay_minimal_downtime} + helperText="Minimal time this relay shall be left on before it can be turned off (in seconds)" + /> + + + + Daily runtime + + + + setRelay((r) => { + return { + ...r, + daily_runtime: v + ? { reset_time: 0, min_runtime: 3600, catch_up_hours: [] } + : undefined, + }; + }) + } + /> + + + {!!relay.daily_runtime && ( + <> + + + setRelay((r) => { + return { + ...r, + daily_runtime: { + ...r.daily_runtime!, + min_runtime: Number(v), + }, + }; + }) + } + size={ + ServerApi.Config.constraints.relay_daily_minimal_runtime + } + helperText="Minimum time, in seconds, that this relay should run each day" + /> + + + + setRelay((r) => { + return { + ...r, + daily_runtime: { + ...r.daily_runtime!, + reset_time: d ? dayjsToTimeOfDay(d) : 0, + }, + }; + }) + } + /> + + + + setRelay((r) => { + return { + ...r, + daily_runtime: { + ...r.daily_runtime!, + reset_time: d ? dayjsToTimeOfDay(d) : 0, + }, + }; + }) + } + /> + + + )} + + + + + + + + ); +} + +function DialogFormTitle(p: React.PropsWithChildren): React.ReactElement { + return ( + <> + + {p.children} + + ); +} diff --git a/central_frontend/src/main.tsx b/central_frontend/src/main.tsx index 03a21cb..782a9ed 100644 --- a/central_frontend/src/main.tsx +++ b/central_frontend/src/main.tsx @@ -13,24 +13,28 @@ import { SnackbarProvider } from "./hooks/context_providers/SnackbarProvider"; import "./index.css"; import { ServerApi } from "./api/ServerApi"; import { AsyncWidget } from "./widgets/AsyncWidget"; +import { LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - - await ServerApi.LoadConfig()} - errMsg="Failed to connect to backend to retrieve static config!" - build={() => } - /> - - - - - + + + + + + + await ServerApi.LoadConfig()} + errMsg="Failed to connect to backend to retrieve static config!" + build={() => } + /> + + + + + + ); diff --git a/central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx b/central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx new file mode 100644 index 0000000..8d39676 --- /dev/null +++ b/central_frontend/src/routes/DeviceRoute/DeviceRelays.tsx @@ -0,0 +1,50 @@ +import AddIcon from "@mui/icons-material/Add"; +import { IconButton, Tooltip } from "@mui/material"; +import React from "react"; +import { Device, DeviceRelay } from "../../api/DeviceApi"; +import { DeviceRouteCard } from "./DeviceRouteCard"; +import { EditDeviceRelaysDialog } from "../../dialogs/EditDeviceRelaysDialog"; + +export function DeviceRelays(p: { + device: Device; + onReload: () => void; +}): React.ReactElement { + const [dialogOpen, setDialogOpen] = React.useState(false); + const [currRelay, setCurrRelay] = React.useState(); + + const createNewRelay = () => { + setDialogOpen(true); + setCurrRelay(undefined); + }; + + return ( + <> + {dialogOpen && ( + setDialogOpen(false)} + relay={currRelay} + onUpdated={() => { + setDialogOpen(false); + p.onReload(); + }} + /> + )} + + = p.device.info.max_relays} + > + + + + } + > + TODO : relays list ({p.device.relays.length}) relays now) + + + ); +} diff --git a/central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx b/central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx index 19a65fc..50e0368 100644 --- a/central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx +++ b/central_frontend/src/routes/DeviceRoute/DeviceRoute.tsx @@ -1,5 +1,5 @@ import DeleteIcon from "@mui/icons-material/Delete"; -import { IconButton, Tooltip } from "@mui/material"; +import { Grid, IconButton, Tooltip } from "@mui/material"; import React from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Device, DeviceApi } from "../../api/DeviceApi"; @@ -10,6 +10,7 @@ import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider"; import { AsyncWidget } from "../../widgets/AsyncWidget"; import { SolarEnergyRouteContainer } from "../../widgets/SolarEnergyRouteContainer"; import { GeneralDeviceInfo } from "./GeneralDeviceInfo"; +import { DeviceRelays } from "./DeviceRelays"; export function DeviceRoute(): React.ReactElement { const { id } = useParams(); @@ -79,7 +80,14 @@ function DeviceRouteInner(p: { } > - + + + + + + + + ); } diff --git a/central_frontend/src/routes/DeviceRoute/GeneralDeviceInfo.tsx b/central_frontend/src/routes/DeviceRoute/GeneralDeviceInfo.tsx index 40c77db..7c1ef6e 100644 --- a/central_frontend/src/routes/DeviceRoute/GeneralDeviceInfo.tsx +++ b/central_frontend/src/routes/DeviceRoute/GeneralDeviceInfo.tsx @@ -62,6 +62,7 @@ export function GeneralDeviceInfo(p: { {p.label} - {p.value} + {p.value} ); } diff --git a/central_frontend/src/utils/DateUtils.ts b/central_frontend/src/utils/DateUtils.ts index 1b74dea..48d716e 100644 --- a/central_frontend/src/utils/DateUtils.ts +++ b/central_frontend/src/utils/DateUtils.ts @@ -1,6 +1,29 @@ +import dayjs, { Dayjs } from "dayjs"; + /** * Get current UNIX time, in seconds */ export function time(): number { return Math.floor(new Date().getTime() / 1000); } + +/** + * Get dayjs representation of given time of day + */ +export function timeOfDay(time: number): Dayjs { + const hours = Math.floor(time / 3600); + const minutes = Math.floor(time / 60) - hours * 60; + + return dayjs( + `2022-04-17T${hours.toString().padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}` + ); +} + +/** + * Get time of day (in secs) from a given dayjs representation + */ +export function dayjsToTimeOfDay(d: Dayjs): number { + return d.hour() * 3600 + d.minute() * 60 + d.second(); +}