Use react-query for Calendar UI

This commit is contained in:
Mark McDowall 2025-11-09 21:31:15 -08:00
parent 6a3e1278a5
commit ccb7f07c26
28 changed files with 637 additions and 797 deletions

View file

@ -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<History>;
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 (
<FilterModal

View file

@ -106,7 +106,7 @@ function QueueRow(props: QueueRowProps) {
} = props;
const series = useSeries(seriesId);
const episodes = useEpisodes(episodeIds, 'episodes');
const episodes = useEpisodes(episodeIds);
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);

View file

@ -3,7 +3,6 @@ import { FilterBuilderTypes } from 'Helpers/Props/filterBuilderTypes';
import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes';
import { Error } from './AppSectionState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CaptchaAppState from './CaptchaAppState';
import CommandAppState from './CommandAppState';
import CustomFiltersAppState from './CustomFiltersAppState';
@ -81,7 +80,6 @@ export interface AppSectionState {
interface AppState {
app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState;
captcha: CaptchaAppState;
commands: CommandAppState;
customFilters: CustomFiltersAppState;

View file

@ -1,29 +0,0 @@
import moment from 'moment';
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
import { CalendarView } from 'Calendar/calendarViews';
import { CalendarItem } from 'typings/Calendar';
interface CalendarOptions {
showEpisodeInformation: boolean;
showFinaleIcon: boolean;
showSpecialIcon: boolean;
showCutoffUnmetIcon: boolean;
collapseMultipleEpisodes: boolean;
fullColorEvents: boolean;
}
interface CalendarAppState
extends AppSectionState<CalendarItem>,
AppSectionFilterState<CalendarItem> {
searchMissingCommandId: number | null;
start: moment.Moment;
end: moment.Moment;
dates: string[];
time: string;
view: CalendarView;
options: CalendarOptions;
}
export default CalendarAppState;

View file

@ -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 (
<div className={styles.agenda}>
{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 <AgendaEvent key={item.id} showDate={showDate} {...item} />;
})}

View file

@ -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);

View file

@ -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<ReturnType<typeof setTimeout>>();
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<Episode, number>(
items,
'episodeFileId'
);
const episodeFileIds = selectUniqueIds<Episode, number>(
data,
'episodeFileId'
);
if (episodeFileIds.length) {
dispatch(fetchEpisodeFiles({ episodeFileIds }));
}
if (episodeFileIds.length) {
dispatch(fetchEpisodeFiles({ episodeFileIds }));
}
}, [items, previousItems, dispatch]);
}, [data, dispatch]);
return (
<div className={styles.calendar}>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{isLoading ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
) : null}
{!error && isPopulated && view === 'agenda' ? (
{!error && !isLoading && view === 'agenda' ? (
<div className={styles.calendarContent}>
<CalendarHeader />
<Agenda />
</div>
) : null}
{!error && isPopulated && view !== 'agenda' ? (
{!error && !isLoading && view !== 'agenda' ? (
<div className={styles.calendarContent}>
<CalendarHeader />
<DaysOfWeek />

View file

@ -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<History>;
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 (
<FilterModal
{...props}
sectionItems={sectionItems}
filterBuilderProps={filterBuilderProps}
sectionItems={data}
filterBuilderProps={FILTER_BUILDER}
customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter}
/>

View file

@ -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<number[]>((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<number[]>((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(() => {}, []);

View file

@ -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<Episode, number>(items, 'id');
}, [items]);
return selectUniqueIds<Episode, number>(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 (
<QueueDetails episodeIds={episodeIds}>
@ -135,7 +135,7 @@ function CalendarPage() {
alignMenu={align.RIGHT}
isDisabled={!hasSeries}
selectedFilterKey={selectedFilterKey}
filters={filters}
filters={FILTERS}
customFilters={customFilters}
filterModalConnectorComponent={CalendarFilterModal}
onFilterSelect={handleFilterSelect}

View file

@ -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<HTMLDivElement>(null);

View file

@ -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;

View file

@ -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());

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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 = [];

View file

@ -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<CalendarOptions>) => {
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}
/>
</FormGroup>
@ -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}
/>
</FormGroup>
@ -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}
/>
</FormGroup>
@ -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}
/>
</FormGroup>
@ -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}
/>
</FormGroup>
@ -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}
/>
</FormGroup>

View file

@ -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<CalendarOptions>('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;

View file

@ -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<CalendarItem>[] = [
{
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<CalendarStore>(() => ({
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<CalendarItem[]>({
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,
};
}
}

View file

@ -0,0 +1,5 @@
export type SelectedFilterKey = string | number;
export interface SetFilter {
selectedFilterKey: SelectedFilterKey;
}

View file

@ -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<T> {
isOpen: boolean;
@ -10,7 +11,7 @@ export interface FilterModalProps<T> {
customFilterType: string;
filterBuilderProps: FilterBuilderProp<T>[];
sectionItems: T[];
dispatchSetFilter: (payload: { selectedFilterKey: string | number }) => void;
dispatchSetFilter: (payload: SetFilter) => void;
onModalClose: () => void;
}

View file

@ -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<EpisodeQueryKeyStore>(() => ({
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<CalendarItem[]>(queryKey)
?.find((e) => e.id === episodeId)
: undefined;
}
return result;
};
export default useEpisode;

View file

@ -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<Episode[]>((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));
}

View file

@ -32,12 +32,15 @@ const useApiQuery = <T>(options: QueryOptions<T>) => {
};
}, [options]);
return useQuery({
...options.queryOptions,
return {
queryKey,
queryFn: async ({ signal }) =>
fetchJson<T, unknown>({ ...requestOptions, signal }),
});
...useQuery({
...options.queryOptions,
queryKey,
queryFn: async ({ signal }) =>
fetchJson<T, unknown>({ ...requestOptions, signal }),
}),
};
};
export default useApiQuery;

View file

@ -43,6 +43,14 @@ export const createOptionsStore = <T extends TSettingd>(
return store((state) => state[key]);
};
const getOptions = () => {
return store.getState();
};
const getOption = <K extends keyof T>(key: K) => {
return store.getState()[key];
};
const setOptions = (options: Partial<T>) => {
store.setState((state) => ({
...state,
@ -61,6 +69,8 @@ export const createOptionsStore = <T extends TSettingd>(
store,
useOptions,
useOption,
getOptions,
getOption,
setOptions,
setOption,
};

View file

@ -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);

View file

@ -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,