diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..f9803fc --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,60 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './dist' + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + diff --git a/src/ScheduleContext.tsx b/src/ScheduleContext.tsx index 529f0dd..acd3dbb 100644 --- a/src/ScheduleContext.tsx +++ b/src/ScheduleContext.tsx @@ -1,13 +1,35 @@ -import React, { createContext, PropsWithChildren, useContext, useState } from "react"; -import { Schedule } from "./types.ts"; -import dummyScheduleMap from "./dummyScheduleMap.ts"; +import React, { + createContext, + PropsWithChildren, + useContext, + useState, + useMemo, + useCallback, +} from 'react'; +import { Schedule } from './types.ts'; +import dummyScheduleMap from './dummyScheduleMap.ts'; interface ScheduleContextType { schedulesMap: Record; - setSchedulesMap: React.Dispatch>>; + setSchedulesMap: React.Dispatch< + React.SetStateAction> + >; } -const ScheduleContext = createContext(undefined); +// schedulesMap만 제공하는 Context +const ScheduleMapContext = createContext< + Record | undefined +>(undefined); + +// setSchedulesMap만 제공하는 Context (드래그 시 리렌더링 방지) +const ScheduleMapActionsContext = createContext< + React.Dispatch>> | undefined +>(undefined); + +// 기존 호환성을 위한 통합 Context +const ScheduleContext = createContext( + undefined +); export const useScheduleContext = () => { const context = useContext(ScheduleContext); @@ -17,12 +39,54 @@ export const useScheduleContext = () => { return context; }; +// setSchedulesMap만 필요한 경우: schedulesMap 변경 시 리렌더링되지 않음 +export const useSetSchedulesMap = () => { + const context = useContext(ScheduleMapActionsContext); + if (context === undefined) { + throw new Error( + 'useSetSchedulesMap must be used within a ScheduleProvider' + ); + } + return context; +}; + +// schedulesMap만 필요한 경우 +export const useSchedulesMap = () => { + const context = useContext(ScheduleMapContext); + if (context === undefined) { + throw new Error('useSchedulesMap must be used within a ScheduleProvider'); + } + return context; +}; + export const ScheduleProvider = ({ children }: PropsWithChildren) => { - const [schedulesMap, setSchedulesMap] = useState>(dummyScheduleMap); + const [schedulesMap, setSchedulesMap] = + useState>(dummyScheduleMap); + + // setSchedulesMap을 안정적인 함수로 메모이제이션 + const stableSetSchedulesMap = useCallback( + (value: React.SetStateAction>) => { + setSchedulesMap(value); + }, + [] + ); + + // Context value를 메모이제이션하여 불필요한 리렌더링 방지 + const value = useMemo( + () => ({ + schedulesMap, + setSchedulesMap: stableSetSchedulesMap, + }), + [schedulesMap, stableSetSchedulesMap] + ); return ( - - {children} + + + + {children} + + ); }; diff --git a/src/ScheduleDndProvider.tsx b/src/ScheduleDndProvider.tsx index ca15f52..64d5441 100644 --- a/src/ScheduleDndProvider.tsx +++ b/src/ScheduleDndProvider.tsx @@ -1,7 +1,13 @@ -import { DndContext, Modifier, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; -import { PropsWithChildren } from "react"; -import { CellSize, DAY_LABELS } from "./constants.ts"; -import { useScheduleContext } from "./ScheduleContext.tsx"; +import { + DndContext, + Modifier, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { PropsWithChildren, useCallback } from 'react'; +import { CellSize, DAY_LABELS } from './constants.ts'; +import { useSetSchedulesMap } from './ScheduleContext.tsx'; function createSnapModifier(): Modifier { return ({ transform, containerNodeRect, draggingNodeRect }) => { @@ -17,19 +23,30 @@ function createSnapModifier(): Modifier { const maxX = containerRight - right; const maxY = containerBottom - bottom; - - return ({ + return { ...transform, - x: Math.min(Math.max(Math.round(transform.x / CellSize.WIDTH) * CellSize.WIDTH, minX), maxX), - y: Math.min(Math.max(Math.round(transform.y / CellSize.HEIGHT) * CellSize.HEIGHT, minY), maxY), - }) + x: Math.min( + Math.max( + Math.round(transform.x / CellSize.WIDTH) * CellSize.WIDTH, + minX + ), + maxX + ), + y: Math.min( + Math.max( + Math.round(transform.y / CellSize.HEIGHT) * CellSize.HEIGHT, + minY + ), + maxY + ), + }; }; } -const modifiers = [createSnapModifier()] +const modifiers = [createSnapModifier()]; export default function ScheduleDndProvider({ children }: PropsWithChildren) { - const { schedulesMap, setSchedulesMap } = useScheduleContext(); + const setSchedulesMap = useSetSchedulesMap(); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -39,32 +56,44 @@ export default function ScheduleDndProvider({ children }: PropsWithChildren) { ); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handleDragEnd = (event: any) => { - const { active, delta } = event; - const { x, y } = delta; - const [tableId, index] = active.id.split(':'); - const schedule = schedulesMap[tableId][index]; - const nowDayIndex = DAY_LABELS.indexOf(schedule.day as typeof DAY_LABELS[number]) - const moveDayIndex = Math.floor(x / 80); - const moveTimeIndex = Math.floor(y / 30); + const handleDragEnd = useCallback( + (event: any) => { + const { active, delta } = event; + const { x, y } = delta; + const [tableId, index] = active.id.split(':'); + const moveDayIndex = Math.floor(x / 80); + const moveTimeIndex = Math.floor(y / 30); + + setSchedulesMap((prev) => { + const schedule = prev[tableId][Number(index)]; + const nowDayIndex = DAY_LABELS.indexOf( + schedule.day as (typeof DAY_LABELS)[number] + ); - setSchedulesMap({ - ...schedulesMap, - [tableId]: schedulesMap[tableId].map((targetSchedule, targetIndex) => { - if (targetIndex !== Number(index)) { - return { ...targetSchedule } - } return { - ...targetSchedule, - day: DAY_LABELS[nowDayIndex + moveDayIndex], - range: targetSchedule.range.map(time => time + moveTimeIndex), - } - }) - }) - }; + ...prev, + [tableId]: prev[tableId].map((targetSchedule, targetIndex) => { + if (targetIndex !== Number(index)) { + return { ...targetSchedule }; + } + return { + ...targetSchedule, + day: DAY_LABELS[nowDayIndex + moveDayIndex], + range: targetSchedule.range.map((time) => time + moveTimeIndex), + }; + }), + }; + }); + }, + [setSchedulesMap] + ); return ( - + {children} ); diff --git a/src/ScheduleTable.tsx b/src/ScheduleTable.tsx index ea17b6a..31d832c 100644 --- a/src/ScheduleTable.tsx +++ b/src/ScheduleTable.tsx @@ -11,19 +11,19 @@ import { PopoverContent, PopoverTrigger, Text, -} from "@chakra-ui/react"; -import { CellSize, DAY_LABELS, 분 } from "./constants.ts"; -import { Schedule } from "./types.ts"; -import { fill2, parseHnM } from "./utils.ts"; -import { useDndContext, useDraggable } from "@dnd-kit/core"; -import { CSS } from "@dnd-kit/utilities"; -import { ComponentProps, Fragment } from "react"; +} from '@chakra-ui/react'; +import { CellSize, DAY_LABELS, 분 } from './constants.ts'; +import { Schedule } from './types.ts'; +import { fill2, parseHnM } from './utils.ts'; +import { useDndContext, useDraggable } from '@dnd-kit/core'; +import { CSS } from '@dnd-kit/utilities'; +import { ComponentProps, Fragment, memo } from 'react'; interface Props { tableId: string; schedules: Schedule[]; - onScheduleTimeClick?: (timeInfo: { day: string, time: number }) => void; - onDeleteButtonClick?: (timeInfo: { day: string, time: number }) => void; + onScheduleTimeClick?: (timeInfo: { day: string; time: number }) => void; + onDeleteButtonClick?: (timeInfo: { day: string; time: number }) => void; } const TIMES = [ @@ -38,11 +38,15 @@ const TIMES = [ .map((v) => `${parseHnM(v)}~${parseHnM(v + 50 * 분)}`), ] as const; -const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => { - +const ScheduleTable = ({ + tableId, + schedules, + onScheduleTimeClick, + onDeleteButtonClick, +}: Props) => { const getColor = (lectureId: string): string => { const lectures = [...new Set(schedules.map(({ lecture }) => lecture.id))]; - const colors = ["#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd"]; + const colors = ['#fdd', '#ffd', '#dff', '#ddf', '#fdf', '#dfd']; return colors[lectures.indexOf(lectureId) % colors.length]; }; @@ -51,17 +55,17 @@ const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButton const getActiveTableId = () => { const activeId = dndContext.active?.id; if (activeId) { - return String(activeId).split(":")[0]; + return String(activeId).split(':')[0]; } return null; - } + }; const activeTableId = getActiveTableId(); return ( {DAY_LABELS.map((day) => ( - + {day} @@ -93,7 +102,9 @@ const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButton bg={timeIndex > 17 ? 'gray.200' : 'gray.100'} > - {fill2(timeIndex + 1)} ({time}) + + {fill2(timeIndex + 1)} ({time}) + {DAY_LABELS.map((day) => ( @@ -104,7 +115,9 @@ const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButton bg={timeIndex > 17 ? 'gray.100' : 'white'} cursor="pointer" _hover={{ bg: 'yellow.100' }} - onClick={() => onScheduleTimeClick?.({ day, time: timeIndex + 1 })} + onClick={() => + onScheduleTimeClick?.({ day, time: timeIndex + 1 }) + } /> ))} @@ -117,10 +130,12 @@ const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButton id={`${tableId}:${index}`} data={schedule} bg={getColor(schedule.lecture.id)} - onDeleteButtonClick={() => onDeleteButtonClick?.({ - day: schedule.day, - time: schedule.range[0], - })} + onDeleteButtonClick={() => + onDeleteButtonClick?.({ + day: schedule.day, + time: schedule.range[0], + }) + } /> ))} @@ -128,16 +143,16 @@ const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButton }; const DraggableSchedule = ({ - id, - data, - bg, - onDeleteButtonClick + id, + data, + bg, + onDeleteButtonClick, }: { id: string; data: Schedule } & ComponentProps & { - onDeleteButtonClick: () => void -}) => { + onDeleteButtonClick: () => void; + }) => { const { day, range, room, lecture } = data; const { attributes, setNodeRef, listeners, transform } = useDraggable({ id }); - const leftIndex = DAY_LABELS.indexOf(day as typeof DAY_LABELS[number]); + const leftIndex = DAY_LABELS.indexOf(day as (typeof DAY_LABELS)[number]); const topIndex = range[0] - 1; const size = range.length; @@ -146,10 +161,10 @@ const DraggableSchedule = ({ - {lecture.title} + + {lecture.title} + {room} - event.stopPropagation()}> - - + event.stopPropagation()}> + + 강의를 삭제하시겠습니까? - - + + + setSearchInfo({ tableId, ...timeInfo })} - onDeleteButtonClick={({ day, time }) => setSchedulesMap((prev) => ({ - ...prev, - [tableId]: prev[tableId].filter(schedule => schedule.day !== day || !schedule.range.includes(time)) - }))} + onScheduleTimeClick={(timeInfo) => + setSearchInfo({ tableId, ...timeInfo }) + } + onDeleteButtonClick={({ day, time }) => + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: prev[tableId].filter( + (schedule) => + schedule.day !== day || !schedule.range.includes(time) + ), + })) + } /> ))} - setSearchInfo(null)}/> + setSearchInfo(null)} + /> ); -} +}; diff --git a/src/SearchDialog.tsx b/src/SearchDialog.tsx index 593951f..39d33d3 100644 --- a/src/SearchDialog.tsx +++ b/src/SearchDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, memo } from 'react'; import { Box, Button, @@ -28,12 +28,12 @@ import { Tr, VStack, Wrap, -} from "@chakra-ui/react"; -import { useScheduleContext } from "./ScheduleContext.tsx"; -import { Lecture } from "./types.ts"; -import { parseSchedule } from "./utils.ts"; -import axios from "axios"; -import { DAY_LABELS } from "./constants.ts"; +} from '@chakra-ui/react'; +import { useSetSchedulesMap } from './ScheduleContext.tsx'; +import { Lecture } from './types.ts'; +import { parseSchedule } from './utils.ts'; +import axios, { AxiosResponse } from 'axios'; +import { DAY_LABELS } from './constants.ts'; interface Props { searchInfo: { @@ -45,59 +45,118 @@ interface Props { } interface SearchOption { - query?: string, - grades: number[], - days: string[], - times: number[], - majors: string[], - credits?: number, + query?: string; + grades: number[]; + days: string[]; + times: number[]; + majors: string[]; + credits?: number; } const TIME_SLOTS = [ - { id: 1, label: "09:00~09:30" }, - { id: 2, label: "09:30~10:00" }, - { id: 3, label: "10:00~10:30" }, - { id: 4, label: "10:30~11:00" }, - { id: 5, label: "11:00~11:30" }, - { id: 6, label: "11:30~12:00" }, - { id: 7, label: "12:00~12:30" }, - { id: 8, label: "12:30~13:00" }, - { id: 9, label: "13:00~13:30" }, - { id: 10, label: "13:30~14:00" }, - { id: 11, label: "14:00~14:30" }, - { id: 12, label: "14:30~15:00" }, - { id: 13, label: "15:00~15:30" }, - { id: 14, label: "15:30~16:00" }, - { id: 15, label: "16:00~16:30" }, - { id: 16, label: "16:30~17:00" }, - { id: 17, label: "17:00~17:30" }, - { id: 18, label: "17:30~18:00" }, - { id: 19, label: "18:00~18:50" }, - { id: 20, label: "18:55~19:45" }, - { id: 21, label: "19:50~20:40" }, - { id: 22, label: "20:45~21:35" }, - { id: 23, label: "21:40~22:30" }, - { id: 24, label: "22:35~23:25" }, + { id: 1, label: '09:00~09:30' }, + { id: 2, label: '09:30~10:00' }, + { id: 3, label: '10:00~10:30' }, + { id: 4, label: '10:30~11:00' }, + { id: 5, label: '11:00~11:30' }, + { id: 6, label: '11:30~12:00' }, + { id: 7, label: '12:00~12:30' }, + { id: 8, label: '12:30~13:00' }, + { id: 9, label: '13:00~13:30' }, + { id: 10, label: '13:30~14:00' }, + { id: 11, label: '14:00~14:30' }, + { id: 12, label: '14:30~15:00' }, + { id: 13, label: '15:00~15:30' }, + { id: 14, label: '15:30~16:00' }, + { id: 15, label: '16:00~16:30' }, + { id: 16, label: '16:30~17:00' }, + { id: 17, label: '17:00~17:30' }, + { id: 18, label: '17:30~18:00' }, + { id: 19, label: '18:00~18:50' }, + { id: 20, label: '18:55~19:45' }, + { id: 21, label: '19:50~20:40' }, + { id: 22, label: '20:45~21:35' }, + { id: 23, label: '21:40~22:30' }, + { id: 24, label: '22:35~23:25' }, ]; const PAGE_SIZE = 100; -const fetchMajors = () => axios.get('/schedules-majors.json'); -const fetchLiberalArts = () => axios.get('/schedules-liberal-arts.json'); +// 테이블 행 컴포넌트: 메모이제이션하여 추가되는 행만 렌더링 +interface LectureRowProps { + lecture: Lecture; + onAddSchedule: (lecture: Lecture) => void; +} + +const LectureRow = memo(({ lecture, onAddSchedule }: LectureRowProps) => { + return ( + + {lecture.id} + {lecture.grade} + {lecture.title} + {lecture.credits} + + + + + + + ); +}); + +LectureRow.displayName = 'LectureRow'; + +// 클로저를 이용한 캐시 함수 생성 +const createCachedFetch = () => { + let majorsCache: Promise> | null = null; + let liberalArtsCache: Promise> | null = null; + + const fetchMajors = () => { + if (!majorsCache) { + const baseUrl = + (import.meta as { env?: { BASE_URL?: string } }).env?.BASE_URL || '/'; + majorsCache = axios.get(`${baseUrl}schedules-majors.json`); + } + return majorsCache; + }; -// TODO: 이 코드를 개선해서 API 호출을 최소화 해보세요 + Promise.all이 현재 잘못 사용되고 있습니다. 같이 개선해주세요. -const fetchAllLectures = async () => await Promise.all([ - (console.log('API Call 1', performance.now()), await fetchMajors()), - (console.log('API Call 2', performance.now()), await fetchLiberalArts()), - (console.log('API Call 3', performance.now()), await fetchMajors()), - (console.log('API Call 4', performance.now()), await fetchLiberalArts()), - (console.log('API Call 5', performance.now()), await fetchMajors()), - (console.log('API Call 6', performance.now()), await fetchLiberalArts()), -]); + const fetchLiberalArts = () => { + if (!liberalArtsCache) { + const baseUrl = + (import.meta as { env?: { BASE_URL?: string } }).env?.BASE_URL || '/'; + liberalArtsCache = axios.get( + `${baseUrl}schedules-liberal-arts.json` + ); + } + return liberalArtsCache; + }; + + return { fetchMajors, fetchLiberalArts }; +}; + +const { fetchMajors, fetchLiberalArts } = createCachedFetch(); + +const fetchAllLectures = async () => + await Promise.all([ + (console.log('API Call 1', performance.now()), fetchMajors()), + (console.log('API Call 2', performance.now()), fetchLiberalArts()), + (console.log('API Call 3', performance.now()), fetchMajors()), + (console.log('API Call 4', performance.now()), fetchLiberalArts()), + (console.log('API Call 5', performance.now()), fetchMajors()), + (console.log('API Call 6', performance.now()), fetchLiberalArts()), + ]); -// TODO: 이 컴포넌트에서 불필요한 연산이 발생하지 않도록 다양한 방식으로 시도해주세요. const SearchDialog = ({ searchInfo, onClose }: Props) => { - const { setSchedulesMap } = useScheduleContext(); + const setSchedulesMap = useSetSchedulesMap(); const loaderWrapperRef = useRef(null); const loaderRef = useRef(null); @@ -111,70 +170,151 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { majors: [], }); - const getFilteredLectures = () => { + // 필터링 함수: 지연 평가를 위한 헬퍼 함수 + const matchesFilter = useMemo(() => { const { query = '', credits, grades, days, times, majors } = searchOptions; - return lectures - .filter(lecture => - lecture.title.toLowerCase().includes(query.toLowerCase()) || - lecture.id.toLowerCase().includes(query.toLowerCase()) - ) - .filter(lecture => grades.length === 0 || grades.includes(lecture.grade)) - .filter(lecture => majors.length === 0 || majors.includes(lecture.major)) - .filter(lecture => !credits || lecture.credits.startsWith(String(credits))) - .filter(lecture => { - if (days.length === 0) { - return true; + const lowerQuery = query.toLowerCase(); + + // parseSchedule 결과를 캐싱하여 중복 호출 방지 + const scheduleCache = new Map>(); + const getSchedules = (schedule: string) => { + if (!schedule) return []; + if (!scheduleCache.has(schedule)) { + scheduleCache.set(schedule, parseSchedule(schedule)); + } + return scheduleCache.get(schedule) || []; + }; + + return (lecture: Lecture) => { + // 검색어 필터 + if (query) { + const matchesQuery = + lecture.title.toLowerCase().includes(lowerQuery) || + lecture.id.toLowerCase().includes(lowerQuery); + if (!matchesQuery) return false; + } + + // 학년 필터 + if (grades.length > 0 && !grades.includes(lecture.grade)) { + return false; + } + + // 전공 필터 + if (majors.length > 0 && !majors.includes(lecture.major)) { + return false; + } + + // 학점 필터 + if (credits && !lecture.credits.startsWith(String(credits))) { + return false; + } + + // 요일 필터 + if (days.length > 0) { + const schedules = getSchedules(lecture.schedule); + if (!schedules.some((s) => days.includes(s.day))) { + return false; } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => days.includes(s.day)); - }) - .filter(lecture => { - if (times.length === 0) { - return true; + } + + // 시간 필터 + if (times.length > 0) { + const schedules = getSchedules(lecture.schedule); + if ( + !schedules.some((s) => s.range.some((time) => times.includes(time))) + ) { + return false; } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => s.range.some(time => times.includes(time))); - }); - } + } + + return true; + }; + // lectures는 필터링 시점에 사용되므로 의존성에서 제외 + }, [searchOptions]); + + // 지연 평가: 필요한 만큼만 필터링하여 visibleLectures 계산 + const visibleLectures = useMemo(() => { + const result: Lecture[] = []; + const targetCount = page * PAGE_SIZE; + + // 필요한 만큼만 필터링 (지연 평가) + for (const lecture of lectures) { + if (matchesFilter(lecture)) { + result.push(lecture); + if (result.length >= targetCount) { + break; // 필요한 만큼만 계산하고 중단 + } + } + } - const filteredLectures = getFilteredLectures(); - const lastPage = Math.ceil(filteredLectures.length / PAGE_SIZE); - const visibleLectures = filteredLectures.slice(0, page * PAGE_SIZE); - const allMajors = [...new Set(lectures.map(lecture => lecture.major))]; + return result; + }, [lectures, matchesFilter, page]); - const changeSearchOption = (field: keyof SearchOption, value: SearchOption[typeof field]) => { + // 전체 개수 계산: 지연 평가로 필요한 경우에만 계산 + const filteredLecturesCount = useMemo(() => { + // visibleLectures가 이미 필요한 만큼 계산되었고, + // 만약 visibleLectures.length < page * PAGE_SIZE라면 전체 개수를 알 수 있음 + // 하지만 정확한 개수를 표시하려면 전체를 순회해야 함 + // 지연 평가를 위해 캐싱된 결과가 있으면 재사용 + let count = 0; + for (const lecture of lectures) { + if (matchesFilter(lecture)) { + count++; + } + } + return count; + }, [lectures, matchesFilter]); + + const allMajors = useMemo( + () => [...new Set(lectures.map((lecture) => lecture.major))], + [lectures] + ); + + // 정렬된 times 배열을 메모이제이션 + const sortedTimes = useMemo( + () => [...searchOptions.times].sort((a, b) => a - b), + [searchOptions.times] + ); + + const changeSearchOption = ( + field: keyof SearchOption, + value: SearchOption[typeof field] + ) => { setPage(1); - setSearchOptions(({ ...searchOptions, [field]: value })); + setSearchOptions((prev) => ({ ...prev, [field]: value })); loaderWrapperRef.current?.scrollTo(0, 0); }; - const addSchedule = (lecture: Lecture) => { - if (!searchInfo) return; + const addSchedule = useCallback( + (lecture: Lecture) => { + if (!searchInfo) return; - const { tableId } = searchInfo; + const { tableId } = searchInfo; - const schedules = parseSchedule(lecture.schedule).map(schedule => ({ - ...schedule, - lecture - })); + const schedules = parseSchedule(lecture.schedule).map((schedule) => ({ + ...schedule, + lecture, + })); - setSchedulesMap(prev => ({ - ...prev, - [tableId]: [...prev[tableId], ...schedules] - })); + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: [...prev[tableId], ...schedules], + })); - onClose(); - }; + onClose(); + }, + [searchInfo, setSchedulesMap, onClose] + ); useEffect(() => { const start = performance.now(); - console.log('API 호출 시작: ', start) - fetchAllLectures().then(results => { + console.log('API 호출 시작: ', start); + fetchAllLectures().then((results: AxiosResponse[]) => { const end = performance.now(); - console.log('모든 API 호출 완료 ', end) - console.log('API 호출에 걸린 시간(ms): ', end - start) - setLectures(results.flatMap(result => result.data)); - }) + console.log('모든 API 호출 완료 ', end); + console.log('API 호출에 걸린 시간(ms): ', end - start); + setLectures(results.flatMap((result) => result.data)); + }); }, []); useEffect(() => { @@ -186,9 +326,13 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { } const observer = new IntersectionObserver( - entries => { + (entries) => { if (entries[0].isIntersecting) { - setPage(prevPage => Math.min(lastPage, prevPage + 1)); + // 함수형 업데이트로 lastPage 의존성 제거 + setPage((prevPage) => { + const maxPage = Math.ceil(filteredLecturesCount / PAGE_SIZE); + return Math.min(maxPage, prevPage + 1); + }); } }, { threshold: 0, root: $loaderWrapper } @@ -197,23 +341,39 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { observer.observe($loader); return () => observer.unobserve($loader); - }, [lastPage]); + // filteredLecturesCount만 의존성으로 사용 + }, [filteredLecturesCount]); useEffect(() => { - setSearchOptions(prev => ({ - ...prev, - days: searchInfo?.day ? [searchInfo.day] : [], - times: searchInfo?.time ? [searchInfo.time] : [], - })) + // 실제 값이 변경된 경우에만 상태 업데이트 + setSearchOptions((prev) => { + const newDays = searchInfo?.day ? [searchInfo.day] : []; + const newTimes = searchInfo?.time ? [searchInfo.time] : []; + + // 값이 실제로 변경된 경우에만 업데이트 + if ( + prev.days.length !== newDays.length || + prev.days[0] !== newDays[0] || + prev.times.length !== newTimes.length || + prev.times[0] !== newTimes[0] + ) { + return { + ...prev, + days: newDays, + times: newTimes, + }; + } + return prev; + }); setPage(1); }, [searchInfo]); return ( - + 수업 검색 - + @@ -230,7 +390,9 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { 학점