import { useState, useEffect, useRef, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar as CalendarIcon, List } from 'lucide-react'; import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; import { useInView } from 'react-intersection-observer'; import { MobileScheduleListCard, MobileScheduleSearchCard, MobileBirthdayCard, fireBirthdayConfetti, } from '@/components/schedule'; import { getSchedules, searchSchedules } from '@/api/schedules'; import { useScheduleStore } from '@/stores'; import { getTodayKST, dayjs, getCategoryInfo } from '@/utils'; const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']; const MONTHS = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; const SEARCH_LIMIT = 20; const MIN_YEAR = 2017; /** * Mobile 스케줄 페이지 */ function MobileSchedule() { const navigate = useNavigate(); const scrollContainerRef = useRef(null); // 상태 관리 (zustand store) const { currentDate, setCurrentDate, selectedDate: storedSelectedDate, setSelectedDate: setStoredSelectedDate, selectedCategories, setSelectedCategories, isSearchMode, setIsSearchMode, searchInput, setSearchInput, searchTerm, setSearchTerm, } = useScheduleStore(); const selectedDate = storedSelectedDate === undefined ? getTodayKST() : storedSelectedDate; const setSelectedDate = setStoredSelectedDate; // 로컬 상태 const [viewMode, setViewMode] = useState('calendar'); // 'calendar' | 'list' const [showMonthPicker, setShowMonthPicker] = useState(false); const year = currentDate.getFullYear(); const month = currentDate.getMonth(); // 월별 일정 데이터 const { data: schedules = [], isLoading: loading } = useQuery({ queryKey: ['schedules', year, month + 1], queryFn: () => getSchedules(year, month + 1), }); // 검색 무한 스크롤 const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' }); const { data: searchData, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['scheduleSearch', searchTerm], queryFn: async ({ pageParam = 0 }) => { return searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT }); }, getNextPageParam: (lastPage) => { if (lastPage.hasMore) { return lastPage.offset + lastPage.schedules.length; } return undefined; }, enabled: !!searchTerm && isSearchMode, }); const searchResults = useMemo(() => { if (!searchData?.pages) return []; return searchData.pages.flatMap((page) => page.schedules); }, [searchData]); // 무한 스크롤 트리거 const prevInViewRef = useRef(false); useEffect(() => { if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) { fetchNextPage(); } prevInViewRef.current = inView; }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); // 오늘 생일 폭죽 useEffect(() => { if (loading || schedules.length === 0) return; const today = getTodayKST(); const confettiKey = `birthday-confetti-${today}`; if (localStorage.getItem(confettiKey)) return; const hasBirthdayToday = schedules.some((s) => s.is_birthday && s.date === today); if (hasBirthdayToday) { const timer = setTimeout(() => { fireBirthdayConfetti(); localStorage.setItem(confettiKey, 'true'); }, 500); return () => clearTimeout(timer); } }, [schedules, loading]); // 달력 계산 const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate(); const getFirstDayOfMonth = (y, m) => new Date(y, m, 1).getDay(); const daysInMonth = getDaysInMonth(year, month); const firstDay = getFirstDayOfMonth(year, month); // 일정 날짜별 맵 const scheduleDateMap = useMemo(() => { const map = new Map(); schedules.forEach((s) => { const dateStr = s.date; if (!map.has(dateStr)) { map.set(dateStr, []); } map.get(dateStr).push(s); }); return map; }, [schedules]); // 카테고리 추출 const categories = useMemo(() => { const categoryMap = new Map(); schedules.forEach((s) => { if (s.category_id && !categoryMap.has(s.category_id)) { categoryMap.set(s.category_id, { id: s.category_id, name: s.category_name, color: s.category_color, }); } }); return Array.from(categoryMap.values()); }, [schedules]); // 필터링된 스케줄 const filteredSchedules = useMemo(() => { if (isSearchMode && searchTerm) { if (selectedCategories.length === 0) return searchResults; return searchResults.filter((s) => selectedCategories.includes(s.category_id)); } return schedules .filter((s) => { const matchesDate = selectedDate ? s.date === selectedDate : true; const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id); return matchesDate && matchesCategory; }) .sort((a, b) => { // 생일 우선 if (a.is_birthday && !b.is_birthday) return -1; if (!a.is_birthday && b.is_birthday) return 1; // 시간순 return (a.time || '00:00:00').localeCompare(b.time || '00:00:00'); }); }, [schedules, selectedDate, selectedCategories, isSearchMode, searchTerm, searchResults]); // 날짜별 그룹화 (리스트 모드용) const groupedSchedules = useMemo(() => { if (isSearchMode && searchTerm) { const groups = new Map(); searchResults.forEach((s) => { if (!groups.has(s.date)) { groups.set(s.date, []); } groups.get(s.date).push(s); }); return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0])); } const groups = new Map(); schedules.forEach((s) => { if (selectedCategories.length > 0 && !selectedCategories.includes(s.category_id)) return; if (!groups.has(s.date)) { groups.set(s.date, []); } groups.get(s.date).push(s); }); return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0])); }, [schedules, selectedCategories, isSearchMode, searchTerm, searchResults]); // 월 이동 const canGoPrevMonth = !(year === MIN_YEAR && month === 0); const prevMonth = () => { if (!canGoPrevMonth) return; const newDate = new Date(year, month - 1, 1); setCurrentDate(newDate); }; const nextMonth = () => { const newDate = new Date(year, month + 1, 1); setCurrentDate(newDate); }; // 날짜 선택 const selectDate = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; setSelectedDate(dateStr); }; // 일정 클릭 const handleScheduleClick = (schedule) => { if (schedule.is_birthday) { const scheduleYear = new Date(schedule.date).getFullYear(); navigate(`/birthday/${encodeURIComponent(schedule.member_names)}/${scheduleYear}`); return; } if ([2, 3, 6].includes(schedule.category_id)) { navigate(`/schedule/${schedule.id}`); return; } if (!schedule.description && schedule.source?.url) { window.open(schedule.source.url, '_blank'); } else { navigate(`/schedule/${schedule.id}`); } }; // 검색 모드 종료 const exitSearchMode = () => { setIsSearchMode(false); setSearchInput(''); setSearchTerm(''); }; return (
{/* 헤더 */}
{isSearchMode ? ( // 검색 모드 헤더
setSearchInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && searchInput.trim()) { setSearchTerm(searchInput); } }} className="w-full pl-10 pr-10 py-2 bg-gray-100 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-primary/50" /> {searchInput && ( )}
) : ( // 일반 모드 헤더 <>
{/* 월 선택 드롭다운 */} {showMonthPicker && (
{year}년
{MONTHS.map((m, i) => ( ))}
)}
{/* 달력 모드 - 달력 그리드 */} {viewMode === 'calendar' && (
{/* 월 네비게이션 */}
{month + 1}월
{/* 요일 헤더 */}
{WEEKDAYS.map((day, i) => (
{day}
))}
{/* 날짜 그리드 */}
{/* 전달 빈 칸 */} {Array.from({ length: firstDay }).map((_, i) => (
))} {/* 현재 달 날짜 */} {Array.from({ length: daysInMonth }).map((_, i) => { const day = i + 1; const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const isSelected = selectedDate === dateStr; const isToday = dateStr === getTodayKST(); const daySchedules = scheduleDateMap.get(dateStr) || []; const dayOfWeek = (firstDay + i) % 7; return ( ); })}
)} )}
{/* 일정 목록 */}
{loading ? (
) : isSearchMode && searchTerm ? ( // 검색 결과
{searchResults.length > 0 ? ( <> {searchResults.map((schedule) => (
{schedule.is_birthday ? ( handleScheduleClick(schedule)} /> ) : ( handleScheduleClick(schedule)} /> )}
))}
{isFetchingNextPage && (
)}
) : (
검색 결과가 없습니다
)}
) : viewMode === 'calendar' ? ( // 달력 모드 - 선택된 날짜의 일정
{filteredSchedules.length > 0 ? ( filteredSchedules.map((schedule) => (
{schedule.is_birthday ? ( handleScheduleClick(schedule)} /> ) : ( handleScheduleClick(schedule)} /> )}
)) ) : (
{selectedDate ? '이 날짜에 일정이 없습니다' : '이번 달에 일정이 없습니다'}
)}
) : ( // 리스트 모드 - 날짜별 그룹화
{groupedSchedules.length > 0 ? ( groupedSchedules.map(([date, daySchedules]) => { const d = dayjs(date); return (
{d.format('M월 D일')} ({WEEKDAYS[d.day()]})
{daySchedules.map((schedule) => (
{schedule.is_birthday ? ( handleScheduleClick(schedule)} /> ) : ( handleScheduleClick(schedule)} /> )}
))}
); }) ) : (
이번 달에 일정이 없습니다
)}
)}
); } export default MobileSchedule;