From d3d1242d40859e5e185f50d7ad7463d43712f7e9 Mon Sep 17 00:00:00 2001 From: Christian Benincasa Date: Fri, 8 Nov 2024 12:19:20 -0500 Subject: [PATCH] perf: do not re-render entire time slot page when slot changes --- shared/src/util/seq.ts | 5 +- web/package.json | 1 - .../slot_scheduler/AddTimeSlotButton.tsx | 31 ++- .../slot_scheduler/ClearSlotsButton.tsx | 17 ++ .../slot_scheduler/MissingProgramsAlert.tsx | 65 ++++++ .../components/slot_scheduler/TimeSlotRow.tsx | 219 ++++++++++-------- .../useSlotProgramOptions.ts | 42 ++-- web/src/pages/channels/TimeSlotEditorPage.tsx | 174 +++++++------- web/src/store/selectors.ts | 25 ++ web/src/types/index.ts | 4 +- 10 files changed, 360 insertions(+), 223 deletions(-) create mode 100644 web/src/components/slot_scheduler/ClearSlotsButton.tsx create mode 100644 web/src/components/slot_scheduler/MissingProgramsAlert.tsx diff --git a/shared/src/util/seq.ts b/shared/src/util/seq.ts index 24475292e..25f85873a 100644 --- a/shared/src/util/seq.ts +++ b/shared/src/util/seq.ts @@ -9,7 +9,7 @@ export function intersperse(arr: T[], v: T, makeLast: boolean = false): T[] { */ export function collect( arr: T[] | null | undefined, - f: (t: T) => U | null | undefined, + f: (t: T, index: number, arr: T[]) => U | null | undefined, ): U[] { if (isNil(arr)) { return []; @@ -18,8 +18,9 @@ export function collect( const func = isFunction(f) ? f : (t: T) => t[f]; const results: U[] = []; + let i = 0; for (const el of arr) { - const res = func(el); + const res = func(el, i++, arr); if (!isNil(res)) { results.push(res); } diff --git a/web/package.json b/web/package.json index f171de27c..7b98beb1c 100644 --- a/web/package.json +++ b/web/package.json @@ -56,7 +56,6 @@ "@tanstack/router-devtools": "^1.36.0", "@tanstack/router-vite-plugin": "^1.35.4", "@types/lodash-es": "4.17.9", - "@types/lodash": "4.17.10", "@types/pluralize": "^0.0.33", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", diff --git a/web/src/components/slot_scheduler/AddTimeSlotButton.tsx b/web/src/components/slot_scheduler/AddTimeSlotButton.tsx index b8d066208..d49aa292e 100644 --- a/web/src/components/slot_scheduler/AddTimeSlotButton.tsx +++ b/web/src/components/slot_scheduler/AddTimeSlotButton.tsx @@ -4,32 +4,27 @@ import { TimeSlot } from '@tunarr/types/api'; import dayjs from 'dayjs'; import { maxBy } from 'lodash-es'; import { useCallback } from 'react'; -import { Control, UseFormSetValue, useWatch } from 'react-hook-form'; +import { UseFieldArrayAppend } from 'react-hook-form'; import { TimeSlotForm } from '../../pages/channels/TimeSlotEditorPage.tsx'; export const AddTimeSlotButton = ({ - control, - setValue, + slots, + append, }: AddTimeSlotButtonProps) => { - const currentSlots = useWatch({ control, name: 'slots' }); + // const currentSlots = useWatch({ control, name: 'slots' }); const addSlot = useCallback(() => { - const maxSlot = maxBy(currentSlots, (p) => p.startTime); + const maxSlot = maxBy(slots, (p) => p.startTime); const newStartTime = maxSlot ? dayjs.duration(maxSlot.startTime).add(1, 'hour') : dayjs.duration(0); - const newSlots: TimeSlot[] = [ - ...currentSlots, - { - programming: { type: 'flex' }, - startTime: newStartTime.asMilliseconds(), - order: 'next', - }, - ]; - - setValue('slots', newSlots, { shouldDirty: true }); - }, [currentSlots, setValue]); + append({ + programming: { type: 'flex' }, + startTime: newStartTime.asMilliseconds(), + order: 'next', + }); + }, [append, slots]); return ( + ) + ); +}; diff --git a/web/src/components/slot_scheduler/MissingProgramsAlert.tsx b/web/src/components/slot_scheduler/MissingProgramsAlert.tsx new file mode 100644 index 000000000..363d65ae4 --- /dev/null +++ b/web/src/components/slot_scheduler/MissingProgramsAlert.tsx @@ -0,0 +1,65 @@ +import { slotOptionIsScheduled } from '@/helpers/slotSchedulerUtil'; +import { useSlotProgramOptions } from '@/hooks/programming_controls/useSlotProgramOptions'; +import { TimeSlotForm } from '@/pages/channels/TimeSlotEditorPage'; +import { ExpandLess, ExpandMore } from '@mui/icons-material'; +import { Alert, Collapse, IconButton, ListItem } from '@mui/material'; +import { isEmpty, map, reject } from 'lodash-es'; +import pluralize from 'pluralize'; +import { useMemo } from 'react'; +import { Control, useWatch } from 'react-hook-form'; +import { useToggle } from 'usehooks-ts'; + +type Props = { + control: Control; +}; + +export const MissingProgramsAlert = ({ control }: Props) => { + const programOptions = useSlotProgramOptions(); + const currentSlots = useWatch({ control, name: 'slots' }); + const [unscheduledOpen, toggleUnscheduledOpen] = useToggle(false); + + const unscheduledOptions = useMemo( + () => + reject(programOptions, (item) => + slotOptionIsScheduled(currentSlots, item), + ), + [currentSlots, programOptions], + ); + + return ( + !isEmpty(unscheduledOptions) && ( + { + toggleUnscheduledOpen(); + }} + > + {!unscheduledOpen ? ( + + ) : ( + + )} + + } + > + There are {unscheduledOptions.length} unscheduled{' '} + {pluralize('program', unscheduledOptions.length)}. Unscheduled items + will be removed from the channel when saving. + + <> + {map(unscheduledOptions, (option) => ( + + {option.description} ({option.type}) + + ))} + + + + ) + ); +}; diff --git a/web/src/components/slot_scheduler/TimeSlotRow.tsx b/web/src/components/slot_scheduler/TimeSlotRow.tsx index 5ec892e75..c35cd0ac6 100644 --- a/web/src/components/slot_scheduler/TimeSlotRow.tsx +++ b/web/src/components/slot_scheduler/TimeSlotRow.tsx @@ -10,14 +10,9 @@ import { import { TimePicker } from '@mui/x-date-pickers/TimePicker'; import { TimeSlot, TimeSlotProgramming } from '@tunarr/types/api'; import dayjs from 'dayjs'; -import { map, reject } from 'lodash-es'; +import { isNil, map, uniqBy } from 'lodash-es'; import { Fragment, useCallback } from 'react'; -import { - Control, - Controller, - UseFormSetValue, - useWatch, -} from 'react-hook-form'; +import { Control, Controller, useWatch } from 'react-hook-form'; import { ProgramOption } from '../../helpers/slotSchedulerUtil.ts'; import { OneDayMillis, @@ -46,61 +41,42 @@ const showOrderOptions = [ ]; type TimeSlotProps = { - slot: TimeSlot; index: number; control: Control; - setValue: UseFormSetValue; + removeSlot: () => void; programOptions: ProgramOption[]; }; export const TimeSlotRow = ({ - slot, index, control, - setValue, + removeSlot, programOptions, }: TimeSlotProps) => { - const start = dayjs.tz().startOf('day'); const currentSlots = useWatch({ control, name: 'slots' }); + const slot = currentSlots[index]; const currentPeriod = useWatch({ control, name: 'period' }); - const updateSlotTime = useCallback( - (idx: number, time: dayjs.Dayjs) => { - setValue( - `slots.${idx}.startTime`, - time - .mod(dayjs.duration(1, 'day')) - .subtract({ minutes: new Date().getTimezoneOffset() }) - .asMilliseconds(), - { shouldDirty: true }, - ); - }, - [setValue], - ); - - const removeSlot = useCallback( - (idx: number) => { - setValue( - 'slots', - reject(currentSlots, (_, i) => idx === i), - { shouldDirty: true }, - ); - }, - [currentSlots, setValue], - ); - const updateSlotDay = useCallback( - (idx: number, currentDay: number, dayOfWeek: number) => { - const slot = currentSlots[idx]; + ( + currentDay: number, + dayOfWeek: number, + originalOnChange: (...args: unknown[]) => void, + ) => { + const slot = currentSlots[index]; const daylessStartTime = slot.startTime - currentDay * OneDayMillis; const newStartTime = daylessStartTime + dayOfWeek * OneDayMillis; - setValue(`slots.${idx}.startTime`, newStartTime, { shouldDirty: true }); + originalOnChange(newStartTime); }, - [currentSlots, setValue], + [currentSlots, index], ); const updateSlotType = useCallback( - (idx: number, slotId: string) => { + ( + idx: number, + slotId: string, + originalOnChange: (...args: unknown[]) => void, + ) => { let slotProgram: TimeSlotProgramming; if (slotId.startsWith('show')) { @@ -136,37 +112,27 @@ export const TimeSlotRow = ({ }; const curr = currentSlots[idx]; - - setValue( - `slots.${idx}`, - { ...slot, startTime: curr.startTime }, - { shouldDirty: true }, - ); + originalOnChange({ ...slot, startTime: curr.startTime }); }, - [currentSlots, setValue], + [currentSlots], ); - const startTime = start.add(slot.startTime); - // .subtract(new Date().getTimezoneOffset(), 'minutes'); - let selectValue: string; - switch (slot.programming.type) { - case 'show': { - selectValue = `show.${slot.programming.showId}`; - break; - } - case 'redirect': { - selectValue = `redirect.${slot.programming.channelId}`; - break; - } - case 'custom-show': { - selectValue = `${slot.programming.type}.${slot.programming.customShowId}`; - break; - } - default: { - selectValue = slot.programming.type; - break; + const getProgramDropdownValue = (programming: TimeSlotProgramming) => { + switch (programming.type) { + case 'show': { + return `show.${programming.showId}`; + } + case 'redirect': { + return `redirect.${programming.channelId}`; + } + case 'custom-show': { + return `${programming.type}.${programming.customShowId}`; + } + default: { + return programming.type; + } } - } + }; const isShowType = slot.programming.type === 'show'; let showInputSize = currentPeriod === 'week' ? 7 : 9; @@ -177,45 +143,100 @@ export const TimeSlotRow = ({ const dayOfTheWeek = Math.floor(slot.startTime / OneDayMillis); return ( - + {currentPeriod === 'week' ? ( - + { + if (uniqBy(slots, 'startTime').length !== slots.length) { + return 'BAD'; + } + }, + }, + }} + render={({ field }) => ( + + )} + /> ) : null} - value && updateSlotTime(index, value)} - value={startTime} - label="Start Time" + ( + { + value + ? field.onChange( + value + .mod(dayjs.duration(1, currentPeriod)) + .subtract({ minutes: new Date().getTimezoneOffset() }) + .asMilliseconds(), + ) + : void 0; + }} + label="Start Time" + closeOnSelect={false} + slotProps={{ + textField: { + error: !isNil(error), + }, + }} + /> + )} /> Program - + ( + + )} + /> {isShowType && ( @@ -239,7 +260,7 @@ export const TimeSlotRow = ({ )} - removeSlot(index)} color="error"> + diff --git a/web/src/hooks/programming_controls/useSlotProgramOptions.ts b/web/src/hooks/programming_controls/useSlotProgramOptions.ts index 25cfcf328..93a2230ec 100644 --- a/web/src/hooks/programming_controls/useSlotProgramOptions.ts +++ b/web/src/hooks/programming_controls/useSlotProgramOptions.ts @@ -1,25 +1,37 @@ import { ProgramOption } from '@/helpers/slotSchedulerUtil'; -import { useChannelEditor } from '@/store/selectors'; -import { isUICustomProgram, isUIRedirectProgram } from '@/types'; -import { CustomShow, isContentProgram } from '@tunarr/types'; -import { chain, filter, isEmpty, isUndefined, some } from 'lodash-es'; +import { isNonEmptyString } from '@/helpers/util'; +import useStore from '@/store'; +import { + isUICondensedCustomProgram, + isUICondensedRedirectProgram, +} from '@/types'; +import { seq } from '@tunarr/shared/util'; +import { CustomShow } from '@tunarr/types'; +import { chain, isEmpty, isUndefined, some } from 'lodash-es'; import { useMemo } from 'react'; -import { useCustomShows } from '../useCustomShows'; export const useSlotProgramOptions = () => { - const { programList: newLineup } = useChannelEditor(); - const { data: customShows } = useCustomShows(); + // const { programList: newLineup } = useChannelEditor(); + const { programList: newLineup, programLookup } = useStore( + (s) => s.channelEditor, + ); + // const { data: customShows } = useCustomShows(); const customShowsById = useMemo(() => { const byId: Record = {}; - for (const show of customShows) { - byId[show.id] = show; - } + // for (const show of customShows) { + // byId[show.id] = show; + // } return byId; - }, [customShows]); + }, []); return useMemo(() => { - const contentPrograms = filter(newLineup, isContentProgram); + const contentPrograms = seq.collect(newLineup, (program) => { + if (program.type === 'content' && isNonEmptyString(program.id)) { + return programLookup[program.id]; + } + }); + // const contentPrograms = filter(newLineup, isUICondensedContentProgram); const opts: ProgramOption[] = [ { value: 'flex', description: 'Flex', type: 'flex' }, ]; @@ -48,7 +60,7 @@ export const useSlotProgramOptions = () => { opts.push( ...chain(newLineup) - .filter(isUICustomProgram) + .filter(isUICondensedCustomProgram) .reject((p) => isUndefined(customShowsById[p.customShowId])) .uniqBy((p) => p.customShowId) .map( @@ -65,7 +77,7 @@ export const useSlotProgramOptions = () => { opts.push( ...chain(newLineup) - .filter(isUIRedirectProgram) + .filter(isUICondensedRedirectProgram) .uniqBy((p) => p.channel) .map( (p) => @@ -80,5 +92,5 @@ export const useSlotProgramOptions = () => { ); return opts; - }, [newLineup, customShowsById]); + }, [newLineup, programLookup, customShowsById]); }; diff --git a/web/src/pages/channels/TimeSlotEditorPage.tsx b/web/src/pages/channels/TimeSlotEditorPage.tsx index 535c9e298..6373650ab 100644 --- a/web/src/pages/channels/TimeSlotEditorPage.tsx +++ b/web/src/pages/channels/TimeSlotEditorPage.tsx @@ -1,29 +1,19 @@ -import { - lineupItemAppearsInSchedule, - slotOptionIsScheduled, -} from '@/helpers/slotSchedulerUtil.ts'; +import { ClearSlotsButton } from '@/components/slot_scheduler/ClearSlotsButton.tsx'; +import { MissingProgramsAlert } from '@/components/slot_scheduler/MissingProgramsAlert.tsx'; +import { lineupItemAppearsInSchedule } from '@/helpers/slotSchedulerUtil.ts'; import { useSlotProgramOptions } from '@/hooks/programming_controls/useSlotProgramOptions.ts'; -import { useChannelEditor } from '@/store/selectors.ts'; -import { - ArrowBack, - Autorenew, - ClearAll, - ExpandLess, - ExpandMore, -} from '@mui/icons-material'; +import { useChannelEditorLazy } from '@/store/selectors.ts'; +import { ArrowBack, Autorenew } from '@mui/icons-material'; import { Alert, Box, Button, - Collapse, Divider, FormControl, FormGroup, FormHelperText, Grid, - IconButton, InputLabel, - ListItem, MenuItem, Select, SelectChangeEvent, @@ -35,6 +25,7 @@ import { import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; import { Link as RouterLink } from '@tanstack/react-router'; import { dayjsMod, scheduleTimeSlots } from '@tunarr/shared'; +import { seq } from '@tunarr/shared/util'; import { TimeSlot, TimeSlotSchedule } from '@tunarr/types/api'; import dayjs from 'dayjs'; import timezone from 'dayjs/plugin/timezone'; @@ -43,17 +34,17 @@ import { chain, filter, first, - isEmpty, + groupBy, isUndefined, map, range, - reject, + some, + values, } from 'lodash-es'; import { useSnackbar } from 'notistack'; import pluralize from 'pluralize'; -import { useCallback, useMemo, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { useToggle } from 'usehooks-ts'; +import { useCallback, useEffect, useState } from 'react'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; import Breadcrumbs from '../../components/Breadcrumbs.tsx'; import PaddedPaper from '../../components/base/PaddedPaper.tsx'; import ChannelProgrammingList from '../../components/channel_config/ChannelProgrammingList.tsx'; @@ -135,10 +126,13 @@ export default function TimeSlotEditorPage() { // Requires that the channel was already loaded... not the case if // we navigated directly, so we need to handle that const { - currentEntity: channel, - programList: newLineup, - schedule: loadedSchedule, - } = useChannelEditor(); + channelEditor: { + currentEntity: channel, + // programList: newLineup, + schedule: loadedSchedule, + }, + getMaterializedProgramList, + } = useChannelEditorLazy(); const [startTime, setStartTime] = useState( channel?.startTime ? dayjs(channel?.startTime) : dayjs(), @@ -148,20 +142,69 @@ export default function TimeSlotEditorPage() { const theme = useTheme(); const smallViewport = useMediaQuery(theme.breakpoints.down('sm')); const programOptions = useSlotProgramOptions(); - const [unscheduledOpen, toggleUnscheduledOpen] = useToggle(false); const { control, getValues, setValue, watch, - formState: { isValid, isDirty }, + formState: { isValid, isDirty, errors }, + setError, + clearErrors, reset, } = useForm({ defaultValues: !isUndefined(loadedSchedule) && loadedSchedule.type === 'time' ? loadedSchedule : defaultTimeSlotSchedule, + mode: 'all', + }); + + useEffect(() => { + const sub = watch(({ slots }, { name }) => { + if (name?.startsWith('slots') && slots) { + const grouped = groupBy(slots, 'startTime'); + const isError = some(values(grouped), (group) => group.length > 1); + if (isError) { + const badIndexes = seq.collect(slots, (slot, index) => { + if ( + !isUndefined(slot?.startTime) && + grouped[slot.startTime]?.length > 1 + ) { + return index; + } + }); + + setError('slots', { + message: 'All slot start times must be unique', + type: 'unique', + }); + + badIndexes.forEach((index) => { + setError(`slots.${index}.startTime`, { + message: 'All slot start times must be unique', + type: 'unique', + }); + }); + } else { + const keys = range(0, slots.length).map( + (i) => `slots.${i}.startTime` as const, + ); + clearErrors(['slots', ...keys]); + } + } + }); + return () => { + sub.unsubscribe(); + }; + }, [setError, watch, clearErrors]); + + const slotArray = useFieldArray({ + control, + name: 'slots', + rules: { + required: true, + }, }); const updateLineupMutation = useUpdateLineup({ @@ -175,15 +218,6 @@ export default function TimeSlotEditorPage() { // Have to use a watch here because rendering depends on this value const currentPeriod = watch('period'); - const currentSlots = watch('slots'); - - const unscheduledOptions = useMemo( - () => - reject(programOptions, (item) => - slotOptionIsScheduled(currentSlots, item), - ), - [currentSlots, programOptions], - ); const [generatedList, setGeneratedList] = useState< UIChannelProgram[] | undefined @@ -203,7 +237,7 @@ export default function TimeSlotEditorPage() { }; // Find programs that have active slots - const filteredLineup = filter(newLineup, (item) => + const filteredLineup = filter(getMaterializedProgramList(), (item) => lineupItemAppearsInSchedule(getValues('slots'), item), ); @@ -222,6 +256,7 @@ export default function TimeSlotEditorPage() { const value = e.target.value as TimeSlotSchedule['period']; setValue('period', value, { shouldDirty: true }); let newSlots: TimeSlot[] = []; + const currentSlots = getValues('slots'); if (value === 'day') { // Remove slots // This is (sort of) what the original behavior was... keep @@ -262,21 +297,20 @@ export default function TimeSlotEditorPage() { } // Add slots - setValue('slots', newSlots, { shouldDirty: true }); + slotArray.replace(newSlots); }, - [setValue, currentSlots], + [setValue, getValues, slotArray], ); const renderTimeSlots = () => { - const slots = map(currentSlots, (slot, idx) => { + const slots = map(slotArray.fields, (slot, idx) => { return ( slotArray.remove(idx)} /> ); }); @@ -314,7 +348,7 @@ export default function TimeSlotEditorPage() { timeZoneOffset: new Date().getTimezoneOffset(), type: 'time', }, - newLineup, + getMaterializedProgramList(), ) .then((res) => { performance.mark('guide-end'); @@ -353,55 +387,23 @@ export default function TimeSlotEditorPage() { {channel.name} - {!isEmpty(unscheduledOptions) && ( - { - toggleUnscheduledOpen(); - }} - > - {!unscheduledOpen ? ( - - ) : ( - - )} - - } - > - There are {unscheduledOptions.length} unscheduled{' '} - {pluralize('program', unscheduledOptions.length)}. Unscheduled items - will be removed from the channel when saving. - - <> - {map(unscheduledOptions, (option) => ( - - {option.description} ({option.type}) - - ))} - - - + + {errors.slots?.message && ( + {errors.slots.message} )} Time Slots - - {!isEmpty(currentSlots) && ( - - )} + + {renderTimeSlots()} diff --git a/web/src/store/selectors.ts b/web/src/store/selectors.ts index 310f572ce..85f15107b 100644 --- a/web/src/store/selectors.ts +++ b/web/src/store/selectors.ts @@ -1,5 +1,6 @@ import { CondensedChannelProgram, ContentProgram } from '@tunarr/types'; import { chain, isNil, isUndefined } from 'lodash-es'; +import { useCallback, useMemo } from 'react'; import { UIChannelProgram, UIIndex } from '../types/index.ts'; import useStore, { State } from './index.ts'; @@ -71,6 +72,30 @@ export const useChannelEditor = () => { }); }; +export const useChannelEditorLazy = () => { + const channelEditor = useStore((s) => s.channelEditor); + const materializeLineup = useCallback( + ( + lineup: (CondensedChannelProgram & UIIndex)[], + programLookup: Record, + ) => { + return materializeProgramList(lineup, programLookup); + }, + [], + ); + const materializeNewLineup = useCallback( + () => + materializeLineup(channelEditor.programList, channelEditor.programLookup), + [channelEditor.programList, channelEditor.programLookup, materializeLineup], + ); + return useMemo(() => { + return { + channelEditor, + getMaterializedProgramList: materializeNewLineup, + }; + }, [channelEditor, materializeNewLineup]); +}; + export const useCustomShowEditor = () => { return useStore((s) => { const editor = s.customShowEditor; diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 0370be30f..ae5612abd 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -73,8 +73,8 @@ export const isUICondensedCustomProgram = ( ): p is UICondensedCustomProgram => p.type === 'custom'; export const isUICondensedRedirectProgram = ( - p: UIRedirectProgram, -): p is UIRedirectProgram => p.type === 'redirect'; + p: UICondensedChannelProgram, +): p is UICondensedRedirectProgram => p.type === 'redirect'; // A UIChannelProgram is a ChannelProgram with some other UI-specific fields // The default type is any ChannelProgram (e.g. content, flex, etc) with the