From c68d73fbdb45f576741103634db6aea5862814b6 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 18 Feb 2026 14:38:47 -0600 Subject: [PATCH] Update DVR to not require group selection, use new channel summary endpoint. --- frontend/src/components/forms/Recording.jsx | 97 ++++----------------- frontend/src/pages/DVR.jsx | 26 ++---- frontend/src/pages/__tests__/DVR.test.jsx | 88 ++++++++++++------- 3 files changed, 78 insertions(+), 133 deletions(-) diff --git a/frontend/src/components/forms/Recording.jsx b/frontend/src/components/forms/Recording.jsx index 0bcfc7ee..23f78867 100644 --- a/frontend/src/components/forms/Recording.jsx +++ b/frontend/src/components/forms/Recording.jsx @@ -88,13 +88,11 @@ const RecordingModal = ({ isOpen, onClose, }) => { - const channelGroups = useChannelsStore((s) => s.channelGroups); const fetchRecordings = useChannelsStore((s) => s.fetchRecordings); const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules); - // Local state: selected group and channels for that group - const [selectedGroupId, setSelectedGroupId] = useState(null); - const [localChannels, setLocalChannels] = useState([]); + // All channels loaded via lightweight summary API + const [allChannels, setAllChannels] = useState([]); const [isChannelsLoading, setIsChannelsLoading] = useState(false); const [mode, setMode] = useState('single'); @@ -216,36 +214,19 @@ const RecordingModal = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, recording, channel]); - // Initialize group selection to the first available group when modal opens - useEffect(() => { - if (!isOpen) return; - if (selectedGroupId) return; - const firstGroup = Object.values(channelGroups || {}).find( - (g) => g?.hasChannels - ); - if (firstGroup) { - setSelectedGroupId(String(firstGroup.id)); - } - }, [isOpen, selectedGroupId, channelGroups]); - - // Fetch channels for selected group (no global load) + // Load all channels via lightweight summary API when modal opens useEffect(() => { let cancelled = false; const run = async () => { if (!isOpen) return; - if (!selectedGroupId) return; - const group = channelGroups?.[Number(selectedGroupId)]; - if (!group?.name) return; try { setIsChannelsLoading(true); - const params = new URLSearchParams(); - params.set('channel_group', group.name); - const chans = await API.getChannelsForParams(params); + const chans = await API.getChannelsSummary(); if (cancelled) return; - setLocalChannels(Array.isArray(chans) ? chans : []); + setAllChannels(Array.isArray(chans) ? chans : []); } catch (e) { - console.warn('Failed to load channels for group', group?.name, e); - if (!cancelled) setLocalChannels([]); + console.warn('Failed to load channels for recording form', e); + if (!cancelled) setAllChannels([]); } finally { if (!cancelled) setIsChannelsLoading(false); } @@ -254,41 +235,10 @@ const RecordingModal = ({ return () => { cancelled = true; }; - }, [isOpen, selectedGroupId, channelGroups]); - - // When group changes, clear selected channel in both forms - useEffect(() => { - if (!isOpen) return; - singleForm.setFieldValue('channel_id', ''); - recurringForm.setFieldValue('channel_id', ''); - }, [isOpen, selectedGroupId]); - - // After channels load for a group, auto-select the first channel - useEffect(() => { - if (!isOpen) { - return; - } - if (!selectedGroupId) { - return; - } - const first = Array.isArray(localChannels) ? localChannels[0] : null; - if (!first) { - return; - } - if (mode === 'single') { - if (!singleForm.values.channel_id) { - singleForm.setFieldValue('channel_id', `${first.id}`); - } - } else { - if (!recurringForm.values.channel_id) { - recurringForm.setFieldValue('channel_id', `${first.id}`); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, selectedGroupId, localChannels, mode]); + }, [isOpen]); const channelOptions = useMemo(() => { - const list = Array.isArray(localChannels) ? [...localChannels] : []; + const list = Array.isArray(allChannels) ? [...allChannels] : []; list.sort((a, b) => { const aNum = Number(a.channel_number) || 0; const bNum = Number(b.channel_number) || 0; @@ -297,18 +247,11 @@ const RecordingModal = ({ }); return list.map((item) => ({ value: `${item.id}`, - label: item.name || `Channel ${item.id}`, + label: item.channel_number + ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}` + : item.name || `Channel ${item.id}`, })); - }, [localChannels]); - - // Group options (no "All") - const groupOptions = useMemo(() => { - const arr = Object.values(channelGroups || {}).filter( - (g) => g?.hasChannels - ); - arr.sort((a, b) => (a?.name || '').localeCompare(b?.name || '')); - return arr.map((g) => ({ value: String(g.id), label: g.name })); - }, [channelGroups]); + }, [allChannels]); const resetForms = () => { singleForm.reset(); @@ -419,20 +362,10 @@ const RecordingModal = ({
- {/* Group selection (required, no "All") */} - { const recordings = useChannelsStore((s) => s.recordings); const fetchRecordings = useChannelsStore((s) => s.fetchRecordings); const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules); - const channelIds = useChannelsStore((s) => s.channelIds); const [channelsById, setChannelsById] = useState({}); const { toUserTime, userNow } = useTimeHelpers(); @@ -95,29 +94,16 @@ const DVRPage = () => { fetchRecurringRules(); }, [fetchRecordings, fetchRecurringRules]); - // When recordings change, ensure we have local channel details on demand + // Load channel details for recordings via lightweight summary API useEffect(() => { - const neededIds = new Set(); - for (const rec of Array.isArray(recordings) ? recordings : []) { - if (rec?.channel) neededIds.add(rec.channel); - } - const missing = Array.from(neededIds).filter( - (id) => !channelsById[id] - ); - if (missing.length === 0) return; - let cancelled = false; (async () => { try { - const fetched = await Promise.all( - missing.map((id) => API.getChannel(id)) - ); + const channels = await API.getChannelsSummary(); if (cancelled) return; - setChannelsById((prev) => { - const next = { ...prev }; - for (const ch of fetched) if (ch?.id) next[ch.id] = ch; - return next; - }); + const byId = {}; + for (const ch of channels) if (ch?.id) byId[ch.id] = ch; + setChannelsById(byId); } catch (e) { console.warn('Failed to fetch channels for DVR page', e); } @@ -125,7 +111,7 @@ const DVRPage = () => { return () => { cancelled = true; }; - }, [recordings, channelsById]); + }, []); // Re-render every second so time-based bucketing updates without a refresh const [now, setNow] = useState(userNow()); diff --git a/frontend/src/pages/__tests__/DVR.test.jsx b/frontend/src/pages/__tests__/DVR.test.jsx index ba7b187f..83e3e03f 100644 --- a/frontend/src/pages/__tests__/DVR.test.jsx +++ b/frontend/src/pages/__tests__/DVR.test.jsx @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + waitFor, + act, +} from '@testing-library/react'; import DVRPage from '../DVR'; import dayjs from 'dayjs'; import useChannelsStore from '../../store/channels'; @@ -171,8 +177,8 @@ describe('DVRPage', () => { const now = new Date('2024-01-15T12:00:00Z'); vi.setSystemTime(now); - // Default: API.getChannel returns null (no channel data loaded by default) - API.getChannel.mockResolvedValue(null); + // Default: API.getChannelsSummary returns empty array + API.getChannelsSummary.mockResolvedValue([]); isAfter.mockImplementation((a, b) => new Date(a) > new Date(b)); isBefore.mockImplementation((a, b) => new Date(a) < new Date(b)); @@ -228,21 +234,25 @@ describe('DVRPage', () => { }); describe('Initial Render', () => { - it('renders new recording buttons', () => { - render(); + it('renders new recording buttons', async () => { + await act(async () => { + render(); + }); expect(screen.getByText('New Recording')).toBeInTheDocument(); }); - it('renders empty state when no recordings', () => { - render(); + it('renders empty state when no recordings', async () => { + await act(async () => { + render(); + }); expect(screen.getByText('No upcoming recordings.')).toBeInTheDocument(); }); }); describe('Recording Display', () => { - it('displays recordings grouped by date', () => { + it('displays recordings grouped by date', async () => { const now = dayjs('2024-01-15T12:00:00Z'); const recordings = [ { @@ -266,7 +276,9 @@ describe('DVRPage', () => { return selector ? selector(state) : state; }); - render(); + await act(async () => { + render(); + }); expect(screen.getByTestId('recording-card-1')).toBeInTheDocument(); expect(screen.getByTestId('recording-card-2')).toBeInTheDocument(); @@ -275,24 +287,34 @@ describe('DVRPage', () => { describe('New Recording', () => { it('opens recording form when new recording button is clicked', async () => { - render(); + await act(async () => { + render(); + }); const newButton = screen.getByText('New Recording'); - fireEvent.click(newButton); + act(() => { + fireEvent.click(newButton); + }); expect(screen.getByTestId('recording-form')).toBeInTheDocument(); }); it('closes recording form when close is clicked', async () => { - render(); + await act(async () => { + render(); + }); const newButton = screen.getByText('New Recording'); - fireEvent.click(newButton); + act(() => { + fireEvent.click(newButton); + }); expect(screen.getByTestId('recording-form')).toBeInTheDocument(); const closeButton = screen.getByText('Close Form'); - fireEvent.click(closeButton); + act(() => { + fireEvent.click(closeButton); + }); expect(screen.queryByTestId('recording-form')).not.toBeInTheDocument(); }); @@ -395,10 +417,13 @@ describe('DVRPage', () => { return selector ? selector(state) : state; }); - render(); + await act(async () => { + render(); + }); - const recurringButton = screen.getByText('Open Recurring'); - fireEvent.click(recurringButton); + act(() => { + fireEvent.click(screen.getByText('Open Recurring')); + }); expect(screen.getByTestId('recurring-modal')).toBeInTheDocument(); expect(screen.getByText('Rule ID: 100')).toBeInTheDocument(); @@ -426,15 +451,19 @@ describe('DVRPage', () => { return selector ? selector(state) : state; }); - render(); + await act(async () => { + render(); + }); - const recurringButton = screen.getByText('Open Recurring'); - fireEvent.click(recurringButton); + act(() => { + fireEvent.click(screen.getByText('Open Recurring')); + }); expect(screen.getByTestId('recurring-modal')).toBeInTheDocument(); - const closeButton = screen.getByText('Close Recurring'); - fireEvent.click(closeButton); + act(() => { + fireEvent.click(screen.getByText('Close Recurring')); + }); expect(screen.queryByTestId('recurring-modal')).not.toBeInTheDocument(); }); @@ -453,14 +482,11 @@ describe('DVRPage', () => { custom_properties: { Title: 'Live Show' }, }; - // DVR.jsx now lazy-loads channel data via API.getChannel rather than - // reading s.channels from the store. Mock the API so channelsById - // gets populated before the Watch Live handler runs. - API.getChannel.mockResolvedValue({ - id: 1, - name: 'Channel 1', - stream_url: 'http://stream.url', - }); + // DVR.jsx loads all channel data via getChannelsSummary. + // Mock the API so channelsById gets populated before the handler runs. + API.getChannelsSummary.mockResolvedValue([ + { id: 1, name: 'Channel 1', stream_url: 'http://stream.url' }, + ]); useChannelsStore.mockImplementation((selector) => { const state = { @@ -479,7 +505,7 @@ describe('DVRPage', () => { // Wait for channelsById to be populated from the async API call await waitFor(() => { - expect(API.getChannel).toHaveBeenCalledWith(1); + expect(API.getChannelsSummary).toHaveBeenCalled(); }); const watchLiveButton = screen.getByText('Watch Live');