diff --git a/frontend/src/Activity/Queue/QueueFilterModal.tsx b/frontend/src/Activity/Queue/QueueFilterModal.tsx index 26c55f55e..36b7f0da2 100644 --- a/frontend/src/Activity/Queue/QueueFilterModal.tsx +++ b/frontend/src/Activity/Queue/QueueFilterModal.tsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react'; +import { SetFilter } from 'Components/Filter/Filter'; import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal'; import { setQueueOption } from './queueOptionsStore'; import useQueue, { FILTER_BUILDER } from './useQueue'; @@ -8,12 +9,9 @@ type QueueFilterModalProps = FilterModalProps; export default function QueueFilterModal(props: QueueFilterModalProps) { const { records } = useQueue(); - const dispatchSetFilter = useCallback( - ({ selectedFilterKey }: { selectedFilterKey: string | number }) => { - setQueueOption('selectedFilterKey', selectedFilterKey); - }, - [] - ); + const dispatchSetFilter = useCallback(({ selectedFilterKey }: SetFilter) => { + setQueueOption('selectedFilterKey', selectedFilterKey); + }, []); return ( , - AppSectionFilterState { - searchMissingCommandId: number | null; - start: moment.Moment; - end: moment.Moment; - dates: string[]; - time: string; - view: CalendarView; - options: CalendarOptions; -} - -export default CalendarAppState; diff --git a/frontend/src/Calendar/Agenda/Agenda.tsx b/frontend/src/Calendar/Agenda/Agenda.tsx index fdef40466..9375e307e 100644 --- a/frontend/src/Calendar/Agenda/Agenda.tsx +++ b/frontend/src/Calendar/Agenda/Agenda.tsx @@ -1,20 +1,19 @@ import moment from 'moment'; import React from 'react'; -import { useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; +import useCalendar from 'Calendar/useCalendar'; import AgendaEvent from './AgendaEvent'; import styles from './Agenda.css'; function Agenda() { - const { items } = useSelector((state: AppState) => state.calendar); + const { data } = useCalendar(); return (
- {items.map((item, index) => { + {data.map((item, index) => { const momentDate = moment(item.airDateUtc); const showDate = index === 0 || - !moment(items[index - 1].airDateUtc).isSame(momentDate, 'day'); + !moment(data[index - 1].airDateUtc).isSame(momentDate, 'day'); return ; })} diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.tsx b/frontend/src/Calendar/Agenda/AgendaEvent.tsx index 0188be058..87ea414a0 100644 --- a/frontend/src/Calendar/Agenda/AgendaEvent.tsx +++ b/frontend/src/Calendar/Agenda/AgendaEvent.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames'; import React, { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider'; -import AppState from 'App/State/AppState'; +import { useCalendarOptions } from 'Calendar/calendarOptionsStore'; import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; import getStatusStyle from 'Calendar/getStatusStyle'; import Icon from 'Components/Icon'; @@ -66,7 +66,7 @@ function AgendaEvent(props: AgendaEventProps) { showFinaleIcon, showSpecialIcon, showCutoffUnmetIcon, - } = useSelector((state: AppState) => state.calendar.options); + } = useCalendarOptions(); const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); diff --git a/frontend/src/Calendar/Calendar.tsx b/frontend/src/Calendar/Calendar.tsx index 3b80fe904..87500fa33 100644 --- a/frontend/src/Calendar/Calendar.tsx +++ b/frontend/src/Calendar/Calendar.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; import * as commandNames from 'Commands/commandNames'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; @@ -8,17 +7,11 @@ import Episode from 'Episode/Episode'; import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { kinds } from 'Helpers/Props'; -import { - clearCalendar, - fetchCalendar, - gotoCalendarToday, -} from 'Store/Actions/calendarActions'; import { clearEpisodeFiles, fetchEpisodeFiles, } from 'Store/Actions/episodeFileActions'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import { registerPagePopulator, @@ -26,9 +19,11 @@ import { } from 'Utilities/pagePopulator'; import translate from 'Utilities/String/translate'; import Agenda from './Agenda/Agenda'; +import { useCalendarOption } from './calendarOptionsStore'; import CalendarDays from './Day/CalendarDays'; import DaysOfWeek from './Day/DaysOfWeek'; import CalendarHeader from './Header/CalendarHeader'; +import useCalendar, { goToToday } from './useCalendar'; import styles from './Calendar.css'; const UPDATE_DELAY = 3600000; // 1 hour @@ -38,54 +33,44 @@ function Calendar() { const requestCurrentPage = useCurrentPage(); const updateTimeout = useRef>(); - const { isFetching, isPopulated, error, items, time, view } = useSelector( - (state: AppState) => state.calendar - ); + const { data, isFetching, isLoading, error, refetch } = useCalendar(); + const view = useCalendarOption('view'); const isRefreshingSeries = useSelector( createCommandExecutingSelector(commandNames.REFRESH_SERIES) ); - const firstDayOfWeek = useSelector( - (state: AppState) => state.settings.ui.item.firstDayOfWeek - ); - const wasRefreshingSeries = usePrevious(isRefreshingSeries); - const previousFirstDayOfWeek = usePrevious(firstDayOfWeek); - const previousItems = usePrevious(items); const handleScheduleUpdate = useCallback(() => { clearTimeout(updateTimeout.current); function updateCalendar() { - dispatch(gotoCalendarToday()); + goToToday(); updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY); } updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY); - }, [dispatch]); + }, []); useEffect(() => { handleScheduleUpdate(); return () => { - dispatch(clearCalendar()); dispatch(clearEpisodeFiles()); clearTimeout(updateTimeout.current); }; }, [dispatch, handleScheduleUpdate]); useEffect(() => { - if (requestCurrentPage) { - dispatch(fetchCalendar()); - } else { - dispatch(gotoCalendarToday()); + if (!requestCurrentPage) { + goToToday(); } }, [requestCurrentPage, dispatch]); useEffect(() => { const repopulate = () => { - dispatch(fetchCalendar({ time, view })); + refetch(); }; registerPagePopulator(repopulate, [ @@ -96,53 +81,42 @@ function Calendar() { return () => { unregisterPagePopulator(repopulate); }; - }, [time, view, dispatch]); + }, [refetch]); useEffect(() => { handleScheduleUpdate(); - }, [time, handleScheduleUpdate]); - - useEffect(() => { - if ( - previousFirstDayOfWeek != null && - firstDayOfWeek !== previousFirstDayOfWeek - ) { - dispatch(fetchCalendar({ time, view })); - } - }, [time, view, firstDayOfWeek, previousFirstDayOfWeek, dispatch]); + }, [handleScheduleUpdate]); useEffect(() => { if (wasRefreshingSeries && !isRefreshingSeries) { - dispatch(fetchCalendar({ time, view })); + refetch(); } - }, [time, view, isRefreshingSeries, wasRefreshingSeries, dispatch]); + }, [isRefreshingSeries, wasRefreshingSeries, refetch]); useEffect(() => { - if (!previousItems || hasDifferentItems(items, previousItems)) { - const episodeFileIds = selectUniqueIds( - items, - 'episodeFileId' - ); + const episodeFileIds = selectUniqueIds( + data, + 'episodeFileId' + ); - if (episodeFileIds.length) { - dispatch(fetchEpisodeFiles({ episodeFileIds })); - } + if (episodeFileIds.length) { + dispatch(fetchEpisodeFiles({ episodeFileIds })); } - }, [items, previousItems, dispatch]); + }, [data, dispatch]); return (
- {isFetching && !isPopulated ? : null} + {isLoading ? : null} {!isFetching && error ? ( {translate('CalendarLoadError')} ) : null} - {!error && isPopulated && view === 'agenda' ? ( + {!error && !isLoading && view === 'agenda' ? (
) : null} - {!error && isPopulated && view !== 'agenda' ? ( + {!error && !isLoading && view !== 'agenda' ? (
diff --git a/frontend/src/Calendar/CalendarFilterModal.tsx b/frontend/src/Calendar/CalendarFilterModal.tsx index 24edba2ff..6b7367029 100644 --- a/frontend/src/Calendar/CalendarFilterModal.tsx +++ b/frontend/src/Calendar/CalendarFilterModal.tsx @@ -1,49 +1,24 @@ import React, { useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; +import { SetFilter } from 'Components/Filter/Filter'; import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal'; -import { setCalendarFilter } from 'Store/Actions/calendarActions'; - -function createCalendarSelector() { - return createSelector( - (state: AppState) => state.calendar.items, - (calendar) => { - return calendar; - } - ); -} - -function createFilterBuilderPropsSelector() { - return createSelector( - (state: AppState) => state.calendar.filterBuilderProps, - (filterBuilderProps) => { - return filterBuilderProps; - } - ); -} +import { setCalendarOption } from './calendarOptionsStore'; +import useCalendar, { FILTER_BUILDER } from './useCalendar'; type CalendarFilterModalProps = FilterModalProps; export default function CalendarFilterModal(props: CalendarFilterModalProps) { - const sectionItems = useSelector(createCalendarSelector()); - const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const { data } = useCalendar(); const customFilterType = 'calendar'; - const dispatch = useDispatch(); - - const dispatchSetFilter = useCallback( - (payload: unknown) => { - dispatch(setCalendarFilter(payload)); - }, - [dispatch] - ); + const dispatchSetFilter = useCallback(({ selectedFilterKey }: SetFilter) => { + setCalendarOption('selectedFilterKey', selectedFilterKey); + }, []); return ( diff --git a/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx b/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx index 05ac698fe..66bf8903f 100644 --- a/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx +++ b/frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx @@ -3,66 +3,61 @@ import React, { useCallback } from 'react'; import { useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import { useQueueDetails } from 'Activity/Queue/Details/QueueDetailsProvider'; -import AppState from 'App/State/AppState'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import { icons } from 'Helpers/Props'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; -import Queue from 'typings/Queue'; import { isCommandExecuting } from 'Utilities/Command'; import isBefore from 'Utilities/Date/isBefore'; import translate from 'Utilities/String/translate'; +import useCalendar, { + useCalendarRange, + useCalendarSearchMissingCommandId, +} from './useCalendar'; -function createIsSearchingSelector() { - return createSelector( - (state: AppState) => state.calendar.searchMissingCommandId, - createCommandsSelector(), - (searchMissingCommandId, commands) => { - if (searchMissingCommandId == null) { - return false; - } - - return isCommandExecuting( - commands.find((command) => { - return command.id === searchMissingCommandId; - }) - ); +function createIsSearchingSelector(searchMissingCommandId: number | undefined) { + return createSelector(createCommandsSelector(), (commands) => { + if (searchMissingCommandId == null) { + return false; } - ); + + return isCommandExecuting( + commands.find((command) => { + return command.id === searchMissingCommandId; + }) + ); + }); } -function createMissingEpisodeIdsSelector(queueDetails: Queue[]) { - return createSelector( - (state: AppState) => state.calendar.start, - (state: AppState) => state.calendar.end, - (state: AppState) => state.calendar.items, - (start, end, episodes) => { - return episodes.reduce((acc, episode) => { - const airDateUtc = episode.airDateUtc; +const useMissingEpisodeIdsSelector = () => { + const { start, end } = useCalendarRange(); + const { data } = useCalendar(); + const queueDetails = useQueueDetails(); - if ( - !episode.episodeFileId && - moment(airDateUtc).isAfter(start) && - moment(airDateUtc).isBefore(end) && - isBefore(episode.airDateUtc) && - !queueDetails.some( - (details) => !!details.episode && details.episode.id === episode.id - ) - ) { - acc.push(episode.id); - } + return data.reduce((acc, episode) => { + const airDateUtc = episode.airDateUtc; - return acc; - }, []); + if ( + !episode.episodeFileId && + moment(airDateUtc).isAfter(start) && + moment(airDateUtc).isBefore(end) && + isBefore(episode.airDateUtc) && + !queueDetails.some( + (details) => !!details.episode && details.episode.id === episode.id + ) + ) { + acc.push(episode.id); } - ); -} + + return acc; + }, []); +}; export default function CalendarMissingEpisodeSearchButton() { - const queueDetails = useQueueDetails(); - const missingEpisodeIds = useSelector( - createMissingEpisodeIdsSelector(queueDetails) + const searchMissingCommandId = useCalendarSearchMissingCommandId(); + const missingEpisodeIds = useMissingEpisodeIdsSelector(); + const isSearchingForMissing = useSelector( + createIsSearchingSelector(searchMissingCommandId) ); - const isSearchingForMissing = useSelector(createIsSearchingSelector()); const handlePress = useCallback(() => {}, []); diff --git a/frontend/src/Calendar/CalendarPage.tsx b/frontend/src/Calendar/CalendarPage.tsx index 5f91beddb..7bf1f54be 100644 --- a/frontend/src/Calendar/CalendarPage.tsx +++ b/frontend/src/Calendar/CalendarPage.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import QueueDetails from 'Activity/Queue/Details/QueueDetailsProvider'; -import AppState from 'App/State/AppState'; import * as commandNames from 'Commands/commandNames'; import FilterMenu from 'Components/Menu/FilterMenu'; import PageContent from 'Components/Page/PageContent'; @@ -14,10 +13,6 @@ import Episode from 'Episode/Episode'; import useMeasure from 'Helpers/Hooks/useMeasure'; import { align, icons } from 'Helpers/Props'; import NoSeries from 'Series/NoSeries'; -import { - setCalendarDaysCount, - setCalendarFilter, -} from 'Store/Actions/calendarActions'; import { executeCommand } from 'Store/Actions/commandActions'; import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; @@ -27,9 +22,15 @@ import translate from 'Utilities/String/translate'; import Calendar from './Calendar'; import CalendarFilterModal from './CalendarFilterModal'; import CalendarMissingEpisodeSearchButton from './CalendarMissingEpisodeSearchButton'; +import { setCalendarOption, useCalendarOption } from './calendarOptionsStore'; import CalendarLinkModal from './iCal/CalendarLinkModal'; import Legend from './Legend/Legend'; import CalendarOptionsModal from './Options/CalendarOptionsModal'; +import useCalendar, { + FILTERS, + setCalendarDayCount, + useCalendarPage, +} from './useCalendar'; import styles from './CalendarPage.css'; const MINIMUM_DAY_WIDTH = 120; @@ -37,9 +38,11 @@ const MINIMUM_DAY_WIDTH = 120; function CalendarPage() { const dispatch = useDispatch(); - const { selectedFilterKey, filters, items } = useSelector( - (state: AppState) => state.calendar - ); + const selectedFilterKey = useCalendarOption('selectedFilterKey'); + const { data } = useCalendar(); + + useCalendarPage(); + const isRssSyncExecuting = useSelector( createCommandExecutingSelector(commandNames.RSS_SYNC) ); @@ -77,16 +80,13 @@ function CalendarPage() { ); }, [dispatch]); - const handleFilterSelect = useCallback( - (key: string | number) => { - dispatch(setCalendarFilter({ selectedFilterKey: key })); - }, - [dispatch] - ); + const handleFilterSelect = useCallback((key: string | number) => { + setCalendarOption('selectedFilterKey', key); + }, []); const episodeIds = useMemo(() => { - return selectUniqueIds(items, 'id'); - }, [items]); + return selectUniqueIds(data, 'id'); + }, [data]); useEffect(() => { if (width === 0) { @@ -98,8 +98,8 @@ function CalendarPage() { Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)) ); - dispatch(setCalendarDaysCount({ dayCount })); - }, [width, dispatch]); + setCalendarDayCount(dayCount); + }, [width]); return ( @@ -135,7 +135,7 @@ function CalendarPage() { alignMenu={align.RIGHT} isDisabled={!hasSeries} selectedFilterKey={selectedFilterKey} - filters={filters} + filters={FILTERS} customFilters={customFilters} filterModalConnectorComponent={CalendarFilterModal} onFilterSelect={handleFilterSelect} diff --git a/frontend/src/Calendar/Day/CalendarDay.tsx b/frontend/src/Calendar/Day/CalendarDay.tsx index a619109ca..537e6290f 100644 --- a/frontend/src/Calendar/Day/CalendarDay.tsx +++ b/frontend/src/Calendar/Day/CalendarDay.tsx @@ -1,12 +1,11 @@ import classNames from 'classnames'; import moment from 'moment'; import React from 'react'; -import { useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; -import AppState from 'App/State/AppState'; +import { useCalendarOption } from 'Calendar/calendarOptionsStore'; import * as calendarViews from 'Calendar/calendarViews'; import CalendarEvent from 'Calendar/Events/CalendarEvent'; import CalendarEventGroup from 'Calendar/Events/CalendarEventGroup'; +import useCalendar, { useCalendarTime } from 'Calendar/useCalendar'; import { CalendarEvent as CalendarEventModel, CalendarEventGroup as CalendarEventGroupModel, @@ -28,63 +27,61 @@ function sort(items: (CalendarEventModel | CalendarEventGroupModel)[]) { }); } -function createCalendarEventsConnector(date: string) { - return createSelector( - (state: AppState) => state.calendar.items, - (state: AppState) => state.calendar.options.collapseMultipleEpisodes, - (items, collapseMultipleEpisodes) => { - const momentDate = moment(date); - - const filtered = items.filter((item) => { - return momentDate.isSame(moment(item.airDateUtc), 'day'); - }); - - if (!collapseMultipleEpisodes) { - return sort( - filtered.map((item) => ({ - isGroup: false, - ...item, - })) - ); - } - - const groupedObject = Object.groupBy( - filtered, - (item: CalendarItem) => `${item.seriesId}-${item.seasonNumber}` - ); - - const grouped = Object.entries(groupedObject).reduce< - (CalendarEventModel | CalendarEventGroupModel)[] - >((acc, [, events]) => { - if (!events) { - return acc; - } - - if (events.length === 1) { - acc.push({ - isGroup: false, - ...events[0], - }); - } else { - acc.push({ - isGroup: true, - seriesId: events[0].seriesId, - seasonNumber: events[0].seasonNumber, - episodeIds: events.map((event) => event.id), - events: events.sort( - (a, b) => - moment(a.airDateUtc).unix() - moment(b.airDateUtc).unix() - ), - }); - } - - return acc; - }, []); - - return sort(grouped); - } +const useCalendarEvents = (date: string) => { + const { data } = useCalendar(); + const collapseMultipleEpisodes = useCalendarOption( + 'collapseMultipleEpisodes' ); -} + + const momentDate = moment(date); + + const filtered = data.filter((item) => { + return momentDate.isSame(moment(item.airDateUtc), 'day'); + }); + + if (!collapseMultipleEpisodes) { + return sort( + filtered.map((item) => ({ + isGroup: false, + ...item, + })) + ); + } + + const groupedObject = Object.groupBy( + filtered, + (item: CalendarItem) => `${item.seriesId}-${item.seasonNumber}` + ); + + const grouped = Object.entries(groupedObject).reduce< + (CalendarEventModel | CalendarEventGroupModel)[] + >((acc, [, events]) => { + if (!events) { + return acc; + } + + if (events.length === 1) { + acc.push({ + isGroup: false, + ...events[0], + }); + } else { + acc.push({ + isGroup: true, + seriesId: events[0].seriesId, + seasonNumber: events[0].seasonNumber, + episodeIds: events.map((event) => event.id), + events: events.sort( + (a, b) => moment(a.airDateUtc).unix() - moment(b.airDateUtc).unix() + ), + }); + } + + return acc; + }, []); + + return sort(grouped); +}; interface CalendarDayProps { date: string; @@ -97,8 +94,9 @@ function CalendarDay({ isTodaysDate, onEventModalOpenToggle, }: CalendarDayProps) { - const { time, view } = useSelector((state: AppState) => state.calendar); - const events = useSelector(createCalendarEventsConnector(date)); + const view = useCalendarOption('view'); + const time = useCalendarTime(); + const events = useCalendarEvents(date); const ref = React.useRef(null); diff --git a/frontend/src/Calendar/Day/CalendarDays.tsx b/frontend/src/Calendar/Day/CalendarDays.tsx index 149dc1455..75efa315b 100644 --- a/frontend/src/Calendar/Day/CalendarDays.tsx +++ b/frontend/src/Calendar/Day/CalendarDays.tsx @@ -3,17 +3,20 @@ import moment from 'moment'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import AppState from 'App/State/AppState'; +import { useCalendarOption } from 'Calendar/calendarOptionsStore'; import * as calendarViews from 'Calendar/calendarViews'; import { - gotoCalendarNextRange, - gotoCalendarPreviousRange, -} from 'Store/Actions/calendarActions'; + goToNextRange, + goToPreviousRange, + useCalendarDates, +} from 'Calendar/useCalendar'; import CalendarDay from './CalendarDay'; import styles from './CalendarDays.css'; function CalendarDays() { const dispatch = useDispatch(); - const { dates, view } = useSelector((state: AppState) => state.calendar); + const view = useCalendarOption('view'); + const dates = useCalendarDates(); const isSidebarVisible = useSelector( (state: AppState) => state.app.isSidebarVisible ); @@ -71,12 +74,12 @@ function CalendarDays() { currentTouch > touchStart.current && currentTouch - touchStart.current > 100 ) { - dispatch(gotoCalendarPreviousRange()); + dispatch(goToPreviousRange()); } else if ( currentTouch < touchStart.current && touchStart.current - currentTouch > 100 ) { - dispatch(gotoCalendarNextRange()); + dispatch(goToNextRange()); } touchStart.current = null; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.tsx b/frontend/src/Calendar/Day/DaysOfWeek.tsx index 64bc886cc..716f743f8 100644 --- a/frontend/src/Calendar/Day/DaysOfWeek.tsx +++ b/frontend/src/Calendar/Day/DaysOfWeek.tsx @@ -1,14 +1,16 @@ import moment from 'moment'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; +import { useCalendarOption } from 'Calendar/calendarOptionsStore'; import * as calendarViews from 'Calendar/calendarViews'; +import { useCalendarDates } from 'Calendar/useCalendar'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import DayOfWeek from './DayOfWeek'; import styles from './DaysOfWeek.css'; function DaysOfWeek() { - const { dates, view } = useSelector((state: AppState) => state.calendar); + const view = useCalendarOption('view'); + const dates = useCalendarDates(); const { calendarWeekColumnHeader, shortDateFormat, showRelativeDates } = useSelector(createUISettingsSelector()); diff --git a/frontend/src/Calendar/Events/CalendarEvent.tsx b/frontend/src/Calendar/Events/CalendarEvent.tsx index 64ed3d153..3a2c89a6a 100644 --- a/frontend/src/Calendar/Events/CalendarEvent.tsx +++ b/frontend/src/Calendar/Events/CalendarEvent.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames'; import React, { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider'; -import AppState from 'App/State/AppState'; +import { useCalendarOptions } from 'Calendar/calendarOptionsStore'; import getStatusStyle from 'Calendar/getStatusStyle'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; @@ -70,7 +70,7 @@ function CalendarEvent(props: CalendarEventProps) { showSpecialIcon, showCutoffUnmetIcon, fullColorEvents, - } = useSelector((state: AppState) => state.calendar.options); + } = useCalendarOptions(); const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.tsx b/frontend/src/Calendar/Events/CalendarEventGroup.tsx index 1458011c1..9b64574b5 100644 --- a/frontend/src/Calendar/Events/CalendarEventGroup.tsx +++ b/frontend/src/Calendar/Events/CalendarEventGroup.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames'; import React, { useCallback, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider'; -import AppState from 'App/State/AppState'; +import { useCalendarOptions } from 'Calendar/calendarOptionsStore'; import getStatusStyle from 'Calendar/getStatusStyle'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; @@ -39,7 +39,7 @@ function CalendarEventGroup({ ); const { showEpisodeInformation, showFinaleIcon, fullColorEvents } = - useSelector((state: AppState) => state.calendar.options); + useCalendarOptions(); const [isExpanded, setIsExpanded] = useState(false); diff --git a/frontend/src/Calendar/Header/CalendarHeader.tsx b/frontend/src/Calendar/Header/CalendarHeader.tsx index 94bc8635e..d2e86bccb 100644 --- a/frontend/src/Calendar/Header/CalendarHeader.tsx +++ b/frontend/src/Calendar/Header/CalendarHeader.tsx @@ -1,7 +1,18 @@ import moment from 'moment'; import React, { useCallback, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; +import { useSelector } from 'react-redux'; +import { + setCalendarOption, + useCalendarOption, +} from 'Calendar/calendarOptionsStore'; +import { CalendarView } from 'Calendar/calendarViews'; +import useCalendar, { + goToNextRange, + goToPreviousRange, + goToToday, + useCalendarRange, + useCalendarTime, +} from 'Calendar/useCalendar'; import Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; @@ -10,12 +21,6 @@ import MenuButton from 'Components/Menu/MenuButton'; import MenuContent from 'Components/Menu/MenuContent'; import ViewMenuItem from 'Components/Menu/ViewMenuItem'; import { align, icons } from 'Helpers/Props'; -import { - gotoCalendarNextRange, - gotoCalendarPreviousRange, - gotoCalendarToday, - setCalendarView, -} from 'Store/Actions/calendarActions'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import translate from 'Utilities/String/translate'; @@ -23,11 +28,10 @@ import CalendarHeaderViewButton from './CalendarHeaderViewButton'; import styles from './CalendarHeader.css'; function CalendarHeader() { - const dispatch = useDispatch(); - - const { isFetching, view, time, start, end } = useSelector( - (state: AppState) => state.calendar - ); + const { isFetching } = useCalendar(); + const view = useCalendarOption('view'); + const time = useCalendarTime(); + const { start, end } = useCalendarRange(); const { isSmallScreen, isLargeScreen } = useSelector( createDimensionsSelector() @@ -35,24 +39,21 @@ function CalendarHeader() { const { longDateFormat } = useSelector(createUISettingsSelector()); - const handleViewChange = useCallback( - (newView: string) => { - dispatch(setCalendarView({ view: newView })); - }, - [dispatch] - ); + const handleViewChange = useCallback((newView: string) => { + setCalendarOption('view', newView as CalendarView); + }, []); const handleTodayPress = useCallback(() => { - dispatch(gotoCalendarToday()); - }, [dispatch]); + goToToday(); + }, []); const handlePreviousPress = useCallback(() => { - dispatch(gotoCalendarPreviousRange()); - }, [dispatch]); + goToPreviousRange(); + }, []); const handleNextPress = useCallback(() => { - dispatch(gotoCalendarNextRange()); - }, [dispatch]); + goToNextRange(); + }, []); const title = useMemo(() => { const timeMoment = moment(time); diff --git a/frontend/src/Calendar/Legend/Legend.tsx b/frontend/src/Calendar/Legend/Legend.tsx index b9887f856..33e49107e 100644 --- a/frontend/src/Calendar/Legend/Legend.tsx +++ b/frontend/src/Calendar/Legend/Legend.tsx @@ -1,6 +1,9 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; +import { + useCalendarOption, + useCalendarOptions, +} from 'Calendar/calendarOptionsStore'; import { icons, kinds } from 'Helpers/Props'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import translate from 'Utilities/String/translate'; @@ -9,13 +12,13 @@ import LegendItem from './LegendItem'; import styles from './Legend.css'; function Legend() { - const view = useSelector((state: AppState) => state.calendar.view); + const view = useCalendarOption('view'); const { showFinaleIcon, showSpecialIcon, showCutoffUnmetIcon, fullColorEvents, - } = useSelector((state: AppState) => state.calendar.options); + } = useCalendarOptions(); const { enableColorImpairedMode } = useSelector(createUISettingsSelector()); const iconsToShow = []; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx b/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx index 4f974dda3..6c2ef3b06 100644 --- a/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx @@ -1,6 +1,10 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import AppState from 'App/State/AppState'; +import { + CalendarOptions, + setCalendarOption, + useCalendarOptions, +} from 'Calendar/calendarOptionsStore'; import FieldSet from 'Components/FieldSet'; import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; @@ -11,13 +15,13 @@ import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import { OptionChanged } from 'Helpers/Hooks/useOptionsStore'; import { inputTypes } from 'Helpers/Props'; import { firstDayOfWeekOptions, timeFormatOptions, weekColumnOptions, } from 'Settings/UI/UISettings'; -import { setCalendarOption } from 'Store/Actions/calendarActions'; import { saveUISettings } from 'Store/Actions/settingsActions'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import { InputChanged } from 'typings/inputs'; @@ -40,7 +44,7 @@ function CalendarOptionsModalContent({ showSpecialIcon, showCutoffUnmetIcon, fullColorEvents, - } = useSelector((state: AppState) => state.calendar.options); + } = useCalendarOptions(); const uiSettings = useSelector(createUISettingsSelector()); @@ -59,10 +63,10 @@ function CalendarOptionsModalContent({ } = state; const handleOptionInputChange = useCallback( - ({ name, value }: InputChanged) => { - dispatch(setCalendarOption({ [name]: value })); + ({ name, value }: OptionChanged) => { + setCalendarOption(name, value); }, - [dispatch] + [] ); const handleGlobalInputChange = useCallback( @@ -98,6 +102,7 @@ function CalendarOptionsModalContent({ name="collapseMultipleEpisodes" value={collapseMultipleEpisodes} helpText={translate('CollapseMultipleEpisodesHelpText')} + // @ts-expect-error - The typing for inputs needs more work onChange={handleOptionInputChange} /> @@ -110,6 +115,7 @@ function CalendarOptionsModalContent({ name="showEpisodeInformation" value={showEpisodeInformation} helpText={translate('ShowEpisodeInformationHelpText')} + // @ts-expect-error - The typing for inputs needs more work onChange={handleOptionInputChange} /> @@ -122,6 +128,7 @@ function CalendarOptionsModalContent({ name="showFinaleIcon" value={showFinaleIcon} helpText={translate('IconForFinalesHelpText')} + // @ts-expect-error - The typing for inputs needs more work onChange={handleOptionInputChange} /> @@ -134,6 +141,7 @@ function CalendarOptionsModalContent({ name="showSpecialIcon" value={showSpecialIcon} helpText={translate('IconForSpecialsHelpText')} + // @ts-expect-error - The typing for inputs needs more work onChange={handleOptionInputChange} /> @@ -146,6 +154,7 @@ function CalendarOptionsModalContent({ name="showCutoffUnmetIcon" value={showCutoffUnmetIcon} helpText={translate('IconForCutoffUnmetHelpText')} + // @ts-expect-error - The typing for inputs needs more work onChange={handleOptionInputChange} /> @@ -158,6 +167,7 @@ function CalendarOptionsModalContent({ name="fullColorEvents" value={fullColorEvents} helpText={translate('FullColorEventsHelpText')} + // @ts-expect-error - The typing for inputs needs more work onChange={handleOptionInputChange} /> diff --git a/frontend/src/Calendar/calendarOptionsStore.ts b/frontend/src/Calendar/calendarOptionsStore.ts new file mode 100644 index 000000000..35ff1a212 --- /dev/null +++ b/frontend/src/Calendar/calendarOptionsStore.ts @@ -0,0 +1,35 @@ +import { SelectedFilterKey } from 'Components/Filter/Filter'; +import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore'; +import { CalendarView } from './calendarViews'; + +export interface CalendarOptions { + collapseMultipleEpisodes: boolean; + showEpisodeInformation: boolean; + showFinaleIcon: boolean; + showSpecialIcon: boolean; + showCutoffUnmetIcon: boolean; + fullColorEvents: boolean; + selectedFilterKey: SelectedFilterKey; + view: CalendarView; +} + +const { useOptions, useOption, getOptions, getOption, setOptions, setOption } = + createOptionsStore('calendar_options', () => { + return { + collapseMultipleEpisodes: false, + showEpisodeInformation: true, + showFinaleIcon: false, + showSpecialIcon: false, + showCutoffUnmetIcon: false, + fullColorEvents: false, + selectedFilterKey: 'monitored', + view: window.innerWidth > 768 ? 'week' : 'day', + }; + }); + +export const useCalendarOptions = useOptions; +export const getCalendarOptions = getOptions; +export const setCalendarOptions = setOptions; +export const useCalendarOption = useOption; +export const getCalendarOption = getOption; +export const setCalendarOption = setOption; diff --git a/frontend/src/Calendar/useCalendar.ts b/frontend/src/Calendar/useCalendar.ts new file mode 100644 index 000000000..bb0af5db0 --- /dev/null +++ b/frontend/src/Calendar/useCalendar.ts @@ -0,0 +1,304 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import moment from 'moment'; +import { useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { create } from 'zustand'; +import AppState, { Filter, FilterBuilderProp } from 'App/State/AppState'; +import Command from 'Commands/Command'; +import * as commandNames from 'Commands/commandNames'; +import { setEpisodeQueryKey } from 'Episode/useEpisode'; +import useApiQuery from 'Helpers/Hooks/useApiQuery'; +import { filterBuilderValueTypes } from 'Helpers/Props'; +import { executeCommandHelper } from 'Store/Actions/commandActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import { CalendarItem } from 'typings/Calendar'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; +import translate from 'Utilities/String/translate'; +import { getCalendarOption, useCalendarOption } from './calendarOptionsStore'; +import { CalendarView } from './calendarViews'; + +export const FILTERS: Filter[] = [ + { + key: 'all', + label: () => translate('All'), + filters: [], + }, + { + key: 'monitored', + label: () => translate('MonitoredOnly'), + filters: [ + { + key: 'unmonitored', + value: [false], + type: 'equal', + }, + ], + }, +]; + +export const FILTER_BUILDER: FilterBuilderProp[] = [ + { + name: 'unmonitored', + label: () => translate('IncludeUnmonitored'), + type: 'equal', + valueType: filterBuilderValueTypes.BOOL, + }, + { + name: 'tags', + label: () => translate('Tags'), + type: 'contains', + valueType: filterBuilderValueTypes.TAG, + }, +]; + +interface CalendarStore { + time: moment.Moment; + dates: string[]; + dayCount: number; + searchMissingCommandId?: number; +} + +const calendarStore = create(() => ({ + time: moment(), + dates: [], + dayCount: 7, + queryKey: null, +})); + +const VIEW_RANGES: Record< + CalendarView, + moment.unitOfTime.DurationConstructor | undefined +> = { + agenda: undefined, + day: 'day', + week: 'week', + month: 'month', + forecast: 'day', +}; + +const useCalendar = () => { + const dates = useCalendarDates(); + const time = useCalendarTime(); + const selectedFilterKey = useCalendarOption('selectedFilterKey'); + const view = useCalendarOption('view'); + const customFilters = useSelector(createCustomFiltersSelector('calendar')); + + const { start, end } = useMemo(() => { + return getPopulatableRange(dates[0], dates[dates.length - 1], view); + }, [dates, view]); + + const { unmonitored, tags } = useMemo(() => { + const selectedFilters = findSelectedFilters( + selectedFilterKey, + FILTERS, + customFilters + ); + + return selectedFilters.reduce<{ + unmonitored: boolean; + tags?: number[] | undefined; + }>( + (acc, filter) => { + if (filter.key === 'unmonitored' && Array.isArray(filter.value)) { + acc.unmonitored = (filter.value as boolean[]).includes(true); + } + + if (filter.key === 'tags' && filter.type === 'contains') { + acc.tags = filter.value as number[]; + } + + return acc; + }, + { + unmonitored: false, + } + ); + }, [customFilters, selectedFilterKey]); + + const { queryKey, ...result } = useApiQuery({ + path: '/calendar', + queryParams: { + start, + end, + unmonitored, + tags, + }, + queryOptions: { + enabled: !!time && !!start && !!end, + placeholderData: keepPreviousData, + }, + }); + + useEffect(() => { + setEpisodeQueryKey('calendar', queryKey); + }, [queryKey]); + + return { + ...result, + data: result.data ?? [], + }; +}; + +export default useCalendar; + +export const useCalendarPage = () => { + const dayCount = useCalendarDayCount(); + const time = useCalendarTime(); + const view = useCalendarOption('view'); + const firstDayOfWeek = useSelector( + (state: AppState) => state.settings.ui.item.firstDayOfWeek + ); + + useEffect(() => { + const { dates } = getDates(time, view, firstDayOfWeek, dayCount); + + calendarStore.setState({ dates }); + }, [firstDayOfWeek, dayCount, time, view]); +}; + +export const useCalendarTime = () => { + return calendarStore((state) => state.time); +}; + +export const useCalendarDates = () => { + return calendarStore((state) => state.dates); +}; + +export const useCalendarDayCount = () => { + return calendarStore((state) => state.dayCount); +}; + +export const useCalendarRange = () => { + const dates = useCalendarDates(); + + return { + start: dates[0], + end: dates[dates.length - 1], + }; +}; + +export const useCalendarSearchMissingCommandId = () => { + return calendarStore((state) => state.searchMissingCommandId); +}; + +export const useSearchMissing = (episodeIds: number[]) => { + const dispatch = useDispatch(); + + const commandPayload = { + name: commandNames.EPISODE_SEARCH, + episodeIds, + }; + + executeCommandHelper(commandPayload, dispatch).then((data: Command) => { + calendarStore.setState({ searchMissingCommandId: data.id }); + }); +}; + +export const setCalendarDayCount = (dayCount: number) => { + calendarStore.setState({ dayCount }); +}; + +export const goToToday = () => { + setCalendarTime(moment()); +}; + +export const goToPreviousRange = () => { + const { dayCount, time } = calendarStore.getState(); + const view = getCalendarOption('view'); + + const amount = view === 'forecast' ? dayCount : 1; + const newTime = moment(time).subtract(amount, VIEW_RANGES[view]); + + setCalendarTime(newTime); +}; + +export const goToNextRange = () => { + const { dayCount, time } = calendarStore.getState(); + const view = getCalendarOption('view'); + + const amount = view === 'forecast' ? dayCount : 1; + const newTime = moment(time).add(amount, VIEW_RANGES[view]); + + setCalendarTime(newTime); +}; + +const setCalendarTime = (time: moment.Moment) => { + calendarStore.setState({ time }); +}; + +const getDays = (start: moment.Moment, end: moment.Moment) => { + const startTime = moment(start); + const endTime = moment(end); + const difference = endTime.diff(startTime, 'days'); + + return Array(difference + 1) + .fill(0) + .map((_, i) => startTime.clone().add(i, 'days').toISOString()); +}; + +const getDates = ( + time: moment.Moment, + view: CalendarView, + firstDayOfWeek: number, + dayCount: number +) => { + const weekName = firstDayOfWeek === 0 ? 'week' : 'isoWeek'; + + let start = time.clone().startOf('day'); + let end = time.clone().endOf('day'); + + if (view === 'week') { + start = time.clone().startOf(weekName); + end = time.clone().endOf(weekName); + } + + if (view === 'forecast') { + start = time.clone().subtract(1, 'day').startOf('day'); + end = time + .clone() + .add(dayCount - 2, 'days') + .endOf('day'); + } + + if (view === 'month') { + start = time.clone().startOf('month').startOf(weekName); + end = time.clone().endOf('month').endOf(weekName); + } + + if (view === 'agenda') { + start = time.clone().subtract(1, 'day').startOf('day'); + end = time.clone().add(1, 'month').endOf('day'); + } + + return { + start: start.toISOString(), + end: end.toISOString(), + time: time.toISOString(), + dates: getDays(start, end), + }; +}; + +function getPopulatableRange( + startDate: string, + endDate: string, + view: CalendarView +) { + switch (view) { + case 'day': + return { + start: moment(startDate).subtract(1, 'day').toISOString(), + end: moment(endDate).add(1, 'day').toISOString(), + }; + case 'week': + case 'forecast': + return { + start: moment(startDate).subtract(1, 'week').toISOString(), + end: moment(endDate).add(1, 'week').toISOString(), + }; + default: + return { + start: startDate, + end: endDate, + }; + } +} diff --git a/frontend/src/Components/Filter/Filter.ts b/frontend/src/Components/Filter/Filter.ts new file mode 100644 index 000000000..1dd1f84cb --- /dev/null +++ b/frontend/src/Components/Filter/Filter.ts @@ -0,0 +1,5 @@ +export type SelectedFilterKey = string | number; + +export interface SetFilter { + selectedFilterKey: SelectedFilterKey; +} diff --git a/frontend/src/Components/Filter/FilterModal.tsx b/frontend/src/Components/Filter/FilterModal.tsx index 4a8224698..69038a552 100644 --- a/frontend/src/Components/Filter/FilterModal.tsx +++ b/frontend/src/Components/Filter/FilterModal.tsx @@ -3,6 +3,7 @@ import { CustomFilter, FilterBuilderProp } from 'App/State/AppState'; import Modal from 'Components/Modal/Modal'; import FilterBuilderModalContent from './Builder/FilterBuilderModalContent'; import CustomFiltersModalContent from './CustomFilters/CustomFiltersModalContent'; +import { SetFilter } from './Filter'; export interface FilterModalProps { isOpen: boolean; @@ -10,7 +11,7 @@ export interface FilterModalProps { customFilterType: string; filterBuilderProps: FilterBuilderProp[]; sectionItems: T[]; - dispatchSetFilter: (payload: { selectedFilterKey: string | number }) => void; + dispatchSetFilter: (payload: SetFilter) => void; onModalClose: () => void; } diff --git a/frontend/src/Episode/useEpisode.ts b/frontend/src/Episode/useEpisode.ts index c0a65b846..88f822c36 100644 --- a/frontend/src/Episode/useEpisode.ts +++ b/frontend/src/Episode/useEpisode.ts @@ -1,7 +1,9 @@ +import { QueryKey, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { createSelector } from 'reselect'; +import { create } from 'zustand'; import AppState from 'App/State/AppState'; -import Episode from './Episode'; +import { CalendarItem } from 'typings/Calendar'; export type EpisodeEntity = | 'calendar' @@ -10,6 +12,14 @@ export type EpisodeEntity = | 'wanted.cutoffUnmet' | 'wanted.missing'; +interface EpisodeQueryKeyStore { + calendar: QueryKey | null; +} + +const episodeQueryKeyStore = create(() => ({ + calendar: null, +})); + function createEpisodeSelector(episodeId?: number) { return createSelector( (state: AppState) => state.episodes.items, @@ -19,11 +29,12 @@ function createEpisodeSelector(episodeId?: number) { ); } -function createCalendarEpisodeSelector(episodeId?: number) { +// No-op...ish +function createCalendarEpisodeSelector(_episodeId?: number) { return createSelector( - (state: AppState) => state.calendar.items as Episode[], - (episodes) => { - return episodes.find(({ id }) => id === episodeId); + (state: AppState) => state.episodes.items, + (_episodes) => { + return undefined; } ); } @@ -46,10 +57,24 @@ function createWantedMissingEpisodeSelector(episodeId?: number) { ); } -function useEpisode( +export const setEpisodeQueryKey = ( + episodeEntity: EpisodeEntity, + queryKey: QueryKey | null +) => { + switch (episodeEntity) { + case 'calendar': + episodeQueryKeyStore.setState({ calendar: queryKey }); + break; + default: + break; + } +}; + +const useEpisode = ( episodeId: number | undefined, episodeEntity: EpisodeEntity -) { +) => { + const queryClient = useQueryClient(); let selector = createEpisodeSelector; switch (episodeEntity) { @@ -66,7 +91,19 @@ function useEpisode( break; } - return useSelector(selector(episodeId)); -} + const result = useSelector(selector(episodeId)); + + if (episodeEntity === 'calendar') { + const queryKey = episodeQueryKeyStore((state) => state.calendar); + + return queryKey + ? queryClient + .getQueryData(queryKey) + ?.find((e) => e.id === episodeId) + : undefined; + } + + return result; +}; export default useEpisode; diff --git a/frontend/src/Episode/useEpisodes.ts b/frontend/src/Episode/useEpisodes.ts index 4a4c0ab9b..371b5b95d 100644 --- a/frontend/src/Episode/useEpisodes.ts +++ b/frontend/src/Episode/useEpisodes.ts @@ -3,13 +3,6 @@ import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; import Episode from './Episode'; -export type EpisodeEntity = - | 'calendar' - | 'episodes' - | 'interactiveImport.episodes' - | 'wanted.cutoffUnmet' - | 'wanted.missing'; - function getEpisodes(episodeIds: number[], episodes: Episode[]) { return episodeIds.reduce((acc, id) => { const episode = episodes.find((episode) => episode.id === id); @@ -31,52 +24,6 @@ function createEpisodeSelector(episodeIds: number[]) { ); } -function createCalendarEpisodeSelector(episodeIds: number[]) { - return createSelector( - (state: AppState) => state.calendar.items as Episode[], - (episodes) => { - return getEpisodes(episodeIds, episodes); - } - ); -} - -function createWantedCutoffUnmetEpisodeSelector(episodeIds: number[]) { - return createSelector( - (state: AppState) => state.wanted.cutoffUnmet.items, - (episodes) => { - return getEpisodes(episodeIds, episodes); - } - ); -} - -function createWantedMissingEpisodeSelector(episodeIds: number[]) { - return createSelector( - (state: AppState) => state.wanted.missing.items, - (episodes) => { - return getEpisodes(episodeIds, episodes); - } - ); -} - -export default function useEpisodes( - episodeIds: number[], - episodeEntity: EpisodeEntity -) { - let selector = createEpisodeSelector; - - switch (episodeEntity) { - case 'calendar': - selector = createCalendarEpisodeSelector; - break; - case 'wanted.cutoffUnmet': - selector = createWantedCutoffUnmetEpisodeSelector; - break; - case 'wanted.missing': - selector = createWantedMissingEpisodeSelector; - break; - default: - break; - } - - return useSelector(selector(episodeIds)); +export default function useEpisodes(episodeIds: number[]) { + return useSelector(createEpisodeSelector(episodeIds)); } diff --git a/frontend/src/Helpers/Hooks/useApiQuery.ts b/frontend/src/Helpers/Hooks/useApiQuery.ts index bdeab78bf..1f66dd27b 100644 --- a/frontend/src/Helpers/Hooks/useApiQuery.ts +++ b/frontend/src/Helpers/Hooks/useApiQuery.ts @@ -32,12 +32,15 @@ const useApiQuery = (options: QueryOptions) => { }; }, [options]); - return useQuery({ - ...options.queryOptions, + return { queryKey, - queryFn: async ({ signal }) => - fetchJson({ ...requestOptions, signal }), - }); + ...useQuery({ + ...options.queryOptions, + queryKey, + queryFn: async ({ signal }) => + fetchJson({ ...requestOptions, signal }), + }), + }; }; export default useApiQuery; diff --git a/frontend/src/Helpers/Hooks/useOptionsStore.ts b/frontend/src/Helpers/Hooks/useOptionsStore.ts index 9f599b56a..9f4285298 100644 --- a/frontend/src/Helpers/Hooks/useOptionsStore.ts +++ b/frontend/src/Helpers/Hooks/useOptionsStore.ts @@ -43,6 +43,14 @@ export const createOptionsStore = ( return store((state) => state[key]); }; + const getOptions = () => { + return store.getState(); + }; + + const getOption = (key: K) => { + return store.getState()[key]; + }; + const setOptions = (options: Partial) => { store.setState((state) => ({ ...state, @@ -61,6 +69,8 @@ export const createOptionsStore = ( store, useOptions, useOption, + getOptions, + getOption, setOptions, setOption, }; diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js deleted file mode 100644 index 5ff9f7126..000000000 --- a/frontend/src/Store/Actions/calendarActions.js +++ /dev/null @@ -1,427 +0,0 @@ -import _ from 'lodash'; -import moment from 'moment'; -import { createAction } from 'redux-actions'; -import { batchActions } from 'redux-batched-actions'; -import * as calendarViews from 'Calendar/calendarViews'; -import * as commandNames from 'Commands/commandNames'; -import { filterBuilderTypes, filterBuilderValueTypes, filterTypes } from 'Helpers/Props'; -import { createThunk, handleThunks } from 'Store/thunks'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; -import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; -import translate from 'Utilities/String/translate'; -import { set, update } from './baseActions'; -import { executeCommandHelper } from './commandActions'; -import createHandleActions from './Creators/createHandleActions'; -import createClearReducer from './Creators/Reducers/createClearReducer'; - -// -// Variables - -export const section = 'calendar'; - -const viewRanges = { - [calendarViews.DAY]: 'day', - [calendarViews.WEEK]: 'week', - [calendarViews.MONTH]: 'month', - [calendarViews.FORECAST]: 'day' -}; - -// -// State - -export const defaultState = { - isFetching: false, - isPopulated: false, - start: null, - end: null, - dates: [], - dayCount: 7, - view: window.innerWidth > 768 ? 'week' : 'day', - error: null, - items: [], - searchMissingCommandId: null, - - options: { - collapseMultipleEpisodes: false, - showEpisodeInformation: true, - showFinaleIcon: false, - showSpecialIcon: false, - showCutoffUnmetIcon: false, - fullColorEvents: false - }, - - selectedFilterKey: 'monitored', - - filters: [ - { - key: 'all', - label: () => translate('All'), - filters: [ - { - key: 'unmonitored', - value: [true], - type: filterTypes.EQUAL - } - ] - }, - { - key: 'monitored', - label: () => translate('MonitoredOnly'), - filters: [ - { - key: 'unmonitored', - value: [false], - type: filterTypes.EQUAL - } - ] - } - ], - - filterBuilderProps: [ - { - name: 'unmonitored', - label: () => translate('IncludeUnmonitored'), - type: filterBuilderTypes.EQUAL, - valueType: filterBuilderValueTypes.BOOL - }, - { - name: 'tags', - label: () => translate('Tags'), - type: filterBuilderTypes.CONTAINS, - valueType: filterBuilderValueTypes.TAG - } - ] -}; - -export const persistState = [ - 'calendar.view', - 'calendar.selectedFilterKey', - 'calendar.options', - 'calendar.customFilters' -]; - -// -// Actions Types - -export const FETCH_CALENDAR = 'calendar/fetchCalendar'; -export const SET_CALENDAR_DAYS_COUNT = 'calendar/setCalendarDaysCount'; -export const SET_CALENDAR_FILTER = 'calendar/setCalendarFilter'; -export const SET_CALENDAR_VIEW = 'calendar/setCalendarView'; -export const GOTO_CALENDAR_TODAY = 'calendar/gotoCalendarToday'; -export const GOTO_CALENDAR_NEXT_RANGE = 'calendar/gotoCalendarNextRange'; -export const CLEAR_CALENDAR = 'calendar/clearCalendar'; -export const SET_CALENDAR_OPTION = 'calendar/setCalendarOption'; -export const SEARCH_MISSING = 'calendar/searchMissing'; -export const GOTO_CALENDAR_PREVIOUS_RANGE = 'calendar/gotoCalendarPreviousRange'; - -// -// Helpers - -function getDays(start, end) { - const startTime = moment(start); - const endTime = moment(end); - const difference = endTime.diff(startTime, 'days'); - - // Difference is one less than the number of days we need to account for. - return _.times(difference + 1, (i) => { - return startTime.clone().add(i, 'days').toISOString(); - }); -} - -function getDates(time, view, firstDayOfWeek, dayCount) { - const weekName = firstDayOfWeek === 0 ? 'week' : 'isoWeek'; - - let start = time.clone().startOf('day'); - let end = time.clone().endOf('day'); - - if (view === calendarViews.WEEK) { - start = time.clone().startOf(weekName); - end = time.clone().endOf(weekName); - } - - if (view === calendarViews.FORECAST) { - start = time.clone().subtract(1, 'day').startOf('day'); - end = time.clone().add(dayCount - 2, 'days').endOf('day'); - } - - if (view === calendarViews.MONTH) { - start = time.clone().startOf('month').startOf(weekName); - end = time.clone().endOf('month').endOf(weekName); - } - - if (view === calendarViews.AGENDA) { - start = time.clone().subtract(1, 'day').startOf('day'); - end = time.clone().add(1, 'month').endOf('day'); - } - - return { - start: start.toISOString(), - end: end.toISOString(), - time: time.toISOString(), - dates: getDays(start, end) - }; -} - -function getPopulatableRange(startDate, endDate, view) { - switch (view) { - case calendarViews.DAY: - return { - start: moment(startDate).subtract(1, 'day').toISOString(), - end: moment(endDate).add(1, 'day').toISOString() - }; - case calendarViews.WEEK: - case calendarViews.FORECAST: - return { - start: moment(startDate).subtract(1, 'week').toISOString(), - end: moment(endDate).add(1, 'week').toISOString() - }; - default: - return { - start: startDate, - end: endDate - }; - } -} - -function isRangePopulated(start, end, state) { - const { - start: currentStart, - end: currentEnd, - view: currentView - } = state; - - if (!currentStart || !currentEnd) { - return false; - } - - const { - start: currentPopulatedStart, - end: currentPopulatedEnd - } = getPopulatableRange(currentStart, currentEnd, currentView); - - if ( - moment(start).isAfter(currentPopulatedStart) && - moment(start).isBefore(currentPopulatedEnd) - ) { - return true; - } - - return false; -} - -function getCustomFilters(state, type) { - return state.customFilters.items.filter((customFilter) => customFilter.type === type); -} - -// -// Action Creators - -export const fetchCalendar = createThunk(FETCH_CALENDAR); -export const setCalendarDaysCount = createThunk(SET_CALENDAR_DAYS_COUNT); -export const setCalendarFilter = createThunk(SET_CALENDAR_FILTER); -export const setCalendarView = createThunk(SET_CALENDAR_VIEW); -export const gotoCalendarToday = createThunk(GOTO_CALENDAR_TODAY); -export const gotoCalendarPreviousRange = createThunk(GOTO_CALENDAR_PREVIOUS_RANGE); -export const gotoCalendarNextRange = createThunk(GOTO_CALENDAR_NEXT_RANGE); -export const clearCalendar = createAction(CLEAR_CALENDAR); -export const setCalendarOption = createAction(SET_CALENDAR_OPTION); -export const searchMissing = createThunk(SEARCH_MISSING); - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - [FETCH_CALENDAR]: function(getState, payload, dispatch) { - const state = getState(); - const calendar = state.calendar; - const customFilters = getCustomFilters(state, section); - const selectedFilters = findSelectedFilters(calendar.selectedFilterKey, calendar.filters, customFilters); - - const { - time = calendar.time, - view = calendar.view - } = payload; - - const dayCount = state.calendar.dayCount; - const dates = getDates(moment(time), view, state.settings.ui.item.firstDayOfWeek, dayCount); - const { start, end } = getPopulatableRange(dates.start, dates.end, view); - const isPrePopulated = isRangePopulated(start, end, state.calendar); - - const basesAttrs = { - section, - isFetching: true - }; - - const attrs = isPrePopulated ? - { - view, - ...basesAttrs, - ...dates - } : - basesAttrs; - - dispatch(set(attrs)); - - const requestParams = { - start, - end - }; - - selectedFilters.forEach((selectedFilter) => { - if (selectedFilter.key === 'unmonitored') { - requestParams.unmonitored = selectedFilter.value.includes(true); - } - - if (selectedFilter.key === 'tags') { - requestParams.tags = selectedFilter.value.join(','); - } - }); - - requestParams.unmonitored = requestParams.unmonitored ?? false; - - const promise = createAjaxRequest({ - url: '/calendar', - data: requestParams - }).request; - - promise.done((data) => { - dispatch(batchActions([ - update({ section, data }), - - set({ - section, - view, - ...dates, - isFetching: false, - isPopulated: true, - error: null - }) - ])); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isFetching: false, - isPopulated: false, - error: xhr - })); - }); - }, - - [SET_CALENDAR_DAYS_COUNT]: function(getState, payload, dispatch) { - if (payload.dayCount === getState().calendar.dayCount) { - return; - } - - dispatch(set({ - section, - dayCount: payload.dayCount - })); - - const state = getState(); - const { time, view } = state.calendar; - - dispatch(fetchCalendar({ time, view })); - }, - - [SET_CALENDAR_FILTER]: function(getState, payload, dispatch) { - dispatch(set({ - section, - selectedFilterKey: payload.selectedFilterKey - })); - - const state = getState(); - const { time, view } = state.calendar; - - dispatch(fetchCalendar({ time, view })); - }, - - [SET_CALENDAR_VIEW]: function(getState, payload, dispatch) { - const state = getState(); - const view = payload.view; - const time = view === calendarViews.FORECAST || calendarViews.AGENDA ? - moment() : - state.calendar.time; - - dispatch(fetchCalendar({ time, view })); - }, - - [GOTO_CALENDAR_TODAY]: function(getState, payload, dispatch) { - const state = getState(); - const view = state.calendar.view; - const time = moment(); - - dispatch(fetchCalendar({ time, view })); - }, - - [GOTO_CALENDAR_PREVIOUS_RANGE]: function(getState, payload, dispatch) { - const state = getState(); - - const { - view, - dayCount - } = state.calendar; - - const amount = view === calendarViews.FORECAST ? dayCount : 1; - const time = moment(state.calendar.time).subtract(amount, viewRanges[view]); - - dispatch(fetchCalendar({ time, view })); - }, - - [GOTO_CALENDAR_NEXT_RANGE]: function(getState, payload, dispatch) { - const state = getState(); - - const { - view, - dayCount - } = state.calendar; - - const amount = view === calendarViews.FORECAST ? dayCount : 1; - const time = moment(state.calendar.time).add(amount, viewRanges[view]); - - dispatch(fetchCalendar({ time, view })); - }, - - [SEARCH_MISSING]: function(getState, payload, dispatch) { - const { episodeIds } = payload; - - const commandPayload = { - name: commandNames.EPISODE_SEARCH, - episodeIds - }; - - executeCommandHelper(commandPayload, dispatch).then((data) => { - dispatch(set({ - section, - searchMissingCommandId: data.id - })); - }); - } -}); - -// -// Reducers - -export const reducers = createHandleActions({ - - [CLEAR_CALENDAR]: createClearReducer(section, { - isFetching: false, - isPopulated: false, - error: null, - items: [] - }), - - [SET_CALENDAR_OPTION]: function(state, { payload }) { - const options = state.options; - - return { - ...state, - options: { - ...options, - ...payload - } - }; - } - -}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 608b3c585..0fb276011 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -1,5 +1,4 @@ import * as app from './appActions'; -import * as calendar from './calendarActions'; import * as captcha from './captchaActions'; import * as commands from './commandActions'; import * as customFilters from './customFilterActions'; @@ -25,7 +24,6 @@ import * as wanted from './wantedActions'; export default [ app, - calendar, captcha, commands, customFilters,