diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 757e39c..80cdc50 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,62 +4,28 @@ - [ ] 배포 후 url 제출 -- [ ] API 호출 최적화(`Promise.all` 이해) +- [x] API 호출 최적화(`Promise.all` 이해) -- [ ] SearchDialog 불필요한 연산 최적화 -- [ ] SearchDialog 불필요한 리렌더링 최적화 +- [x] SearchDialog 불필요한 연산 최적화 +- [x] SearchDialog 불필요한 리렌더링 최적화 -- [ ] 시간표 블록 드래그시 렌더링 최적화 -- [ ] 시간표 블록 드롭시 렌더링 최적화 +- [x] 시간표 블록 드래그시 렌더링 최적화 +- [x] 시간표 블록 드롭시 렌더링 최적화 ## 과제 셀프회고 ### 기술적 성장 - -### 코드 품질 - -### 학습 효과 분석 - +### 코드 품질 -### 과제 피드백 - -## 리뷰 받고 싶은 내용 +### 학습 효과 분석 - \ No newline at end of file +## 리뷰 받고 싶은 내용 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..bcc58f7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,35 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Install dependencies + run: pnpm install + + - name: Build + run: pnpm build + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..85bd7ae --- /dev/null +++ b/TODO.md @@ -0,0 +1,94 @@ +# 작업 우선순위 (투두 리스트) + +아래는 `.github/pull_request_template.md`의 체크리스트를 기준으로 우선순위화한 작업 목록입니다. 각 항목 옆에 간단한 확인 방법과 권장 명령을 적어두었습니다. 지금 이 파일에서 바로 진행 상황을 체크할 수 있습니다. + +- [ ] 1. 개발 서버 실행 및 핵심 동작 확인 (우선) + + - 목적: UI와 주요 기능(검색, 드래그/드롭, 시간표 렌더링) 동작 확인 + - 실행: ```powershell + pnpm install + pnpm run dev + + ``` + + ``` + +- [ ] 2. TypeScript 검사 및 콘솔 오류 확인 + + - 목적: 타입/런타임 오류를 먼저 정리하면 최적화 작업이 쉬워집니다. + - 실행: ```powershell + pnpm tsc --noEmit + pnpm run build + + ``` + + ``` + +- [ ] 3. 배포 및 URL 제출 준비 + + - 목적: 과제 요구사항(배포 URL 제출) 충족 + - 절차: `pnpm run build` → 정적 호스팅(예: Vercel/Netlify/GitHub Pages) + +- [x] 4. API 호출 최적화 (Promise.all 적용 검토) + + - 목적: 여러 API 호출을 병렬화해 로드 시간 단축 + - 점검 위치: `ScheduleContext.tsx`, `utils.ts` 등 API 호출 부분 + - 완료: `SearchDialog.tsx`에서 `fetchAllLectures` 함수를 개선하여 중복 API 호출 제거 및 `Promise.all`을 올바르게 적용. 이제 `schedules-majors.json`과 `schedules-liberal-arts.json`을 각각 한 번씩 병렬 호출하여 데이터를 합침. + - 추가: `lectureService.ts`에 API 호출 로직 분리 및 모듈 레벨 캐싱 구현, `useLectures` 훅으로 데이터 로딩 관리, `SearchDialog`에서 훅 사용으로 로직 분리. + +- [x] 5. `SearchDialog` 불필요한 연산 최적화 + + - 목적: 검색창에서 불필요한 계산(필터, 매칭)이 반복 실행되는지 확인 + - 기법: `useMemo`, `useCallback` 사용 검토 + - 완료: `SearchDialog.tsx`에서 `getFilteredLectures`, `allMajors`, `changeSearchOption`을 각각 `useMemo`와 `useCallback`으로 최적화하여 불필요한 재계산 방지. + - 추가: `filterLectures` 함수를 `utils.ts`에 추가하여 여러 filter 체이닝을 단일 순회로 통합, `useFilteredLectures` 훅으로 필터링 결과 메모이제이션. + +- [x] 6. `SearchDialog` 불필요한 리렌더링 최적화 + + - 목적: 부모로부터 전달되는 props 변경으로 인한 잦은 리렌더링 방지 + - 기법: `React.memo`, props 구조 분리 + - 완료: `SearchDialog.tsx` 컴포넌트를 `React.memo`로 감싸서 props가 변경되지 않으면 리렌더링하지 않도록 최적화. + +- [x] 7. 시간표 블록 드래그 시 렌더링 최적화 + + - 목적: 드래그 중 전체 리렌더링을 줄여 성능 향상 + - 점검 위치: `ScheduleDndProvider.tsx`, DnD 설정 + - 기법: `useCallback`으로 핸들러 최적화 + - 완료: `ScheduleDndProvider.tsx`의 `handleDragEnd` 함수를 `useCallback`으로 감싸서 불필요한 리렌더링 방지. + +- [x] 8. 시간표 블록 드롭 시 렌더링 최적화 + + - 목적: 드롭 처리 후 필요한 부분만 업데이트 + - 기법: 상태 분리, 불변성 유지, 개별 블록 키 관리 + - 완료: `ScheduleTable.tsx`의 블록 key를 `lecture.id` 기반으로 개선하고, 컴포넌트를 `React.memo`로 감싸서 불필요한 리렌더링 방지. + +- [x] 9. 과제 회고 문서 및 PR 템플릿 작성 + + - 목적: PR 템플릿에 있는 회고/리뷰 요청 항목을 채워 제출 준비 + - 완료: `.github/pull_request_template.md`에 기술적 성장, 코드 품질, 학습 효과 분석, 과제 피드백, 리뷰 요청 내용을 작성. + +- [ ] 10. 변경사항 커밋 / PR 생성 + - 권장 절차: 기능 단위 브랜치 → 커밋 → 원격 푸시 → PR 생성 + +간단한 안내: + +-- 개발 서버(1번)부터 시작하시고, 에러 로그와 타입스크립트 결과(2번)를 먼저 공유해 주세요. + +- 원하시면 제가 지금 터미널에서 1번과 2번을 실행해 결과(에러/경고 로그)를 가져오겠습니다. + +자동배포 (GitHub Pages) 설정 안내: + +- 생성된 워크플로우: `.github/workflows/deploy.yml` + - 동작: `main` 브랜치에 푸시되면 `pnpm install` → `pnpm build` 후 `./dist` 폴더를 `gh-pages` 브랜치로 배포합니다. + - 주의: Vite를 사용 중이므로 GitHub Pages에 리포지토리 경로가 포함된 URL로 배포하는 경우 `vite.config.ts`의 `base` 값을 리포 이름(예: `/front_7th_chapter4-2/`)으로 설정해야 합니다. + +설정 확인 및 배포 흐름: + +- 1. 로컬에서 먼저 `pnpm build` 실행해 `dist` 폴더가 정상 생성되는지 확인하세요. +- 2. `main` 브랜치에 푸시하면 워크플로우가 자동 실행되어 `gh-pages`에 배포됩니다. +- 3. GitHub 리포의 `Settings > Pages`에서 배포 브랜치(`gh-pages`)와 경로(`/`)가 올바른지 확인하세요. + +원하시면 제가 추가로 도와드릴 수 있습니다: + +- `vite.config.ts`의 `base` 자동 설정(원하시면 제가 파일을 업데이트) +- 워크플로우 정상 작동 확인을 위한 테스트 커밋 생성 및 푸시(사용자 승인 필요) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 79e1e76..50b4d61 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ +packages: + - '.' + onlyBuiltDependencies: - '@swc/core' - msw diff --git a/src/App.tsx b/src/App.tsx index 664bf6d..1a9365a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,21 @@ import { ChakraProvider } from "@chakra-ui/react"; -import { ScheduleProvider } from "./ScheduleContext.tsx"; -import { ScheduleTables } from "./ScheduleTables.tsx"; -import ScheduleDndProvider from "./ScheduleDndProvider.tsx"; +import { ScheduleProvider } from "./contexts/ScheduleContext.tsx"; +import { SearchDialogProvider } from "./contexts/SearchDialogContext.tsx"; +import { ScheduleTables } from "./components/schedule/ScheduleTables.tsx"; +import SearchDialog from "./components/search/SearchDialog.tsx"; +import ScheduleDndProvider from "./providers/ScheduleDndProvider.tsx"; function App() { return ( - - - + + + + + + ); diff --git a/src/ScheduleContext.tsx b/src/ScheduleContext.tsx deleted file mode 100644 index 529f0dd..0000000 --- a/src/ScheduleContext.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { createContext, PropsWithChildren, useContext, useState } from "react"; -import { Schedule } from "./types.ts"; -import dummyScheduleMap from "./dummyScheduleMap.ts"; - -interface ScheduleContextType { - schedulesMap: Record; - setSchedulesMap: React.Dispatch>>; -} - -const ScheduleContext = createContext(undefined); - -export const useScheduleContext = () => { - const context = useContext(ScheduleContext); - if (context === undefined) { - throw new Error('useSchedule must be used within a ScheduleProvider'); - } - return context; -}; - -export const ScheduleProvider = ({ children }: PropsWithChildren) => { - const [schedulesMap, setSchedulesMap] = useState>(dummyScheduleMap); - - return ( - - {children} - - ); -}; diff --git a/src/ScheduleDndProvider.tsx b/src/ScheduleDndProvider.tsx deleted file mode 100644 index ca15f52..0000000 --- a/src/ScheduleDndProvider.tsx +++ /dev/null @@ -1,71 +0,0 @@ -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"; - -function createSnapModifier(): Modifier { - return ({ transform, containerNodeRect, draggingNodeRect }) => { - const containerTop = containerNodeRect?.top ?? 0; - const containerLeft = containerNodeRect?.left ?? 0; - const containerBottom = containerNodeRect?.bottom ?? 0; - const containerRight = containerNodeRect?.right ?? 0; - - const { top = 0, left = 0, bottom = 0, right = 0 } = draggingNodeRect ?? {}; - - const minX = containerLeft - left + 120 + 1; - const minY = containerTop - top + 40 + 1; - const maxX = containerRight - right; - const maxY = containerBottom - bottom; - - - 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), - }) - }; -} - -const modifiers = [createSnapModifier()] - -export default function ScheduleDndProvider({ children }: PropsWithChildren) { - const { schedulesMap, setSchedulesMap } = useScheduleContext(); - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }) - ); - - // 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); - - 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), - } - }) - }) - }; - - return ( - - {children} - - ); -} diff --git a/src/ScheduleTable.tsx b/src/ScheduleTable.tsx deleted file mode 100644 index ea17b6a..0000000 --- a/src/ScheduleTable.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { - Box, - Button, - Flex, - Grid, - GridItem, - Popover, - PopoverArrow, - PopoverBody, - PopoverCloseButton, - 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"; - -interface Props { - tableId: string; - schedules: Schedule[]; - onScheduleTimeClick?: (timeInfo: { day: string, time: number }) => void; - onDeleteButtonClick?: (timeInfo: { day: string, time: number }) => void; -} - -const TIMES = [ - ...Array(18) - .fill(0) - .map((v, k) => v + k * 30 * 분) - .map((v) => `${parseHnM(v)}~${parseHnM(v + 30 * 분)}`), - - ...Array(6) - .fill(18 * 30 * 분) - .map((v, k) => v + k * 55 * 분) - .map((v) => `${parseHnM(v)}~${parseHnM(v + 50 * 분)}`), -] as const; - -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"]; - return colors[lectures.indexOf(lectureId) % colors.length]; - }; - - const dndContext = useDndContext(); - - const getActiveTableId = () => { - const activeId = dndContext.active?.id; - if (activeId) { - return String(activeId).split(":")[0]; - } - return null; - } - - const activeTableId = getActiveTableId(); - - return ( - - - - - 교시 - - - {DAY_LABELS.map((day) => ( - - - {day} - - - ))} - {TIMES.map((time, timeIndex) => ( - - 17 ? 'gray.200' : 'gray.100'} - > - - {fill2(timeIndex + 1)} ({time}) - - - {DAY_LABELS.map((day) => ( - 17 ? 'gray.100' : 'white'} - cursor="pointer" - _hover={{ bg: 'yellow.100' }} - onClick={() => onScheduleTimeClick?.({ day, time: timeIndex + 1 })} - /> - ))} - - ))} - - - {schedules.map((schedule, index) => ( - onDeleteButtonClick?.({ - day: schedule.day, - time: schedule.range[0], - })} - /> - ))} - - ); -}; - -const DraggableSchedule = ({ - id, - data, - bg, - onDeleteButtonClick -}: { id: string; data: Schedule } & ComponentProps & { - 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 topIndex = range[0] - 1; - const size = range.length; - - return ( - - - - {lecture.title} - {room} - - - event.stopPropagation()}> - - - - 강의를 삭제하시겠습니까? - - - - - ); -} - -export default ScheduleTable; diff --git a/src/ScheduleTables.tsx b/src/ScheduleTables.tsx deleted file mode 100644 index 44dbd7a..0000000 --- a/src/ScheduleTables.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Button, ButtonGroup, Flex, Heading, Stack } from "@chakra-ui/react"; -import ScheduleTable from "./ScheduleTable.tsx"; -import { useScheduleContext } from "./ScheduleContext.tsx"; -import SearchDialog from "./SearchDialog.tsx"; -import { useState } from "react"; - -export const ScheduleTables = () => { - const { schedulesMap, setSchedulesMap } = useScheduleContext(); - const [searchInfo, setSearchInfo] = useState<{ - tableId: string; - day?: string; - time?: number; - } | null>(null); - - const disabledRemoveButton = Object.keys(schedulesMap).length === 1; - - const duplicate = (targetId: string) => { - setSchedulesMap(prev => ({ - ...prev, - [`schedule-${Date.now()}`]: [...prev[targetId]] - })) - }; - - const remove = (targetId: string) => { - setSchedulesMap(prev => { - delete prev[targetId]; - return { ...prev }; - }) - }; - - return ( - <> - - {Object.entries(schedulesMap).map(([tableId, schedules], index) => ( - - - 시간표 {index + 1} - - - - - - - setSearchInfo({ tableId, ...timeInfo })} - onDeleteButtonClick={({ day, time }) => setSchedulesMap((prev) => ({ - ...prev, - [tableId]: prev[tableId].filter(schedule => schedule.day !== day || !schedule.range.includes(time)) - }))} - /> - - ))} - - setSearchInfo(null)}/> - - ); -} diff --git a/src/SearchDialog.tsx b/src/SearchDialog.tsx deleted file mode 100644 index 593951f..0000000 --- a/src/SearchDialog.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { - Box, - Button, - Checkbox, - CheckboxGroup, - FormControl, - FormLabel, - HStack, - Input, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalHeader, - ModalOverlay, - Select, - Stack, - Table, - Tag, - TagCloseButton, - TagLabel, - Tbody, - Td, - Text, - Th, - Thead, - 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"; - -interface Props { - searchInfo: { - tableId: string; - day?: string; - time?: number; - } | null; - onClose: () => void; -} - -interface SearchOption { - 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" }, -]; - -const PAGE_SIZE = 100; - -const fetchMajors = () => axios.get('/schedules-majors.json'); -const fetchLiberalArts = () => axios.get('/schedules-liberal-arts.json'); - -// 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()), -]); - -// TODO: 이 컴포넌트에서 불필요한 연산이 발생하지 않도록 다양한 방식으로 시도해주세요. -const SearchDialog = ({ searchInfo, onClose }: Props) => { - const { setSchedulesMap } = useScheduleContext(); - - const loaderWrapperRef = useRef(null); - const loaderRef = useRef(null); - const [lectures, setLectures] = useState([]); - const [page, setPage] = useState(1); - const [searchOptions, setSearchOptions] = useState({ - query: '', - grades: [], - days: [], - times: [], - majors: [], - }); - - const getFilteredLectures = () => { - 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 schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => days.includes(s.day)); - }) - .filter(lecture => { - if (times.length === 0) { - return true; - } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => s.range.some(time => times.includes(time))); - }); - } - - 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))]; - - const changeSearchOption = (field: keyof SearchOption, value: SearchOption[typeof field]) => { - setPage(1); - setSearchOptions(({ ...searchOptions, [field]: value })); - loaderWrapperRef.current?.scrollTo(0, 0); - }; - - const addSchedule = (lecture: Lecture) => { - if (!searchInfo) return; - - const { tableId } = searchInfo; - - const schedules = parseSchedule(lecture.schedule).map(schedule => ({ - ...schedule, - lecture - })); - - setSchedulesMap(prev => ({ - ...prev, - [tableId]: [...prev[tableId], ...schedules] - })); - - onClose(); - }; - - useEffect(() => { - const start = performance.now(); - console.log('API 호출 시작: ', start) - fetchAllLectures().then(results => { - const end = performance.now(); - console.log('모든 API 호출 완료 ', end) - console.log('API 호출에 걸린 시간(ms): ', end - start) - setLectures(results.flatMap(result => result.data)); - }) - }, []); - - useEffect(() => { - const $loader = loaderRef.current; - const $loaderWrapper = loaderWrapperRef.current; - - if (!$loader || !$loaderWrapper) { - return; - } - - const observer = new IntersectionObserver( - entries => { - if (entries[0].isIntersecting) { - setPage(prevPage => Math.min(lastPage, prevPage + 1)); - } - }, - { threshold: 0, root: $loaderWrapper } - ); - - observer.observe($loader); - - return () => observer.unobserve($loader); - }, [lastPage]); - - useEffect(() => { - setSearchOptions(prev => ({ - ...prev, - days: searchInfo?.day ? [searchInfo.day] : [], - times: searchInfo?.time ? [searchInfo.time] : [], - })) - setPage(1); - }, [searchInfo]); - - return ( - - - - 수업 검색 - - - - - - 검색어 - changeSearchOption('query', e.target.value)} - /> - - - - 학점 - - - - - - - 학년 - changeSearchOption('grades', value.map(Number))} - > - - {[1, 2, 3, 4].map(grade => ( - {grade}학년 - ))} - - - - - - 요일 - changeSearchOption('days', value as string[])} - > - - {DAY_LABELS.map(day => ( - {day} - ))} - - - - - - - - 시간 - changeSearchOption('times', values.map(Number))} - > - - {searchOptions.times.sort((a, b) => a - b).map(time => ( - - {time}교시 - changeSearchOption('times', searchOptions.times.filter(v => v !== time))}/> - - ))} - - - {TIME_SLOTS.map(({ id, label }) => ( - - - {id}교시({label}) - - - ))} - - - - - - 전공 - changeSearchOption('majors', values as string[])} - > - - {searchOptions.majors.map(major => ( - - {major.split("

").pop()} - changeSearchOption('majors', searchOptions.majors.filter(v => v !== major))}/> - - ))} - - - {allMajors.map(major => ( - - - {major.replace(/

/gi, ' ')} - - - ))} - - - - - - 검색결과: {filteredLectures.length}개 - - - - - - - - - - - - - - -
과목코드학년과목명학점전공시간
- - - - - {visibleLectures.map((lecture, index) => ( - - - - - - - - ))} - -
{lecture.id}{lecture.grade}{lecture.title}{lecture.credits} - - - -
- - -
- - - - - ); -}; - -export default SearchDialog; \ No newline at end of file diff --git a/src/components/schedule/DraggableSchedule.tsx b/src/components/schedule/DraggableSchedule.tsx new file mode 100644 index 0000000..fc6ee2d --- /dev/null +++ b/src/components/schedule/DraggableSchedule.tsx @@ -0,0 +1,93 @@ +import React, { useMemo } from "react"; +import { Box, Button, Popover, PopoverArrow, PopoverBody, PopoverCloseButton, PopoverContent, PopoverTrigger, Text } from "@chakra-ui/react"; +import { CSS } from "@dnd-kit/utilities"; +import { useDraggable } from "@dnd-kit/core"; +import { CellSize, DAY_LABELS } from "../../constants.ts"; +import { Schedule } from "../../types.ts"; + +interface Props { + id: string; + data: Schedule; + bg?: string; + tableId: string; + onDeleteButtonClick: () => void; +} + +// tableId는 React.memo 비교 함수(아래)에서 사용되므로 Props에 필요 +const DraggableScheduleInner = ({ id, data, bg, tableId: _tableId, onDeleteButtonClick }: Props) => { + const { day, range, room, lecture } = data; + const { attributes, setNodeRef, listeners, transform, isDragging } = useDraggable({ id }); + + const leftIndex = DAY_LABELS.indexOf(day as typeof DAY_LABELS[number]); + const topIndex = range[0] - 1; + const size = range.length; + + // 위치 계산 메모이제이션 - transform만 변경되므로 리렌더링 최소화 + const style = useMemo(() => ({ + position: 'absolute' as const, + left: `${120 + (CellSize.WIDTH * leftIndex) + 1}px`, + top: `${40 + (topIndex * CellSize.HEIGHT + 1)}px`, + width: `${CellSize.WIDTH - 1}px`, + height: `${CellSize.HEIGHT * size - 1}px`, + transform: transform ? CSS.Translate.toString(transform) : undefined, + opacity: isDragging ? 0.8 : 1, + zIndex: isDragging ? 1000 : 'auto', + }), [leftIndex, topIndex, size, transform, isDragging]); + + return ( + + + + {lecture.title} + {room} + + + event.stopPropagation()}> + + + + 강의를 삭제하시겠습니까? + + + + + ); +}; + +// memoize to avoid re-renders unless props identity changes +// 드래그 중에는 transform만 변경되므로 불필요한 리렌더링 방지 +// tableId는 이 비교 함수에서 사용되므로 컴포넌트 Props에 필요함 +export default React.memo(DraggableScheduleInner, (prev, next) => { + // id, bg, tableId, onDeleteButtonClick는 참조 비교 (tableId는 위 컴포넌트에서 언더스코어로 처리) + if (prev.id !== next.id || prev.bg !== next.bg || prev.tableId !== next.tableId || prev.onDeleteButtonClick !== next.onDeleteButtonClick) { + return false; + } + + // data는 깊은 비교 필요 - schedule의 주요 속성만 비교 + if (prev.data !== next.data) { + const prevSchedule = prev.data; + const nextSchedule = next.data; + + return ( + prevSchedule.day === nextSchedule.day && + prevSchedule.lecture.id === nextSchedule.lecture.id && + prevSchedule.range.length === nextSchedule.range.length && + prevSchedule.range.every((time, idx) => time === nextSchedule.range[idx]) && + prevSchedule.room === nextSchedule.room + ); + } + + return true; +}); + diff --git a/src/components/schedule/ScheduleTable.tsx b/src/components/schedule/ScheduleTable.tsx new file mode 100644 index 0000000..7c3e6b0 --- /dev/null +++ b/src/components/schedule/ScheduleTable.tsx @@ -0,0 +1,118 @@ +import { Box } from "@chakra-ui/react"; +import React, { useCallback, useMemo, useEffect } from "react"; +import { useIsActiveTable } from "../../providers/ScheduleDndProvider.tsx"; +import { useScheduleTable } from "../../contexts/ScheduleContext.tsx"; +import DraggableSchedule from "./DraggableSchedule.tsx"; +import ScheduleTableLayout, { registerClickHandler, unregisterClickHandler } from "./ScheduleTableLayout.tsx"; + +const COLORS = ["#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd"] as const; + +// outline을 별도 컴포넌트로 분리하여 ScheduleTable 리렌더링 방지 +const TableOutline = React.memo(({ tableId }: { tableId: string }) => { + const isActiveTable = useIsActiveTable(tableId); + + if (!isActiveTable) return null; + + return ( + + ); +}, (prevProps, nextProps) => { + return prevProps.tableId === nextProps.tableId; +}); +TableOutline.displayName = 'TableOutline'; + + +interface Props { + tableId: string; + onScheduleTimeClick?: (timeInfo: { day: string, time: number }) => void; + onDeleteButtonClick?: (timeInfo: { day: string, time: number }) => void; +} + +// ScheduleTable - schedules를 useScheduleTable로 직접 가져옴 +const ScheduleTable = ({ tableId, onScheduleTimeClick, onDeleteButtonClick }: Props) => { + // schedules를 useScheduleTable로 직접 가져와서 해당 테이블만 구독 + const schedules = useScheduleTable(tableId); + + // 콜백을 ref로 저장하여 안정화 - 콜백이 변경되어도 리렌더링되지 않도록 + const onScheduleTimeClickRef = React.useRef(onScheduleTimeClick); + const onDeleteButtonClickRef = React.useRef(onDeleteButtonClick); + + React.useEffect(() => { + onScheduleTimeClickRef.current = onScheduleTimeClick; + onDeleteButtonClickRef.current = onDeleteButtonClick; + }, [onScheduleTimeClick, onDeleteButtonClick]); + + // 클릭 핸들러를 레지스트리에 등록/해제 + useEffect(() => { + if (onScheduleTimeClickRef.current) { + registerClickHandler(tableId, (day: string, timeIndex: number) => { + onScheduleTimeClickRef.current?.({ day, time: timeIndex + 1 }); + }); + } + return () => { + unregisterClickHandler(tableId); + }; + }, [tableId]); + + // colorMap을 schedules의 lecture.id만 추적하도록 최적화 + const lectureIdsStr = useMemo(() => { + const ids = new Set(schedules.map(({ lecture }) => lecture.id)); + return Array.from(ids).sort().join(','); + }, [schedules]); + + const colorMap = useMemo(() => { + const uniqueLectures = lectureIdsStr ? lectureIdsStr.split(',') : []; + return new Map(uniqueLectures.map((lectureId, index) => [lectureId, COLORS[index % COLORS.length]])); + }, [lectureIdsStr]); + + const getColor = useCallback((lectureId: string): string => { + return colorMap.get(lectureId) || '#fdd'; + }, [colorMap]); + + // onDeleteButtonClick을 안정화하여 매번 새로 생성되지 않도록 + const stableOnDeleteButtonClick = useCallback((day: string, time: number) => { + onDeleteButtonClickRef.current?.({ day, time }); + }, []); + + // DraggableSchedule 목록을 메모이제이션 + const draggableSchedules = useMemo(() => { + return schedules.map((schedule, index) => { + const handleDelete = () => { + stableOnDeleteButtonClick(schedule.day, schedule.range[0]); + }; + + return ( + + ); + }); + }, [schedules, tableId, getColor, stableOnDeleteButtonClick]); + + return ( + + + + {draggableSchedules} + + ); +}; + +// React.memo를 사용하되, schedules는 내부에서 직접 구독하므로 props에서 제거 +// 콜백은 ref로 저장되므로 비교하지 않음 - 콜백 변경 시에도 리렌더링되지 않음 +export default React.memo(ScheduleTable, (prevProps, nextProps) => { + // tableId만 비교 + return prevProps.tableId === nextProps.tableId; +}); diff --git a/src/components/schedule/ScheduleTableLayout.tsx b/src/components/schedule/ScheduleTableLayout.tsx new file mode 100644 index 0000000..aa53920 --- /dev/null +++ b/src/components/schedule/ScheduleTableLayout.tsx @@ -0,0 +1,184 @@ +import { + Flex, + Grid, + GridItem, + Text, +} from "@chakra-ui/react"; +import { CellSize, DAY_LABELS, 분 } from "../../constants.ts"; +import { fill2, parseHnM } from "../../utils.ts"; +import React, { Fragment, useCallback, useMemo } from "react"; + +// 상수 정의 +const TIMES = [ + ...Array(18) + .fill(0) + .map((v, k) => v + k * 30 * 분) + .map((v) => `${parseHnM(v)}~${parseHnM(v + 30 * 분)}`), + + ...Array(6) + .fill(18 * 30 * 분) + .map((v, k) => v + k * 55 * 분) + .map((v) => `${parseHnM(v)}~${parseHnM(v + 50 * 분)}`), +] as const; + +const GRID_TEMPLATE_COLUMNS = `120px repeat(${DAY_LABELS.length}, ${CellSize.WIDTH}px)`; +const GRID_TEMPLATE_ROWS = `40px repeat(${TIMES.length}, ${CellSize.HEIGHT}px)`; + +// 클릭 핸들러를 전역으로 관리하여 셀 컴포넌트가 안정적으로 유지되도록 함 +const clickHandlerRegistry = new Map void>(); + +// 클릭 핸들러 등록 함수 (외부에서 사용) +export const registerClickHandler = (tableId: string, handler: (day: string, timeIndex: number) => void) => { + clickHandlerRegistry.set(tableId, handler); +}; + +// 클릭 핸들러 제거 함수 (외부에서 사용) +export const unregisterClickHandler = (tableId: string) => { + clickHandlerRegistry.delete(tableId); +}; + +// 교시 라벨 셀 - 완전히 정적 +const PeriodLabelCell = React.memo(() => { + return ( + + + 교시 + + + ); +}); +PeriodLabelCell.displayName = 'PeriodLabelCell'; + +// 요일 라벨 셀 - 완전히 정적 +const DayLabelCell = React.memo(({ day }: { day: string }) => { + return ( + + + {day} + + + ); +}, (prevProps, nextProps) => prevProps.day === nextProps.day); +DayLabelCell.displayName = 'DayLabelCell'; + +// 시간 라벨 셀 - 완전히 정적 +const TimeLabelCell = React.memo(({ timeIndex, time }: { timeIndex: number; time: string }) => { + const labelText = useMemo(() => `${fill2(timeIndex + 1)} (${time})`, [timeIndex, time]); + const bgColor = useMemo(() => timeIndex > 17 ? 'gray.200' : 'gray.100', [timeIndex]); + + return ( + + + {labelText} + + + ); +}, (prevProps, nextProps) => { + return prevProps.timeIndex === nextProps.timeIndex && + prevProps.time === nextProps.time; +}); +TimeLabelCell.displayName = 'TimeLabelCell'; + +// 클릭 가능한 빈 셀 - 드래그와 무관하지만 클릭 이벤트가 있음 +const ScheduleCell = React.memo(({ + day, + timeIndex, + tableId +}: { + day: string; + timeIndex: number; + tableId: string; +}) => { + const handleClick = useCallback(() => { + const handler = clickHandlerRegistry.get(tableId); + handler?.(day, timeIndex); + }, [day, timeIndex, tableId]); + + const bgColor = useMemo(() => timeIndex > 17 ? 'gray.100' : 'white', [timeIndex]); + + return ( + + ); +}, (prevProps, nextProps) => { + return prevProps.day === nextProps.day && + prevProps.timeIndex === nextProps.timeIndex && + prevProps.tableId === nextProps.tableId; +}); +ScheduleCell.displayName = 'ScheduleCell'; + +// 드래그와 관련없는 정적 테이블 레이아웃 +// 모든 정적 셀들을 포함하며, 한 번 렌더링되면 재렌더링되지 않음 +const ScheduleTableLayout = React.memo(({ + tableId +}: { + tableId: string; +}) => { + // 시간 라벨 목록 메모이제이션 - 한 번만 생성 + const timeLabels = useMemo(() => { + return TIMES.map((time, timeIndex) => ( + + )); + }, []); + + // 요일 라벨 목록 메모이제이션 - 한 번만 생성 + const dayLabels = useMemo(() => { + return DAY_LABELS.map((day) => ( + + )); + }, []); + + // 스케줄 셀 목록을 메모이제이션 - tableId가 안정적이므로 한 번만 생성 + const scheduleCells = useMemo(() => { + return TIMES.map((_time, timeIndex) => ( + + {timeLabels[timeIndex]} + {DAY_LABELS.map((day) => ( + + ))} + + )); + }, [timeLabels, tableId]); + + return ( + + + {dayLabels} + {scheduleCells} + + ); +}, (prevProps, nextProps) => { + // tableId만 비교 - 완전히 정적 컴포넌트이므로 tableId가 같으면 리렌더링하지 않음 + return prevProps.tableId === nextProps.tableId; +}); +ScheduleTableLayout.displayName = 'ScheduleTableLayout'; + +export default ScheduleTableLayout; + diff --git a/src/components/schedule/ScheduleTables.tsx b/src/components/schedule/ScheduleTables.tsx new file mode 100644 index 0000000..950dc3f --- /dev/null +++ b/src/components/schedule/ScheduleTables.tsx @@ -0,0 +1,108 @@ +import { Button, ButtonGroup, Flex, Heading, Stack } from "@chakra-ui/react"; +import ScheduleTable from "./ScheduleTable.tsx"; +import { useScheduleSetAction, useScheduleTableKeys } from "../../contexts/ScheduleContext.tsx"; +import { useSearchDialogSetAction } from "../../contexts/SearchDialogContext.tsx"; +import React, { useCallback, useMemo } from "react"; +import { Schedule } from "../../types.ts"; + +export const ScheduleTables = () => { + const tableKeys = useScheduleTableKeys(); + + // disabledRemoveButton을 테이블 키 개수만 추적하도록 최적화 + const disabledRemoveButton = useMemo(() => { + return tableKeys.length === 1; + }, [tableKeys.length]); + + // TableCard: 각 테이블 블록을 독립된 컴포넌트로 분리하여 + // 각 테이블이 자신의 스케줄만 구독하도록 함 + const TableCard = React.memo(function TableCard({ tableId, index, disabledRemoveButton }: { tableId: string; index: number; disabledRemoveButton: boolean }) { + const setSchedulesMap = useScheduleSetAction(); + const setSearchInfo = useSearchDialogSetAction(); + + const onOpenSearch = useCallback(() => { + setSearchInfo({ tableId }); + }, [tableId, setSearchInfo]); + + const onScheduleTimeClick = useCallback((timeInfo: { day: string; time: number }) => { + setSearchInfo({ tableId, ...timeInfo }); + }, [tableId, setSearchInfo]); + + const onDeleteButtonClick = useCallback(({ day, time }: { day: string; time: number }) => { + setSchedulesMap((prev: Record) => { + const currentSchedules = prev[tableId] || []; + const filteredSchedules = currentSchedules.filter((schedule: Schedule) => + schedule.day !== day || !schedule.range.includes(time) + ); + + // 실제로 변경이 없으면 이전 상태 반환 + if (filteredSchedules.length === currentSchedules.length) { + return prev; + } + + return { + ...prev, + [tableId]: filteredSchedules + }; + }); + }, [setSchedulesMap, tableId]); + + const duplicateTable = useCallback(() => { + setSchedulesMap((prev: Record) => { + const currentSchedules = prev[tableId]; + if (!currentSchedules) return prev; + + const newTableId = `schedule-${Date.now()}`; + return { + ...prev, + [newTableId]: [...currentSchedules] + }; + }); + }, [setSchedulesMap, tableId]); + + const removeTable = useCallback(() => { + setSchedulesMap((prev: Record) => { + if (!(tableId in prev)) return prev; + + const copy = { ...prev }; + delete copy[tableId]; + return copy; + }); + }, [setSchedulesMap, tableId]); + + return ( + + + 시간표 {index + 1} + + + + + + + + + ); + }, (prevProps, nextProps) => { + // tableId, index, disabledRemoveButton만 비교 + // schedules는 ScheduleTable 내부에서 직접 구독하므로 여기서는 비교하지 않음 + return prevProps.tableId === nextProps.tableId && + prevProps.index === nextProps.index && + prevProps.disabledRemoveButton === nextProps.disabledRemoveButton; + }); + + return ( + + {tableKeys.map((tableId, index) => ( + + ))} + + ); +} + +export default React.memo(ScheduleTables); + diff --git a/src/components/search/SearchDialog.tsx b/src/components/search/SearchDialog.tsx new file mode 100644 index 0000000..74885f5 --- /dev/null +++ b/src/components/search/SearchDialog.tsx @@ -0,0 +1,644 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Box, + Button, + Checkbox, + CheckboxGroup, + FormControl, + FormLabel, + HStack, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Select, + Stack, + Table, + Tag, + TagCloseButton, + TagLabel, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + VStack, + Wrap, +} from "@chakra-ui/react"; +import { useScheduleSetAction } from "../../contexts/ScheduleContext.tsx"; +import { useSearchDialog } from "../../contexts/SearchDialogContext.tsx"; +import { Lecture } from "../../types.ts"; +import { parseSchedule } from "../../utils.ts"; +import { DAY_LABELS } from "../../constants.ts"; +import { useLectures } from "../../hooks/useLectures.ts"; +import { useFilteredLectures } from "../../hooks/useFilteredLectures.ts"; + +interface SearchOption { + 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" }, +]; + +const INITIAL_PAGE_SIZE = 100; // 초기 렌더링 크기 +const PAGE_SIZE_INCREMENT = 40; // 스크롤 시 추가로 로드할 크기 + +// 검색 필터 컴포넌트들을 분리하여 각각 메모이제이션 +const QueryInput = React.memo(({ value, onChange }: { value: string; onChange: (value: string) => void }) => { + return ( + + 검색어 + onChange(e.target.value)} + /> + + ); +}, (prevProps, nextProps) => prevProps.value === nextProps.value && prevProps.onChange === nextProps.onChange); +QueryInput.displayName = 'QueryInput'; + +const CreditsSelect = React.memo(({ value, onChange }: { value?: number; onChange: (value: string) => void }) => { + return ( + + 학점 + + + ); +}, (prevProps, nextProps) => prevProps.value === nextProps.value && prevProps.onChange === nextProps.onChange); +CreditsSelect.displayName = 'CreditsSelect'; + +const GradesCheckboxGroup = React.memo(({ value, onChange }: { value: number[]; onChange: (value: number[]) => void }) => { + return ( + + 학년 + onChange(values.map(Number))}> + + {[1, 2, 3, 4].map(grade => ( + {grade}학년 + ))} + + + + ); +}, (prevProps, nextProps) => { + if (prevProps.value.length !== nextProps.value.length) return false; + return prevProps.value.every((v, i) => v === nextProps.value[i]) && + prevProps.onChange === nextProps.onChange; +}); +GradesCheckboxGroup.displayName = 'GradesCheckboxGroup'; + +const DaysCheckboxGroup = React.memo(({ value, onChange }: { value: string[]; onChange: (value: string[]) => void }) => { + return ( + + 요일 + onChange(values as string[])}> + + {DAY_LABELS.map(day => ( + {day} + ))} + + + + ); +}, (prevProps, nextProps) => { + if (prevProps.value.length !== nextProps.value.length) return false; + return prevProps.value.every((v, i) => v === nextProps.value[i]) && + prevProps.onChange === nextProps.onChange; +}); +DaysCheckboxGroup.displayName = 'DaysCheckboxGroup'; + +const TimesCheckboxGroup = React.memo(({ value, sortedTimes, onChange, onRemoveTime }: { + value: number[]; + sortedTimes: number[]; + onChange: (value: number[]) => void; + onRemoveTime: (time: number) => void; +}) => { + return ( + + 시간 + onChange(values.map(Number))}> + + {sortedTimes.map(time => ( + + {time}교시 + onRemoveTime(time)}/> + + ))} + + + {TIME_SLOTS.map(({ id, label }) => ( + + + {id}교시({label}) + + + ))} + + + + ); +}, (prevProps, nextProps) => { + if (prevProps.value.length !== nextProps.value.length) return false; + if (prevProps.sortedTimes.length !== nextProps.sortedTimes.length) return false; + return prevProps.value.every((v, i) => v === nextProps.value[i]) && + prevProps.sortedTimes.every((v, i) => v === nextProps.sortedTimes[i]) && + prevProps.onChange === nextProps.onChange && + prevProps.onRemoveTime === nextProps.onRemoveTime; +}); +TimesCheckboxGroup.displayName = 'TimesCheckboxGroup'; + +const MajorsCheckboxGroup = React.memo(({ value, allMajors, onChange, onRemoveMajor }: { + value: string[]; + allMajors: string[]; + onChange: (value: string[]) => void; + onRemoveMajor: (major: string) => void; +}) => { + return ( + + 전공 + onChange(values as string[])}> + + {value.map(major => ( + + {major.split("

").pop()} + onRemoveMajor(major)}/> + + ))} + + + {allMajors.map(major => ( + + + {major.replace(/

/gi, ' ')} + + + ))} + + + + ); +}, (prevProps, nextProps) => { + if (prevProps.value.length !== nextProps.value.length) return false; + if (prevProps.allMajors.length !== nextProps.allMajors.length) return false; + return prevProps.value.every((v, i) => v === nextProps.value[i]) && + prevProps.allMajors.every((v, i) => v === nextProps.allMajors[i]) && + prevProps.onChange === nextProps.onChange && + prevProps.onRemoveMajor === nextProps.onRemoveMajor; +}); +MajorsCheckboxGroup.displayName = 'MajorsCheckboxGroup'; + +// 테이블 행 컴포넌트를 분리하여 메모이제이션 +// dangerouslySetInnerHTML을 useMemo로 최적화 +const LectureRow = React.memo(({ lecture, onAddSchedule }: { lecture: Lecture; onAddSchedule: (lecture: Lecture) => void }) => { + const handleAdd = useCallback(() => { + onAddSchedule(lecture); + }, [lecture, onAddSchedule]); + + // HTML 콘텐츠를 메모이제이션하여 불필요한 재생성 방지 + const majorHtml = React.useMemo(() => ({ __html: lecture.major }), [lecture.major]); + const scheduleHtml = React.useMemo(() => ({ __html: lecture.schedule || '' }), [lecture.schedule]); + + return ( + + {lecture.id} + {lecture.grade} + {lecture.title} + {lecture.credits} + + + + + + + ); +}, (prevProps, nextProps) => { + // lecture 객체의 참조가 같으면 리렌더링 방지 + return prevProps.lecture === nextProps.lecture && + prevProps.onAddSchedule === nextProps.onAddSchedule; +}); +LectureRow.displayName = 'LectureRow'; + +// 검색 결과 테이블 컴포넌트 분리 +const ResultsTable = React.memo(({ + visibleLectures, + filteredCount, + onAddSchedule, + loaderWrapperRef, + loaderRef, + onLoadMore, + hasMore +}: { + visibleLectures: Array<{ lecture: Lecture; index: number }>; + filteredCount: number; + onAddSchedule: (lecture: Lecture) => void; + loaderWrapperRef: React.RefObject; + loaderRef: React.RefObject; + onLoadMore: () => void; + hasMore: boolean; +}) => { + // IntersectionObserver를 ResultsTable 내부로 이동 + // 성능 최적화를 위해 requestIdleCallback과 requestAnimationFrame 조합 사용 + const loadingRef = React.useRef(false); + const scheduledRef = React.useRef(false); + + React.useEffect(() => { + // 더 로드할 항목이 없으면 observer 설정하지 않음 + if (!hasMore) { + return; + } + + const $loader = loaderRef.current; + const $loaderWrapper = loaderWrapperRef.current; + + if (!$loader || !$loaderWrapper) { + return; + } + + let observer: IntersectionObserver | null = null; + let rafId: number | null = null; + let idleId: number | null = null; + + // observer를 즉시 설정 (timeout 제거) + observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting && !loadingRef.current && !scheduledRef.current) { + scheduledRef.current = true; + + // requestIdleCallback을 사용하여 브라우저가 idle 상태일 때 로드 + const scheduleLoad = () => { + if (typeof requestIdleCallback !== 'undefined') { + idleId = requestIdleCallback(() => { + if (!loadingRef.current) { + loadingRef.current = true; + onLoadMore(); + setTimeout(() => { + loadingRef.current = false; + scheduledRef.current = false; + }, 300); + } + }, { timeout: 100 }); + } else { + // requestIdleCallback이 없으면 requestAnimationFrame 사용 + rafId = requestAnimationFrame(() => { + if (!loadingRef.current) { + loadingRef.current = true; + onLoadMore(); + setTimeout(() => { + loadingRef.current = false; + scheduledRef.current = false; + }, 300); + } + }); + } + }; + + // React 18의 startTransition 사용 (있으면) + if (typeof (React as any).startTransition === 'function') { + (React as any).startTransition(scheduleLoad); + } else { + scheduleLoad(); + } + } + }, + { + threshold: 0, + root: $loaderWrapper, + // 90개 정도 보였을 때 로드하도록 rootMargin 조정 + // 행 높이를 약 40px로 추정하면, 10개 행 = 400px 정도 + // 하지만 너무 크면 조기 로드되므로 200px로 조정 + rootMargin: '200px' + } + ); + + observer.observe($loader); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + if (idleId !== null && typeof cancelIdleCallback !== 'undefined') { + cancelIdleCallback(idleId); + } + if (observer) { + observer.disconnect(); + } + }; + }, [loaderRef, loaderWrapperRef, onLoadMore, hasMore]); + + return ( + <> + + 검색결과: {filteredCount}개 + + + + + + + + + + + + + + +
과목코드학년과목명학점전공시간
+ + + + + {visibleLectures.map(({ lecture, index }) => ( + + ))} + +
+ {hasMore && } + +
+ + ); +}, (prevProps, nextProps) => { + // visibleLectures 배열의 길이와 참조 비교 + if (prevProps.visibleLectures.length !== nextProps.visibleLectures.length) return false; + if (prevProps.filteredCount !== nextProps.filteredCount) return false; + if (prevProps.hasMore !== nextProps.hasMore) return false; + + // 각 lecture의 참조 비교 + const lecturesChanged = prevProps.visibleLectures.some((item, index) => + item.lecture !== nextProps.visibleLectures[index]?.lecture + ); + if (lecturesChanged) return false; + + return prevProps.onAddSchedule === nextProps.onAddSchedule && + prevProps.onLoadMore === nextProps.onLoadMore; +}); +ResultsTable.displayName = 'ResultsTable'; + +// TODO: 이 컴포넌트에서 불필요한 연산이 발생하지 않도록 다양한 방식으로 시도해주세요. +const SearchDialog = () => { + const { searchInfo, setSearchInfo } = useSearchDialog(); + + const onClose = useCallback(() => { + setSearchInfo(null); + }, [setSearchInfo]); + // setSchedulesMap만 필요하므로 useScheduleSetAction 사용하여 불필요한 리렌더링 방지 + const setSchedulesMap = useScheduleSetAction(); + const { lectures } = useLectures(); + + const loaderWrapperRef = useRef(null); + const loaderRef = useRef(null); + const [page, setPage] = useState(1); + const [searchOptions, setSearchOptions] = useState({ + query: '', + grades: [], + days: [], + times: [], + majors: [], + }); + + const filteredLectures = useFilteredLectures(lectures, searchOptions); + + // 점진적 로드를 위한 visibleCount 계산 + // 초기 100개, 이후 100개씩 추가 + // filteredLectures.length를 초과하지 않도록 보장 + const visibleCount = useMemo(() => { + let count: number; + if (page === 1) { + count = INITIAL_PAGE_SIZE; // 100개 + } else { + count = INITIAL_PAGE_SIZE + (page - 1) * PAGE_SIZE_INCREMENT; // 100 + (page-1) * 100 + } + // filteredLectures.length를 초과하지 않도록 제한 + return Math.min(count, filteredLectures.length); + }, [page, filteredLectures.length]); + + // 더 로드할 항목이 있는지 확인 + const hasMore = useMemo(() => { + return visibleCount < filteredLectures.length; + }, [visibleCount, filteredLectures.length]); + + const visibleLectures = useMemo(() => + filteredLectures.slice(0, visibleCount).map((lecture, index) => ({ + lecture, + index, + })), + [filteredLectures, visibleCount] + ); + + // allMajors를 메모이제이션하여 lectures가 변경되지 않으면 재계산하지 않음 + const allMajors = useMemo(() => { + const majorsSet = new Set(); + for (const lecture of lectures) { + majorsSet.add(lecture.major); + } + return Array.from(majorsSet); + }, [lectures]); + + // sortedTimes를 메모이제이션하여 매번 정렬하지 않도록 + // 배열의 참조가 같으면 이전 결과 반환 + const sortedTimes = useMemo(() => { + if (searchOptions.times.length === 0) return []; + return [...searchOptions.times].sort((a, b) => a - b); + }, [searchOptions.times]); + + const changeSearchOption = useCallback((field: keyof SearchOption, value: SearchOption[typeof field]) => { + setPage(1); + setSearchOptions(prev => ({ ...prev, [field]: value })); + loaderWrapperRef.current?.scrollTo(0, 0); + }, []); + + // 각 필터별 콜백을 메모이제이션하여 불필요한 리렌더링 방지 + const handleQueryChange = useCallback((value: string) => changeSearchOption('query', value), [changeSearchOption]); + const handleCreditsChange = useCallback((value: string) => changeSearchOption('credits', value ? Number(value) : undefined), [changeSearchOption]); + const handleGradesChange = useCallback((value: number[]) => changeSearchOption('grades', value), [changeSearchOption]); + const handleDaysChange = useCallback((value: string[]) => changeSearchOption('days', value), [changeSearchOption]); + const handleTimesChange = useCallback((value: number[]) => changeSearchOption('times', value), [changeSearchOption]); + const handleTimesRemove = useCallback((time: number) => changeSearchOption('times', searchOptions.times.filter(v => v !== time)), [changeSearchOption, searchOptions.times]); + const handleMajorsChange = useCallback((value: string[]) => changeSearchOption('majors', value), [changeSearchOption]); + const handleMajorsRemove = useCallback((major: string) => changeSearchOption('majors', searchOptions.majors.filter(v => v !== major)), [changeSearchOption, searchOptions.majors]); + + const addSchedule = useCallback((lecture: Lecture) => { + if (!searchInfo) return; + + const { tableId } = searchInfo; + + const schedules = lecture.schedule ? parseSchedule(lecture.schedule).map(schedule => ({ + ...schedule, + lecture + })) : []; + + setSchedulesMap(prev => ({ + ...prev, + [tableId]: [...prev[tableId], ...schedules] + })); + + onClose(); + }, [searchInfo, setSchedulesMap, onClose]); + + // 무한 스크롤을 위한 로드 더보기 핸들러 + // React 18의 startTransition 사용하여 우선순위 낮춤 + const handleLoadMore = useCallback(() => { + if (!hasMore) return; + + const loadMore = () => { + setPage(prevPage => { + // hasMore가 true일 때만 페이지 증가 + // visibleCount가 filteredLectures.length를 초과하지 않도록 보장됨 + return prevPage + 1; + }); + }; + + if (typeof (React as any).startTransition === 'function') { + (React as any).startTransition(loadMore); + } else { + // requestIdleCallback을 사용하여 브라우저가 idle 상태일 때 로드 + if (typeof requestIdleCallback !== 'undefined') { + requestIdleCallback(loadMore, { timeout: 100 }); + } else { + requestAnimationFrame(loadMore); + } + } + }, [hasMore]); + + // searchInfo가 변경될 때만 검색 옵션 업데이트 + useEffect(() => { + if (!searchInfo) return; + + setSearchOptions(prev => { + const newDays = searchInfo.day ? [searchInfo.day] : []; + const newTimes = searchInfo.time ? [searchInfo.time] : []; + + // 실제로 변경이 없으면 이전 상태 유지 + if (prev.days.length === newDays.length && + prev.days.every((d, i) => d === newDays[i]) && + prev.times.length === newTimes.length && + prev.times.every((t, i) => t === newTimes[i])) { + return prev; + } + + return { + ...prev, + days: newDays, + times: newTimes, + }; + }); + setPage(1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchInfo?.day, searchInfo?.time]); + + return ( + + + + 수업 검색 + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +// Context를 구독하되, searchInfo가 변경될 때만 리렌더링 +export default React.memo(SearchDialog); + diff --git a/src/contexts/ScheduleContext.tsx b/src/contexts/ScheduleContext.tsx new file mode 100644 index 0000000..e0e6832 --- /dev/null +++ b/src/contexts/ScheduleContext.tsx @@ -0,0 +1,254 @@ +import React, { createContext, PropsWithChildren, useContext, useMemo, useCallback } from "react"; +import { Schedule } from "../types.ts"; +import dummyScheduleMap from "../dummyScheduleMap.ts"; + +interface ScheduleContextType { + schedulesMap: Record; + setSchedulesMap: React.Dispatch>>; +} + +// 외부 스토어를 사용하여 각 테이블별로 구독 +class ScheduleStore { + private schedulesMap: Record = {}; + private listeners = new Map void>>(); // tableId별 리스너 + private keysListeners = new Set<() => void>(); // 키 목록 변경 리스너 + private keysCache: string[] = []; + private keysCacheStr: string = ''; + private schedulesCache = new Map(); // tableId별 스케줄 캐시 + private stateCache: Record | null = null; // 전체 상태 캐시 + + getSchedules(tableId: string): Schedule[] { + const currentSchedules = this.schedulesMap[tableId] || []; + const cachedSchedules = this.schedulesCache.get(tableId); + + // 배열 참조가 같으면 캐시된 배열 반환 + if (cachedSchedules === currentSchedules) { + return cachedSchedules; + } + + // 배열 참조가 변경되었으면 캐시 업데이트 + this.schedulesCache.set(tableId, currentSchedules); + return currentSchedules; + } + + getKeys(): string[] { + const currentKeys = Object.keys(this.schedulesMap); + const currentKeysStr = currentKeys.join(','); + + // 키 목록이 변경되지 않았으면 캐시된 배열 반환 + if (currentKeysStr === this.keysCacheStr) { + return this.keysCache; + } + + // 키 목록이 변경되었으면 새 배열 반환 및 캐시 업데이트 + this.keysCache = currentKeys; + this.keysCacheStr = currentKeysStr; + return currentKeys; + } + + // 현재 전체 상태를 반환 (참조 안정성을 위해 캐시 사용) + getCurrentState(): Record { + // 캐시가 있고 모든 키의 배열 참조가 같으면 캐시 반환 + if (this.stateCache) { + const keys = this.getKeys(); + const cacheKeys = Object.keys(this.stateCache); + if (keys.length === cacheKeys.length && + keys.every(key => cacheKeys.includes(key) && + this.stateCache![key] === this.getSchedules(key))) { + return this.stateCache; + } + } + + // 캐시가 없거나 변경되었으면 새로 생성 + const state: Record = {}; + const keys = this.getKeys(); + for (const key of keys) { + state[key] = this.getSchedules(key); + } + this.stateCache = state; + return state; + } + + setSchedulesMap(schedulesMap: Record) { + const prevSchedulesMap = this.schedulesMap; + const prevKeys = Object.keys(prevSchedulesMap); + const currentKeys = Object.keys(schedulesMap); + const keysChanged = prevKeys.length !== currentKeys.length || + !prevKeys.every(key => currentKeys.includes(key)); + + this.schedulesMap = schedulesMap; + + // 상태가 변경되었으면 캐시 무효화 + this.stateCache = null; + + // 키 목록이 변경되었을 때 키 리스너 호출 + if (keysChanged) { + this.keysListeners.forEach(listener => listener()); + } + + // 각 tableId별로 배열 참조가 변경되었는지 확인하고 리스너 호출 + const allTableIds = new Set([ + ...Object.keys(prevSchedulesMap), + ...Object.keys(schedulesMap) + ]); + + allTableIds.forEach(tableId => { + const prevSchedules = prevSchedulesMap[tableId] || []; + const currentSchedules = schedulesMap[tableId] || []; + + // 배열 참조가 변경되었을 때만 리스너 호출 + if (prevSchedules !== currentSchedules) { + const listeners = this.listeners.get(tableId); + if (listeners) { + listeners.forEach(listener => listener()); + } + } + }); + } + + subscribe(tableId: string, listener: () => void) { + if (!this.listeners.has(tableId)) { + this.listeners.set(tableId, new Set()); + } + this.listeners.get(tableId)!.add(listener); + return () => { + const listeners = this.listeners.get(tableId); + if (listeners) { + listeners.delete(listener); + if (listeners.size === 0) { + this.listeners.delete(tableId); + } + } + }; + } + + subscribeKeys(listener: () => void) { + this.keysListeners.add(listener); + return () => { + this.keysListeners.delete(listener); + }; + } +} + +// dummyScheduleMap을 깊은 복사하여 참조를 고정 +const createInitialSchedulesMap = () => { + const initialMap: Record = {}; + for (const [key, schedules] of Object.entries(dummyScheduleMap)) { + initialMap[key] = [...schedules]; + } + return initialMap; +}; + +const scheduleStore = new ScheduleStore(); +const initialSchedulesMap = createInitialSchedulesMap(); +scheduleStore.setSchedulesMap(initialSchedulesMap); + +// setSchedulesMap만 필요할 때 사용하는 Context 분리 +const ScheduleSetActionContext = createContext>> | undefined>(undefined); + +export const useScheduleSetAction = () => { + const context = useContext(ScheduleSetActionContext); + if (context === undefined) { + throw new Error('useScheduleSetAction must be used within a ScheduleProvider'); + } + return context; +}; + +const ScheduleContext = createContext(undefined); + +export const useScheduleContext = () => { + const context = useContext(ScheduleContext); + if (context === undefined) { + throw new Error('useSchedule must be used within a ScheduleProvider'); + } + return context; +}; + +// 스토어 Context (구독하지 않음) +export const ScheduleStoreContext = React.createContext(scheduleStore); + +// 특정 테이블의 스케줄만 구독하는 hook +// useSyncExternalStore를 사용하여 해당 tableId의 배열 참조만 추적 +export const useScheduleTable = (tableId: string) => { + const store = useContext(ScheduleStoreContext); + + return React.useSyncExternalStore( + (onStoreChange) => store.subscribe(tableId, onStoreChange), + () => store.getSchedules(tableId), + () => store.getSchedules(tableId) + ); +}; + +// 테이블 키 목록만 구독하는 hook +export const useScheduleTableKeys = () => { + const store = useContext(ScheduleStoreContext); + + return React.useSyncExternalStore( + (onStoreChange) => store.subscribeKeys(onStoreChange), + () => store.getKeys(), + () => [] + ); +}; + +export const ScheduleProvider = ({ children }: PropsWithChildren) => { + // setSchedulesMap을 useCallback으로 메모이제이션 + // scheduleStore를 직접 업데이트하고, 변경된 테이블만 새 배열로 만들고, 다른 테이블은 이전 참조 유지 + const memoizedSetSchedulesMap = useCallback((updater: React.SetStateAction>) => { + // scheduleStore에서 현재 상태를 가져옴 (캐시된 참조 사용) + const currentState = scheduleStore.getCurrentState(); + + const newState = typeof updater === 'function' ? updater(currentState) : updater; + + // 참조가 같으면 이전 상태 반환 (리렌더링 방지) + if (currentState === newState) { + return; + } + + // 변경된 테이블만 새 배열로 만들고, 다른 테이블은 이전 참조 유지 + const allKeys = new Set([...Object.keys(currentState), ...Object.keys(newState)]); + let hasChanges = false; + const optimizedState: Record = {}; + + for (const key of allKeys) { + const prevSchedules = currentState[key]; + const newSchedules = newState[key]; + + // 키가 새 상태에 없으면 (삭제된 경우) 제외 + if (!(key in newState)) { + hasChanges = true; + continue; + } + + // 배열 참조가 같으면 이전 참조 유지 (리렌더링 방지) + if (prevSchedules === newSchedules) { + optimizedState[key] = prevSchedules; + } else { + // 배열 참조가 다르면 새 참조 사용 + optimizedState[key] = newSchedules; + hasChanges = true; + } + } + + // 실제로 변경이 있으면 스토어 업데이트 + if (hasChanges) { + scheduleStore.setSchedulesMap(optimizedState); + } + }, []); + + // Context value를 메모이제이션하여 불필요한 리렌더링 방지 + // schedulesMap은 더 이상 필요하지 않으므로 제거 + const contextValue = useMemo(() => ({ + schedulesMap: initialSchedulesMap, // 더미 값 (실제로 사용되지 않음) + setSchedulesMap: memoizedSetSchedulesMap, + }), [memoizedSetSchedulesMap]); + + return ( + + + + {children} + + + + ); +}; diff --git a/src/contexts/SearchDialogContext.tsx b/src/contexts/SearchDialogContext.tsx new file mode 100644 index 0000000..5f619b3 --- /dev/null +++ b/src/contexts/SearchDialogContext.tsx @@ -0,0 +1,69 @@ +import React, { createContext, useContext, useState, useCallback, useMemo } from "react"; + +interface SearchInfo { + tableId: string; + day?: string; + time?: number; +} + +// setSearchInfo만 필요할 때 사용하는 Context 분리 +const SearchDialogSetActionContext = createContext<((info: SearchInfo | null) => void) | undefined>(undefined); + +export const useSearchDialogSetAction = () => { + const context = useContext(SearchDialogSetActionContext); + if (context === undefined) { + throw new Error('useSearchDialogSetAction must be used within a SearchDialogProvider'); + } + return context; +}; + +// searchInfo만 구독하는 hook +const SearchInfoContext = createContext(null); + +export const useSearchInfo = () => { + return useContext(SearchInfoContext); +}; + +// 전체 Context (하위 호환성을 위해 유지) +interface SearchDialogContextType { + searchInfo: SearchInfo | null; + setSearchInfo: (info: SearchInfo | null) => void; +} + +const SearchDialogContext = createContext(undefined); + +export const useSearchDialog = () => { + const context = useContext(SearchDialogContext); + if (context === undefined) { + throw new Error('useSearchDialog must be used within a SearchDialogProvider'); + } + return context; +}; + +export const SearchDialogProvider = ({ children }: React.PropsWithChildren) => { + const [searchInfo, setSearchInfo] = useState(null); + + const handleSetSearchInfo = useCallback((info: SearchInfo | null) => { + setSearchInfo(info); + }, []); + + // setSearchInfo만 필요할 때 사용하는 Context value + const setActionValue = useMemo(() => handleSetSearchInfo, [handleSetSearchInfo]); + + // 전체 Context value (하위 호환성) + const fullValue = useMemo(() => ({ + searchInfo, + setSearchInfo: handleSetSearchInfo, + }), [searchInfo, handleSetSearchInfo]); + + return ( + + + + {children} + + + + ); +}; + diff --git a/src/hooks/useFilteredLectures.ts b/src/hooks/useFilteredLectures.ts new file mode 100644 index 0000000..5ca704f --- /dev/null +++ b/src/hooks/useFilteredLectures.ts @@ -0,0 +1,22 @@ +import { useMemo } from "react"; +import { Lecture } from "../types.ts"; +import { filterLectures } from "../utils.ts"; + +interface SearchOption { + query?: string, + grades: number[], + days: string[], + times: number[], + majors: string[], + credits?: number, +} + +export const useFilteredLectures = (lectures: Lecture[], searchOptions: SearchOption) => { + // searchOptions의 개별 필드를 의존성으로 사용하여 + // searchOptions 객체의 참조가 변경되어도 실제 값이 같으면 재계산하지 않음 + const { query, credits, grades, days, times, majors } = searchOptions; + + return useMemo(() => { + return filterLectures(lectures, searchOptions); + }, [lectures, query, credits, grades, days, times, majors]); +}; \ No newline at end of file diff --git a/src/hooks/useLectures.ts b/src/hooks/useLectures.ts new file mode 100644 index 0000000..ed5429a --- /dev/null +++ b/src/hooks/useLectures.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; +import { Lecture } from "../types.ts"; +import { fetchAllLectures } from "../services/lectureService.ts"; + +export const useLectures = () => { + const [lectures, setLectures] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadLectures = async () => { + try { + const data = await fetchAllLectures(); + setLectures(data); + } catch (err) { + setError('Failed to load lectures'); + console.error(err); + } finally { + setLoading(false); + } + }; + + loadLectures(); + }, []); + + return { lectures, loading, error }; +}; \ No newline at end of file diff --git a/src/providers/ScheduleDndProvider.tsx b/src/providers/ScheduleDndProvider.tsx new file mode 100644 index 0000000..d2524df --- /dev/null +++ b/src/providers/ScheduleDndProvider.tsx @@ -0,0 +1,150 @@ +import { DndContext, Modifier, PointerSensor, useSensor, useSensors, DragEndEvent, DragStartEvent } from "@dnd-kit/core"; +import React, { PropsWithChildren, useCallback, useMemo, useState, useRef } from "react"; +import { CellSize, DAY_LABELS } from "../constants.ts"; +import { useScheduleSetAction } from "../contexts/ScheduleContext.tsx"; + +// 드래그 중인 테이블 ID를 관리하는 Context +const ActiveTableIdContext = React.createContext(null); + +export const useActiveTableId = () => React.useContext(ActiveTableIdContext); + +// 특정 테이블이 활성화되어 있는지 확인하는 hook +// Context를 구독하되, 값이 변경되지 않으면 리렌더링을 방지하기 위해 useRef 사용 +export const useIsActiveTable = (tableId: string) => { + const activeTableId = useActiveTableId(); + const prevValueRef = useRef(activeTableId === tableId); + const prevActiveTableIdRef = useRef(activeTableId); + + // activeTableId가 변경되었을 때만 값 업데이트 + if (prevActiveTableIdRef.current !== activeTableId) { + prevActiveTableIdRef.current = activeTableId; + prevValueRef.current = activeTableId === tableId; + } + + return prevValueRef.current; +}; + +function createSnapModifier(): Modifier { + return ({ transform, containerNodeRect, draggingNodeRect }) => { + const containerTop = containerNodeRect?.top ?? 0; + const containerLeft = containerNodeRect?.left ?? 0; + const containerBottom = containerNodeRect?.bottom ?? 0; + const containerRight = containerNodeRect?.right ?? 0; + + const { top = 0, left = 0, bottom = 0, right = 0 } = draggingNodeRect ?? {}; + + const minX = containerLeft - left + 120 + 1; + const minY = containerTop - top + 40 + 1; + const maxX = containerRight - right; + const maxY = containerBottom - bottom; + + + 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), + }) + }; +} + +const modifiers = [createSnapModifier()] + +const ScheduleDndProvider = ({ children }: PropsWithChildren) => { + const setSchedulesMap = useScheduleSetAction(); + const [activeTableId, setActiveTableId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + const handleDragStart = useCallback((event: DragStartEvent) => { + const activeId = String(event.active.id); + const [tableId] = activeId.split(':'); + setActiveTableId(tableId); + }, []); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + setActiveTableId(null); + const { active, delta } = event; + const { x, y } = delta; + + // 드래그가 없으면 무시 + if (x === 0 && y === 0) return; + + const activeId = String(active.id); + const [tableId, index] = activeId.split(':'); + + if (!tableId || index === undefined) return; + + const moveDayIndex = Math.floor(x / 80); + const moveTimeIndex = Math.floor(y / 30); + + // 이동이 없으면 무시 + if (moveDayIndex === 0 && moveTimeIndex === 0) return; + + setSchedulesMap(prev => { + const schedule = prev[tableId]?.[Number(index)]; + if (!schedule) return prev; + + const nowDayIndex = DAY_LABELS.indexOf(schedule.day as typeof DAY_LABELS[number]); + const newDayIndex = Math.max(0, Math.min(DAY_LABELS.length - 1, nowDayIndex + moveDayIndex)); + const newRange = schedule.range.map(time => Math.max(1, Math.min(24, time + moveTimeIndex))); + + // 실제로 변경이 없으면 이전 상태 반환 (리렌더링 방지) + if (newDayIndex === nowDayIndex && + schedule.range.length === newRange.length && + schedule.range.every((time, idx) => time === newRange[idx])) { + return prev; + } + + // 변경된 스케줄만 새 객체로 생성 + const updatedSchedule = { + ...schedule, + day: DAY_LABELS[newDayIndex], + range: newRange, + }; + + // 변경된 테이블의 배열만 새로 생성하고, 다른 테이블은 참조 유지 + // 변경되지 않은 스케줄은 참조를 유지하여 해당 테이블만 리렌더링되도록 함 + const updatedSchedules = prev[tableId].map((targetSchedule, targetIndex) => { + if (targetIndex !== Number(index)) { + return targetSchedule; // 변경되지 않은 스케줄은 참조 유지 + } + return updatedSchedule; // 변경된 스케줄만 새 객체 + }); + + // memoizedSetSchedulesMap에서 최적화가 제대로 동작하도록 + // 변경된 테이블만 새 배열로 전달하고, 다른 테이블은 이전 참조 유지 + return { + ...prev, + [tableId]: updatedSchedules, + }; + }); + }, [setSchedulesMap]); + + // Context value를 메모이제이션하여 불필요한 리렌더링 방지 + const contextValue = useMemo(() => activeTableId, [activeTableId]); + + return ( + + setActiveTableId(null)} + modifiers={modifiers} + // 드래그 중 리렌더링 최소화를 위한 설정 + autoScroll={{ threshold: { x: 0.2, y: 0.2 } }} + > + {children} + + + ); +} + +export default React.memo(ScheduleDndProvider); + diff --git a/src/services/lectureService.ts b/src/services/lectureService.ts new file mode 100644 index 0000000..a0d03f2 --- /dev/null +++ b/src/services/lectureService.ts @@ -0,0 +1,60 @@ +import { Lecture } from "../types.ts"; + +let cachedLectures: Lecture[] | null = null; + +export const fetchAllLectures = async (): Promise => { + if (cachedLectures) { + return cachedLectures; + } + + // Vite의 base URL을 사용하여 올바른 경로로 fetch + // import.meta.env.BASE_URL은 vite.config.ts의 base 설정을 반영합니다 + const baseUrl = import.meta.env.BASE_URL; + + const startTime = performance.now(); + console.log('API 호출 시작: ', startTime); + + // Promise.all을 사용하여 두 JSON 파일을 병렬로 fetch + const fetchPromises = [ + fetch(`${baseUrl}schedules-majors.json`).then(response => { + console.log('API Call 1', performance.now()); + return response; + }), + fetch(`${baseUrl}schedules-liberal-arts.json`).then(response => { + console.log('API Call 2', performance.now()); + return response; + }) + ]; + + const [majorsResponse, liberalArtsResponse] = await Promise.all(fetchPromises); + + console.log('API Call 3', performance.now()); + + // 404 오류 체크 + if (!majorsResponse.ok) { + throw new Error(`Failed to fetch schedules-majors.json: ${majorsResponse.status}`); + } + if (!liberalArtsResponse.ok) { + throw new Error(`Failed to fetch schedules-liberal-arts.json: ${liberalArtsResponse.status}`); + } + + const [majorsData, liberalArtsData] = await Promise.all([ + majorsResponse.json().then(data => { + console.log('API Call 4', performance.now()); + return data as Lecture[]; + }), + liberalArtsResponse.json().then(data => { + console.log('API Call 5', performance.now()); + return data as Lecture[]; + }) + ]); + + console.log('API Call 6', performance.now()); + cachedLectures = [...majorsData, ...liberalArtsData]; + + const endTime = performance.now(); + console.log('모든 API 호출 완료 ', endTime); + console.log('API 호출에 걸린 시간(ms): ', endTime - startTime); + + return cachedLectures; +}; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 16118bf..7618071 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,7 @@ export interface Lecture { title: string; credits: string; major: string; - schedule: string; + schedule: string | null; grade: number; } diff --git a/src/utils.ts b/src/utils.ts index 8b6eb66..b68bc78 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -28,3 +28,51 @@ export const parseSchedule = (schedule: string) => { return { day, range, room }; }); }; + +import { Lecture } from "./types.ts"; + +interface SearchOption { + query?: string, + grades: number[], + days: string[], + times: number[], + majors: string[], + credits?: number, +} + +export const filterLectures = (lectures: Lecture[], searchOptions: SearchOption) => { + const { query = '', credits, grades, days, times, majors } = searchOptions; + return lectures.filter(lecture => { + // Query filter + if (query && !lecture.title.toLowerCase().includes(query.toLowerCase()) && !lecture.id.toLowerCase().includes(query.toLowerCase())) { + return false; + } + // Grades filter + if (grades.length > 0 && !grades.includes(lecture.grade)) { + return false; + } + // Majors filter + if (majors.length > 0 && !majors.includes(lecture.major)) { + return false; + } + // Credits filter + if (credits && !lecture.credits.startsWith(String(credits))) { + return false; + } + // Days filter + if (days.length > 0) { + const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; + if (!schedules.some(s => days.includes(s.day))) { + return false; + } + } + // Times filter + if (times.length > 0) { + const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; + if (!schedules.some(s => s.range.some(time => times.includes(time)))) { + return false; + } + } + return true; + }); +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 1cdac55..3a52d68 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react-swc'; export default mergeConfig( defineConfig({ plugins: [react()], + base: '/front_7th_chapter4-2/', }), defineTestConfig({ test: {