optimizations

This commit is contained in:
proddy
2025-10-28 22:19:08 +01:00
parent 55b893362c
commit 3abfb7bb9c
93 changed files with 3953 additions and 3361 deletions

View File

@@ -1,60 +1,67 @@
export const numberValue = (value?: number) => {
if (value !== undefined) {
return isNaN(value) ? '' : value.toString();
}
return '';
};
/**
* Converts a number value to a string for input fields.
* Returns empty string for undefined or NaN values.
*/
export const numberValue = (value?: number): string =>
value === undefined || isNaN(value) ? '' : String(value);
export const extractEventValue = (event: React.ChangeEvent<HTMLInputElement>) => {
switch (event.target.type) {
case 'number':
return event.target.valueAsNumber;
case 'checkbox':
return event.target.checked;
default:
return event.target.value;
}
/**
* Extracts the appropriate value from an input event based on input type.
*/
export const extractEventValue = (
event: React.ChangeEvent<HTMLInputElement>
): string | number | boolean => {
const { type, valueAsNumber, checked, value } = event.target;
if (type === 'number') return valueAsNumber;
if (type === 'checkbox') return checked;
return value;
};
type UpdateEntity<S> = (state: (prevState: Readonly<S>) => S) => void;
/**
* Creates an event handler that updates an entity's state based on input changes.
*/
export const updateValue =
<S>(updateEntity: UpdateEntity<S>) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
<S extends Record<string, unknown>>(updateEntity: UpdateEntity<S>) =>
(event: React.ChangeEvent<HTMLInputElement>): void => {
const { name } = event.target;
const value = extractEventValue(event);
updateEntity((prevState) => ({
...prevState,
[event.target.name]: extractEventValue(event)
[name]: value
}));
};
/**
* Creates an event handler that tracks dirty flags for modified fields.
* Optimized to minimize state updates and unnecessary array operations.
*/
export const updateValueDirty =
(
origData: unknown,
<T extends Record<string, unknown>>(
origData: T,
dirtyFlags: string[],
setDirtyFlags: React.Dispatch<React.SetStateAction<string[]>>,
updateDataValue: (value: unknown) => void
updateDataValue: (updater: (prevState: T) => T) => void
) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
const updated_value = extractEventValue(event);
const name = event.target.name;
(event: React.ChangeEvent<HTMLInputElement>): void => {
const { name } = event.target;
const updatedValue = extractEventValue(event);
updateDataValue((prevState: unknown) => ({
...(prevState as Record<string, unknown>),
[name]: updated_value
updateDataValue((prevState) => ({
...prevState,
[name]: updatedValue
}));
const arr: string[] = dirtyFlags;
const isDirty = origData[name] !== updatedValue;
const wasDirty = dirtyFlags.includes(name);
if ((origData as Record<string, unknown>)[name] !== updated_value) {
if (!arr.includes(name)) {
arr.push(name);
}
} else {
const startIndex = arr.indexOf(name);
if (startIndex !== -1) {
arr.splice(startIndex, 1);
}
// Only update dirty flags if the state changed
if (isDirty !== wasDirty) {
setDirtyFlags(
isDirty ? [...dirtyFlags, name] : dirtyFlags.filter((f) => f !== name)
);
}
setDirtyFlags(arr);
};

View File

@@ -1,11 +1,28 @@
export const saveFile = (json: unknown, filename: string, extension: string) => {
const anchor = document.createElement('a');
anchor.href = URL.createObjectURL(
new Blob([JSON.stringify(json, null, 2)], {
type: 'text/plain'
})
);
anchor.download = 'emsesp_' + filename + extension;
anchor.click();
URL.revokeObjectURL(anchor.href);
export const saveFile = (
json: unknown,
filename: string,
extension: string
): void => {
try {
const blob = new Blob([JSON.stringify(json, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `emsesp_${filename}${extension}`;
// Trigger download
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
// Delay revocation to ensure download starts properly
setTimeout(() => {
URL.revokeObjectURL(url);
}, 100);
} catch (error) {
console.error('Failed to save file:', error);
throw new Error(`Unable to save file: ${filename}${extension}`);
}
};

View File

@@ -1,8 +1,10 @@
// Cache for formatters to avoid recreation
// Cache for formatters to avoid recreation (with size limits to prevent memory leaks)
const MAX_CACHE_SIZE = 50;
const formatterCache = new Map<string, Intl.DateTimeFormat>();
const rtfCache = new Map<string, Intl.RelativeTimeFormat>();
// Pre-computed time divisions for relative time formatting
// Pre-computed constants
const MS_TO_MINUTES = 60000; // 60 * 1000
const TIME_DIVISIONS = [
{ amount: 60, name: 'seconds' as const },
{ amount: 60, name: 'minutes' as const },
@@ -13,30 +15,79 @@ const TIME_DIVISIONS = [
{ amount: Number.POSITIVE_INFINITY, name: 'years' as const }
] as const;
// Cached navigator languages to avoid repeated array spreads
let cachedLanguages: readonly string[] | null = null;
/**
* Get or create a cached DateTimeFormat instance
* Get navigator languages with caching
*/
function getNavigatorLanguages(): readonly string[] {
if (!cachedLanguages) {
cachedLanguages = window.navigator.languages;
}
return cachedLanguages;
}
/**
* Create a fast cache key from DateTimeFormat options
*/
function createFormatterKey(options: Intl.DateTimeFormatOptions): string {
// Build key from most common properties for better performance than JSON.stringify
return `${options.day}-${options.month}-${options.year}-${options.hour}-${options.minute}-${options.second}-${options.hour12}`;
}
/**
* Get or create a cached DateTimeFormat instance with LRU-like cache management
*/
function getDateTimeFormatter(
options: Intl.DateTimeFormatOptions
): Intl.DateTimeFormat {
const key = JSON.stringify(options);
if (!formatterCache.has(key)) {
formatterCache.set(
key,
new Intl.DateTimeFormat([...window.navigator.languages], options)
);
const key = createFormatterKey(options);
if (formatterCache.has(key)) {
// Move to end for LRU behavior
const formatter = formatterCache.get(key)!;
formatterCache.delete(key);
formatterCache.set(key, formatter);
return formatter;
}
return formatterCache.get(key)!;
// Limit cache size
if (formatterCache.size >= MAX_CACHE_SIZE) {
const firstKey = formatterCache.keys().next().value;
if (firstKey) {
formatterCache.delete(firstKey);
}
}
const formatter = new Intl.DateTimeFormat(getNavigatorLanguages(), options);
formatterCache.set(key, formatter);
return formatter;
}
/**
* Get or create a cached RelativeTimeFormat instance
* Get or create a cached RelativeTimeFormat instance with cache size management
*/
function getRelativeTimeFormatter(locale: string): Intl.RelativeTimeFormat {
if (!rtfCache.has(locale)) {
rtfCache.set(locale, new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }));
if (rtfCache.has(locale)) {
// Move to end for LRU behavior
const formatter = rtfCache.get(locale)!;
rtfCache.delete(locale);
rtfCache.set(locale, formatter);
return formatter;
}
return rtfCache.get(locale)!;
// Limit cache size
if (rtfCache.size >= MAX_CACHE_SIZE) {
const firstKey = rtfCache.keys().next().value;
if (firstKey) {
rtfCache.delete(firstKey);
}
}
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
rtfCache.set(locale, formatter);
return formatter;
}
/**
@@ -49,7 +100,7 @@ function formatTimeAgo(locale: string, date: Date): string {
const rtf = getRelativeTimeFormatter(locale);
// Use for...of for better performance and readability
// Find the appropriate time division
for (const division of TIME_DIVISIONS) {
if (Math.abs(duration) < division.amount) {
return rtf.format(Math.round(duration), division.name);
@@ -57,7 +108,8 @@ function formatTimeAgo(locale: string, date: Date): string {
duration /= division.amount;
}
return rtf.format(0, 'seconds');
// This should never be reached due to POSITIVE_INFINITY in divisions
return rtf.format(Math.round(duration), 'years');
}
/**
@@ -102,8 +154,8 @@ export const formatLocalDateTime = (date: Date): string => {
return 'Invalid date';
}
// Calculate local time offset in milliseconds
const offsetMs = date.getTimezoneOffset() * 60000;
// Calculate local time offset using pre-computed constant
const offsetMs = date.getTimezoneOffset() * MS_TO_MINUTES;
const localTime = date.getTime() - offsetMs;
// Convert to ISO string and remove timezone info

View File

@@ -2,24 +2,43 @@ import { useEffect, useRef } from 'react';
const DEFAULT_DELAY = 3000;
// adapted from https://www.joshwcomeau.com/snippets/react-hooks/use-interval/
export const useInterval = (callback: () => void, delay: number = DEFAULT_DELAY) => {
const intervalRef = useRef<number | null>(null);
const savedCallback = useRef<() => void>(callback);
/**
* Custom hook for setting up an interval with proper cleanup
* Adapted from https://www.joshwcomeau.com/snippets/react-hooks/use-interval/
*
* @param callback - Function to be called at each interval
* @param delay - Delay in milliseconds (default: 3000ms)
* @param immediate - If true, executes callback immediately on mount (default: false)
* @returns Reference to the interval ID
*/
export const useInterval = (
callback: () => void,
delay: number = DEFAULT_DELAY,
immediate = false
) => {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const savedCallback = useRef(callback);
// Remember the latest callback without resetting the interval
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
const tick = () => savedCallback.current();
intervalRef.current = window.setInterval(tick, delay);
// Execute immediately if requested
if (immediate) {
tick();
}
intervalRef.current = setInterval(tick, delay);
return () => {
if (intervalRef.current !== null) {
window.clearInterval(intervalRef.current);
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [delay]);
}, [delay, immediate]);
return intervalRef;
};

View File

@@ -4,23 +4,38 @@ export const usePersistState = <T>(
initial_value: T,
id: string
): [T, (new_state: T) => void] => {
// Set initial value
// Set initial value - only computed once on mount
const _initial_value = useMemo(() => {
const local_storage_value_str = localStorage.getItem('state:' + id);
// If there is a value stored in localStorage, use that
if (local_storage_value_str) {
return JSON.parse(local_storage_value_str) as T;
try {
const local_storage_value_str = localStorage.getItem(`state:${id}`);
// If there is a value stored in localStorage, use that
if (local_storage_value_str) {
return JSON.parse(local_storage_value_str) as T;
}
} catch (error) {
// If parsing fails, fall back to initial_value
console.warn(
`Failed to parse localStorage value for key "state:${id}"`,
error
);
}
// Otherwise use initial_value that was passed to the function
return initial_value;
}, []);
}, [id]); // initial_value intentionally omitted - only read on first mount
const [state, setState] = useState(_initial_value);
useEffect(() => {
const state_str = JSON.stringify(state); // Stringified state
localStorage.setItem('state:' + id, state_str); // Set stringified state as item in localStorage
}, [state]);
try {
const state_str = JSON.stringify(state);
localStorage.setItem(`state:${id}`, state_str);
} catch (error) {
console.warn(
`Failed to save state to localStorage for key "state:${id}"`,
error
);
}
}, [state, id]);
return [state, setState];
};

View File

@@ -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';
@@ -11,10 +11,12 @@ export interface RestRequestOptions<D> {
update: (value: D) => Method<AlovaGenerics>;
}
const REBOOT_ERROR_MESSAGE = 'Reboot required';
export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
const { LL } = useI18nContext();
const [errorMessage, setErrorMessage] = useState<string>();
const [restartNeeded, setRestartNeeded] = useState<boolean>(false);
const [restartNeeded, setRestartNeeded] = useState(false);
const [origData, setOrigData] = useState<D>();
const [dirtyFlags, setDirtyFlags] = useState<string[]>([]);
const blocker = useBlocker(dirtyFlags.length !== 0);
@@ -35,55 +37,71 @@ export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
setDirtyFlags([]);
});
// Memoize updateDataValue to prevent unnecessary re-renders
const updateDataValue = useCallback(
(new_data: D) => {
updateData({ data: new_data });
},
(new_data: D) => updateData({ data: new_data }),
[updateData]
);
// Memoize loadData to prevent unnecessary re-renders
const loadData = useCallback(async () => {
setDirtyFlags([]);
setErrorMessage(undefined);
await readData().catch((error: Error) => {
toast.error(error.message);
setErrorMessage(error.message);
});
try {
await readData();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
setErrorMessage(message);
}
}, [readData]);
// Memoize saveData to prevent unnecessary re-renders
const saveData = useCallback(async () => {
if (!data) {
return;
}
if (!data) return;
// Reset states before saving
setRestartNeeded(false);
setErrorMessage(undefined);
setDirtyFlags([]);
setOrigData(data as D);
await writeData(data as D).catch((error: Error) => {
if (error.message === 'Reboot required') {
try {
await writeData(data as D);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === REBOOT_ERROR_MESSAGE) {
setRestartNeeded(true);
} else {
toast.error(error.message);
setErrorMessage(error.message);
toast.error(message);
setErrorMessage(message);
}
});
}
}, [data, writeData]);
return {
loadData,
saveData,
saving: saving as boolean,
updateDataValue,
data: data as D,
origData: origData as D,
dirtyFlags,
setDirtyFlags,
setOrigData,
blocker,
errorMessage,
restartNeeded
} as const;
return useMemo(
() => ({
loadData,
saveData,
saving: !!saving,
updateDataValue,
data: data as D,
origData: origData as D,
dirtyFlags,
setDirtyFlags,
setOrigData,
blocker,
errorMessage,
restartNeeded
}),
[
loadData,
saveData,
saving,
updateDataValue,
data,
origData,
dirtyFlags,
blocker,
errorMessage,
restartNeeded
]
);
};