길고 긴 버그와 버그와 버그와 버그 끝에 시간표를 렌더링할 수 있게 되었다. 정말 길고 길었고, 이전에 '시간표 페이지(SchedulePage) 구성하기(2)'에서 가장 어려웠다고 언급했던 것과는 비교가 안될 정도로 어려웠다.
시간표를 렌더링하는데 있어서 아래 사항들을 중점에 두었다.
1. 시간표는 SchedulePage의 right-section에 맞추어 배치되어야 한다.
2. 담은 과목들의 체크박스를 수정할 때마다 실시간으로 반영되어야 한다.
3. 각 과목의 수업시간을 요일과 교시로 분리하고, 각 교시마다 차지해야하는 시간을 명확히 해야 한다.
4. 각 과목이 차지하는 시간만큼 셀을 병합하고, 그 내부를 과목 정보로 채워야 한다.
5. 시간과 구분선을 표기해야 한다.
6. 과목마다 다른 색을 가져야 한다.
7. 시간대가 겹치면 배치할 수 없다.
이 중 가장 문제가 되었던 것이 4. 셀 병합과 5. 시간 표기였다. 특히 셀을 병합하는 과정에서 다양한 버그가 발생해서 버그만 4일 정도 수정한 것 같다. 5.의 경우도, 본래 15분 단위로 셀을 분할하고 시간도 15분 단위로 표기해 디테일하게 참고할 수 있도록 하려 했으나 너무 난잡하고 굳이 그럴 필요가 없다고 판단되어 시간 단위로 표기하였다. 구분선마다 시간 표기해서 보기 편하게 하려다 도저히 안되서 포기하긴 했다.
아무튼 시간표 렌더링 구현은 끝났고. 디테일한 UI 구성은 앞으로도 수정이 필요하지만 기능적으로는 문제가 없다.
우선, 각 과목의 수업시간을 분석하고 시간표 렌더링을 위해 시간을 분석하는 부분을 TimeUtils.js로 분리하였다.
TimeUtils.js
// src/main/frontend/src/pages/SchedulePage/utils/TimeUtils.js
// 요일 배열을 정의
export const days = ['월', '화', '수', '목', '금'];
우선 요일을 월~금까지 정의하였다. 토요일도 넣을까 했지만. 어차피 정규 개설과목 중에는 토요일인 과목이 없으니 어디까지나 시간표를 짜는데 도움을 주는 프로그램으로써 금요일까지만 정의해도 되겠다고 생각했다.
/**
* 시작 시간과 종료 시간 사이의 모든 시간을 15분 단위로 생성하여 반환.
* @param {number} startHour - 시작 시간 (시간 단위)
* @param {number} endHour - 종료 시간 (시간 단위)
* @returns {Array<string>} - 15분 단위로 생성된 시간 문자열 배열
*/
export const generateTimes = (startHour, endHour) => {
const times = [];
for (let hour = startHour; hour <= endHour; hour++) {
times.push(`${hour.toString().padStart(2, '0')}:00`); // 정각 시간 추가
times.push(`${hour.toString().padStart(2, '0')}:15`); // 15분 추가
times.push(`${hour.toString().padStart(2, '0')}:30`); // 30분 추가
times.push(`${hour.toString().padStart(2, '0')}:45`); // 45분 추가
}
return times;
};
15분 단위로 행을 구성한다. 이 함수는 startHour부터 endHour까지의 시간 범위를 순회하면서, 각 시간에 대해 00, 15, 30, 45의 분 단위를 포함한 시간을 문자열로 생성하고, 각 시간 문자열을 배열 times에 추가한다.
예를 들어, startHour가 9이고 endHour가 10이라면, 배열에는 "09:00", "09:15", "09:30", "09:45", "10:00", "10:15", "10:30", "10:45"가 포함된다.
/**
* 주어진 시간 문자열을 요일과 시간 범위로 파싱.
* @param {string} timeString - 요일과 시간 범위를 나타내는 문자열 (예: '월A', '목B', '금7' 등)
* @returns {Object} - 요일, 시작 시간, 종료 시간을 포함하는 객체
*/
export const parseTime = (timeString) => {
const day = timeString.charAt(0); // 첫 글자는 요일을 나타냄
const period = timeString.slice(1); // 나머지는 시간대를 나타냄
const isAlphabetic = isNaN(period); // 알파벳 시간대인지 여부를 판별
if (isAlphabetic) {
const startTimes = { 'A': '09:00', 'B': '10:30', 'C': '12:00', 'D': '13:30', 'E': '15:00', 'F': '16:30', 'G': '18:00', 'H': '19:30', 'I': '21:00', 'J': '22:30' };
const endTimes = { 'A': '10:15', 'B': '11:45', 'C': '13:15', 'D': '14:45', 'E': '16:15', 'F': '17:45', 'G': '19:15', 'H': '20:45', 'I': '22:15', 'J': '23:45' };
return { day, start: startTimes[period], end: endTimes[period] }; // 알파벳 시간대에 따른 시작과 종료 시간 반환
} else {
const periodNumber = parseFloat(period); // 숫자 시간대 처리
const startHour = 8 + Math.floor(periodNumber); // 시작 시간 계산
const startMinute = (periodNumber % 1) * 60; // 시작 분 계산
const startTime = `${startHour.toString().padStart(2, '0')}:${startMinute.toString().padStart(2, '0')}`; // 시작 시간 문자열 생성
const endHour = startHour + 1; // 종료 시간은 1시간 뒤
const endTime = `${endHour.toString().padStart(2, '0')}:${startMinute.toString().padStart(2, '0')}`; // 종료 시간 문자열 생성
return { day, start: startTime, end: endTime }; // 숫자 시간대에 따른 시작과 종료 시간 반환
}
};
과목별 수업시간은 "요일"+"교시" 조합으로 되어 있다. 그런데 "교시"는 A, B, C......의 알파벳과 1, 2, 3.....의 숫자 교시 두가지가 있다. 두 종류의 교시는 차지하는 시간도 다르기 때문에 각 과목의 시간을 "요일"과 "교시"로 분리하고, 각각의 교시에 대한 정의가 필요했다.
timeString의 첫 글자는 요일을 나타내므로, 이를 day 변수에 저장한다. 나머지 문자열을 period로 저장하고, 숫자인지 알파벳인지 판별한다. 알파벳 시간대(A~J)일 경우, startTimes와 endTimes 객체를 통해 알파벳을 시간 범위로 변환한다.
숫자 시간대일 경우, period를 숫자로 변환하고, 이를 통해 시작 시간을 계산한다, 1시간 뒤의 종료 시간을 계산하는데, 사실 숫자 교시는 50분의 수업시간과 10분의 수업시간으로 구성된다. 하지만 이미 15분 단위로 조절한 상태에서 어떻게 해야 할까라는 고민이 있었고, "일단 돌아가면 건들지 말라"라는 격언에 따라 그대로 두었다. 나중에 css를 조정해 5/6만 채우는 등으로 조절할 수도 있을 것 같다.
다만, 실험과목 같은 경우 8.5교시 이런식으로 소수점 단위가 추가되는 문제가 있어 숫자 교시에 대한 처리가 꽤 복잡하다.
최종적으로 이 함수는 요일(day), 시작 시간(start), 종료 시간(end)을 객체로 반환한다.
/**
* 시작 시간과 종료 시간 사이의 기간을 15분 단위로 계산.
* @param {string} start - 시작 시간 (HH:mm 형식)
* @param {string} end - 종료 시간 (HH:mm 형식)
* @returns {number} - 15분 단위로 계산된 기간
*/
export const calculateDuration = (start, end) => {
const [startHour, startMinute] = start.split(':').map(Number); // 시작 시간 분리
const [endHour, endMinute] = end.split(':').map(Number); // 종료 시간 분리
return ((endHour - startHour) * 60 + (endMinute - startMinute)) / 15; // 15분 단위로 기간 계산
};
이 함수는 start와 end 문자열을 :로 분리하여 시간(startHour, endHour)과 분(startMinute, endMinute)으로 나눈다. 시작 시간과 종료 시간 사이의 간격을 분 단위로 계산하는데, 이를 15분으로 나누어 각 과목이 몇개의 셀을 차지해야 할지를 결정한다. 예를 들어, 45분이면 3을 반환한다. 이 값은 몇개의 셀을 병합해야 하는지 결정하기 위해 사용된다.
이 유틸 함수들은 모두 백엔드에서 처리가 가능하긴 하다. 어차히 과목별로 주키가 설정되어 있기 때문에, API 파일에서 불러온 과목을 바탕으로 백엔드에 요청해 가져올 수 있지만, 이 역시 "일단 돌아가면 건들지 말라"라는 격언에 따라 그대로 두었다. 알았을 땐 너무 늦었고, 수정해보려 했으나 버그버그버그로 관두기로 했다.
ColorUtils.js
다음으로 각 과목마다 다른 색상을 할당하기 위한 훅을 설계했다.
1. 각 과목은 다른 색을 사용해야 한다.
2. 과목을 삭제하면 그 색은 다시 사용할 수 있어야 한다.
이 두가지를 구현해야 할 요소로 보았다.
// src/main/frontend/src/SchedulePage/utils/ColorUtils.js
import { useState } from 'react';
// 색상 팔레트 정의: 일정표에서 과목을 구분하기 위한 색상 목록
const colorPalette = [
'#58ACFA',
'#58FAD0',
'#FA5858',
'#FACC2E',
'#AC58FA',
'#A9D0F5',
];
임의로 설정한 색상 팔레트. 이 색상 팔레트에 정의된 색상들이 순차적으로 할당된다.
/**
* useColorManager 훅: 과목별로 고유한 색상을 관리하는 훅
* @returns {Object} getColor와 releaseColor 함수를 반환
*/
export const useColorManager = () => {
// 사용된 색상 목록을 관리하는 상태
const [usedColors, setUsedColors] = useState([]);
// 과목 이름과 할당된 색상 매핑을 관리하는 상태
const [colorMap, setColorMap] = useState({});
const getColor = (subjectName) => {
{/* 내용 */}
return availableColor;
};
const releaseColor = (subjectName) => {
{/* 내용 */}
return { getColor, releaseColor };
};
useColorManager는 React 훅으로, 상태를 관리하기 위해 사용된다. usedColors는 현재 사용 중인 색상을 저장하는 배열이다. colorMap은 각 과목에 할당된 색상을 저장하는 객체로, 키는 과목명이고 값은 색상이다.
/**
* getColor: 과목 이름에 따라 색상을 반환하는 함수
* @param {string} subjectName - 색상을 할당할 과목 이름
* @returns {string} 할당된 색상
*/
const getColor = (subjectName) => {
// 이미 과목에 색상이 할당되어 있다면 반환
if (colorMap[subjectName]) {
return colorMap[subjectName];
}
// 사용되지 않은 색상 찾기
const availableColor = colorPalette.find(color => !usedColors.includes(color));
if (!availableColor) {
// 모든 색상이 사용 중인 경우, 순환적으로 다시 사용
const nextColor = colorPalette[usedColors.length % colorPalette.length];
setUsedColors([...usedColors, nextColor]);
setColorMap({ ...colorMap, [subjectName]: nextColor });
return nextColor;
}
// 사용 가능한 색상 할당
setUsedColors([...usedColors, availableColor]);
setColorMap({ ...colorMap, [subjectName]: availableColor });
return availableColor;
};
getColor 함수는 주어진 과목 이름(subjectName)에 대해 색상을 반환한다. 해당 과목에 이미 색상이 할당된 경우, colorMap에서 색상을 반환한다. 할당된 색상이 없다면, colorPalette에서 사용되지 않은 색상을 찾는다. 사용되지 않은 색상이 없을 경우, colorPalette에서 순환적으로 색상을 할당한다. 추후 애초에 색을 많이 정의해두면 해결될 문제지만 예외를 방지하기 위한 목적으로써는 유지해도 좋을 것 같다.
새로운 색상이 할당되면 usedColors와 colorMap을 업데이트한다.
/**
* releaseColor: 과목이 삭제될 때 색상을 반환하는 함수
* @param {string} subjectName - 색상을 반환할 과목 이름
*/
const releaseColor = (subjectName) => {
const color = colorMap[subjectName];
if (!color) return;
// 사용된 색상 목록에서 제거
setUsedColors(usedColors.filter(c => c !== color));
const newColorMap = { ...colorMap };
delete newColorMap[subjectName];
setColorMap(newColorMap);
};
return { getColor, releaseColor };
releaseColor 함수는 주어진 과목 이름(subjectName)에 할당된 색상을 반환한다. colorMap에서 해당 과목에 할당된 색상을 찾고 해당 색상을 usedColors에서 삭제하고, colorMap에서 해당 과목의 색상 매핑을 삭제한다.
이 함수는 과목을 시간표에 추가했다가 삭제할 경우 그 색상을 다시 사용할 수 있도록 하기 위해 사용된다.
useColorManager 훅은 getColor와 releaseColor 함수를 반환하여, 이 훅을 사용하는 컴포넌트에서 과목별 색상을 할당하고 반환할 수 있게 한다.
그리고 시간표의 렌더링은 ScheduleTable.js라는 별도의 컴포넌트가 수행하게 된다.
ScheduleTable.js
// src/main/frontend/src/SchedulePage/components/ScheduleTable.js
import React, { useMemo, useCallback, useEffect } from 'react';
import './ScheduleTable.css';
import { days, generateTimes, parseTime, calculateDuration } from '../utils/TimeUtils';
import { useColorManager } from '../utils/ColorUtils';
function ScheduleTable({ schedules }) {
const { getColor, releaseColor } = useColorManager();
// 사용자가 선택한 스케줄이 변경될 때마다 실행됨
// 선택되지 않은 과목은 releaseColor를 호출하여 할당된 색상을 반환
useEffect(() => {
schedules.forEach(schedule => {
if (!schedule.selected) {
releaseColor(schedule.subject.subjectName);
}
});
}, [schedules]);
const filteredSchedules = useMemo(() => schedules.filter(schedule => schedule.selected), [schedules]);
const getFilteredTimes = useCallback(() => {
{/* 내용 */}
}, [filteredSchedules]);
const filteredTimes = useMemo(() => getFilteredTimes(), [getFilteredTimes]);
const renderScheduleTable = () => {
{/* 내용 */}
};
const renderTimeTable = () => {
{/* 내용 */}
};
// 전체 시간표 컴포넌트 반환
return (
<div className="schedule-container">
{renderTimeTable()}
{renderScheduleTable()}
</div>
);
}
export default ScheduleTable;
전체적인 구조를 요약했다.
useEffect
// 사용자가 선택한 스케줄이 변경될 때마다 실행됨
// 선택되지 않은 과목은 releaseColor를 호출하여 할당된 색상을 반환
useEffect(() => {
schedules.forEach(schedule => {
if (!schedule.selected) {
releaseColor(schedule.subject.subjectName);
}
});
}, [schedules]);
이 훅은 schedules 배열이 변경될 때마다 실행된다. 즉, 사용자가 시간표에 과목을 추가할 때마다 실행되고 시간표를 검사하여, 선택되지 않은(selected가 false인) 과목에 대해 releaseColor를 호출하여 색상을 반환한다.
filteredSchedules
/**
* @description 선택된 과목들만 필터링하여 배열로 반환
* @param {Array} schedules - 모든 스케줄 데이터
* @returns {Array} 선택된 과목들의 배열
*/
const filteredSchedules = useMemo(() => schedules.filter(schedule => schedule.selected), [schedules]);
useMemo를 사용하여 schedules 배열에서 선택된 과목들(selected가 true인)을 필터링한다. 필터링된 결과는 filteredSchedules 배열에 저장된다.
useMemo hook
리액트에서 Memoization을 쉽게 사용할 수 있도록 해주는 함수.
Memoization
기존에 수행한 연산의 결과값을 어딘가에 저장해두고 동일한 입력이 들어오면 재활용하는 프로그래밍 기법. memoization을 절적히 적용하면 중복 연산을 피할 수 있기 때문에 메모리를 조금 더 쓰더라도 애플리케이션의 성능을 최적화할 수 있다.
useMemo함수는 2개의 인자를 받는데, 첫번째는 계산을 수행하는 팩토리 함수로, 이 함수가 반환하는 값이 memoization된다. 두번째는 기존 결과값 재활용 여부의 기준이되는 입력값 배열로, 이 배열이 변경될 때만 첫번째 인수의 함수가 다시 실행된다.
여기서는 시간표 데이터가 변할 때만 다시 렌더링되기 때문에 useMemo가 사용되었다.
getFilteredTimes
/**
* @description 과목들의 종료 시간을 바탕으로 필요한 시간대를 계산하여 배열로 반환
* @returns {Array} 시간대 배열
*/
const getFilteredTimes = useCallback(() => {
let lastEndTime = "09:00"; // 초기 값으로 09:00 설정
// 각 과목의 종료 시간을 계산하여 가장 늦은 종료 시간을 찾음
filteredSchedules.forEach(schedule => {
const times = schedule.subject.time.split(', '); // 여러 시간대가 있을 경우 분리
times.forEach(time => {
const { end } = parseTime(time); // 종료 시간을 파싱
if (end > lastEndTime) { // 현재 종료 시간보다 늦은 시간일 경우 업데이트
lastEndTime = end;
}
});
});
// 종료 시간을 기준으로 다음 시간대를 계산
const [lastHour, lastMinute] = lastEndTime.split(':').map(Number); // 종료 시간을 시, 분으로 분리
let nextHour = lastHour; // 다음 시간의 시(hour)를 초기화
if (lastMinute > 0) {
nextHour += 1; // 분이 존재하면 다음 시(hour)로 설정
}
const startHour = 9; // 시작 시간은 9시로 고정
// 시작 시간부터 마지막 시간까지 15분 단위로 시간대를 생성
return generateTimes(startHour, nextHour - 1);
}, [filteredSchedules]);
filteredSchedules 배열에서 각 과목의 종료 시간을 검사하여 마지막 종료 시간을 찾는다. 종료 시간을 기준으로 다음 시간대를 계산하여 시작 시간(startHour)부터 마지막 시간(nextHour - 1)까지 15분 단위로 시간대를 생성하고, generateTimes 함수를 호출하여 시간대 배열을 반환한다.
useCallBack hook
useMemo와 유사하게 useCallback()은 함수를 메모이제이션(memoization)하기 위해서 사용된다. 차이점은 useCallBack은 의존성 배열이 변하지 않는 한 계속해서 같은 함수 객체를 반환한다는 것이다.
filteredTimes
/**
* @description getFilteredTimes 함수의 결과를 메모이제이션하여 반환
* @returns {Array} 필터링된 시간대 배열
*/
const filteredTimes = useMemo(() => getFilteredTimes(), [getFilteredTimes]);
useMemo를 사용하여 getFilteredTimes 함수를 호출하고 결과를 filteredTimes 배열에 저장한다.
renderScheduleTable
/**
* @description 시간표 테이블을 렌더링하는 함수
* @returns {JSX.Element} 시간표 테이블 JSX
*/
const renderScheduleTable = () => {
const cells = {}; // 각 셀의 위치를 추적하는 객체
const occupiedCells = {}; // 병합된 셀을 추적하여 중복 병합을 방지
filteredSchedules.forEach(schedule => {
const times = schedule.subject.time.split(', '); // 여러 시간대가 있을 경우 분리
let firstTimeIndex = null; // 병합된 셀의 첫 번째 시간대의 인덱스
let lastTimeIndex = null; // 병합된 셀의 마지막 시간대의 인덱스
let dayIndex = null; // 요일의 인덱스
let duration = 0; // 병합된 셀의 지속 시간 (rowspan)
let savedDay = ''; // 저장된 요일
const bgColor = getColor(schedule.subject.subjectName); // 과목별 색상 할당
times.forEach((time) => {
const { day, start, end } = parseTime(time); // 시간대 정보를 파싱
savedDay = day; // 요일 저장
const dayIndexCurrent = days.indexOf(day) + 1; // 요일의 인덱스 계산 (+1 for header)
const timeIndex = filteredTimes.indexOf(start) + 1; // 시작 시간의 인덱스 계산 (+1 for header)
const durationCurrent = calculateDuration(start, end); // 시작 시간과 종료 시간을 바탕으로 지속 시간 계산
// 셀이 이미 병합된 상태인지 확인하고, 병합된 셀은 건너뜀
for (let i = 0; i < durationCurrent; i++) {
if (occupiedCells[timeIndex + i] && occupiedCells[timeIndex + i][dayIndexCurrent]) {
return; // 이미 병합된 셀은 건너뜀
}
}
if (firstTimeIndex === null) {
firstTimeIndex = timeIndex; // 첫 번째 시간대 인덱스 설정
lastTimeIndex = timeIndex + durationCurrent - 1; // 마지막 시간대 인덱스 설정
duration = durationCurrent; // 병합된 셀의 지속 시간 설정
dayIndex = dayIndexCurrent; // 요일 인덱스 설정
} else {
// 연속된 셀이면, rowspan을 늘림
if (dayIndex === dayIndexCurrent && timeIndex === lastTimeIndex + 1) {
lastTimeIndex += durationCurrent; // 마지막 시간대 인덱스 확장
duration += durationCurrent; // 지속 시간 확장
} else {
// 연속되지 않은 셀의 경우 현재까지 병합된 셀을 설정
if (!cells[firstTimeIndex]) cells[firstTimeIndex] = {};
cells[firstTimeIndex][dayIndex] = {
content: (
<div className="schedule-item" key={`${savedDay}-${firstTimeIndex}`} style={{ backgroundColor: bgColor }}>
<div className="subject-name">{schedule.subject.subjectName}</div>
<div className="subject-info">{schedule.subject.professor} <br /> {schedule.subject.location}</div>
</div>
),
rowspan: duration // 병합된 셀의 rowspan 설정
};
firstTimeIndex = timeIndex; // 새로운 첫 번째 시간대 인덱스 설정
lastTimeIndex = timeIndex + durationCurrent - 1; // 새로운 마지막 시간대 인덱스 설정
duration = durationCurrent; // 새로운 지속 시간 설정
dayIndex = dayIndexCurrent; // 새로운 요일 인덱스 설정
}
}
// 병합된 셀의 범위를 추적하여 중복 병합 방지
for (let i = 0; i < durationCurrent; i++) {
if (!occupiedCells[timeIndex + i]) occupiedCells[timeIndex + i] = {};
occupiedCells[timeIndex + i][dayIndexCurrent] = true;
}
});
// 마지막 셀 병합 처리
if (firstTimeIndex !== null) {
if (!cells[firstTimeIndex]) cells[firstTimeIndex] = {};
cells[firstTimeIndex][dayIndex] = {
content: (
<div className="schedule-item" key={`${savedDay}-${firstTimeIndex}`} style={{ backgroundColor: bgColor }}>
<div className="subject-name">{schedule.subject.subjectName}</div>
<div className="subject-info">{schedule.subject.professor} <br /> {schedule.subject.location}</div>
</div>
),
rowspan: duration // 병합된 셀의 rowspan 설정
};
}
});
// 렌더링된 시간표 테이블 반환
return (
<table className="schedule-table">
<thead>
<tr>
<th>시간</th>
{days.map(day => (
<th key={day}>{day}</th>
))}
</tr>
</thead>
<tbody>
{filteredTimes.map((time, rowIndex) => (
<tr key={time}>
<td>{time}</td>
{days.map((day, colIndex) => {
const cellData = cells[rowIndex + 1] && cells[rowIndex + 1][colIndex + 1];
if (cellData && cellData.rowspan) {
return (
<td key={`${day}-${time}`} rowSpan={cellData.rowspan}>
{cellData.content}
</td>
);
} else if (occupiedCells[rowIndex + 1] && occupiedCells[rowIndex + 1][colIndex + 1]) {
return null; // 병합된 셀 위치는 비워둠
}
return <td key={`${day}-${time}`}></td>;
})}
</tr>
))}
</tbody>
</table>
);
};
각 과목의 시간대와 요일을 기준으로 셀을 병합하여 시간표를 렌더링한다. 병합된 셀의 범위를 추적하여 이미 병합된 셀을 건너뛰고, 병합되지 않은 셀을 생성한다. 이 부분은 셀이 병합될 때 테이블 바깥으로 셀이 밀려나는 버그를 수정하기 위해 도입되었다. 각 schedule-item마다 getColor 함수를 사용하여 각 과목에 고유한 색상을 할당한다.
renderTimeTable
/**
* @description 시간 테이블을 렌더링하는 함수
* @returns {JSX.Element} 시간 테이블 JSX
*/
const renderTimeTable = () => {
const convertTimeFormat = (time) => {
const [hour] = time.split(':').map(Number);
const formattedHour = hour > 12 ? hour - 12 : hour; // 12시간제로 변환
return formattedHour.toString(); // 형식을 "9, 10, 11, ..."으로 변환
};
// 렌더링된 시간 테이블 반환
return (
<table className="time-table">
<tbody>
<tr>
<th> </th> {/* 첫 번째 셀 - 비워둠 */}
</tr>
{filteredTimes.map((time, index) => (
<tr key={index}>
<td>
{time.endsWith(':00') ? convertTimeFormat(time) : '\u00A0'} {/* 정각이면 시간 표시, 아니면 공백 */}
</td>
</tr>
))}
</tbody>
</table>
);
};
각 시간대가 정각일 때만 시간을 표시하고, 나머지 경우는 공백( )을 채워 시간 테이블을 렌더링한다. 24시간제를 12시간제로 시간 형식을 변환하여 표시한다.
이 시간 테이블은, 시간표 테이블이 첫 번째 열에 입력되는 시간 값에 의존하기 때문에 추가하였다. 시간표 테이블의 과목 배치는, 첫 번째 열인 시간 열의 데이터로부터 행의 위치를 구분하여 이루어진다. 그렇기 때문에 시간테이블에서 시간을 정각만 표시하고 셀을 병합하면, 시간 테이블은 어디에 어떤 과목을 배치해야 할 지 알 수 없게 된다.
이를 수정하기 위해 여러 시도를 해보았지만 해결할 수 없었다. 대신 별도로 시간 테이블을 생성해 시간표 테이블의 첫 번째 열을 덮도록 css를 설정했다. 돌아가면 그만.
return
// 전체 시간표 컴포넌트 반환
return (
<div className="schedule-container">
{renderTimeTable()}
{renderScheduleTable()}
</div>
);
이 컴포넌트는 ScheduleTable이라는 이름으로 export되며, 시간표와 시간 테이블을 함께 렌더링한다.
ScheduleHandlers.js
동일한 시간대의 과목이 배치되지 못하도록 isTimeOverlapping 함수를 추가하고 handleCheckboxChange를 수정하였다.
isTimeOverlapping
/**
* 주어진 시간표가 이미 선택된 시간표들과 시간대가 겹치는지 확인하는 함수
* @param {object} scheduleToCheck - 확인할 시간표 객체
* @param {Array} selectedSchedules - 이미 선택된 시간표들의 배열
* @returns {boolean} - 시간대가 겹치면 true, 그렇지 않으면 false
*/
const isTimeOverlapping = (scheduleToCheck, selectedSchedules) => {
// 확인할 시간표의 시간대 배열을 생성 (요일, 시작 시간, 종료 시간을 포함)
const scheduleTimesToCheck = scheduleToCheck.subject.time.split(', ').map(parseTime);
// 선택된 시간표들 중 하나라도 겹치는 시간이 있는지 확인
return selectedSchedules.some(selectedSchedule => {
// 이미 선택된 시간표의 시간대 배열을 생성 (요일, 시작 시간, 종료 시간을 포함)
const selectedScheduleTimes = selectedSchedule.subject.time.split(', ').map(parseTime);
// 각 시간대의 요일과 시간이 겹치는지 확인
return scheduleTimesToCheck.some(({ day: day1, start: startTime1, end: endTime1 }) => {
return selectedScheduleTimes.some(({ day: day2, start: startTime2, end: endTime2 }) => {
// 같은 요일(day1 === day2)에 있고, 시간대가 겹치면 true를 반환
return day1 === day2 &&
!(endTime1 <= startTime2 || startTime1 >= endTime2);
});
});
});
};
handleCheckboxChange
// 시간대 겹침 확인
const scheduleToCheck = groupedSubjects[groupName].find(schedule => schedule.id === scheduleId);
const selectedSchedules = Object.values(groupedSubjects).flat().filter(schedule => schedule.selected);
const overlappingSchedule = selectedSchedules.find(selectedSchedule => isTimeOverlapping(scheduleToCheck, [selectedSchedule]));
if (selected && overlappingSchedule) {
alert(`${overlappingSchedule.subject.subjectName}와 시간이 겹칩니다.`);
return;
}
끝으로, css를 약간 조정해 스크롤을 내릴 경우 시간표가 화면을 벗어나지 않도록 했다. 담은 과목 목록이 많아질수록 아래로 배치되기 때문에, 편의성을 위해 시간표가 함께 이동하도록 하였다.
참고자료 및 출처
'Side Project > 중단' 카테고리의 다른 글
ECEtaskHelper: CloudType을 이용해 배포하기 (0) | 2024.08.14 |
---|---|
ECEtaskHelper: HomePage에 공지사항 스크래핑(Scraping)하기 (0) | 2024.08.13 |
ECEtaskHelper: PrivateRoute, 반응형 사이드바(NavBar) (1) | 2024.08.06 |
ECEtaskHelper: 시간표 페이지 (SchedulePage) 구성하기(2) - 과목 검색/담기/추가/삭제 (0) | 2024.08.05 |
ECEtaskHelper: 소셜 로그인 기능 구현하기(5) (React + SpringBoot + OAuth2.0 + MySQL) 네비게이션 바에 로그인 정보 표기/로그아웃 (0) | 2024.08.02 |