mobile-react-infinite-calendar
Version:
A mobile-optimized infinite scroll calendar component for React
261 lines (260 loc) • 10.9 kB
JavaScript
import { useMemo, useEffect, useCallback, useReducer } from 'react';
import { format, startOfMonth, endOfMonth, getDay, isSameMonth } from 'date-fns';
import { HolidayService } from '../services/holidayService';
import { generateEventId, generateHolidayId } from '../utils/idGenerator';
import { LOCALE_CONSTANTS } from '../constants/calendar';
import { logger, initializeLogger } from '../utils/logger';
import { calendarReducer, createInitialState } from './useCalendarReducer';
// 디버그 로그 활성화
initializeLogger(true);
// 이벤트 타입 체크 함수 (기존 호환성 유지)
const isDetailedEvent = (event) => {
return 'startTime' in event && 'endTime' in event;
};
// 이벤트를 날짜별로 그룹핑하는 함수
const getEventDate = (event) => {
if (isDetailedEvent(event)) {
const startDate = new Date(event.startTime);
return format(startDate, 'yyyy-MM-dd');
}
else {
return event.date;
}
};
// 이벤트에 ID가 없으면 자동 생성
const ensureEventId = (event, index) => {
if (event.id)
return event;
const dateStr = getEventDate(event);
const title = isDetailedEvent(event) ? event.title : (event.title || 'event');
return {
...event,
id: generateEventId(dateStr, title, index)
};
};
export function useCalendarComposer({ events = [], holidays = [], locale = 'ko-KR', holidayServiceKey }) {
// useReducer로 모든 상태 통합 관리
const [state, dispatch] = useReducer(calendarReducer, undefined, createInitialState);
// 액션 생성 함수들 (useCallback으로 메모이제이션)
const setAutoHolidays = useCallback((holidays) => {
dispatch({ type: 'SET_AUTO_HOLIDAYS', payload: holidays });
}, []);
const setHolidayLoading = useCallback((loading) => {
dispatch({ type: 'SET_HOLIDAY_LOADING', payload: loading });
}, []);
const setDisplayMonth = useCallback((date) => {
dispatch({ type: 'SET_DISPLAY_MONTH', payload: date });
}, []);
const setActiveMonth = useCallback((date) => {
dispatch({ type: 'SET_ACTIVE_MONTH', payload: date });
}, []);
const setInfiniteMonths = useCallback((months) => {
dispatch({ type: 'SET_INFINITE_MONTHS', payload: months });
}, []);
const setAvailableHeight = useCallback((height) => {
dispatch({ type: 'SET_AVAILABLE_HEIGHT', payload: height });
}, []);
const setIsCurrentMonthVisible = useCallback((visible) => {
dispatch({ type: 'SET_CURRENT_MONTH_VISIBLE', payload: visible });
}, []);
const setIsInitialScrollSet = useCallback((value) => {
dispatch({ type: 'SET_INITIAL_SCROLL', payload: value });
}, []);
const setSelectedYear = useCallback((year) => {
dispatch({ type: 'SET_SELECTED_YEAR', payload: year });
}, []);
const setSelectedMonth = useCallback((month) => {
dispatch({ type: 'SET_SELECTED_MONTH', payload: month });
}, []);
const setShowYearDropdown = useCallback(() => {
dispatch({ type: 'TOGGLE_YEAR_DROPDOWN' });
}, []);
const setShowMonthDropdown = useCallback(() => {
dispatch({ type: 'TOGGLE_MONTH_DROPDOWN' });
}, []);
const addPrevInfiniteMonth = useCallback(() => {
dispatch({ type: 'ADD_PREV_MONTH' });
}, []);
const addNextInfiniteMonth = useCallback(() => {
dispatch({ type: 'ADD_NEXT_MONTH' });
}, []);
const openDateSelector = useCallback(() => {
dispatch({ type: 'OPEN_DATE_SELECTOR' });
}, []);
const closeDateSelector = useCallback(() => {
dispatch({ type: 'CLOSE_DATE_SELECTOR' });
}, []);
const confirmDateSelection = useCallback(() => {
dispatch({ type: 'CONFIRM_DATE_SELECTION' });
}, []);
// 한국 로케일인지 확인
const enableAutoHolidays = LOCALE_CONSTANTS.KOREAN_LOCALES.includes(locale);
// 상태에서 필요한 값들 추출
const { autoHolidays, holidayLoading, displayMonth, activeMonth, infiniteMonths, availableHeight, isCurrentMonthVisible, isInitialScrollSet, showDateSelector, selectedYear, selectedMonth, showYearDropdown, showMonthDropdown } = state;
// 로케일 또는 서비스 키 변경 시 인스턴스 업데이트 (메모이제이션)
const holidayServiceInstanceMemo = useMemo(() => {
return new HolidayService(holidayServiceKey, locale);
}, [holidayServiceKey, locale]);
// 자동 공휴일 데이터 로딩 (최적화된 버전)
useEffect(() => {
if (!enableAutoHolidays)
return;
let isCancelled = false;
const loadHolidays = async () => {
setHolidayLoading(true);
try {
// 연도별로 그룹화하여 중복 API 호출 방지
const yearGroups = new Map();
state.infiniteMonths.forEach(month => {
const year = month.getFullYear();
if (!yearGroups.has(year)) {
yearGroups.set(year, []);
}
yearGroups.get(year).push(month);
});
// 연도별로 데이터 로드
const yearPromises = Array.from(yearGroups.keys()).map(async (year) => {
// 해당 연도에서 실제로 표시되는 월들만 조회
const monthsInYear = yearGroups.get(year) || [];
const uniqueMonths = Array.from(new Set(monthsInYear.map(date => date.getMonth() + 1)));
const monthPromises = uniqueMonths.map(month => holidayServiceInstanceMemo.getHolidays(year, month));
const monthResults = await Promise.all(monthPromises);
return monthResults.flat();
});
const results = await Promise.all(yearPromises);
if (isCancelled)
return;
const allHolidays = results.flat();
logger.info('공휴일 데이터 로드 완료', {
totalHolidays: allHolidays.length,
years: Array.from(yearGroups.keys()),
holidays: allHolidays.map(h => ({ name: h.name, date: h.date }))
});
setAutoHolidays(allHolidays);
}
catch (error) {
if (!isCancelled) {
logger.error('공휴일 데이터 로드 실패:', error);
setAutoHolidays([]);
}
}
finally {
if (!isCancelled) {
setHolidayLoading(false);
}
}
};
loadHolidays();
return () => {
isCancelled = true;
};
}, [state.infiniteMonths, enableAutoHolidays, holidayServiceInstanceMemo, setHolidayLoading, setAutoHolidays]);
// 이벤트 ID 추가 (메모이제이션)
const eventsWithIds = useMemo(() => {
return events.map((event, index) => ensureEventId(event, index));
}, [events]);
// 최종 공휴일 데이터 (수동 + 자동) (메모이제이션)
const finalHolidays = useMemo(() => {
const allHolidays = enableAutoHolidays ? [...holidays, ...state.autoHolidays] : holidays;
// id가 없는 공휴일에 자동 생성
return allHolidays.map(holiday => ({
...holiday,
id: holiday.id || generateHolidayId(holiday.date, holiday.name)
}));
}, [holidays, state.autoHolidays, enableAutoHolidays]);
// 월별 캘린더 데이터 생성
const monthsData = useMemo(() => {
const result = state.infiniteMonths.map(month => {
const monthStart = startOfMonth(month);
const monthEnd = endOfMonth(month);
// 캘린더 그리드 생성 (주 단위)
const weeks = [];
const firstDayOfWeek = getDay(monthStart); // 0: 일요일, 1: 월요일, ...
let currentDate = new Date(monthStart);
currentDate.setDate(currentDate.getDate() - firstDayOfWeek);
while (currentDate <= monthEnd || getDay(currentDate) !== 0) {
const week = [];
for (let i = 0; i < 7; i++) {
if (isSameMonth(currentDate, month)) {
week.push(new Date(currentDate));
}
else {
week.push(null); // 다른 월의 날짜는 표시하지 않음
}
currentDate.setDate(currentDate.getDate() + 1);
}
weeks.push(week);
if (currentDate > monthEnd && getDay(currentDate) === 0)
break;
}
// 날짜별 이벤트 데이터 그룹핑
const eventsByDate = {};
eventsWithIds.forEach(event => {
const dateKey = getEventDate(event);
if (!eventsByDate[dateKey]) {
eventsByDate[dateKey] = [];
}
eventsByDate[dateKey].push(event);
});
// 날짜별 공휴일 데이터 그룹핑
const holidaysByDate = {};
finalHolidays.forEach(holiday => {
const dateKey = format(new Date(holiday.date), 'yyyy-MM-dd');
if (!holidaysByDate[dateKey]) {
holidaysByDate[dateKey] = [];
}
holidaysByDate[dateKey].push(holiday);
});
return {
month,
weeks,
eventsByDate,
holidaysByDate
};
});
return result;
}, [state.infiniteMonths, eventsWithIds, finalHolidays]);
// 이전 월 추가 (메모이제이션)
const addPrevMonth = useCallback(() => {
addPrevInfiniteMonth();
}, [addPrevInfiniteMonth]);
// 다음 월 추가 (멤모이제이션)
const addNextMonth = useCallback(() => {
addNextInfiniteMonth();
}, [addNextInfiniteMonth]);
return {
// UI 상태
displayMonth,
activeMonth,
infiniteMonths,
monthsData,
availableHeight,
isCurrentMonthVisible,
isInitialScrollSet,
showDateSelector,
selectedYear,
selectedMonth,
showYearDropdown,
showMonthDropdown,
// UI 액션
setActiveMonth,
setDisplayMonth,
setInfiniteMonths,
setAvailableHeight,
setIsCurrentMonthVisible,
setIsInitialScrollSet,
openDateSelector,
closeDateSelector,
setShowYearDropdown,
setShowMonthDropdown,
setSelectedYear,
setSelectedMonth,
confirmDateSelection,
// 데이터 액션
addPrevMonth,
addNextMonth,
// 공휴일 관련
holidayLoading,
autoHolidays
};
}