From cbcf2ac3c21cddae505ea0aa4e7aa506b7c97587 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 19 Jan 2026 20:07:31 -0600 Subject: [PATCH] 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 --- CHANGELOG.md | 8 + .../src/components/cards/RecordingCard.jsx | 82 +++-- .../components/cards/StreamConnectionCard.jsx | 14 +- .../components/cards/VodConnectionCard.jsx | 86 +++-- .../forms/RecordingDetailsModal.jsx | 337 ++++++++++-------- .../components/forms/RecurringRuleModal.jsx | 122 ++++--- frontend/src/components/tables/EPGsTable.jsx | 29 +- frontend/src/components/tables/M3UsTable.jsx | 29 +- frontend/src/components/tables/UsersTable.jsx | 29 +- frontend/src/pages/Guide.jsx | 54 +-- frontend/src/pages/__tests__/Guide.test.jsx | 97 +++-- .../src/utils/__tests__/dateTimeUtils.test.js | 7 +- .../utils/cards/StreamConnectionCardUtils.js | 6 +- .../src/utils/cards/VodConnectionCardUtils.js | 6 +- frontend/src/utils/dateTimeUtils.js | 16 +- 15 files changed, 530 insertions(+), 392 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f53a70c..3d63bbf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/frontend/src/components/cards/RecordingCard.jsx b/frontend/src/components/cards/RecordingCard.jsx index 6f90e0f5..c3bffaee 100644 --- a/frontend/src/components/cards/RecordingCard.jsx +++ b/frontend/src/components/cards/RecordingCard.jsx @@ -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 ; - } - - const WatchRecording = () => { - return + return ( + ); + }; + + const WatchRecording = () => { + return ( + - Watch - - ; - } + + + ); + }; const MainCard = ( { {isSeriesGroup ? 'Next recording' : 'Time'} - {start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)} + {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} + {end.format(timeformat)} @@ -419,4 +433,4 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => { ); }; -export default RecordingCard; \ No newline at end of file +export default RecordingCard; diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index a00f3664..0e441cbe 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -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 }) => ( { bdrs={6} bd={'1px solid rgba(255, 255, 255, 0.08)'} > - {connection.user_agent && - connection.user_agent !== 'Unknown' && ( - - - User Agent: - - - {connection.user_agent.length > 100 - ? `${connection.user_agent.substring(0, 100)}...` - : connection.user_agent} - - - )} + {connection.user_agent && connection.user_agent !== 'Unknown' && ( + + + User Agent: + + + {connection.user_agent.length > 100 + ? `${connection.user_agent.substring(0, 100)}...` + : connection.user_agent} + + + )} @@ -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) )} @@ -120,12 +139,11 @@ const ClientDetails = ({ connection, connectionStartTime }) => { )} ); -} +}; // 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%'} > - + {/* Header with poster and basic info */} - { {connection && metadata.duration_secs && (() => { - const { totalTime, currentTime, percentage} = getProgressInfo(); + const { totalTime, currentTime, percentage } = getProgressInfo(); return totalTime > 0 ? ( @@ -346,8 +366,7 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => { Progress - {formatTime(currentTime)} /{' '} - {formatTime(totalTime)} + {formatTime(currentTime)} / {formatTime(totalTime)} { {isClientExpanded && ( + connectionStartTime={getConnectionStartTime(connection)} + /> )} )} @@ -419,4 +439,4 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => { ); }; -export default VodConnectionCard; \ No newline at end of file +export default VodConnectionCard; diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx index 1abc6f3b..d9ece7bb 100644 --- a/frontend/src/components/forms/RecordingDetailsModal.jsx +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -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 ( - {start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)} + {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} + {end.format(timeformat)} @@ -197,142 +219,153 @@ const RecordingDetailsModal = ({ }; const WatchLive = () => { - return ; - } + return ( + + ); + }; const WatchRecording = () => { - return ; - } + return ( + + ); + }; const Edit = () => { - return ; - } + return ( + + ); + }; const Series = () => { - return - {upcomingEpisodes.length === 0 && ( - - No upcoming episodes found - - )} - {upcomingEpisodes.map((ep) => ( - - ))} - {childOpen && childRec && ( - 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} - /> - )} - ; - } - - const Movie = () => { - return - {recordingName} - - - - {channel ? `${channel.channel_number} • ${channel.name}` : '—'} - - - {onWatchLive && } - {onWatchRecording && } - {onEdit && start.isAfter(userNow()) && } - {customProps.status === 'completed' && - (!customProps?.comskip || - customProps?.comskip?.status !== 'completed') && ( - - )} - - - - {start.format(`${dateformat}, YYYY ${timeformat}`)} – {end.format(timeformat)} - - {rating && ( - - - {rating} - - - )} - {description && ( - - {description} + return ( + + {upcomingEpisodes.length === 0 && ( + + No upcoming episodes found )} - {statRows.length > 0 && ( - - - Stream Stats - - {statRows.map(([k, v]) => ( - - - {k} - - {v} - - ))} - + {upcomingEpisodes.map((ep) => ( + + ))} + {childOpen && childRec && ( + 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} + /> )} - ; - } + ); + }; + + const Movie = () => { + return ( + + {recordingName} + + + + {channel ? `${channel.channel_number} • ${channel.name}` : '—'} + + + {onWatchLive && } + {onWatchRecording && } + {onEdit && start.isAfter(userNow()) && } + {customProps.status === 'completed' && + (!customProps?.comskip || + customProps?.comskip?.status !== 'completed') && ( + + )} + + + + {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} + {end.format(timeformat)} + + {rating && ( + + + {rating} + + + )} + {description && ( + + {description} + + )} + {statRows.length > 0 && ( + + + Stream Stats + + {statRows.map(([k, v]) => ( + + + {k} + + {v} + + ))} + + )} + + + ); + }; return ( { 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 - {upcomingOccurrences.map((occ) => { - const occStart = toUserTime(occ.start_time); - const occEnd = toUserTime(occ.end_time); + return ( + + {upcomingOccurrences.map((occ) => { + const occStart = toUserTime(occ.start_time); + const occEnd = toUserTime(occ.end_time); - return ( - - - - - {occStart.format(`${dateformat}, YYYY`)} - - - {occStart.format(timeformat)} – {occEnd.format(timeformat)} - - - - - + return ( + + + + + {occStart.format(`${dateformat}, YYYY`)} + + + {occStart.format(timeformat)} – {occEnd.format(timeformat)} + + + + + + - - - ); - })} - ; - } + + ); + })} + + ); + }; return ( { No future airings currently scheduled. - ) : } + ) : ( + + )} ); }; -export default RecurringRuleModal; \ No newline at end of file +export default RecurringRuleModal; diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index f6952542..20027088 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -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 ? ( - {new Date(value).toLocaleString()} - ) : ( - Never - ); + if (!value) { + return Never; + } + const formatted = format(value, fullDateTimeFormat); + return {formatted}; }, }, { @@ -391,7 +380,7 @@ const EPGsTable = () => { size: tableSize == 'compact' ? 75 : 100, }, ], - [refreshProgress] + [refreshProgress, fullDateTimeFormat] ); const [isLoading, setIsLoading] = useState(true); diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 6e88af56..6ae42e51 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -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 ? ( - {new Date(value).toLocaleString()} - ) : ( - Never - ); + if (!value) { + return Never; + } + const formatted = format(value, fullDateTimeFormat); + return {formatted}; }, }, { @@ -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 diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index 467423dc..af4e3087 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -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 ( - - {date ? new Date(date).toLocaleDateString() : '-'} - + {date ? format(date, fullDateFormat) : '-'} ); }, }, @@ -224,7 +215,7 @@ const UsersTable = () => { const date = getValue(); return ( - {date ? new Date(date).toLocaleString() : 'Never'} + {date ? format(date, fullDateTimeFormat) : 'Never'} ); }, @@ -280,7 +271,15 @@ const UsersTable = () => { ), }, ], - [theme, editUser, deleteUser, visiblePasswords, togglePasswordVisibility] + [ + theme, + editUser, + deleteUser, + visiblePasswords, + togglePasswordVisibility, + fullDateFormat, + fullDateTimeFormat, + ] ); const closeUserForm = () => { diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index ac0fdf82..64fa3099 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -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 @@ -1125,7 +1127,7 @@ export default function TVChannelGuide({ startDate, endDate }) { @@ -1152,7 +1154,7 @@ export default function TVChannelGuide({ startDate, endDate }) { flex: 1, overflow: 'hidden', }} - pos='relative' + pos="relative" > @@ -1190,7 +1192,7 @@ export default function TVChannelGuide({ startDate, endDate }) { flex: 1, overflow: 'hidden', }} - pos='relative' + pos="relative" > {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} ) : ( - + No channels match your filters @@ -91,7 +100,12 @@ vi.mock('@mantine/core', async () => { TextInput: ({ value, onChange, placeholder, icon, rightSection }) => (
{icon} - + {rightSection}
), @@ -111,7 +125,12 @@ vi.mock('@mantine/core', async () => { ), ActionIcon: ({ children, onClick, variant, size, color }) => ( - ), @@ -122,21 +141,23 @@ vi.mock('@mantine/core', async () => { vi.mock('react-window', () => ({ VariableSizeList: ({ children, itemData, itemCount }) => (
- {Array.from({ length: Math.min(itemCount, 5) }, (_, i) => + {Array.from({ length: Math.min(itemCount, 5) }, (_, i) => (
{children({ index: i, style: {}, - data: itemData.filteredChannels[i] + data: itemData.filteredChannels[i], })}
- )} + ))}
), })); vi.mock('../../components/GuideRow', () => ({ - default: ({ data }) =>
GuideRow for {data?.name}
, + default: ({ data }) => ( +
GuideRow for {data?.name}
+ ), })); 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(); // 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(); // 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(); - 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(); // 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')); }); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js index 54644dcd..62d8190e 100644 --- a/frontend/src/utils/__tests__/dateTimeUtils.test.js +++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js @@ -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'); }); }); diff --git a/frontend/src/utils/cards/StreamConnectionCardUtils.js b/frontend/src/utils/cards/StreamConnectionCardUtils.js index 5c9d9ccc..75d8e9b2 100644 --- a/frontend/src/utils/cards/StreamConnectionCardUtils.js +++ b/frontend/src/utils/cards/StreamConnectionCardUtils.js @@ -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'; diff --git a/frontend/src/utils/cards/VodConnectionCardUtils.js b/frontend/src/utils/cards/VodConnectionCardUtils.js index 3bf635b6..bf0a2219 100644 --- a/frontend/src/utils/cards/VodConnectionCardUtils.js +++ b/frontend/src/utils/cards/VodConnectionCardUtils.js @@ -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 diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index 6d90d42a..2b9c0888 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -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) => {