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 =
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 STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
const STABLE_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md'; 'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/'; const DEV_RELNOTES_URL =
const DEV_RELNOTES_URL =
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md'; 'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
const { send: sendCheckUpgrade } = useRequest( // Types for better type safety
(versions: string) => callAction({ action: 'checkUpgrade', param: versions }), interface VersionData {
{ emsesp_version: string;
immediate: false arduino_version: string;
} esp_platform: string;
).onSuccess((event) => { flash_chip_size: number;
const data = event.data as { psram: boolean;
build_flags?: string;
}
interface UpgradeCheckData {
emsesp_version: string; emsesp_version: string;
dev_upgradeable: boolean; dev_upgradeable: boolean;
stable_upgradeable: boolean; stable_upgradeable: boolean;
}; }
setDevUpgradeAvailable(data.dev_upgradeable);
setStableUpgradeAvailable(data.stable_upgradeable);
});
const { interface VersionInfo {
data: data, name: string;
send: loadData, published_at?: string;
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( // Memoized components for better performance
(url: string) => callAction({ action: 'uploadURL', param: url }), const VersionInfoDialog = memo(
{ ({
immediate: false showVersionInfo,
} latestVersion,
); latestDevVersion,
locale,
LL,
onClose
}: {
showVersionInfo: number;
latestVersion?: VersionInfo;
latestDevVersion?: VersionInfo;
locale: string;
LL: any;
onClose: () => void;
}) => {
if (showVersionInfo === 0) return null;
// called immediately to get the latest versions on page load const isStable = showVersionInfo === 1;
const { data: latestVersion } = useRequest(getStableVersion); const version = isStable ? latestVersion : latestDevVersion;
const { data: latestDevVersion } = useRequest(getDevVersion); const relNotesUrl = isStable ? STABLE_RELNOTES_URL : DEV_RELNOTES_URL;
useEffect(() => {
if (latestVersion && latestDevVersion) {
sendCheckUpgrade(latestDevVersion.name + ',' + latestVersion.name)
.catch((error: Error) => {
toast.error('Failed to check for upgrades: ' + error.message);
})
.finally(() => {
setInternetLive(true);
});
}
}, [latestVersion, latestDevVersion]);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const DIVISIONS: Array<{ amount: number; name: string }> = [
{ amount: 60, name: 'seconds' },
{ amount: 60, name: 'minutes' },
{ amount: 24, name: 'hours' },
{ amount: 7, name: 'days' },
{ amount: 4.34524, name: 'weeks' },
{ amount: 12, name: 'months' },
{ amount: Number.POSITIVE_INFINITY, name: 'years' }
];
function formatTimeAgo(date: Date) {
let duration = (date.getTime() - new Date().getTime()) / 1000;
for (let i = 0; i < DIVISIONS.length; i++) {
const division = DIVISIONS[i];
if (division && Math.abs(duration) < division.amount) {
return rtf.format(
Math.round(duration),
division.name as Intl.RelativeTimeFormatUnit
);
}
if (division) {
duration /= division.amount;
}
}
return rtf.format(0, 'seconds');
}
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const doRestart = async () => {
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
const getBinURL = (showingDev: boolean) => {
if (!internetLive) {
return '';
}
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={showVersionInfo !== 0} onClose={onClose}>
sx={dialogStyle} <DialogTitle>Version Information</DialogTitle>
open={openInstallDialog} <DialogContent dividers>
onClose={() => closeInstallDialog()} <List dense>
<ListItem>
<ListItemText
primary={<span style={{ color: 'lightblue' }}>{LL.TYPE(0)}</span>}
secondary={isStable ? LL.STABLE() : LL.DEVELOPMENT()}
/>
</ListItem>
<ListItem>
<ListItemText
primary={<span style={{ color: 'lightblue' }}>{LL.VERSION()}</span>}
secondary={version?.name}
/>
</ListItem>
{version?.published_at && (
<ListItem>
<ListItemText
primary={<span style={{ color: 'lightblue' }}>Release Date</span>}
secondary={prettyDateTime(locale, new Date(version.published_at))}
/>
</ListItem>
)}
</List>
</DialogContent>
<DialogActions>
<Button
variant="outlined"
component="a"
href={relNotesUrl}
target="_blank"
color="primary"
> >
Changelog
</Button>
<Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()}
</Button>
</DialogActions>
</Dialog>
);
}
);
const InstallDialog = memo(
({
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 version = fetchDevVersion ? latestDevVersion : latestVersion;
const filename = `EMS-ESP-${version.name.replaceAll('.', '_')}-${platform}.bin`;
return fetchDevVersion
? `${DEV_URL}${filename}`
: `${STABLE_URL}v${version.name}/${filename}`;
}, [fetchDevVersion, latestVersion, latestDevVersion, platform]);
return (
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
<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,18 +219,122 @@ 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(() => {
setShowVersionInfo(0);
}, []);
// Effect for checking upgrades
useEffect(() => {
if (latestVersion && latestDevVersion) {
const versions = `${latestDevVersion.name},${latestVersion.name}`;
sendCheckUpgrade(versions)
.catch((error: Error) => {
toast.error(`Failed to check for upgrades: ${error.message}`);
})
.finally(() => {
setInternetLive(true);
});
}
}, [latestVersion, latestDevVersion, sendCheckUpgrade]);
useLayoutTitle('EMS-ESP Firmware');
// Memoized button rendering logic
const showButtons = useCallback(
(showingDev: boolean) => {
const choice = showingDev const choice = showingDev
? !usingDevVersion ? !usingDevVersion
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT()) ? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
@@ -285,9 +369,7 @@ const Version = () => {
); );
} }
if (!me.admin) { if (!me.admin) return null;
return;
}
return ( return (
<Button <Button
@@ -300,15 +382,22 @@ const Version = () => {
{choice} {choice}
</Button> </Button>
); );
}; },
[
usingDevVersion,
devUpgradeAvailable,
stableUpgradeAvailable,
me.admin,
LL,
showFirmwareDialog
]
);
const content = () => { const content = useMemo(() => {
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,4 +1,83 @@
const LOCALE_FORMAT = new Intl.DateTimeFormat([...window.navigator.languages], { // Cache for formatters to avoid recreation
const formatterCache = new Map<string, Intl.DateTimeFormat>();
const rtfCache = new Map<string, Intl.RelativeTimeFormat>();
// Pre-computed time divisions for relative time formatting
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;
/**
* Get or create a cached DateTimeFormat instance
*/
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)
);
}
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', day: 'numeric',
month: 'short', month: 'short',
year: 'numeric', year: 'numeric',
@@ -6,13 +85,51 @@ const LOCALE_FORMAT = new Intl.DateTimeFormat([...window.navigator.languages], {
minute: 'numeric', minute: 'numeric',
second: 'numeric', second: 'numeric',
hour12: false hour12: false
}); });
export const formatDateTime = (dateTime: string) => return formatter.format(date);
LOCALE_FORMAT.format(new Date(dateTime.substring(0, 19))); } catch (error) {
console.warn('Error formatting date:', error);
return 'Invalid date';
}
};
export const formatLocalDateTime = (date: Date) => /**
new Date(date.getTime() - date.getTimezoneOffset() * 60000) * Convert a Date object to local date-time string (ISO format without timezone)
.toISOString() */
.slice(0, -1) export const formatLocalDateTime = (date: Date): string => {
.substring(0, 19); 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})`;
};