Bug fix: - Fixed date/time formatting across all tables to respect user's UI preferences (time format and date format) set in Settings page:

- Stream connection card "Connected" column
  - VOD connection card "Connection Start Time" column
  - M3U table "Updated" column
  - EPG table "Updated" column
  - Users table "Last Login" and "Date Joined" columns
  - All components now use centralized `format()` helper from dateTimeUtils for consistency
- Removed unused imports from table components for cleaner code
This commit is contained in:
SergeantPanda 2026-01-19 20:07:31 -06:00
parent 0b83137ac6
commit cbcf2ac3c2
15 changed files with 530 additions and 392 deletions

View file

@ -33,6 +33,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed date/time formatting across all tables to respect user's UI preferences (time format and date format) set in Settings page:
- Stream connection card "Connected" column
- VOD connection card "Connection Start Time" column
- M3U table "Updated" column
- EPG table "Updated" column
- Users table "Last Login" and "Date Joined" columns
- All components now use centralized `format()` helper from dateTimeUtils for consistency
- Removed unused imports from table components for cleaner code
- Fixed build-dev.sh script stability: Resolved Dockerfile and build context paths to be relative to script location for reliable execution from any working directory, added proper --platform argument handling with array-safe quoting, and corrected push behavior to honor -p flag with accurate messaging. Improved formatting and quoting throughout to prevent word-splitting issues - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes)
- Fixed TypeError on streams table load after container restart: Added robust data validation and type coercion to handle malformed filter options during container startup. The streams table MultiSelect components now safely convert group names to strings and filter out null/undefined values, preventing "right-hand side of 'in' should be an object, got number" errors when the backend hasn't fully initialized. API error handling returns safe defaults.
- Fixed XtreamCodes API crash when channels have NULL channel_group: The `player_api.php` endpoint (`xc_get_live_streams`) now gracefully handles channels without an assigned channel_group by dynamically looking up and assigning them to "Default Group" instead of crashing with AttributeError. Additionally, the Channel serializer now auto-assigns new channels to "Default Group" when `channel_group_id` is omitted during creation, preventing future NULL channel_group issues.

View file

@ -1,7 +1,10 @@
import useChannelsStore from '../../store/channels.jsx';
import useSettingsStore from '../../store/settings.jsx';
import useVideoStore from '../../store/useVideoStore.jsx';
import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
import {
useDateTimeFormat,
useTimeHelpers,
} from '../../utils/dateTimeUtils.js';
import { notifications } from '@mantine/notifications';
import React from 'react';
import {
@ -39,7 +42,8 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
const showVideo = useVideoStore((s) => s.showVideo);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const { toUserTime, userNow } = useTimeHelpers();
const [timeformat, dateformat] = useDateTimeFormat();
const { timeFormat: timeformat, dateFormat: dateformat } =
useDateTimeFormat();
const channel = channels?.[recording.channel];
@ -52,7 +56,11 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
// Poster or channel logo
const posterUrl = getPosterUrl(
customProps.poster_logo_id, customProps, channel?.logo?.cache_url, env_mode);
customProps.poster_logo_id,
customProps,
channel?.logo?.cache_url,
env_mode
);
const start = toUserTime(recording.start_time);
const end = toUserTime(recording.end_time);
@ -161,44 +169,49 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
} else {
onOpenDetails?.(recording);
}
}
};
const WatchLive = () => {
return <Button
size="xs"
variant="light"
onClick={(e) => {
e.stopPropagation();
handleWatchLive();
}}
>
Watch Live
</Button>;
}
const WatchRecording = () => {
return <Tooltip
label={
customProps.file_url || customProps.output_file_url
? 'Watch recording'
: 'Recording playback not available yet'
}
>
return (
<Button
size="xs"
variant="default"
variant="light"
onClick={(e) => {
e.stopPropagation();
handleWatchRecording();
handleWatchLive();
}}
disabled={
customProps.status === 'recording' || !(customProps.file_url || customProps.output_file_url)
>
Watch Live
</Button>
);
};
const WatchRecording = () => {
return (
<Tooltip
label={
customProps.file_url || customProps.output_file_url
? 'Watch recording'
: 'Recording playback not available yet'
}
>
Watch
</Button>
</Tooltip>;
}
<Button
size="xs"
variant="default"
onClick={(e) => {
e.stopPropagation();
handleWatchRecording();
}}
disabled={
customProps.status === 'recording' ||
!(customProps.file_url || customProps.output_file_url)
}
>
Watch
</Button>
</Tooltip>
);
};
const MainCard = (
<Card
@ -310,7 +323,8 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
{isSeriesGroup ? 'Next recording' : 'Time'}
</Text>
<Text size="sm">
{start.format(`${dateformat}, YYYY ${timeformat}`)} {end.format(timeformat)}
{start.format(`${dateformat}, YYYY ${timeformat}`)} {' '}
{end.format(timeformat)}
</Text>
</Group>
@ -419,4 +433,4 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => {
);
};
export default RecordingCard;
export default RecordingCard;

View file

@ -25,7 +25,10 @@ import {
Users,
Video,
} from 'lucide-react';
import { toFriendlyDuration } from '../../utils/dateTimeUtils.js';
import {
toFriendlyDuration,
useDateTimeFormat,
} from '../../utils/dateTimeUtils.js';
import { CustomTable, useTable } from '../tables/CustomTable/index.jsx';
import { TableHelper } from '../../helpers/index.jsx';
import logo from '../../images/logo.png';
@ -68,9 +71,8 @@ const StreamConnectionCard = ({
// Get settings for speed threshold
const settings = useSettingsStore((s) => s.settings);
// Get Date-format from localStorage
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
// Get user's date/time format preferences
const { fullDateTimeFormat } = useDateTimeFormat();
// Create a map of M3U account IDs to names for quick lookup
const m3uAccountsMap = useMemo(() => {
@ -258,7 +260,7 @@ const StreamConnectionCard = ({
{
id: 'connected',
header: 'Connected',
accessorFn: connectedAccessor(dateFormat),
accessorFn: connectedAccessor(fullDateTimeFormat),
cell: ({ cell }) => (
<Tooltip
label={
@ -298,7 +300,7 @@ const StreamConnectionCard = ({
size: 100,
},
],
[]
[fullDateTimeFormat]
);
const channelClientsTable = useTable({

View file

@ -1,10 +1,32 @@
// Format duration for content length
import useLocalStorage from '../../hooks/useLocalStorage.jsx';
import React, { useCallback, useEffect, useState } from 'react';
import logo from '../../images/logo.png';
import { ActionIcon, Badge, Box, Card, Center, Flex, Group, Progress, Stack, Text, Tooltip } from '@mantine/core';
import { convertToSec, fromNow, toFriendlyDuration } from '../../utils/dateTimeUtils.js';
import { ChevronDown, HardDriveUpload, SquareX, Timer, Video } from 'lucide-react';
import {
ActionIcon,
Badge,
Box,
Card,
Center,
Flex,
Group,
Progress,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import {
convertToSec,
fromNow,
toFriendlyDuration,
useDateTimeFormat,
} from '../../utils/dateTimeUtils.js';
import {
ChevronDown,
HardDriveUpload,
SquareX,
Timer,
Video,
} from 'lucide-react';
import {
calculateConnectionDuration,
calculateConnectionStartTime,
@ -28,19 +50,18 @@ const ClientDetails = ({ connection, connectionStartTime }) => {
bdrs={6}
bd={'1px solid rgba(255, 255, 255, 0.08)'}
>
{connection.user_agent &&
connection.user_agent !== 'Unknown' && (
<Group gap={8} align="flex-start">
<Text size="xs" fw={500} c="dimmed" miw={80}>
User Agent:
</Text>
<Text size="xs" ff={'monospace'} flex={1}>
{connection.user_agent.length > 100
? `${connection.user_agent.substring(0, 100)}...`
: connection.user_agent}
</Text>
</Group>
)}
{connection.user_agent && connection.user_agent !== 'Unknown' && (
<Group gap={8} align="flex-start">
<Text size="xs" fw={500} c="dimmed" miw={80}>
User Agent:
</Text>
<Text size="xs" ff={'monospace'} flex={1}>
{connection.user_agent.length > 100
? `${connection.user_agent.substring(0, 100)}...`
: connection.user_agent}
</Text>
</Group>
)}
<Group gap={8}>
<Text size="xs" fw={500} c="dimmed" miw={80}>
@ -86,9 +107,7 @@ const ClientDetails = ({ connection, connectionStartTime }) => {
{' '}
({Math.round(connection.last_seek_byte / (1024 * 1024))}
MB /{' '}
{Math.round(
connection.total_content_size / (1024 * 1024)
)}
{Math.round(connection.total_content_size / (1024 * 1024))}
MB)
</span>
)}
@ -120,12 +139,11 @@ const ClientDetails = ({ connection, connectionStartTime }) => {
)}
</Stack>
);
}
};
// Create a VOD Card component similar to ChannelCard
const VodConnectionCard = ({ vodContent, stopVODClient }) => {
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
const { fullDateTimeFormat } = useDateTimeFormat();
const [isClientExpanded, setIsClientExpanded] = useState(false);
const [, setUpdateTrigger] = useState(0); // Force re-renders for progress updates
@ -197,9 +215,9 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
// Get connection start time for tooltip
const getConnectionStartTime = useCallback(
(connection) => {
return calculateConnectionStartTime(connection, dateFormat);
return calculateConnectionStartTime(connection, fullDateTimeFormat);
},
[dateFormat]
[fullDateTimeFormat]
);
return (
@ -211,14 +229,16 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
style={{
backgroundColor: '#27272A',
}}
color='#FFF'
color="#FFF"
maw={700}
w={'100%'}
>
<Stack pos='relative' >
<Stack pos="relative">
{/* Header with poster and basic info */}
<Group justify="space-between">
<Box h={100} display='flex'
<Box
h={100}
display="flex"
style={{
alignItems: 'center',
justifyContent: 'center',
@ -338,7 +358,7 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
{connection &&
metadata.duration_secs &&
(() => {
const { totalTime, currentTime, percentage} = getProgressInfo();
const { totalTime, currentTime, percentage } = getProgressInfo();
return totalTime > 0 ? (
<Stack gap="xs" mt="sm">
<Group justify="space-between" align="center">
@ -346,8 +366,7 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
Progress
</Text>
<Text size="xs" c="dimmed">
{formatTime(currentTime)} /{' '}
{formatTime(totalTime)}
{formatTime(currentTime)} / {formatTime(totalTime)}
</Text>
</Group>
<Progress
@ -410,7 +429,8 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
{isClientExpanded && (
<ClientDetails
connection={connection}
connectionStartTime={getConnectionStartTime(connection)} />
connectionStartTime={getConnectionStartTime(connection)}
/>
)}
</Stack>
)}
@ -419,4 +439,4 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
);
};
export default VodConnectionCard;
export default VodConnectionCard;

View file

@ -1,7 +1,20 @@
import useChannelsStore from '../../store/channels.jsx';
import { useDateTimeFormat, useTimeHelpers } from '../../utils/dateTimeUtils.js';
import {
useDateTimeFormat,
useTimeHelpers,
} from '../../utils/dateTimeUtils.js';
import React from 'react';
import { Badge, Button, Card, Flex, Group, Image, Modal, Stack, Text, } from '@mantine/core';
import {
Badge,
Button,
Card,
Flex,
Group,
Image,
Modal,
Stack,
Text,
} from '@mantine/core';
import useVideoStore from '../../store/useVideoStore.jsx';
import { notifications } from '@mantine/notifications';
import {
@ -19,22 +32,23 @@ import {
} from '../../utils/forms/RecordingDetailsModalUtils.js';
const RecordingDetailsModal = ({
opened,
onClose,
recording,
channel,
posterUrl,
onWatchLive,
onWatchRecording,
env_mode,
onEdit,
}) => {
opened,
onClose,
recording,
channel,
posterUrl,
onWatchLive,
onWatchRecording,
env_mode,
onEdit,
}) => {
const allRecordings = useChannelsStore((s) => s.recordings);
const channelMap = useChannelsStore((s) => s.channels);
const { toUserTime, userNow } = useTimeHelpers();
const [childOpen, setChildOpen] = React.useState(false);
const [childRec, setChildRec] = React.useState(null);
const [timeformat, dateformat] = useDateTimeFormat();
const { timeFormat: timeformat, dateFormat: dateformat } =
useDateTimeFormat();
const safeRecording = recording || {};
const customProps = safeRecording.custom_properties || {};
@ -61,7 +75,13 @@ const RecordingDetailsModal = ({
safeRecording._group_count && safeRecording._group_count > 1
);
const upcomingEpisodes = React.useMemo(() => {
return getUpcomingEpisodes(isSeriesGroup, allRecordings, program, toUserTime, userNow);
return getUpcomingEpisodes(
isSeriesGroup,
allRecordings,
program,
toUserTime,
userNow
);
}, [
allRecordings,
isSeriesGroup,
@ -79,31 +99,32 @@ const RecordingDetailsModal = ({
if (now.isAfter(s) && now.isBefore(e)) {
if (!channelMap[rec.channel]) return;
useVideoStore.getState().showVideo(getShowVideoUrl(channelMap[rec.channel], env_mode), 'live');
useVideoStore
.getState()
.showVideo(getShowVideoUrl(channelMap[rec.channel], env_mode), 'live');
}
}
};
const handleOnWatchRecording = () => {
let fileUrl = getRecordingUrl(childRec.custom_properties, env_mode)
let fileUrl = getRecordingUrl(childRec.custom_properties, env_mode);
if (!fileUrl) return;
useVideoStore.getState().showVideo(fileUrl, 'vod', {
name:
childRec.custom_properties?.program?.title || 'Recording',
name: childRec.custom_properties?.program?.title || 'Recording',
logo: {
url: getPosterUrl(
childRec.custom_properties?.poster_logo_id,
undefined,
channelMap[childRec.channel]?.logo?.cache_url
)
),
},
});
}
};
const handleRunComskip = async (e) => {
e.stopPropagation?.();
try {
await runComSkip(recording)
await runComSkip(recording);
notifications.show({
title: 'Removing commercials',
message: 'Queued comskip for this recording',
@ -113,7 +134,7 @@ const RecordingDetailsModal = ({
} catch (error) {
console.error('Failed to run comskip', error);
}
}
};
if (!recording) return null;
@ -147,7 +168,7 @@ const RecordingDetailsModal = ({
const handleOnMainCardClick = () => {
setChildRec(rec);
setChildOpen(true);
}
};
return (
<Card
withBorder
@ -183,7 +204,8 @@ const RecordingDetailsModal = ({
)}
</Group>
<Text size="xs">
{start.format(`${dateformat}, YYYY ${timeformat}`)} {end.format(timeformat)}
{start.format(`${dateformat}, YYYY ${timeformat}`)} {' '}
{end.format(timeformat)}
</Text>
</Stack>
<Group gap={6}>
@ -197,142 +219,153 @@ const RecordingDetailsModal = ({
};
const WatchLive = () => {
return <Button
size="xs"
variant="light"
onClick={(e) => {
e.stopPropagation?.();
onWatchLive();
}}
>
Watch Live
</Button>;
}
return (
<Button
size="xs"
variant="light"
onClick={(e) => {
e.stopPropagation?.();
onWatchLive();
}}
>
Watch Live
</Button>
);
};
const WatchRecording = () => {
return <Button
size="xs"
variant="default"
onClick={(e) => {
e.stopPropagation?.();
onWatchRecording();
}}
disabled={!canWatchRecording}
>
Watch
</Button>;
}
return (
<Button
size="xs"
variant="default"
onClick={(e) => {
e.stopPropagation?.();
onWatchRecording();
}}
disabled={!canWatchRecording}
>
Watch
</Button>
);
};
const Edit = () => {
return <Button
size="xs"
variant="light"
color="blue"
onClick={(e) => {
e.stopPropagation?.();
onEdit(recording);
}}
>
Edit
</Button>;
}
return (
<Button
size="xs"
variant="light"
color="blue"
onClick={(e) => {
e.stopPropagation?.();
onEdit(recording);
}}
>
Edit
</Button>
);
};
const Series = () => {
return <Stack gap={10}>
{upcomingEpisodes.length === 0 && (
<Text size="sm" c="dimmed">
No upcoming episodes found
</Text>
)}
{upcomingEpisodes.map((ep) => (
<EpisodeRow key={`ep-${ep.id}`} rec={ep} />
))}
{childOpen && childRec && (
<RecordingDetailsModal
opened={childOpen}
onClose={() => setChildOpen(false)}
recording={childRec}
channel={channelMap[childRec.channel]}
posterUrl={getPosterUrl(
childRec.custom_properties?.poster_logo_id,
childRec.custom_properties,
channelMap[childRec.channel]?.logo?.cache_url
)}
env_mode={env_mode}
onWatchLive={handleOnWatchLive}
onWatchRecording={handleOnWatchRecording}
/>
)}
</Stack>;
}
const Movie = () => {
return <Flex gap="lg" align="flex-start">
<Image
src={posterUrl}
w={180}
h={240}
fit="contain"
radius="sm"
alt={recordingName}
fallbackSrc="/logo.png"
/>
<Stack gap={8} style={{ flex: 1 }}>
<Group justify="space-between" align="center">
<Text c="dimmed" size="sm">
{channel ? `${channel.channel_number}${channel.name}` : '—'}
</Text>
<Group gap={8}>
{onWatchLive && <WatchLive />}
{onWatchRecording && <WatchRecording />}
{onEdit && start.isAfter(userNow()) && <Edit />}
{customProps.status === 'completed' &&
(!customProps?.comskip ||
customProps?.comskip?.status !== 'completed') && (
<Button
size="xs"
variant="light"
color="teal"
onClick={handleRunComskip}
>
Remove commercials
</Button>
)}
</Group>
</Group>
<Text size="sm">
{start.format(`${dateformat}, YYYY ${timeformat}`)} {end.format(timeformat)}
</Text>
{rating && (
<Group gap={8}>
<Badge color="yellow" title={ratingSystem}>
{rating}
</Badge>
</Group>
)}
{description && (
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>
{description}
return (
<Stack gap={10}>
{upcomingEpisodes.length === 0 && (
<Text size="sm" c="dimmed">
No upcoming episodes found
</Text>
)}
{statRows.length > 0 && (
<Stack gap={4} pt={6}>
<Text fw={600} size="sm">
Stream Stats
</Text>
{statRows.map(([k, v]) => (
<Group key={k} justify="space-between">
<Text size="xs" c="dimmed">
{k}
</Text>
<Text size="xs">{v}</Text>
</Group>
))}
</Stack>
{upcomingEpisodes.map((ep) => (
<EpisodeRow key={`ep-${ep.id}`} rec={ep} />
))}
{childOpen && childRec && (
<RecordingDetailsModal
opened={childOpen}
onClose={() => setChildOpen(false)}
recording={childRec}
channel={channelMap[childRec.channel]}
posterUrl={getPosterUrl(
childRec.custom_properties?.poster_logo_id,
childRec.custom_properties,
channelMap[childRec.channel]?.logo?.cache_url
)}
env_mode={env_mode}
onWatchLive={handleOnWatchLive}
onWatchRecording={handleOnWatchRecording}
/>
)}
</Stack>
</Flex>;
}
);
};
const Movie = () => {
return (
<Flex gap="lg" align="flex-start">
<Image
src={posterUrl}
w={180}
h={240}
fit="contain"
radius="sm"
alt={recordingName}
fallbackSrc="/logo.png"
/>
<Stack gap={8} style={{ flex: 1 }}>
<Group justify="space-between" align="center">
<Text c="dimmed" size="sm">
{channel ? `${channel.channel_number}${channel.name}` : '—'}
</Text>
<Group gap={8}>
{onWatchLive && <WatchLive />}
{onWatchRecording && <WatchRecording />}
{onEdit && start.isAfter(userNow()) && <Edit />}
{customProps.status === 'completed' &&
(!customProps?.comskip ||
customProps?.comskip?.status !== 'completed') && (
<Button
size="xs"
variant="light"
color="teal"
onClick={handleRunComskip}
>
Remove commercials
</Button>
)}
</Group>
</Group>
<Text size="sm">
{start.format(`${dateformat}, YYYY ${timeformat}`)} {' '}
{end.format(timeformat)}
</Text>
{rating && (
<Group gap={8}>
<Badge color="yellow" title={ratingSystem}>
{rating}
</Badge>
</Group>
)}
{description && (
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>
{description}
</Text>
)}
{statRows.length > 0 && (
<Stack gap={4} pt={6}>
<Text fw={600} size="sm">
Stream Stats
</Text>
{statRows.map(([k, v]) => (
<Group key={k} justify="space-between">
<Text size="xs" c="dimmed">
{k}
</Text>
<Text size="xs">{v}</Text>
</Group>
))}
</Stack>
)}
</Stack>
</Flex>
);
};
return (
<Modal
@ -359,4 +392,4 @@ const RecordingDetailsModal = ({
);
};
export default RecordingDetailsModal;
export default RecordingDetailsModal;

View file

@ -10,7 +10,19 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from '@mantine/form';
import dayjs from 'dayjs';
import { notifications } from '@mantine/notifications';
import { Badge, Button, Card, Group, Modal, MultiSelect, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
import {
Badge,
Button,
Card,
Group,
Modal,
MultiSelect,
Select,
Stack,
Switch,
Text,
TextInput,
} from '@mantine/core';
import { DatePickerInput, TimeInput } from '@mantine/dates';
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
import {
@ -28,7 +40,8 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const recordings = useChannelsStore((s) => s.recordings);
const { toUserTime, userNow } = useTimeHelpers();
const [timeformat, dateformat] = useDateTimeFormat();
const { timeFormat: timeformat, dateFormat: dateformat } =
useDateTimeFormat();
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
@ -198,73 +211,70 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
const handleEnableChange = (event) => {
form.setFieldValue('enabled', event.currentTarget.checked);
handleToggleEnabled(event.currentTarget.checked);
}
};
const handleStartDateChange = (value) => {
form.setFieldValue('start_date', value || dayjs().toDate());
}
};
const handleEndDateChange = (value) => {
form.setFieldValue('end_date', value);
}
};
const handleStartTimeChange = (value) => {
form.setFieldValue('start_time', toTimeString(value));
}
};
const handleEndTimeChange = (value) => {
form.setFieldValue('end_time', toTimeString(value));
}
};
const UpcomingList = () => {
return <Stack gap="xs">
{upcomingOccurrences.map((occ) => {
const occStart = toUserTime(occ.start_time);
const occEnd = toUserTime(occ.end_time);
return (
<Stack gap="xs">
{upcomingOccurrences.map((occ) => {
const occStart = toUserTime(occ.start_time);
const occEnd = toUserTime(occ.end_time);
return (
<Card
key={`occ-${occ.id}`}
withBorder
padding="sm"
radius="md"
>
<Group justify="space-between" align="center">
<Stack gap={2} flex={1}>
<Text fw={600} size="sm">
{occStart.format(`${dateformat}, YYYY`)}
</Text>
<Text size="xs" c="dimmed">
{occStart.format(timeformat)} {occEnd.format(timeformat)}
</Text>
</Stack>
<Group gap={6}>
<Button
size="xs"
variant="subtle"
onClick={() => {
onClose();
onEditOccurrence?.(occ);
}}
>
Edit
</Button>
<Button
size="xs"
color="red"
variant="light"
loading={busyOccurrence === occ.id}
onClick={() => handleCancelOccurrence(occ)}
>
Cancel
</Button>
return (
<Card key={`occ-${occ.id}`} withBorder padding="sm" radius="md">
<Group justify="space-between" align="center">
<Stack gap={2} flex={1}>
<Text fw={600} size="sm">
{occStart.format(`${dateformat}, YYYY`)}
</Text>
<Text size="xs" c="dimmed">
{occStart.format(timeformat)} {occEnd.format(timeformat)}
</Text>
</Stack>
<Group gap={6}>
<Button
size="xs"
variant="subtle"
onClick={() => {
onClose();
onEditOccurrence?.(occ);
}}
>
Edit
</Button>
<Button
size="xs"
color="red"
variant="light"
loading={busyOccurrence === occ.id}
onClick={() => handleCancelOccurrence(occ)}
>
Cancel
</Button>
</Group>
</Group>
</Group>
</Card>
);
})}
</Stack>;
}
</Card>
);
})}
</Stack>
);
};
return (
<Modal
@ -371,11 +381,13 @@ const RecurringRuleModal = ({ opened, onClose, ruleId, onEditOccurrence }) => {
<Text size="sm" c="dimmed">
No future airings currently scheduled.
</Text>
) : <UpcomingList />}
) : (
<UpcomingList />
)}
</Stack>
</Stack>
</Modal>
);
};
export default RecurringRuleModal;
export default RecurringRuleModal;

View file

@ -3,7 +3,6 @@ import API from '../../api';
import useEPGsStore from '../../store/epgs';
import EPGForm from '../forms/EPG';
import DummyEPGForm from '../forms/DummyEPG';
import { TableHelper } from '../../helpers';
import {
ActionIcon,
Text,
@ -14,7 +13,6 @@ import {
Flex,
useMantineTheme,
Switch,
Badge,
Progress,
Stack,
Group,
@ -31,9 +29,9 @@ import {
SquarePlus,
ChevronDown,
} from 'lucide-react';
import dayjs from 'dayjs';
import useSettingsStore from '../../store/settings';
import { format } from '../../utils/dateTimeUtils.js';
import useLocalStorage from '../../hooks/useLocalStorage';
import { useDateTimeFormat } from '../../utils/dateTimeUtils.js';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from './CustomTable';
@ -116,17 +114,8 @@ const EPGsTable = () => {
const refreshProgress = useEPGsStore((s) => s.refreshProgress);
const theme = useMantineTheme();
// Get tableSize directly from localStorage instead of the store
const { fullDateTimeFormat } = useDateTimeFormat();
const [tableSize] = useLocalStorage('table-size', 'default');
// Get proper size for action icons to match ChannelsTable
const iconSize =
tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'md' : 'sm';
// Calculate density for Mantine Table
const tableDensity =
tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'xl' : 'md';
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
@ -356,11 +345,11 @@ const EPGsTable = () => {
enableSorting: false,
cell: ({ cell }) => {
const value = cell.getValue();
return value ? (
<Text size="xs">{new Date(value).toLocaleString()}</Text>
) : (
<Text size="xs">Never</Text>
);
if (!value) {
return <Text size="xs">Never</Text>;
}
const formatted = format(value, fullDateTimeFormat);
return <Text size="xs">{formatted}</Text>;
},
},
{
@ -391,7 +380,7 @@ const EPGsTable = () => {
size: tableSize == 'compact' ? 75 : 100,
},
],
[refreshProgress]
[refreshProgress, fullDateTimeFormat]
);
const [isLoading, setIsLoading] = useState(true);

View file

@ -19,9 +19,6 @@ import {
ActionIcon,
Tooltip,
Switch,
Progress,
Stack,
Badge,
Group,
Center,
} from '@mantine/core';
@ -29,16 +26,13 @@ import {
SquareMinus,
SquarePen,
RefreshCcw,
Check,
X,
ArrowUpDown,
ArrowUpNarrowWide,
ArrowDownWideNarrow,
SquarePlus,
} from 'lucide-react';
import dayjs from 'dayjs';
import useSettingsStore from '../../store/settings';
import useLocalStorage from '../../hooks/useLocalStorage';
import { useDateTimeFormat, format } from '../../utils/dateTimeUtils.js';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from './CustomTable';
@ -131,9 +125,7 @@ const RowActions = ({
const M3UTable = () => {
const [playlist, setPlaylist] = useState(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [activeFilterValue, setActiveFilterValue] = useState('all');
const [playlistCreated, setPlaylistCreated] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
@ -152,6 +144,7 @@ const M3UTable = () => {
const theme = useMantineTheme();
const [tableSize] = useLocalStorage('table-size', 'default');
const { fullDateTimeFormat } = useDateTimeFormat();
const generateStatusString = (data) => {
if (data.progress == 100) {
@ -582,11 +575,11 @@ const M3UTable = () => {
size: 175,
cell: ({ cell }) => {
const value = cell.getValue();
return value ? (
<Text size="xs">{new Date(value).toLocaleString()}</Text>
) : (
<Text size="xs">Never</Text>
);
if (!value) {
return <Text size="xs">Never</Text>;
}
const formatted = format(value, fullDateTimeFormat);
return <Text size="xs">{formatted}</Text>;
},
},
{
@ -611,7 +604,13 @@ const M3UTable = () => {
size: tableSize == 'compact' ? 75 : 100,
},
],
[refreshPlaylist, editPlaylist, deletePlaylist, toggleActive]
[
refreshPlaylist,
editPlaylist,
deletePlaylist,
toggleActive,
fullDateTimeFormat,
]
);
//optionally access the underlying virtualizer instance

View file

@ -5,14 +5,7 @@ import useUsersStore from '../../store/users';
import useAuthStore from '../../store/auth';
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
import useWarningsStore from '../../store/warnings';
import {
SquarePlus,
SquareMinus,
SquarePen,
EllipsisVertical,
Eye,
EyeOff,
} from 'lucide-react';
import { SquarePlus, SquareMinus, SquarePen, Eye, EyeOff } from 'lucide-react';
import {
ActionIcon,
Box,
@ -22,14 +15,13 @@ import {
Flex,
Group,
useMantineTheme,
Menu,
UnstyledButton,
LoadingOverlay,
Stack,
} from '@mantine/core';
import { CustomTable, useTable } from './CustomTable';
import ConfirmationDialog from '../ConfirmationDialog';
import useLocalStorage from '../../hooks/useLocalStorage';
import { useDateTimeFormat, format } from '../../utils/dateTimeUtils.js';
const UserRowActions = ({ theme, row, editUser, deleteUser }) => {
const [tableSize, _] = useLocalStorage('table-size', 'default');
@ -78,6 +70,7 @@ const UserRowActions = ({ theme, row, editUser, deleteUser }) => {
const UsersTable = () => {
const theme = useMantineTheme();
const { fullDateFormat, fullDateTimeFormat } = useDateTimeFormat();
/**
* STORES
@ -210,9 +203,7 @@ const UsersTable = () => {
cell: ({ getValue }) => {
const date = getValue();
return (
<Text size="sm">
{date ? new Date(date).toLocaleDateString() : '-'}
</Text>
<Text size="sm">{date ? format(date, fullDateFormat) : '-'}</Text>
);
},
},
@ -224,7 +215,7 @@ const UsersTable = () => {
const date = getValue();
return (
<Text size="sm">
{date ? new Date(date).toLocaleString() : 'Never'}
{date ? format(date, fullDateTimeFormat) : 'Never'}
</Text>
);
},
@ -280,7 +271,15 @@ const UsersTable = () => {
),
},
],
[theme, editUser, deleteUser, visiblePasswords, togglePasswordVisibility]
[
theme,
editUser,
deleteUser,
visiblePasswords,
togglePasswordVisibility,
fullDateFormat,
fullDateTimeFormat,
]
);
const closeUserForm = () => {

View file

@ -65,9 +65,7 @@ import {
PROGRAM_HEIGHT,
sortChannels,
} from './guideUtils';
import {
getShowVideoUrl,
} from '../utils/cards/RecordingCardUtils.js';
import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js';
import {
add,
convertToMs,
@ -79,10 +77,12 @@ import {
} from '../utils/dateTimeUtils.js';
import GuideRow from '../components/GuideRow.jsx';
import HourTimeline from '../components/HourTimeline';
const ProgramRecordingModal = React.lazy(() =>
import('../components/forms/ProgramRecordingModal'));
const SeriesRecordingModal = React.lazy(() =>
import('../components/forms/SeriesRecordingModal'));
const ProgramRecordingModal = React.lazy(
() => import('../components/forms/ProgramRecordingModal')
);
const SeriesRecordingModal = React.lazy(
() => import('../components/forms/SeriesRecordingModal')
);
import { showNotification } from '../utils/notificationUtils.js';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
@ -230,7 +230,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
[rowHeights]
);
const [timeFormat, dateFormat] = useDateTimeFormat();
const { timeFormat, dateFormat } = useDateTimeFormat();
// Format day label using relative terms when possible (Today, Tomorrow, etc)
const formatDayLabel = useCallback(
@ -774,9 +774,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
style={{
cursor: 'pointer',
zIndex: isExpanded ? 25 : 5,
transition: isExpanded ? 'height 0.2s ease, width 0.2s ease' : 'height 0.2s ease',
transition: isExpanded
? 'height 0.2s ease, width 0.2s ease'
: 'height 0.2s ease',
}}
pos='absolute'
pos="absolute"
left={leftPx + gapSize}
top={0}
w={isExpanded ? expandedWidthPx : widthPx}
@ -806,7 +808,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
}}
w={'100%'}
h={'100%'}
pos='relative'
pos="relative"
display={'flex'}
p={isExpanded ? 12 : 8}
c={isPast ? '#a0aec0' : '#fff'}
@ -1007,7 +1009,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
}}
w={'100%'}
h={'100%'}
c='#ffffff'
c="#ffffff"
ff={'Roboto, sans-serif'}
onClick={handleClickOutside} // Close expanded program when clicking outside
>
@ -1016,9 +1018,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
direction="column"
style={{
zIndex: 1000,
position: 'sticky'
position: 'sticky',
}}
c='#ffffff'
c="#ffffff"
p={'12px 20px'}
top={0}
>
@ -1101,7 +1103,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
backgroundColor: '#245043',
}}
bd={'1px solid #3BA882'}
color='#FFFFFF'
color="#FFFFFF"
>
Series Rules
</Button>
@ -1125,7 +1127,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
<Box
style={{
zIndex: 100,
position: 'sticky'
position: 'sticky',
}}
display={'flex'}
top={0}
@ -1142,7 +1144,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
w={CHANNEL_WIDTH}
miw={CHANNEL_WIDTH}
h={'40px'}
pos='sticky'
pos="sticky"
left={0}
/>
@ -1152,7 +1154,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
flex: 1,
overflow: 'hidden',
}}
pos='relative'
pos="relative"
>
<Box
ref={timelineRef}
@ -1160,7 +1162,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
overflowX: 'auto',
overflowY: 'hidden',
}}
pos='relative'
pos="relative"
onScroll={handleTimelineScroll}
onWheel={handleTimelineWheel} // Add wheel event handler
>
@ -1190,7 +1192,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
flex: 1,
overflow: 'hidden',
}}
pos='relative'
pos="relative"
>
<LoadingOverlay visible={isLoading || isProgramsLoading} />
{nowPosition >= 0 && (
@ -1200,7 +1202,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
zIndex: 15,
pointerEvents: 'none',
}}
pos='absolute'
pos="absolute"
left={nowPosition + CHANNEL_WIDTH - guideScrollLeft}
top={0}
bottom={0}
@ -1225,7 +1227,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
{GuideRow}
</VariableSizeList>
) : (
<Box p={'30px'} ta='center' color='#a0aec0'>
<Box p={'30px'} ta="center" color="#a0aec0">
<Text size="lg">No channels match your filters</Text>
<Button variant="subtle" onClick={clearFilters} mt={10}>
Clear Filters
@ -1245,8 +1247,12 @@ export default function TVChannelGuide({ startDate, endDate }) {
recording={recordingForProgram}
existingRuleMode={existingRuleMode}
onRecordOne={() => recordOne(recordChoiceProgram)}
onRecordSeriesAll={() => saveSeriesRule(recordChoiceProgram, 'all')}
onRecordSeriesNew={() => saveSeriesRule(recordChoiceProgram, 'new')}
onRecordSeriesAll={() =>
saveSeriesRule(recordChoiceProgram, 'all')
}
onRecordSeriesNew={() =>
saveSeriesRule(recordChoiceProgram, 'new')
}
onExistingRuleModeChange={setExistingRuleMode}
/>
</Suspense>

View file

@ -1,10 +1,5 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
render,
screen,
waitFor,
fireEvent,
} from '@testing-library/react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import dayjs from 'dayjs';
import Guide from '../Guide';
import useChannelsStore from '../../store/channels';
@ -82,8 +77,22 @@ vi.mock('@mantine/core', async () => {
{children}
</div>
),
Button: ({ children, onClick, leftSection, variant, size, color, disabled }) => (
<button onClick={onClick} disabled={disabled} data-variant={variant} data-size={size} data-color={color}>
Button: ({
children,
onClick,
leftSection,
variant,
size,
color,
disabled,
}) => (
<button
onClick={onClick}
disabled={disabled}
data-variant={variant}
data-size={size}
data-color={color}
>
{leftSection}
{children}
</button>
@ -91,7 +100,12 @@ vi.mock('@mantine/core', async () => {
TextInput: ({ value, onChange, placeholder, icon, rightSection }) => (
<div>
{icon}
<input type="text" value={value} onChange={onChange} placeholder={placeholder} />
<input
type="text"
value={value}
onChange={onChange}
placeholder={placeholder}
/>
{rightSection}
</div>
),
@ -111,7 +125,12 @@ vi.mock('@mantine/core', async () => {
</select>
),
ActionIcon: ({ children, onClick, variant, size, color }) => (
<button onClick={onClick} data-variant={variant} data-size={size} data-color={color}>
<button
onClick={onClick}
data-variant={variant}
data-size={size}
data-color={color}
>
{children}
</button>
),
@ -122,21 +141,23 @@ vi.mock('@mantine/core', async () => {
vi.mock('react-window', () => ({
VariableSizeList: ({ children, itemData, itemCount }) => (
<div data-testid="variable-size-list">
{Array.from({ length: Math.min(itemCount, 5) }, (_, i) =>
{Array.from({ length: Math.min(itemCount, 5) }, (_, i) => (
<div key={i}>
{children({
index: i,
style: {},
data: itemData.filteredChannels[i]
data: itemData.filteredChannels[i],
})}
</div>
)}
))}
</div>
),
}));
vi.mock('../../components/GuideRow', () => ({
default: ({ data }) => <div data-testid="guide-row">GuideRow for {data?.name}</div>,
default: ({ data }) => (
<div data-testid="guide-row">GuideRow for {data?.name}</div>
),
}));
vi.mock('../../components/HourTimeline', () => ({
default: ({ hourTimeline }) => (
@ -184,7 +205,9 @@ vi.mock('../guideUtils', async () => {
};
});
vi.mock('../../utils/cards/RecordingCardUtils.js', async () => {
const actual = await vi.importActual('../../utils/cards/RecordingCardUtils.js');
const actual = await vi.importActual(
'../../utils/cards/RecordingCardUtils.js'
);
return {
...actual,
getShowVideoUrl: vi.fn(),
@ -262,7 +285,9 @@ describe('Guide', () => {
});
useEPGsStore.mockImplementation((selector) =>
selector ? selector({ tvgsById: {}, epgs: {} }) : { tvgsById: {}, epgs: {} }
selector
? selector({ tvgsById: {}, epgs: {} })
: { tvgsById: {}, epgs: {} }
);
useSettingsStore.mockReturnValue('production');
@ -274,13 +299,18 @@ describe('Guide', () => {
if (format?.includes('dddd')) return 'Monday, 01/15/2024 • 12:00 PM';
return '12:00 PM';
});
dateTimeUtils.initializeTime.mockImplementation(date => date || now);
dateTimeUtils.initializeTime.mockImplementation((date) => date || now);
dateTimeUtils.startOfDay.mockReturnValue(now.startOf('day'));
dateTimeUtils.add.mockImplementation((date, amount, unit) =>
dayjs(date).add(amount, unit)
);
dateTimeUtils.convertToMs.mockImplementation(date => dayjs(date).valueOf());
dateTimeUtils.useDateTimeFormat.mockReturnValue(['12h', 'MM/DD/YYYY']);
dateTimeUtils.convertToMs.mockImplementation((date) =>
dayjs(date).valueOf()
);
dateTimeUtils.useDateTimeFormat.mockReturnValue({
timeFormat: '12h',
dateFormat: 'MM/DD/YYYY',
});
guideUtils.fetchPrograms.mockResolvedValue([
{
@ -300,8 +330,8 @@ describe('Guide', () => {
]);
guideUtils.fetchRules.mockResolvedValue([]);
guideUtils.filterGuideChannels.mockImplementation(
(channels) => Object.values(channels)
guideUtils.filterGuideChannels.mockImplementation((channels) =>
Object.values(channels)
);
guideUtils.createRecording.mockResolvedValue(undefined);
guideUtils.createSeriesRule.mockResolvedValue(undefined);
@ -348,7 +378,9 @@ describe('Guide', () => {
render(<Guide />);
// await waitFor(() => {
expect(screen.getByText('No channels match your filters')).toBeInTheDocument();
expect(
screen.getByText('No channels match your filters')
).toBeInTheDocument();
// });
});
@ -356,7 +388,7 @@ describe('Guide', () => {
render(<Guide />);
// await waitFor(() => {
expect(screen.getByText(/2 channels/)).toBeInTheDocument();
expect(screen.getByText(/2 channels/)).toBeInTheDocument();
// });
});
});
@ -394,7 +426,8 @@ describe('Guide', () => {
const user = userEvent.setup({ delay: null });
render(<Guide />);
const searchInput = await screen.findByPlaceholderText('Search channels...');
const searchInput =
await screen.findByPlaceholderText('Search channels...');
await user.type(searchInput, 'News');
await waitFor(() => {
@ -457,7 +490,8 @@ describe('Guide', () => {
render(<Guide />);
// Set some filters
const searchInput = await screen.findByPlaceholderText('Search channels...');
const searchInput =
await screen.findByPlaceholderText('Search channels...');
await user.type(searchInput, 'Test');
// Clear them
@ -479,7 +513,9 @@ describe('Guide', () => {
await user.click(rulesButton);
await waitFor(() => {
expect(screen.getByTestId('series-recording-modal')).toBeInTheDocument();
expect(
screen.getByTestId('series-recording-modal')
).toBeInTheDocument();
});
vi.useFakeTimers();
@ -538,7 +574,12 @@ describe('Guide', () => {
describe('Error Handling', () => {
it('shows notification when no channels are available', async () => {
useChannelsStore.mockImplementation((selector) => {
const state = { channels: {}, recordings: [], channelGroups: {}, profiles: {} };
const state = {
channels: {},
recordings: [],
channelGroups: {},
profiles: {},
};
return selector ? selector(state) : state;
});
@ -616,4 +657,4 @@ describe('Guide', () => {
vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
});
});
});
});

View file

@ -336,7 +336,8 @@ describe('dateTimeUtils', () => {
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
expect(result.current).toEqual(['h:mma', 'MMM D']);
expect(result.current.timeFormat).toBe('h:mma');
expect(result.current.dateFormat).toBe('MMM D');
});
it('should return 24h format when set', () => {
@ -344,7 +345,7 @@ describe('dateTimeUtils', () => {
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
expect(result.current[0]).toBe('HH:mm');
expect(result.current.timeFormat).toBe('HH:mm');
});
it('should return dmy date format when set', () => {
@ -352,7 +353,7 @@ describe('dateTimeUtils', () => {
const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
expect(result.current[1]).toBe('D MMM');
expect(result.current.dateFormat).toBe('D MMM');
});
});

View file

@ -68,19 +68,19 @@ export const switchStream = (channel, streamId) => {
return API.switchStream(channel.channel_id, streamId);
};
export const connectedAccessor = (dateFormat) => {
export const connectedAccessor = (fullDateTimeFormat) => {
return (row) => {
// Check for connected_since (which is seconds since connection)
if (row.connected_since) {
// Calculate the actual connection time by subtracting the seconds from current time
const connectedTime = subtract(getNow(), row.connected_since, 'second');
return format(connectedTime, `${dateFormat} HH:mm:ss`);
return format(connectedTime, fullDateTimeFormat);
}
// Fallback to connected_at if it exists
if (row.connected_at) {
const connectedTime = initializeTime(row.connected_at * 1000);
return format(connectedTime, `${dateFormat} HH:mm:ss`);
return format(connectedTime, fullDateTimeFormat);
}
return 'Unknown';

View file

@ -117,9 +117,9 @@ export const calculateConnectionDuration = (connection) => {
return 'Unknown duration';
}
export const calculateConnectionStartTime = (connection, dateFormat) => {
export const calculateConnectionStartTime = (connection, fullDateTimeFormat) => {
if (connection.connected_at) {
return format(connection.connected_at * 1000, `${dateFormat} HH:mm:ss`);
return format(connection.connected_at * 1000, fullDateTimeFormat);
}
// Fallback: calculate from client_id timestamp
@ -128,7 +128,7 @@ export const calculateConnectionStartTime = (connection, dateFormat) => {
const parts = connection.client_id.split('_');
if (parts.length >= 2) {
const clientStartTime = parseInt(parts[1]);
return format(clientStartTime, `${dateFormat} HH:mm:ss`);
return format(clientStartTime, fullDateTimeFormat);
}
} catch {
// Ignore parsing errors

View file

@ -112,7 +112,21 @@ export const useDateTimeFormat = () => {
const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm';
const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM';
return [timeFormat, dateFormat];
// Full format strings for detailed date-time displays
const fullDateFormat = dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
const fullTimeFormat = timeFormatSetting === '12h' ? 'h:mm:ss A' : 'HH:mm:ss';
const fullDateTimeFormat = `${fullDateFormat}, ${fullTimeFormat}`;
return {
timeFormat,
dateFormat,
fullDateFormat,
fullTimeFormat,
fullDateTimeFormat,
// Also return raw settings for cases that need them
timeFormatSetting,
dateFormatSetting,
};
};
export const toTimeString = (value) => {