diff --git a/interface/src/app/main/CustomEntities.tsx b/interface/src/app/main/CustomEntities.tsx index 9c36ad394..a928d8181 100644 --- a/interface/src/app/main/CustomEntities.tsx +++ b/interface/src/app/main/CustomEntities.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useBlocker } from 'react-router'; import { toast } from 'react-toastify'; @@ -35,6 +35,10 @@ import { DeviceValueTypeNames, DeviceValueUOM_s } from './types'; import type { Entities, EntityItem } from './types'; import { entityItemValidation } from './validators'; +const MIN_ID = -100; +const MAX_ID = 100; +const ICON_SIZE = 12; + const CustomEntities = () => { const { LL } = useI18nContext(); const [numChanges, setNumChanges] = useState(0); @@ -53,18 +57,20 @@ const CustomEntities = () => { initialData: [] }); - useInterval(() => { + const intervalCallback = useCallback(() => { if (!dialogOpen && !numChanges) { void fetchEntities(); } - }); + }, [dialogOpen, numChanges, fetchEntities]); + + useInterval(intervalCallback); const { send: writeEntities } = useRequest( (data: Entities) => writeCustomEntities(data), { immediate: false } ); - function hasEntityChanged(ei: EntityItem) { + const hasEntityChanged = useCallback((ei: EntityItem) => { return ( ei.id !== ei.o_id || ei.ram !== ei.o_ram || @@ -80,19 +86,21 @@ const CustomEntities = () => { ei.deleted !== ei.o_deleted || (ei.value || '') !== (ei.o_value || '') ); - } + }, []); - const entity_theme = useTheme({ - Table: ` + const entity_theme = useMemo( + () => + useTheme({ + Table: ` --data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px; `, - BaseRow: ` + BaseRow: ` font-size: 14px; .td { height: 32px; } `, - BaseCell: ` + BaseCell: ` &:nth-of-type(1) { padding: 8px; } @@ -112,7 +120,7 @@ const CustomEntities = () => { text-align: center; } `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -121,7 +129,7 @@ const CustomEntities = () => { height: 36px; } `, - Row: ` + Row: ` background-color: #1e1e1e; position: relative; cursor: pointer; @@ -132,9 +140,11 @@ const CustomEntities = () => { background-color: #177ac9; } ` - }); + }), + [] + ); - const saveEntities = async () => { + const saveEntities = useCallback(async () => { await writeEntities({ entities: entities .filter((ei: EntityItem) => !ei.deleted) @@ -163,7 +173,7 @@ const CustomEntities = () => { await fetchEntities(); setNumChanges(0); }); - }; + }, [entities, writeEntities, LL, fetchEntities]); const editEntityItem = useCallback((ei: EntityItem) => { setCreating(false); @@ -171,36 +181,39 @@ const CustomEntities = () => { setDialogOpen(true); }, []); - const onDialogClose = () => { + const onDialogClose = useCallback(() => { setDialogOpen(false); - }; + }, []); - const onDialogCancel = async () => { + const onDialogCancel = useCallback(async () => { await fetchEntities().then(() => { setNumChanges(0); }); - }; + }, [fetchEntities]); - const onDialogSave = (updatedItem: EntityItem) => { - setDialogOpen(false); - void updateState(readCustomEntities(), (data: EntityItem[]) => { - const new_data = creating - ? [ - ...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id), - updatedItem - ] - : data.map((ei) => - ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei - ); - setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); - return new_data; - }); - }; + const onDialogSave = useCallback( + (updatedItem: EntityItem) => { + setDialogOpen(false); + void updateState(readCustomEntities(), (data: EntityItem[]) => { + const new_data = creating + ? [ + ...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id), + updatedItem + ] + : data.map((ei) => + ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei + ); + setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); + return new_data; + }); + }, + [creating, hasEntityChanged] + ); - const onDialogDup = (item: EntityItem) => { + const onDialogDup = useCallback((item: EntityItem) => { setCreating(true); setSelectedEntityItem({ - id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100), + id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), name: item.name + '_', ram: item.ram, device_id: item.device_id, @@ -215,12 +228,12 @@ const CustomEntities = () => { value: item.value }); setDialogOpen(true); - }; + }, []); - const addEntityItem = () => { + const addEntityItem = useCallback(() => { setCreating(true); setSelectedEntityItem({ - id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100), + id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), name: '', ram: 0, device_id: '0', @@ -235,22 +248,30 @@ const CustomEntities = () => { value: '' }); setDialogOpen(true); - }; + }, []); - function formatValue(value: unknown, uom: number) { + const formatValue = useCallback((value: unknown, uom: number) => { return value === undefined ? '' : typeof value === 'number' ? new Intl.NumberFormat().format(value) + - (uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom]) - : (value as string) + (uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom]); - } + (uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`) + : `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`; + }, []); - function showHex(value: number, digit: number) { - return '0x' + value.toString(16).toUpperCase().padStart(digit, '0'); - } + const showHex = useCallback((value: number, digit: number) => { + return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`; + }, []); - const renderEntity = () => { + const filteredAndSortedEntities = useMemo( + () => + entities + ?.filter((ei: EntityItem) => !ei.deleted) + .sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [], + [entities] + ); + + const renderEntity = useCallback(() => { if (!entities) { return ( @@ -260,9 +281,7 @@ const CustomEntities = () => { return ( !ei.deleted) - .sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) + nodes: filteredAndSortedEntities }} theme={entity_theme} layout={{ custom: true }} @@ -285,7 +304,10 @@ const CustomEntities = () => { {ei.name}  {ei.writeable && ( - + )} @@ -304,7 +326,17 @@ const CustomEntities = () => { )}
); - }; + }, [ + entities, + error, + fetchEntities, + entity_theme, + editEntityItem, + LL, + filteredAndSortedEntities, + showHex, + formatValue + ]); return ( diff --git a/interface/src/app/main/CustomEntitiesDialog.tsx b/interface/src/app/main/CustomEntitiesDialog.tsx index 26eb8f8b0..3041f0bba 100644 --- a/interface/src/app/main/CustomEntitiesDialog.tsx +++ b/interface/src/app/main/CustomEntitiesDialog.tsx @@ -68,32 +68,51 @@ const CustomEntitiesDialog = ({ const { LL } = useI18nContext(); const [editItem, setEditItem] = useState(selectedItem); const [fieldErrors, setFieldErrors] = useState(); - const updateFormValue = updateValue(setEditItem); + const updateFormValue = useMemo( + () => + updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > + ), + [] + ); useEffect(() => { if (open) { setFieldErrors(undefined); // Convert to hex strings - combined into single setEditItem call + const deviceIdHex = + typeof selectedItem.device_id === 'number' + ? selectedItem.device_id.toString(16).toUpperCase() + : selectedItem.device_id; + const typeIdHex = + typeof selectedItem.type_id === 'number' + ? selectedItem.type_id.toString(16).toUpperCase() + : selectedItem.type_id; + const factorValue = + selectedItem.value_type === DeviceValueType.BOOL && + typeof selectedItem.factor === 'number' + ? selectedItem.factor.toString(16).toUpperCase() + : selectedItem.factor; + setEditItem({ ...selectedItem, - device_id: selectedItem.device_id.toString(16).toUpperCase(), - type_id: selectedItem.type_id.toString(16).toUpperCase(), - factor: - selectedItem.value_type === DeviceValueType.BOOL - ? selectedItem.factor.toString(16).toUpperCase() - : selectedItem.factor + device_id: deviceIdHex, + type_id: typeIdHex, + factor: factorValue }); } }, [open, selectedItem]); - const handleClose = ( - _event: React.SyntheticEvent, - reason: 'backdropClick' | 'escapeKeyDown' - ) => { - if (reason !== 'backdropClick') { - onClose(); - } - }; + const handleClose = useCallback( + (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { + if (reason !== 'backdropClick') { + onClose(); + } + }, + [onClose] + ); const save = useCallback(async () => { try { @@ -104,16 +123,16 @@ const CustomEntitiesDialog = ({ const processedItem: EntityItem = { ...editItem }; if (typeof processedItem.device_id === 'string') { - processedItem.device_id = parseInt(processedItem.device_id, 16); + processedItem.device_id = Number.parseInt(processedItem.device_id, 16); } if (typeof processedItem.type_id === 'string') { - processedItem.type_id = parseInt(processedItem.type_id, 16); + processedItem.type_id = Number.parseInt(processedItem.type_id, 16); } if ( processedItem.value_type === DeviceValueType.BOOL && typeof processedItem.factor === 'string' ) { - processedItem.factor = parseInt(processedItem.factor, 16); + processedItem.factor = Number.parseInt(processedItem.factor, 16); } onSave(processedItem); } catch (error) { diff --git a/interface/src/app/main/Customizations.tsx b/interface/src/app/main/Customizations.tsx index 1f030b3b7..c6d9b235f 100644 --- a/interface/src/app/main/Customizations.tsx +++ b/interface/src/app/main/Customizations.tsx @@ -62,16 +62,24 @@ import OptionIcon from './OptionIcon'; import { DeviceEntityMask } from './types'; import type { APIcall, Device, DeviceEntity } from './types'; -export const APIURL = window.location.origin + '/api/'; +export const APIURL = `${window.location.origin}/api/`; + +const MAX_BUFFER_SIZE = 2000; // Helper function to create masked entity ID - extracted to avoid duplication -const createMaskedEntityId = (de: DeviceEntity): string => - de.m.toString(16).padStart(2, '0') + - de.id + - (de.cn || de.mi || de.ma ? '|' : '') + - (de.cn ? de.cn : '') + - (de.mi ? '>' + de.mi : '') + - (de.ma ? '<' + de.ma : ''); +const createMaskedEntityId = (de: DeviceEntity): string => { + const maskHex = de.m.toString(16).padStart(2, '0'); + const hasCustomizations = !!(de.cn || de.mi || de.ma); + const customizations = [ + de.cn || '', + de.mi ? `>${de.mi}` : '', + de.ma ? `<${de.ma}` : '' + ] + .filter(Boolean) + .join(''); + + return `${maskHex}${de.id}${hasCustomizations ? `|${customizations}` : ''}`; +}; const Customizations = () => { const { LL } = useI18nContext(); @@ -277,18 +285,22 @@ const Customizations = () => { return value as string; } - const formatName = (de: DeviceEntity, withShortname: boolean) => - (de.n && de.n[0] === '!' - ? de.t - ? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1) - : LL.COMMAND(1) + ': ' + de.n.slice(1) - : de.cn && de.cn !== '' - ? de.t - ? de.t + ' ' + de.cn - : de.cn - : de.t - ? de.t + ' ' + de.n - : de.n) + (withShortname ? ' ' + de.id : ''); + const formatName = useCallback( + (de: DeviceEntity, withShortname: boolean) => { + let name: string; + if (de.n && de.n[0] === '!') { + name = de.t + ? `${LL.COMMAND(1)}: ${de.t} ${de.n.slice(1)}` + : `${LL.COMMAND(1)}: ${de.n.slice(1)}`; + } else if (de.cn && de.cn !== '') { + name = de.t ? `${de.t} ${de.cn}` : de.cn; + } else { + name = de.t ? `${de.t} ${de.n}` : de.n || ''; + } + return withShortname ? `${name} ${de.id}` : name; + }, + [LL] + ); const getMaskNumber = (newMask: string[]) => { let new_mask = 0; @@ -322,33 +334,29 @@ const Customizations = () => { (de: DeviceEntity) => (de.m & selectedFilters || !selectedFilters) && formatName(de, true).toLowerCase().includes(search.toLowerCase()), - [selectedFilters, search] + [selectedFilters, search, formatName] ); - const maskDisabled = (set: boolean) => { - setDeviceEntities( - deviceEntities.map(function (de) { - if (filter_entity(de)) { - return { - ...de, - m: set - ? de.m | - (DeviceEntityMask.DV_API_MQTT_EXCLUDE | - DeviceEntityMask.DV_WEB_EXCLUDE) - : de.m & - ~( - DeviceEntityMask.DV_API_MQTT_EXCLUDE | - DeviceEntityMask.DV_WEB_EXCLUDE - ) - }; - } else { + const maskDisabled = useCallback( + (set: boolean) => { + setDeviceEntities((prev) => + prev.map((de) => { + if (filter_entity(de)) { + const excludeMask = + DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE; + return { + ...de, + m: set ? de.m | excludeMask : de.m & ~excludeMask + }; + } return de; - } - }) - ); - }; + }) + ); + }, + [filter_entity] + ); - const resetCustomization = async () => { + const resetCustomization = useCallback(async () => { try { await sendResetCustomizations(); toast.info(LL.CUSTOMIZATIONS_RESTART()); @@ -357,24 +365,28 @@ const Customizations = () => { } finally { setConfirmReset(false); } - }; + }, [sendResetCustomizations, LL]); const onDialogClose = () => { setDialogOpen(false); }; - const updateDeviceEntity = (updatedItem: DeviceEntity) => { + const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { setDeviceEntities( - deviceEntities?.map((de) => - de.id === updatedItem.id ? { ...de, ...updatedItem } : de - ) + (prev) => + prev?.map((de) => + de.id === updatedItem.id ? { ...de, ...updatedItem } : de + ) ?? [] ); - }; + }, []); - const onDialogSave = (updatedItem: DeviceEntity) => { - setDialogOpen(false); - updateDeviceEntity(updatedItem); - }; + const onDialogSave = useCallback( + (updatedItem: DeviceEntity) => { + setDialogOpen(false); + updateDeviceEntity(updatedItem); + }, + [updateDeviceEntity] + ); const editDeviceEntity = useCallback((de: DeviceEntity) => { if (de.n === undefined || (de.n && de.n[0] === '!')) { @@ -389,52 +401,54 @@ const Customizations = () => { setDialogOpen(true); }, []); - const saveCustomization = async () => { - if (devices && deviceEntities && selectedDevice !== -1) { - const masked_entities = deviceEntities - .filter((de: DeviceEntity) => hasEntityChanged(de)) - .map((new_de) => createMaskedEntityId(new_de)); - - // check size in bytes to match buffer in CPP, which is 2048 - const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length; - if (bytes > 2000) { - toast.warning(LL.CUSTOMIZATIONS_FULL()); - return; - } - - await sendCustomizationEntities({ - id: selectedDevice, - entity_ids: masked_entities - }) - .then(() => { - toast.success(LL.CUSTOMIZATIONS_SAVED()); - }) - .catch((error: Error) => { - if (error.message === 'Reboot required') { - setRestartNeeded(true); - } else { - toast.error(error.message); - } - }) - .finally(() => { - setOriginalSettings(deviceEntities); - }); + const saveCustomization = useCallback(async () => { + if (!devices || !deviceEntities || selectedDevice === -1) { + return; } - }; - const renameDevice = async () => { + const masked_entities = deviceEntities + .filter((de: DeviceEntity) => hasEntityChanged(de)) + .map((new_de) => createMaskedEntityId(new_de)); + + // check size in bytes to match buffer in CPP, which is 2048 + const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length; + if (bytes > MAX_BUFFER_SIZE) { + toast.warning(LL.CUSTOMIZATIONS_FULL()); + return; + } + + await sendCustomizationEntities({ + id: selectedDevice, + entity_ids: masked_entities + }) + .then(() => { + toast.success(LL.CUSTOMIZATIONS_SAVED()); + }) + .catch((error: Error) => { + if (error.message === 'Reboot required') { + setRestartNeeded(true); + } else { + toast.error(error.message); + } + }) + .finally(() => { + setOriginalSettings(deviceEntities); + }); + }, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]); + + const renameDevice = useCallback(async () => { await sendDeviceName({ id: selectedDevice, name: selectedDeviceName }) .then(() => { toast.success(LL.UPDATED_OF(LL.NAME(1))); }) .catch(() => { - toast.error(LL.UPDATE_OF(LL.NAME(1)) + ' ' + LL.FAILED(1)); + toast.error(`${LL.UPDATE_OF(LL.NAME(1))} ${LL.FAILED(1)}`); }) .finally(async () => { setRename(false); await fetchCoreData(); }); - }; + }, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]); const renderDeviceList = () => ( <> diff --git a/interface/src/app/main/CustomizationsDialog.tsx b/interface/src/app/main/CustomizationsDialog.tsx index 7b15893ad..d989ef08c 100644 --- a/interface/src/app/main/CustomizationsDialog.tsx +++ b/interface/src/app/main/CustomizationsDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import CloseIcon from '@mui/icons-material/Close'; @@ -35,14 +35,17 @@ interface LabelValueProps { value: React.ReactNode; } -const LabelValue = ({ label, value }: LabelValueProps) => ( +const LabelValue = memo(({ label, value }: LabelValueProps) => ( {label}:  {value} -); +)); +LabelValue.displayName = 'LabelValue'; + +const ICON_SIZE = 16; const CustomizationsDialog = ({ open, @@ -54,7 +57,15 @@ const CustomizationsDialog = ({ const [editItem, setEditItem] = useState(selectedItem); const [error, setError] = useState(false); - const updateFormValue = updateValue(setEditItem); + const updateFormValue = useMemo( + () => + updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > + ), + [] + ); const isWriteableNumber = useMemo( () => @@ -93,32 +104,32 @@ const CustomizationsDialog = ({ } }, [isWriteableNumber, editItem, onSave]); - const updateDeviceEntity = useCallback( - (updatedItem: DeviceEntity) => { - setEditItem({ ...editItem, m: updatedItem.m }); - }, - [editItem] + const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { + setEditItem((prev) => ({ ...prev, m: updatedItem.m })); + }, []); + + const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]); + + const writeableIcon = useMemo( + () => + editItem.w ? ( + + ) : ( + + ), + [editItem.w] ); return ( - {LL.EDIT() + ' ' + LL.ENTITY()} + {dialogTitle} - - ) : ( - - ) - } - /> + diff --git a/interface/src/app/main/Dashboard.tsx b/interface/src/app/main/Dashboard.tsx index 657f714bc..dcb8c6e1e 100644 --- a/interface/src/app/main/Dashboard.tsx +++ b/interface/src/app/main/Dashboard.tsx @@ -133,7 +133,7 @@ const Dashboard = memo(() => { ); const tree = useTree( - { nodes: data.nodes }, + { nodes: [...data.nodes] }, { onChange: () => {} // not used but needed }, diff --git a/interface/src/app/main/DeviceIcon.tsx b/interface/src/app/main/DeviceIcon.tsx index db61fb9a1..112d8e328 100644 --- a/interface/src/app/main/DeviceIcon.tsx +++ b/interface/src/app/main/DeviceIcon.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ai'; import { CgSmartHomeBoiler } from 'react-icons/cg'; import { FaSolarPanel } from 'react-icons/fa'; @@ -50,9 +51,9 @@ interface DeviceIconProps { type_id: DeviceType; } -const DeviceIcon = ({ type_id }: DeviceIconProps) => { +const DeviceIcon = memo(({ type_id }: DeviceIconProps) => { const Icon = deviceIconLookup[type_id]; return Icon ? : null; -}; +}); export default DeviceIcon; diff --git a/interface/src/app/main/Devices.tsx b/interface/src/app/main/Devices.tsx index b51571434..bb00dba57 100644 --- a/interface/src/app/main/Devices.tsx +++ b/interface/src/app/main/Devices.tsx @@ -118,30 +118,28 @@ const Devices = memo(() => { ); useLayoutEffect(() => { - function updateSize() { - setSize([window.innerWidth, window.innerHeight]); - } + let raf = 0; + const updateSize = () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + setSize([window.innerWidth, window.innerHeight]); + }); + }; window.addEventListener('resize', updateSize); updateSize(); - return () => window.removeEventListener('resize', updateSize); + return () => { + window.removeEventListener('resize', updateSize); + cancelAnimationFrame(raf); + }; }, []); - const leftOffset = () => { + const leftOffset = useCallback(() => { const devicesWindow = document.getElementById('devices-window'); - if (!devicesWindow) { - return 0; - } - - const clientRect = devicesWindow.getBoundingClientRect(); - const left = clientRect.left; - const right = clientRect.right; - - if (!left || !right) { - return 0; - } - + if (!devicesWindow) return 0; + const { left, right } = devicesWindow.getBoundingClientRect(); + if (!left || !right) return 0; return left + (right - left < 400 ? 0 : 200); - }; + }, []); const common_theme = useMemo( () => @@ -261,7 +259,7 @@ const Devices = memo(() => { }; const dv_sort = useSort( - { nodes: deviceData.nodes }, + { nodes: [...deviceData.nodes] }, {}, { sortIcon: { @@ -291,7 +289,7 @@ const Devices = memo(() => { } const device_select = useRowSelect( - { nodes: coreData.devices }, + { nodes: [...coreData.devices] }, { onChange: onSelectChange } @@ -549,7 +547,7 @@ const Devices = memo(() => { {coreData.connected && ( { sx={{ backgroundColor: 'black', position: 'absolute', - left: () => leftOffset(), + left: leftOffset, right: 0, bottom: 0, top: 64, @@ -744,7 +742,7 @@ const Devices = memo(() => {
{ const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag; const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => { - const handleChange = (_event: unknown, mask: string[]) => { - // Convert selected masks to a number - const newMask = getMaskNumber(mask); + const handleChange = useCallback( + (_event: unknown, mask: string[]) => { + // Convert selected masks to a number + const newMask = getMaskNumber(mask); + const updatedDe = { ...de }; - // Apply business logic for mask interactions - // If entity has no name and is set to readonly, also exclude from web - if (de.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) { - de.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE; - } else { - de.m = newMask; - } + // Apply business logic for mask interactions + // If entity has no name and is set to readonly, also exclude from web + if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) { + updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE; + } else { + updatedDe.m = newMask; + } - // If excluded from web, cannot be favorite - if (hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)) { - de.m = de.m & ~DeviceEntityMask.DV_FAVORITE; - } + // If excluded from web, cannot be favorite + if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) { + updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE; + } - onUpdate(de); - }; + onUpdate(updatedDe); + }, + [de, onUpdate] + ); - // Check if favorite button should be disabled - const isFavoriteDisabled = - hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) || - de.n === undefined; + // Memoize mask string value + const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]); - // Check if readonly button should be disabled - const isReadonlyDisabled = - !de.w || - hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE); + // Memoize disabled states + const isFavoriteDisabled = useMemo( + () => + hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) || + de.n === undefined, + [de.m, de.n] + ); - // Check if api/mqtt exclude button should be disabled - const isApiMqttExcludeDisabled = - de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED); + const isReadonlyDisabled = useMemo( + () => + !de.w || + hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE), + [de.w, de.m] + ); - // Check if web exclude button should be disabled - const isWebExcludeDisabled = - de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED); + const isApiMqttExcludeDisabled = useMemo( + () => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED), + [de.n, de.m] + ); + + const isWebExcludeDisabled = useMemo( + () => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED), + [de.n, de.m] + ); + + // Memoize mask flag checks + const isFavoriteSet = useMemo( + () => hasMask(de.m, DeviceEntityMask.DV_FAVORITE), + [de.m] + ); + const isReadonlySet = useMemo( + () => hasMask(de.m, DeviceEntityMask.DV_READONLY), + [de.m] + ); + const isApiMqttExcludeSet = useMemo( + () => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE), + [de.m] + ); + const isWebExcludeSet = useMemo( + () => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE), + [de.m] + ); + const isDeletedSet = useMemo( + () => hasMask(de.m, DeviceEntityMask.DV_DELETED), + [de.m] + ); return ( - + - + - + - + - + ); diff --git a/interface/src/app/main/Help.tsx b/interface/src/app/main/Help.tsx index b91e1220e..fc13bf85e 100644 --- a/interface/src/app/main/Help.tsx +++ b/interface/src/app/main/Help.tsx @@ -101,9 +101,12 @@ const HelpComponent = () => { toast.error(String(error.error?.message || 'An error occurred')); }); + // Optimize API call memoization + const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []); + const handleDownloadSystemInfo = useCallback(() => { - void sendAPI({ device: 'system', cmd: 'info', id: 0 }); - }, [sendAPI]); + void sendAPI(apiCall); + }, [sendAPI, apiCall]); const handleImageError = useCallback(() => { setImgError(true); @@ -131,6 +134,8 @@ const HelpComponent = () => { [LL] ); + const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]); + // Memoize image source computation const imageSrc = useMemo( () => @@ -161,7 +166,7 @@ const HelpComponent = () => { )} - {me.admin && ( + {isAdmin && ( {helpLinks.map(({ href, icon, label }) => ( diff --git a/interface/src/app/main/Modules.tsx b/interface/src/app/main/Modules.tsx index ef4f7bec0..35d191fdd 100644 --- a/interface/src/app/main/Modules.tsx +++ b/interface/src/app/main/Modules.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { useBlocker } from 'react-router'; import { toast } from 'react-toastify'; @@ -34,16 +34,15 @@ import type { ModuleItem } from './types'; const PENDING_COLOR = 'red'; const ACTIVATED_COLOR = '#00FF7F'; -function hasModulesChanged(mi: ModuleItem): boolean { - return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license; -} +const hasModulesChanged = (mi: ModuleItem): boolean => + mi.enabled !== mi.o_enabled || mi.license !== mi.o_license; -const colorStatus = (status: number) => { +const ColorStatus = memo(({ status }: { status: number }) => { if (status === 1) { return
Pending Activation
; } return
Activated
; -}; +}); const Modules = () => { const { LL } = useI18nContext(); @@ -151,25 +150,23 @@ const Modules = () => { }, [fetchModules]); const saveModules = useCallback(async () => { - await Promise.all( - modules.map((condensed_mi: ModuleItem) => - updateModules({ - key: condensed_mi.key, - enabled: condensed_mi.enabled, - license: condensed_mi.license - }) - ) - ) - .then(() => { - toast.success(LL.MODULES_UPDATED()); - }) - .catch((error: Error) => { - toast.error(error.message); - }) - .finally(async () => { - await fetchModules(); - setNumChanges(0); - }); + try { + await Promise.all( + modules.map((condensed_mi: ModuleItem) => + updateModules({ + key: condensed_mi.key, + enabled: condensed_mi.enabled, + license: condensed_mi.license + }) + ) + ); + toast.success(LL.MODULES_UPDATED()); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } finally { + await fetchModules(); + setNumChanges(0); + } }, [modules, updateModules, LL, fetchModules]); const content = useMemo(() => { @@ -229,7 +226,9 @@ const Modules = () => { {mi.author} {mi.version} {mi.message} - {colorStatus(mi.status)} + + + ))} diff --git a/interface/src/app/main/ModulesDialog.tsx b/interface/src/app/main/ModulesDialog.tsx index 977e53be6..7a694589e 100644 --- a/interface/src/app/main/ModulesDialog.tsx +++ b/interface/src/app/main/ModulesDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import DoneIcon from '@mui/icons-material/Done'; @@ -37,7 +37,15 @@ const ModulesDialog = ({ const { LL } = useI18nContext(); const [editItem, setEditItem] = useState(selectedItem); - const updateFormValue = updateValue(setEditItem); + const updateFormValue = useMemo( + () => + updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > + ), + [] + ); // Sync form state when dialog opens or selected item changes useEffect(() => { @@ -46,9 +54,18 @@ const ModulesDialog = ({ } }, [open, selectedItem]); + const handleSave = useCallback(() => { + onSave(editItem); + }, [editItem, onSave]); + + const dialogTitle = useMemo( + () => `${LL.EDIT()} ${editItem.key}`, + [LL, editItem.key] + ); + return ( - {LL.EDIT() + ' ' + editItem.key} + {dialogTitle} } variant="outlined" - onClick={() => onSave(editItem)} + onClick={handleSave} color="primary" > {LL.UPDATE()} diff --git a/interface/src/app/main/OptionIcon.tsx b/interface/src/app/main/OptionIcon.tsx index 0fad9617a..995049aaf 100644 --- a/interface/src/app/main/OptionIcon.tsx +++ b/interface/src/app/main/OptionIcon.tsx @@ -1,3 +1,5 @@ +import { memo } from 'react'; + import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; @@ -10,34 +12,39 @@ import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'; import type { SvgIconProps } from '@mui/material'; -type OptionType = +export type OptionType = | 'deleted' | 'readonly' | 'web_exclude' | 'api_mqtt_exclude' | 'favorite'; -const OPTION_ICONS: { - [type in OptionType]: [ - React.ComponentType, - React.ComponentType - ]; -} = { +type IconPair = [ + React.ComponentType, + React.ComponentType +]; + +const OPTION_ICONS: Record = { deleted: [DeleteForeverIcon, DeleteOutlineIcon], readonly: [EditOffOutlinedIcon, EditOutlinedIcon], web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon], api_mqtt_exclude: [CommentsDisabledOutlinedIcon, InsertCommentOutlinedIcon], favorite: [StarIcon, StarOutlineIcon] +} as const; + +const ICON_SIZE = 16; +const ICON_SX = { fontSize: ICON_SIZE, verticalAlign: 'middle' } as const; + +export interface OptionIconProps { + readonly type: OptionType; + readonly isSet: boolean; +} + +const OptionIcon = ({ type, isSet }: OptionIconProps) => { + const [SetIcon, UnsetIcon] = OPTION_ICONS[type]; + const Icon = isSet ? SetIcon : UnsetIcon; + + return ; }; -const OptionIcon = ({ type, isSet }: { type: OptionType; isSet: boolean }) => { - const Icon = OPTION_ICONS[type][isSet ? 0 : 1]; - return ( - - ); -}; - -export default OptionIcon; +export default memo(OptionIcon); diff --git a/interface/src/app/main/Scheduler.tsx b/interface/src/app/main/Scheduler.tsx index 751939d84..1a3cd3753 100644 --- a/interface/src/app/main/Scheduler.tsx +++ b/interface/src/app/main/Scheduler.tsx @@ -35,6 +35,76 @@ import { ScheduleFlag } from './types'; import type { Schedule, ScheduleItem } from './types'; import { schedulerItemValidation } from './validators'; +// Constants +const INTERVAL_DELAY = 30000; // 30 seconds +const MIN_ID = -100; +const MAX_ID = 100; +const ICON_SIZE = 16; +const SCHEDULE_FLAG_THRESHOLD = 127; +const REFERENCE_YEAR = 2017; +const REFERENCE_MONTH = '01'; +const LOG_2 = Math.log(2); + +// Days of week starting from Monday (1-7) +const WEEK_DAYS = [1, 2, 3, 4, 5, 6, 7] as const; + +const DEFAULT_SCHEDULE_ITEM: Omit = { + active: false, + deleted: false, + flags: ScheduleFlag.SCHEDULE_DAY, + time: '', + cmd: '', + value: '', + name: '' +}; + +const scheduleTheme = { + Table: ` + --data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px; + `, + BaseRow: ` + font-size: 14px; + .td { + height: 32px; + } + `, + BaseCell: ` + &:nth-of-type(2) { + text-align: center; + } + &:nth-of-type(1) { + text-align: center; + } + `, + HeaderRow: ` + text-transform: uppercase; + background-color: black; + color: #90CAF9; + .th { + border-bottom: 1px solid #565656; + height: 36px; + } + `, + Row: ` + background-color: #1e1e1e; + position: relative; + cursor: pointer; + .td { + border-bottom: 1px solid #565656; + } + &:hover .td { + background-color: #177ac9; + } + ` +}; + +const scheduleTypeLabels: Record = { + [ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate', + [ScheduleFlag.SCHEDULE_TIMER]: 'Timer', + [ScheduleFlag.SCHEDULE_CONDITION]: 'Condition', + [ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change' +}; + const Scheduler = () => { const { LL, locale } = useI18nContext(); const [numChanges, setNumChanges] = useState(0); @@ -74,93 +144,53 @@ const Scheduler = () => { ); }, []); - useInterval(() => { + const intervalCallback = useCallback(() => { if (numChanges === 0) { void fetchSchedule(); } - }, 30000); + }, [numChanges, fetchSchedule]); + + useInterval(intervalCallback, INTERVAL_DELAY); useEffect(() => { const formatter = new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }); - const days = [1, 2, 3, 4, 5, 6, 7].map((day) => { - const dd = day < 10 ? `0${day}` : day; - return new Date(`2017-01-${dd}T00:00:00+00:00`); + const days = WEEK_DAYS.map((day) => { + const dayStr = String(day).padStart(2, '0'); + return new Date( + `${REFERENCE_YEAR}-${REFERENCE_MONTH}-${dayStr}T00:00:00+00:00` + ); }); setDow(days.map((date) => formatter.format(date))); }, [locale]); - const schedule_theme = useTheme( - useMemo( - () => ({ - Table: ` - --data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px; - `, - BaseRow: ` - font-size: 14px; - .td { - height: 32px; - } - `, - BaseCell: ` - &:nth-of-type(2) { - text-align: center; - } - &:nth-of-type(1) { - text-align: center; - } - `, - HeaderRow: ` - text-transform: uppercase; - background-color: black; - color: #90CAF9; - .th { - border-bottom: 1px solid #565656; - height: 36px; - } - `, - Row: ` - background-color: #1e1e1e; - position: relative; - cursor: pointer; - .td { - border-bottom: 1px solid #565656; - } - &:hover .td { - background-color: #177ac9; - } - ` - }), - [] - ) - ); + const schedule_theme = useTheme(scheduleTheme); const saveSchedule = useCallback(async () => { - await updateSchedule({ - schedule: schedule - .filter((si: ScheduleItem) => !si.deleted) - .map((condensed_si: ScheduleItem) => ({ - id: condensed_si.id, - active: condensed_si.active, - flags: condensed_si.flags, - time: condensed_si.time, - cmd: condensed_si.cmd, - value: condensed_si.value, - name: condensed_si.name - })) - }) - .then(() => { - toast.success(LL.SCHEDULE_UPDATED()); - }) - .catch((error: Error) => { - toast.error(error.message); - }) - .finally(async () => { - await fetchSchedule(); - setNumChanges(0); + try { + await updateSchedule({ + schedule: schedule + .filter((si: ScheduleItem) => !si.deleted) + .map((condensed_si: ScheduleItem) => ({ + id: condensed_si.id, + active: condensed_si.active, + flags: condensed_si.flags, + time: condensed_si.time, + cmd: condensed_si.cmd, + value: condensed_si.value, + name: condensed_si.name + })) }); + toast.success(LL.SCHEDULE_UPDATED()); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + toast.error(message); + } finally { + await fetchSchedule(); + setNumChanges(0); + } }, [LL, schedule, updateSchedule, fetchSchedule]); const editScheduleItem = useCallback((si: ScheduleItem) => { @@ -187,10 +217,7 @@ const Scheduler = () => { setDialogOpen(false); void updateState(readSchedule(), (data: ScheduleItem[]) => { const new_data = creating - ? [ - ...data.filter((si) => creating || si.o_id !== updatedItem.o_id), - updatedItem - ] + ? [...data, updatedItem] : data.map((si) => si.id === updatedItem.id ? { ...si, ...updatedItem } : si ); @@ -205,16 +232,11 @@ const Scheduler = () => { const addScheduleItem = useCallback(() => { setCreating(true); - setSelectedScheduleItem({ - id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100), - active: false, - deleted: false, - flags: ScheduleFlag.SCHEDULE_DAY, - time: '', - cmd: '', - value: '', - name: '' - }); + const newItem: ScheduleItem = { + id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), + ...DEFAULT_SCHEDULE_ITEM + }; + setSelectedScheduleItem(newItem); setDialogOpen(true); }, []); @@ -227,44 +249,37 @@ const Scheduler = () => { ); const dayBox = useCallback( - (si: ScheduleItem, flag: number) => ( - <> - - - {dow[Math.log(flag) / Math.log(2)]} - - - - - ), + (si: ScheduleItem, flag: number) => { + const dayIndex = Math.log(flag) / LOG_2; + const isActive = (si.flags & flag) === flag; + + return ( + <> + + + {dow[dayIndex]} + + + + + ); + }, [dow] ); - const scheduleType = useCallback( - (si: ScheduleItem) => ( + const scheduleType = useCallback((si: ScheduleItem) => { + const label = scheduleTypeLabels[si.flags]; + + return ( - {si.flags === ScheduleFlag.SCHEDULE_IMMEDIATE ? ( - <>Immediate - ) : si.flags === ScheduleFlag.SCHEDULE_TIMER ? ( - <>Timer - ) : si.flags === ScheduleFlag.SCHEDULE_CONDITION ? ( - <>Condition - ) : si.flags === ScheduleFlag.SCHEDULE_ONCHANGE ? ( - <>On Change - ) : ( - <> - )} + {label || ''} - ), - [] - ); + ); + }, []); - const renderSchedule = () => { + const renderSchedule = useCallback(() => { if (!schedule) { return ( @@ -293,22 +308,15 @@ const Scheduler = () => { {tableList.map((si: ScheduleItem) => ( editScheduleItem(si)}> - {si.active ? ( - - ) : ( - - )} + - {si.flags > 127 ? ( + {si.flags > SCHEDULE_FLAG_THRESHOLD ? ( scheduleType(si) ) : ( <> @@ -334,7 +342,17 @@ const Scheduler = () => { )}
); - }; + }, [ + schedule, + error, + fetchSchedule, + filteredAndSortedSchedule, + schedule_theme, + editScheduleItem, + LL, + dayBox, + scheduleType + ]); return ( diff --git a/interface/src/app/main/SchedulerDialog.tsx b/interface/src/app/main/SchedulerDialog.tsx index ede07b7a9..06495fd94 100644 --- a/interface/src/app/main/SchedulerDialog.tsx +++ b/interface/src/app/main/SchedulerDialog.tsx @@ -31,6 +31,34 @@ import { validate } from 'validators'; import { ScheduleFlag } from './types'; import type { ScheduleItem } from './types'; +// Constants +const FLAG_MASK_127 = 127; +const SCHEDULE_TYPE_THRESHOLD = 128; +const DEFAULT_TIME = '00:00'; +const TYPOGRAPHY_FONT_SIZE = 10; + +// Day of week flag configuration (static, defined outside component) +const DAY_FLAGS = [ + { value: '2', flag: ScheduleFlag.SCHEDULE_MON }, + { value: '4', flag: ScheduleFlag.SCHEDULE_TUE }, + { value: '8', flag: ScheduleFlag.SCHEDULE_WED }, + { value: '16', flag: ScheduleFlag.SCHEDULE_THU }, + { value: '32', flag: ScheduleFlag.SCHEDULE_FRI }, + { value: '64', flag: ScheduleFlag.SCHEDULE_SAT }, + { value: '1', flag: ScheduleFlag.SCHEDULE_SUN } +] as const; + +// Day of week flag values array (static) +const FLAG_VALUES = [ + ScheduleFlag.SCHEDULE_SUN, + ScheduleFlag.SCHEDULE_MON, + ScheduleFlag.SCHEDULE_TUE, + ScheduleFlag.SCHEDULE_WED, + ScheduleFlag.SCHEDULE_THU, + ScheduleFlag.SCHEDULE_FRI, + ScheduleFlag.SCHEDULE_SAT +] as const; + interface SchedulerDialogProps { open: boolean; creating: boolean; @@ -55,20 +83,30 @@ const SchedulerDialog = ({ const [fieldErrors, setFieldErrors] = useState(); const [scheduleType, setScheduleType] = useState(); - const updateFormValue = updateValue(setEditItem); + const updateFormValue = useMemo( + () => + updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > + ), + [] + ); useEffect(() => { if (open) { setFieldErrors(undefined); setEditItem(selectedItem); - // set the flags based on type when page is loaded... + // Set the flags based on type when page is loaded: // 0-127 is day schedule // 128 is timer // 129 is on change // 130 is on condition // 132 is immediate setScheduleType( - selectedItem.flags < 128 ? ScheduleFlag.SCHEDULE_DAY : selectedItem.flags + selectedItem.flags < SCHEDULE_TYPE_THRESHOLD + ? ScheduleFlag.SCHEDULE_DAY + : selectedItem.flags ); } }, [open, selectedItem]); @@ -101,32 +139,24 @@ const SchedulerDialog = ({ // Optimize DOW flag conversion const getFlagDOWnumber = useCallback((flags: string[]) => { - return flags.reduce((acc, flag) => acc | Number(flag), 0) & 127; + return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127; }, []); const getFlagDOWstring = useCallback((f: number) => { - const flagValues = [ - ScheduleFlag.SCHEDULE_SUN, - ScheduleFlag.SCHEDULE_MON, - ScheduleFlag.SCHEDULE_TUE, - ScheduleFlag.SCHEDULE_WED, - ScheduleFlag.SCHEDULE_THU, - ScheduleFlag.SCHEDULE_FRI, - ScheduleFlag.SCHEDULE_SAT - ]; - return flagValues - .filter((flag) => (f & flag) === flag) - .map((flag) => String(flag)); + return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => + String(flag) + ); }, []); // Day of week display component - const DayOfWeekButton = useMemo( - () => (flag: number) => { + const DayOfWeekButton = useCallback( + (flag: number) => { const dayIndex = Math.log2(flag); + const isSelected = (editItem.flags & flag) === flag; return ( {dow[dayIndex]} @@ -152,25 +182,37 @@ const SchedulerDialog = ({ // wipe the time field when changing the schedule type // set the flags based on type const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? 0 : flag; - setEditItem({ ...editItem, time: '', flags: newFlags }); + setEditItem((prev) => ({ ...prev, time: '', flags: newFlags })); } }, - [editItem] + [] ); const handleDOWChange = useCallback( (_event: React.SyntheticEvent, flags: string[]) => { const newFlags = getFlagDOWnumber(flags); - setEditItem({ ...editItem, flags: newFlags }); + setEditItem((prev) => ({ ...prev, flags: newFlags })); }, - [editItem, getFlagDOWnumber] + [getFlagDOWnumber] ); // Memoize derived values - const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY; - const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER; - const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE; - const needsTimeField = isDaySchedule || isTimerSchedule; + const isDaySchedule = useMemo( + () => scheduleType === ScheduleFlag.SCHEDULE_DAY, + [scheduleType] + ); + const isTimerSchedule = useMemo( + () => scheduleType === ScheduleFlag.SCHEDULE_TIMER, + [scheduleType] + ); + const isImmediateSchedule = useMemo( + () => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE, + [scheduleType] + ); + const needsTimeField = useMemo( + () => isDaySchedule || isTimerSchedule, + [isDaySchedule, isTimerSchedule] + ); const dowFlags = useMemo( () => getFlagDOWstring(editItem.flags), @@ -179,9 +221,9 @@ const SchedulerDialog = ({ const timeFieldValue = useMemo(() => { if (needsTimeField) { - return editItem.time === '' ? '00:00' : editItem.time; + return editItem.time === '' ? DEFAULT_TIME : editItem.time; } - return editItem.time === '00:00' ? '' : editItem.time; + return editItem.time === DEFAULT_TIME ? '' : editItem.time; }, [editItem.time, needsTimeField]); const timeFieldLabel = useMemo(() => { @@ -192,21 +234,10 @@ const SchedulerDialog = ({ return LL.TIME(1); }, [scheduleType, LL]); - // Day of week configuration - const dayFlags = [ - { value: '2', flag: ScheduleFlag.SCHEDULE_MON }, - { value: '4', flag: ScheduleFlag.SCHEDULE_TUE }, - { value: '8', flag: ScheduleFlag.SCHEDULE_WED }, - { value: '16', flag: ScheduleFlag.SCHEDULE_THU }, - { value: '32', flag: ScheduleFlag.SCHEDULE_FRI }, - { value: '64', flag: ScheduleFlag.SCHEDULE_SAT }, - { value: '1', flag: ScheduleFlag.SCHEDULE_SUN } - ]; - return ( - {creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}  + {creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}  {LL.SCHEDULE(1)} @@ -220,7 +251,7 @@ const SchedulerDialog = ({ > {LL.SCHEDULE(0)} @@ -228,7 +259,7 @@ const SchedulerDialog = ({ {LL.TIMER(0)} @@ -236,7 +267,7 @@ const SchedulerDialog = ({ {LL.IMMEDIATE()} @@ -271,7 +302,7 @@ const SchedulerDialog = ({ value={dowFlags} onChange={handleDOWChange} > - {dayFlags.map(({ value, flag }) => ( + {DAY_FLAGS.map(({ value, flag }) => ( {DayOfWeekButton(flag)} diff --git a/interface/src/app/main/Sensors.tsx b/interface/src/app/main/Sensors.tsx index 55f53c4c0..22a629ff2 100644 --- a/interface/src/app/main/Sensors.tsx +++ b/interface/src/app/main/Sensors.tsx @@ -49,6 +49,27 @@ import { temperatureSensorItemValidation } from './validators'; +// Constants +const MS_PER_SECOND = 1000; +const MS_PER_MINUTE = 60 * MS_PER_SECOND; +const MS_PER_HOUR = 60 * MS_PER_MINUTE; +const MS_PER_DAY = 24 * MS_PER_HOUR; +const DEFAULT_GPIO = 21; // Safe GPIO for all platforms +const MIN_TEMP_ID = -100; +const MAX_TEMP_ID = 100; +const GPIO_25 = 25; +const GPIO_26 = 26; + +const HEADER_BUTTON_STYLE: React.CSSProperties = { + fontSize: '14px', + justifyContent: 'flex-start' +}; + +const HEADER_BUTTON_STYLE_END: React.CSSProperties = { + fontSize: '14px', + justifyContent: 'flex-end' +}; + const common_theme = { BaseRow: ` font-size: 14px; @@ -130,11 +151,13 @@ const Sensors = () => { } ); - useInterval(() => { + const intervalCallback = useCallback(() => { if (!temperatureDialogOpen && !analogDialogOpen) { void fetchSensorData(); } - }); + }, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]); + + useInterval(intervalCallback); const temperature_theme = useTheme([common_theme, temperature_theme_config]); const analog_theme = useTheme([common_theme, analog_theme_config]); @@ -160,11 +183,16 @@ const Sensors = () => { }, sortToggleType: SortToggleType.AlternateWithReset, sortFns: { - GPIO: (array) => array.sort((a, b) => a.g - b.g), - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)), - TYPE: (array) => array.sort((a, b) => a.t - b.t), - VALUE: (array) => array.sort((a, b) => a.v - b.v) + GPIO: (array) => + [...array].sort((a, b) => (a as AnalogSensor).g - (b as AnalogSensor).g), + NAME: (array) => + [...array].sort((a, b) => + (a as AnalogSensor).n.localeCompare((b as AnalogSensor).n) + ), + TYPE: (array) => + [...array].sort((a, b) => (a as AnalogSensor).t - (b as AnalogSensor).t), + VALUE: (array) => + [...array].sort((a, b) => (a as AnalogSensor).v - (b as AnalogSensor).v) } } ); @@ -180,9 +208,15 @@ const Sensors = () => { }, sortToggleType: SortToggleType.AlternateWithReset, sortFns: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)), - VALUE: (array) => array.sort((a, b) => a.t - b.t) + NAME: (array) => + [...array].sort((a, b) => + (a as TemperatureSensor).n.localeCompare((b as TemperatureSensor).n) + ), + VALUE: (array) => + [...array].sort( + (a, b) => + ((a as TemperatureSensor).t ?? 0) - ((b as TemperatureSensor).t ?? 0) + ) } } ); @@ -191,21 +225,22 @@ const Sensors = () => { const formatDurationMin = useCallback( (duration_min: number) => { - const days = Math.trunc((duration_min * 60000) / 86400000); - const hours = Math.trunc((duration_min * 60000) / 3600000) % 24; - const minutes = Math.trunc((duration_min * 60000) / 60000) % 60; + const totalMs = duration_min * MS_PER_MINUTE; + const days = Math.trunc(totalMs / MS_PER_DAY); + const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24; + const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60; - let formatted = ''; - if (days) { - formatted += LL.NUM_DAYS({ num: days }) + ' '; + const parts: string[] = []; + if (days > 0) { + parts.push(LL.NUM_DAYS({ num: days })); } - if (hours) { - formatted += LL.NUM_HOURS({ num: hours }) + ' '; + if (hours > 0) { + parts.push(LL.NUM_HOURS({ num: hours })); } - if (minutes) { - formatted += LL.NUM_MINUTES({ num: minutes }); + if (minutes > 0) { + parts.push(LL.NUM_MINUTES({ num: minutes })); } - return formatted; + return parts.join(' '); }, [LL] ); @@ -298,9 +333,9 @@ const Sensors = () => { const addAnalogSensor = useCallback(() => { setCreating(true); setSelectedAnalogSensor({ - id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100), + id: Math.floor(Math.random() * (MAX_TEMP_ID - MIN_TEMP_ID) + MIN_TEMP_ID), n: '', - g: 21, // default GPIO 21 which is safe for all platforms + g: DEFAULT_GPIO, u: 0, v: 0, o: 0, @@ -354,7 +389,7 @@ const Sensors = () => {