show dialog with version information

This commit is contained in:
proddy
2025-10-24 15:31:49 +02:00
parent a837c9398c
commit 43eba7a010
2 changed files with 482 additions and 249 deletions

View File

@@ -1,10 +1,11 @@
import { useContext, useEffect, useState } from 'react'; import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import CheckIcon from '@mui/icons-material/Done'; import CheckIcon from '@mui/icons-material/Done';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import { import {
Box, Box,
@@ -16,7 +17,11 @@ import {
DialogTitle, DialogTitle,
FormControlLabel, FormControlLabel,
Grid, Grid,
IconButton,
Link, Link,
List,
ListItem,
ListItemText,
Typography Typography
} from '@mui/material'; } from '@mui/material';
@@ -36,168 +41,143 @@ import {
} from 'components'; } from 'components';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { prettyDateTime } from 'utils/time';
const Version = () => { // Constants moved outside component to avoid recreation
const { LL, locale } = useI18nContext(); const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
const { me } = useContext(AuthenticatedContext); const STABLE_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
const DEV_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
const [restarting, setRestarting] = useState<boolean>(false); // Types for better type safety
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false); interface VersionData {
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false); emsesp_version: string;
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false); arduino_version: string;
const [devUpgradeAvailable, setDevUpgradeAvailable] = useState<boolean>(false); esp_platform: string;
const [stableUpgradeAvailable, setStableUpgradeAvailable] = flash_chip_size: number;
useState<boolean>(false); psram: boolean;
const [internetLive, setInternetLive] = useState<boolean>(false); build_flags?: string;
const [downloadOnly, setDownloadOnly] = useState<boolean>(false); }
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/'; interface UpgradeCheckData {
const STABLE_RELNOTES_URL = emsesp_version: string;
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md'; dev_upgradeable: boolean;
stable_upgradeable: boolean;
}
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/'; interface VersionInfo {
const DEV_RELNOTES_URL = name: string;
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md'; published_at?: string;
}
const { send: sendCheckUpgrade } = useRequest( // Memoized components for better performance
(versions: string) => callAction({ action: 'checkUpgrade', param: versions }), const VersionInfoDialog = memo(
{ ({
immediate: false showVersionInfo,
} latestVersion,
).onSuccess((event) => { latestDevVersion,
const data = event.data as { locale,
emsesp_version: string; LL,
dev_upgradeable: boolean; onClose
stable_upgradeable: boolean; }: {
}; showVersionInfo: number;
setDevUpgradeAvailable(data.dev_upgradeable); latestVersion?: VersionInfo;
setStableUpgradeAvailable(data.stable_upgradeable); latestDevVersion?: VersionInfo;
}); locale: string;
LL: any;
onClose: () => void;
}) => {
if (showVersionInfo === 0) return null;
const { const isStable = showVersionInfo === 1;
data: data, const version = isStable ? latestVersion : latestDevVersion;
send: loadData, const relNotesUrl = isStable ? STABLE_RELNOTES_URL : DEV_RELNOTES_URL;
error
} = useRequest(SystemApi.readSystemStatus).onSuccess((event) => {
// older version of EMS-ESP using ESP32 (not S3) and no PSRAM, can't use OTA because of SSL support in HttpClient
if (event.data.arduino_version.startsWith('Tasmota')) {
setDownloadOnly(true);
}
setUsingDevVersion(event.data.emsesp_version.includes('dev'));
});
const { send: sendUploadURL } = useRequest( return (
(url: string) => callAction({ action: 'uploadURL', param: url }), <Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
{ <DialogTitle>Version Information</DialogTitle>
immediate: false <DialogContent dividers>
} <List dense>
); <ListItem>
<ListItemText
// called immediately to get the latest versions on page load primary={<span style={{ color: 'lightblue' }}>{LL.TYPE(0)}</span>}
const { data: latestVersion } = useRequest(getStableVersion); secondary={isStable ? LL.STABLE() : LL.DEVELOPMENT()}
const { data: latestDevVersion } = useRequest(getDevVersion); />
</ListItem>
useEffect(() => { <ListItem>
if (latestVersion && latestDevVersion) { <ListItemText
sendCheckUpgrade(latestDevVersion.name + ',' + latestVersion.name) primary={<span style={{ color: 'lightblue' }}>{LL.VERSION()}</span>}
.catch((error: Error) => { secondary={version?.name}
toast.error('Failed to check for upgrades: ' + error.message); />
}) </ListItem>
.finally(() => { {version?.published_at && (
setInternetLive(true); <ListItem>
}); <ListItemText
} primary={<span style={{ color: 'lightblue' }}>Release Date</span>}
}, [latestVersion, latestDevVersion]); secondary={prettyDateTime(locale, new Date(version.published_at))}
/>
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); </ListItem>
const DIVISIONS: Array<{ amount: number; name: string }> = [ )}
{ amount: 60, name: 'seconds' }, </List>
{ amount: 60, name: 'minutes' }, </DialogContent>
{ amount: 24, name: 'hours' }, <DialogActions>
{ amount: 7, name: 'days' }, <Button
{ amount: 4.34524, name: 'weeks' }, variant="outlined"
{ amount: 12, name: 'months' }, component="a"
{ amount: Number.POSITIVE_INFINITY, name: 'years' } href={relNotesUrl}
]; target="_blank"
function formatTimeAgo(date: Date) { color="primary"
let duration = (date.getTime() - new Date().getTime()) / 1000; >
for (let i = 0; i < DIVISIONS.length; i++) { Changelog
const division = DIVISIONS[i]; </Button>
if (division && Math.abs(duration) < division.amount) { <Button variant="outlined" onClick={onClose} color="secondary">
return rtf.format( {LL.CLOSE()}
Math.round(duration), </Button>
division.name as Intl.RelativeTimeFormatUnit </DialogActions>
); </Dialog>
} );
if (division) {
duration /= division.amount;
}
}
return rtf.format(0, 'seconds');
} }
);
const { send: sendAPI } = useRequest((data: APIcall) => API(data), { const InstallDialog = memo(
immediate: false ({
}); openInstallDialog,
fetchDevVersion,
latestVersion,
latestDevVersion,
downloadOnly,
platform,
LL,
onClose,
onInstall
}: {
openInstallDialog: boolean;
fetchDevVersion: boolean;
latestVersion?: VersionInfo;
latestDevVersion?: VersionInfo;
downloadOnly: boolean;
platform: string;
LL: any;
onClose: () => void;
onInstall: (url: string) => void;
}) => {
const binURL = useMemo(() => {
if (!latestVersion || !latestDevVersion) return '';
const doRestart = async () => { const version = fetchDevVersion ? latestDevVersion : latestVersion;
setRestarting(true); const filename = `EMS-ESP-${version.name.replaceAll('.', '_')}-${platform}.bin`;
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
const getBinURL = (showingDev: boolean) => { return fetchDevVersion
if (!internetLive) { ? `${DEV_URL}${filename}`
return ''; : `${STABLE_URL}v${version.name}/${filename}`;
} }, [fetchDevVersion, latestVersion, latestDevVersion, platform]);
const filename =
'EMS-ESP-' +
(showingDev ? latestDevVersion.name : latestVersion.name).replaceAll(
'.',
'_'
) +
'-' +
getPlatform() +
'.bin';
return showingDev
? DEV_URL + filename
: STABLE_URL + 'v' + latestVersion.name + '/' + filename;
};
const getPlatform = () => {
return (
[data.esp_platform, data.flash_chip_size >= 16384 ? '16MB' : '4MB'].join('-') +
(data.psram ? '+' : '')
);
};
const installFirmwareURL = async (url: string) => {
await sendUploadURL(url).catch((error: Error) => {
toast.error(error.message);
});
setRestarting(true);
};
useLayoutTitle('EMS-ESP Firmware');
const renderInstallDialog = () => {
const binURL = getBinURL(fetchDevVersion);
return ( return (
<Dialog <Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
sx={dialogStyle}
open={openInstallDialog}
onClose={() => closeInstallDialog()}
>
<DialogTitle> <DialogTitle>
{LL.UPDATE() + {`${LL.UPDATE()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
' ' +
(fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()) +
' Firmware'}
</DialogTitle> </DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Typography mb={2}> <Typography mb={2}>
@@ -211,7 +191,7 @@ const Version = () => {
<Button <Button
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
variant="outlined" variant="outlined"
onClick={() => closeInstallDialog()} onClick={onClose}
color="secondary" color="secondary"
> >
{LL.CANCEL()} {LL.CANCEL()}
@@ -219,7 +199,7 @@ const Version = () => {
<Button <Button
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
onClick={() => closeInstallDialog()} onClick={onClose}
color="primary" color="primary"
> >
<Link underline="none" target="_blank" href={binURL} color="primary"> <Link underline="none" target="_blank" href={binURL} color="primary">
@@ -230,7 +210,7 @@ const Version = () => {
<Button <Button
startIcon={<WarningIcon color="warning" />} startIcon={<WarningIcon color="warning" />}
variant="outlined" variant="outlined"
onClick={() => installFirmwareURL(binURL)} onClick={() => onInstall(binURL)}
color="primary" color="primary"
> >
{LL.INSTALL()} {LL.INSTALL()}
@@ -239,76 +219,185 @@ const Version = () => {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );
}; }
);
const showFirmwareDialog = (useDevVersion: boolean) => { // Helper function moved outside component
const getPlatform = (data: VersionData): string => {
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
};
const Version = () => {
const { LL, locale } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
// State management
const [restarting, setRestarting] = useState<boolean>(false);
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false);
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
const [devUpgradeAvailable, setDevUpgradeAvailable] = useState<boolean>(false);
const [stableUpgradeAvailable, setStableUpgradeAvailable] =
useState<boolean>(false);
const [internetLive, setInternetLive] = useState<boolean>(false);
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0);
// API calls with optimized error handling
const { send: sendCheckUpgrade } = useRequest(
(versions: string) => callAction({ action: 'checkUpgrade', param: versions }),
{ immediate: false }
).onSuccess((event) => {
const data = event.data as UpgradeCheckData;
setDevUpgradeAvailable(data.dev_upgradeable);
setStableUpgradeAvailable(data.stable_upgradeable);
});
const {
data,
send: loadData,
error
} = useRequest(SystemApi.readSystemStatus).onSuccess((event) => {
const systemData = event.data as VersionData;
if (systemData.arduino_version.startsWith('Tasmota')) {
setDownloadOnly(true);
}
setUsingDevVersion(systemData.emsesp_version.includes('dev'));
});
const { send: sendUploadURL } = useRequest(
(url: string) => callAction({ action: 'uploadURL', param: url }),
{ immediate: false }
);
const { data: latestVersion } = useRequest(getStableVersion);
const { data: latestDevVersion } = useRequest(getDevVersion);
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
// Memoized values
const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]);
const isDev = useMemo(
() => data?.emsesp_version.includes('dev') ?? false,
[data?.emsesp_version]
);
const doRestart = useCallback(async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
}, [sendAPI]);
const installFirmwareURL = useCallback(
async (url: string) => {
await sendUploadURL(url).catch((error: Error) => {
toast.error(error.message);
});
setRestarting(true);
},
[sendUploadURL]
);
const showFirmwareDialog = useCallback((useDevVersion: boolean) => {
setFetchDevVersion(useDevVersion); setFetchDevVersion(useDevVersion);
setOpenInstallDialog(true); setOpenInstallDialog(true);
}; }, []);
const closeInstallDialog = () => { const closeInstallDialog = useCallback(() => {
setOpenInstallDialog(false); setOpenInstallDialog(false);
}; }, []);
const showButtons = (showingDev: boolean) => { const handleVersionInfoClose = useCallback(() => {
const choice = showingDev setShowVersionInfo(0);
? !usingDevVersion }, []);
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
: devUpgradeAvailable // Effect for checking upgrades
? LL.UPDATE_AVAILABLE() useEffect(() => {
: undefined if (latestVersion && latestDevVersion) {
: usingDevVersion const versions = `${latestDevVersion.name},${latestVersion.name}`;
? LL.SWITCH_RELEASE_TYPE(LL.STABLE()) sendCheckUpgrade(versions)
: stableUpgradeAvailable .catch((error: Error) => {
? LL.UPDATE_AVAILABLE() toast.error(`Failed to check for upgrades: ${error.message}`);
: undefined; })
.finally(() => {
setInternetLive(true);
});
}
}, [latestVersion, latestDevVersion, sendCheckUpgrade]);
useLayoutTitle('EMS-ESP Firmware');
// Memoized button rendering logic
const showButtons = useCallback(
(showingDev: boolean) => {
const choice = showingDev
? !usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
: devUpgradeAvailable
? LL.UPDATE_AVAILABLE()
: undefined
: usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.STABLE())
: stableUpgradeAvailable
? LL.UPDATE_AVAILABLE()
: undefined;
if (!choice) {
return (
<>
<CheckIcon
color="success"
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
/>
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
</span>
<Button
sx={{ ml: 2 }}
variant="outlined"
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{LL.REINSTALL()}
</Button>
</>
);
}
if (!me.admin) return null;
if (!choice) {
return ( return (
<> <Button
<CheckIcon sx={{ ml: 2 }}
color="success" variant="outlined"
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }} color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
/> size="small"
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}> onClick={() => showFirmwareDialog(showingDev)}
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())} >
</span> {choice}
<Button </Button>
sx={{ ml: 2 }}
variant="outlined"
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{LL.REINSTALL()}
</Button>
</>
); );
} },
[
usingDevVersion,
devUpgradeAvailable,
stableUpgradeAvailable,
me.admin,
LL,
showFirmwareDialog
]
);
if (!me.admin) { const content = useMemo(() => {
return;
}
return (
<Button
sx={{ ml: 2 }}
variant="outlined"
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{choice}
</Button>
);
};
const content = () => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />; return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
} }
const isDev = data.emsesp_version.includes('dev');
return ( return (
<> <>
<Box p={2} border="1px solid grey" borderRadius={2}> <Box p={2} border="1px solid grey" borderRadius={2}>
@@ -344,7 +433,7 @@ const Version = () => {
</Grid> </Grid>
<Grid size={{ xs: 8, md: 10 }}> <Grid size={{ xs: 8, md: 10 }}>
<Typography> <Typography>
{getPlatform()} {platform}
<Typography variant="caption"> <Typography variant="caption">
&nbsp; &#40; &nbsp; &#40;
{data.psram ? ( {data.psram ? (
@@ -436,15 +525,10 @@ const Version = () => {
</Grid> </Grid>
<Grid size={{ xs: 8, md: 10 }}> <Grid size={{ xs: 8, md: 10 }}>
<Typography> <Typography>
<Link target="_blank" href={STABLE_RELNOTES_URL} color="primary"> {latestVersion?.name}
{latestVersion.name} <IconButton onClick={() => setShowVersionInfo(1)}>
</Link> <InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
{latestVersion.published_at && ( </IconButton>
<Typography component="span" variant="caption">
&nbsp;(
{formatTimeAgo(new Date(latestVersion.published_at))})
</Typography>
)}
{showButtons(false)} {showButtons(false)}
</Typography> </Typography>
</Grid> </Grid>
@@ -454,15 +538,10 @@ const Version = () => {
</Grid> </Grid>
<Grid size={{ xs: 8, md: 10 }}> <Grid size={{ xs: 8, md: 10 }}>
<Typography> <Typography>
<Link target="_blank" href={DEV_RELNOTES_URL} color="primary"> {latestDevVersion?.name}
{latestDevVersion.name} <IconButton onClick={() => setShowVersionInfo(2)}>
</Link> <InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
{latestDevVersion.published_at && ( </IconButton>
<Typography component="span" variant="caption">
&nbsp;(
{formatTimeAgo(new Date(latestDevVersion.published_at))})
</Typography>
)}
{showButtons(true)} {showButtons(true)}
</Typography> </Typography>
</Grid> </Grid>
@@ -476,7 +555,25 @@ const Version = () => {
)} )}
{me.admin && ( {me.admin && (
<> <>
{renderInstallDialog()} <VersionInfoDialog
showVersionInfo={showVersionInfo}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
locale={locale}
LL={LL}
onClose={handleVersionInfoClose}
/>
<InstallDialog
openInstallDialog={openInstallDialog}
fetchDevVersion={fetchDevVersion}
latestVersion={latestVersion}
latestDevVersion={latestDevVersion}
downloadOnly={downloadOnly}
platform={platform}
LL={LL}
onClose={closeInstallDialog}
onInstall={installFirmwareURL}
/>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary"> <Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()} {LL.UPLOAD()}
</Typography> </Typography>
@@ -486,11 +583,30 @@ const Version = () => {
</Box> </Box>
</> </>
); );
}; }, [
data,
error,
loadData,
LL,
platform,
isDev,
internetLive,
latestVersion,
latestDevVersion,
showVersionInfo,
locale,
openInstallDialog,
fetchDevVersion,
downloadOnly,
me.admin,
showButtons,
handleVersionInfoClose,
closeInstallDialog,
installFirmwareURL,
doRestart
]);
return ( return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
);
}; };
export default Version; export default memo(Version);

View File

@@ -1,18 +1,135 @@
const LOCALE_FORMAT = new Intl.DateTimeFormat([...window.navigator.languages], { // Cache for formatters to avoid recreation
day: 'numeric', const formatterCache = new Map<string, Intl.DateTimeFormat>();
month: 'short', const rtfCache = new Map<string, Intl.RelativeTimeFormat>();
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: false
});
export const formatDateTime = (dateTime: string) => // Pre-computed time divisions for relative time formatting
LOCALE_FORMAT.format(new Date(dateTime.substring(0, 19))); const TIME_DIVISIONS = [
{ amount: 60, name: 'seconds' as const },
{ amount: 60, name: 'minutes' as const },
{ amount: 24, name: 'hours' as const },
{ amount: 7, name: 'days' as const },
{ amount: 4.34524, name: 'weeks' as const },
{ amount: 12, name: 'months' as const },
{ amount: Number.POSITIVE_INFINITY, name: 'years' as const }
] as const;
export const formatLocalDateTime = (date: Date) => /**
new Date(date.getTime() - date.getTimezoneOffset() * 60000) * Get or create a cached DateTimeFormat instance
.toISOString() */
.slice(0, -1) function getDateTimeFormatter(
.substring(0, 19); options: Intl.DateTimeFormatOptions
): Intl.DateTimeFormat {
const key = JSON.stringify(options);
if (!formatterCache.has(key)) {
formatterCache.set(
key,
new Intl.DateTimeFormat([...window.navigator.languages], options)
);
}
return formatterCache.get(key)!;
}
/**
* Get or create a cached RelativeTimeFormat instance
*/
function getRelativeTimeFormatter(locale: string): Intl.RelativeTimeFormat {
if (!rtfCache.has(locale)) {
rtfCache.set(locale, new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }));
}
return rtfCache.get(locale)!;
}
/**
* Format a date as relative time (e.g., "2 hours ago", "in 3 days")
*/
function formatTimeAgo(locale: string, date: Date): string {
const now = Date.now();
const targetTime = date.getTime();
let duration = (targetTime - now) / 1000;
const rtf = getRelativeTimeFormatter(locale);
// Use for...of for better performance and readability
for (const division of TIME_DIVISIONS) {
if (Math.abs(duration) < division.amount) {
return rtf.format(Math.round(duration), division.name);
}
duration /= division.amount;
}
return rtf.format(0, 'seconds');
}
/**
* Format a date-time string to locale-specific format
*/
export const formatDateTime = (dateTime: string): string => {
if (!dateTime || typeof dateTime !== 'string') {
return 'Invalid date';
}
try {
// Extract only the first 19 characters (YYYY-MM-DDTHH:mm:ss)
const cleanDateTime = dateTime.substring(0, 19);
const date = new Date(cleanDateTime);
if (isNaN(date.getTime())) {
return 'Invalid date';
}
const formatter = getDateTimeFormatter({
day: 'numeric',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: false
});
return formatter.format(date);
} catch (error) {
console.warn('Error formatting date:', error);
return 'Invalid date';
}
};
/**
* Convert a Date object to local date-time string (ISO format without timezone)
*/
export const formatLocalDateTime = (date: Date): string => {
if (!(date instanceof Date) || isNaN(date.getTime())) {
return 'Invalid date';
}
// Calculate local time offset in milliseconds
const offsetMs = date.getTimezoneOffset() * 60000;
const localTime = date.getTime() - offsetMs;
// Convert to ISO string and remove timezone info
return new Date(localTime).toISOString().slice(0, 19);
};
/**
* Format a date with both short date format and relative time
*/
export const prettyDateTime = (locale: string, date: Date): string => {
if (!(date instanceof Date) || isNaN(date.getTime())) {
return 'Invalid date';
}
if (!locale || typeof locale !== 'string') {
locale = 'en';
}
const shortFormatter = getDateTimeFormatter({
day: 'numeric',
month: 'short',
year: 'numeric'
});
const shortDate = shortFormatter.format(date);
const relativeTime = formatTimeAgo(locale, date);
return `${shortDate} (${relativeTime})`;
};