import { useState, useEffect, useRef, useMemo, useDeferredValue, memo, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft, Link2, X } from 'lucide-react'; import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useInView } from 'react-intersection-observer'; import confetti from 'canvas-confetti'; import { getTodayKST } from '../../../utils/date'; import { getSchedules } from '../../../api/public/schedules'; import useScheduleStore from '../../../stores/useScheduleStore'; // HTML 엔티티 디코딩 함수 const decodeHtmlEntities = (text) => { if (!text) return ''; const textarea = document.createElement('textarea'); textarea.innerHTML = text; return textarea.value; }; // 폭죽 애니메이션 함수 const fireBirthdayConfetti = () => { const duration = 3000; const animationEnd = Date.now() + duration; const colors = ['#ff69b4', '#ff1493', '#da70d6', '#ba55d3', '#9370db', '#8a2be2', '#ffd700', '#ff6347']; const randomInRange = (min, max) => Math.random() * (max - min) + min; const interval = setInterval(() => { const timeLeft = animationEnd - Date.now(); if (timeLeft <= 0) { clearInterval(interval); return; } const particleCount = 50 * (timeLeft / duration); // 왼쪽에서 발사 confetti({ particleCount: Math.floor(particleCount), startVelocity: 30, spread: 60, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, colors: colors, shapes: ['circle', 'square'], gravity: 1.2, scalar: randomInRange(0.8, 1.2), drift: randomInRange(-0.5, 0.5), }); // 오른쪽에서 발사 confetti({ particleCount: Math.floor(particleCount), startVelocity: 30, spread: 60, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, colors: colors, shapes: ['circle', 'square'], gravity: 1.2, scalar: randomInRange(0.8, 1.2), drift: randomInRange(-0.5, 0.5), }); }, 250); // 초기 대형 폭죽 confetti({ particleCount: 100, spread: 100, origin: { x: 0.5, y: 0.6 }, colors: colors, shapes: ['circle', 'square'], startVelocity: 45, }); }; // 생일 카드 컴포넌트 function BirthdayCard({ schedule, formatted, showYear = false, onClick }) { return (
{/* 배경 장식 */}
🎉
{/* 멤버 사진 */} {schedule.member_image && (
{schedule.member_names}
)} {/* 내용 */}
🎂

{schedule.title}

{/* 날짜 뱃지 */}
{showYear && (
{new Date(schedule.date).getFullYear()}
)}
{new Date(schedule.date).getMonth() + 1}월
{formatted.day}
); } function Schedule() { const navigate = useNavigate(); // 상태 관리 (zustand store) const { currentDate, setCurrentDate, selectedDate: storedSelectedDate, setSelectedDate: setStoredSelectedDate, selectedCategories, setSelectedCategories, isSearchMode, setIsSearchMode, searchInput, setSearchInput, searchTerm, setSearchTerm, } = useScheduleStore(); // 초기값 설정 (store에 값이 없으면 오늘 날짜) const selectedDate = storedSelectedDate === undefined ? getTodayKST() : storedSelectedDate; const setSelectedDate = setStoredSelectedDate; const [showYearMonthPicker, setShowYearMonthPicker] = useState(false); const [viewMode, setViewMode] = useState('yearMonth'); const [slideDirection, setSlideDirection] = useState(0); const pickerRef = useRef(null); // 월별 일정 데이터 로드 (useQuery) 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 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]); // 오늘 생일이 있으면 폭죽 발사 (하루에 한 번만) 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 => { if (!s.is_birthday) return false; const scheduleDate = s.date ? s.date.split('T')[0] : ''; return scheduleDate === today; }); if (hasBirthdayToday) { // 약간의 딜레이 후 폭죽 발사 (페이지 렌더링 완료 후) const timer = setTimeout(() => { fireBirthdayConfetti(); localStorage.setItem(confettiKey, 'true'); }, 500); return () => clearTimeout(timer); } }, [schedules, loading]); // 카테고리 필터 툴팁 const [showCategoryTooltip, setShowCategoryTooltip] = useState(false); const categoryRef = useRef(null); const scrollContainerRef = useRef(null); // 일정 목록 스크롤 컨테이너 const searchContainerRef = useRef(null); // 검색 컨테이너 (외부 클릭 감지용) // 검색 관련 로컬 상태 (store에서 관리하지 않는 것들) const [originalSearchQuery, setOriginalSearchQuery] = useState(''); // 사용자가 직접 입력한 원본 값 (필터링용) const [showSuggestions, setShowSuggestions] = useState(false); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const [suggestions, setSuggestions] = useState([]); // 추천 검색어 목록 const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); const SEARCH_LIMIT = 20; // 페이지당 20개 const ESTIMATED_ITEM_HEIGHT = 120; // 아이템 추정 높이 (동적 측정) // Intersection Observer for infinite scroll const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px', }); // useInfiniteQuery for search const { data: searchData, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading: searchLoading, refetch: refetchSearch, } = useInfiniteQuery({ queryKey: ['scheduleSearch', searchTerm], queryFn: async ({ pageParam = 0 }) => { const response = await fetch( `/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}` ); if (!response.ok) throw new Error('Search failed'); return response.json(); }, getNextPageParam: (lastPage) => { if (lastPage.hasMore) { return lastPage.offset + lastPage.schedules.length; } return undefined; }, enabled: !!searchTerm && isSearchMode, }); // Flatten search results and normalize format to match monthly data const searchResults = useMemo(() => { if (!searchData?.pages) return []; return searchData.pages.flatMap(page => page.schedules.map(s => { // datetime → date + time 분리 const dateTime = s.datetime || ''; const [date, time] = dateTime.includes('T') ? dateTime.split('T') : [dateTime, null]; return { ...s, date, time, // category 객체 → flat 구조 category_id: s.category?.id, category_name: s.category?.name, category_color: s.category?.color, // members 배열 → 쉼표 구분 문자열 (기존 호환) member_names: Array.isArray(s.members) ? s.members.join(',') : s.member_names, }; }) ); }, [searchData]); const searchTotal = searchData?.pages?.[0]?.total || 0; // Auto fetch next page when scrolled to bottom // inView가 true로 변경될 때만 fetch (중복 요청 방지) const prevInViewRef = useRef(false); useEffect(() => { // inView가 false→true로 변경될 때만 fetch if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) { fetchNextPage(); } prevInViewRef.current = inView; }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); // 검색어 자동완성 API 호출 (debounce 적용) useEffect(() => { // 검색어가 비어있으면 초기화 if (!originalSearchQuery || originalSearchQuery.trim().length === 0) { setSuggestions([]); return; } // debounce: 200ms 후에 API 호출 const timeoutId = setTimeout(async () => { setIsLoadingSuggestions(true); try { const response = await fetch(`/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10`); if (response.ok) { const data = await response.json(); setSuggestions(data.suggestions || []); } } catch (error) { console.error('추천 검색어 API 오류:', error); setSuggestions([]); } finally { setIsLoadingSuggestions(false); } }, 200); return () => clearTimeout(timeoutId); }, [originalSearchQuery]); // 카테고리/일정 데이터는 상단에서 useQuery로 관리됨 // 외부 클릭시 팝업 닫기 useEffect(() => { const handleClickOutside = (event) => { if (pickerRef.current && !pickerRef.current.contains(event.target)) { setShowYearMonthPicker(false); setViewMode('yearMonth'); } if (categoryRef.current && !categoryRef.current.contains(event.target)) { setShowCategoryTooltip(false); } // 검색 추천 드롭다운 외부 클릭 시 닫기 if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) { setShowSuggestions(false); setSelectedSuggestionIndex(-1); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // 날짜 변경 시 스크롤 맨 위로 초기화 useEffect(() => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = 0; } }, [selectedDate]); // 달력 관련 함수 const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate(); const getFirstDayOfMonth = (y, m) => new Date(y, m, 1).getDay(); // year, month는 상단에서 이미 선언됨 (useQuery) const daysInMonth = getDaysInMonth(year, month); const firstDay = getFirstDayOfMonth(year, month); const days = ['일', '월', '화', '수', '목', '금', '토']; // 스케줄 데이터를 지연 처리하여 달력 UI 응답성 향상 const deferredSchedules = useDeferredValue(schedules); // 일정 날짜별 맵 (O(1) 조회용) - 지연된 데이터로 점 표시 const scheduleDateMap = useMemo(() => { const map = new Map(); deferredSchedules.forEach(s => { const dateStr = s.date ? s.date.split('T')[0] : ''; if (!map.has(dateStr)) { map.set(dateStr, s); } }); return map; }, [deferredSchedules]); // 해당 날짜의 첫 번째 일정 카테고리 색상 (O(1)) const getScheduleColor = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const schedule = scheduleDateMap.get(dateStr); if (!schedule) return null; // schedule에서 직접 색상 가져오기 if (schedule.category_color) return schedule.category_color; const cat = categories.find(c => c.id === schedule.category_id); return cat?.color || '#4A7C59'; }; // 해당 날짜에 일정이 있는지 확인 (O(1)) const hasSchedule = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; return scheduleDateMap.has(dateStr); }; const prevMonth = () => { setSlideDirection(-1); const newDate = new Date(year, month - 1, 1); setCurrentDate(newDate); // 이번달이면 오늘, 다른 달이면 1일 선택 const today = new Date(); if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) { setSelectedDate(getTodayKST()); } else { const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`; setSelectedDate(firstDay); } }; const nextMonth = () => { setSlideDirection(1); const newDate = new Date(year, month + 1, 1); setCurrentDate(newDate); // 이번달이면 오늘, 다른 달이면 1일 선택 const today = new Date(); if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) { setSelectedDate(getTodayKST()); } else { const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`; setSelectedDate(firstDay); } }; // 날짜 선택 (토글 없이 항상 선택) const selectDate = (day) => { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; setSelectedDate(dateStr); }; const selectYear = (newYear) => { setCurrentDate(new Date(newYear, month, 1)); }; const selectMonth = (newMonth) => { const newDate = new Date(year, newMonth, 1); setCurrentDate(newDate); // 이번달이면 오늘, 다른 달이면 1일 선택 const today = new Date(); if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) { setSelectedDate(getTodayKST()); } else { const firstDay = `${year}-${String(newMonth + 1).padStart(2, '0')}-01`; setSelectedDate(firstDay); } setShowYearMonthPicker(false); setViewMode('yearMonth'); }; // 카테고리 토글 const toggleCategory = (categoryId) => { if (selectedCategories.includes(categoryId)) { setSelectedCategories(selectedCategories.filter(id => id !== categoryId)); } else { setSelectedCategories([...selectedCategories, categoryId]); } }; // 필터링된 스케줄 (useMemo로 성능 최적화, 시간순 정렬) const currentYearMonth = `${year}-${String(month + 1).padStart(2, '0')}`; const filteredSchedules = useMemo(() => { // 검색 모드일 때 if (isSearchMode) { // 검색 전엔 빈 목록, 검색 후엔 API 결과 (Meilisearch 유사도순 유지) if (!searchTerm) return []; // 카테고리 필터링 적용 if (selectedCategories.length === 0) return searchResults; return searchResults.filter(s => selectedCategories.includes(s.category_id)); } // 일반 모드: 기존 필터링 return schedules .filter(s => { const scheduleDate = s.date ? s.date.split('T')[0] : ''; const matchesDate = selectedDate ? scheduleDate === selectedDate : scheduleDate.startsWith(currentYearMonth); const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id); return matchesDate && matchesCategory; }) .sort((a, b) => { const dateA = a.date ? a.date.split('T')[0] : ''; const dateB = b.date ? b.date.split('T')[0] : ''; if (dateA !== dateB) return dateA.localeCompare(dateB); const timeA = a.time || '00:00:00'; const timeB = b.time || '00:00:00'; return timeA.localeCompare(timeB); }); }, [schedules, selectedDate, currentYearMonth, selectedCategories, isSearchMode, searchTerm, searchResults]); // 가상 스크롤 설정 (검색 모드에서만 활성화, 동적 높이 지원) const virtualizer = useVirtualizer({ count: isSearchMode && searchTerm ? filteredSchedules.length : 0, getScrollElement: () => scrollContainerRef.current, estimateSize: () => ESTIMATED_ITEM_HEIGHT, overscan: 5, // 버퍼 아이템 수 }); // 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준 const categoryCounts = useMemo(() => { // 검색어가 있을 때만 검색 결과 사용, 아니면 기존 schedules 사용 const source = (isSearchMode && searchTerm) ? searchResults : schedules; const counts = new Map(); let total = 0; source.forEach(s => { const scheduleDate = s.date ? s.date.split('T')[0] : ''; // 검색 모드에서 검색어가 있을 때는 전체 대상 // 그 외에는 선택된 날짜 기준으로 필터링 if (!(isSearchMode && searchTerm) && selectedDate) { if (scheduleDate !== selectedDate) return; } const catId = s.category_id; if (catId) { counts.set(catId, (counts.get(catId) || 0) + 1); total++; } }); counts.set('total', total); return counts; }, [schedules, searchResults, isSearchMode, searchTerm, selectedDate]); const formatDate = (dateStr) => { const date = new Date(dateStr); const dayNames = ['일', '월', '화', '수', '목', '금', '토']; return { month: date.getMonth() + 1, day: date.getDate(), weekday: dayNames[date.getDay()], }; }; // 일정 클릭 핸들러 const handleScheduleClick = (schedule) => { // 생일 일정은 생일 페이지로 이동 if (schedule.is_birthday || String(schedule.id).startsWith('birthday-')) { const scheduleYear = new Date(schedule.date).getFullYear(); const memberName = schedule.member_names; // 한글 이름 navigate(`/birthday/${encodeURIComponent(memberName)}/${scheduleYear}`); return; } // 유튜브(id=2), X(id=3), 콘서트(id=6) 카테고리는 상세 페이지로 이동 if (schedule.category_id === 2 || schedule.category_id === 3 || schedule.category_id === 6) { navigate(`/schedule/${schedule.id}`); return; } // 설명이 없고 URL만 있으면 바로 링크 열기 if (!schedule.description && schedule.source?.url) { window.open(schedule.source?.url, '_blank'); } else { // 상세 페이지로 이동 navigate(`/schedule/${schedule.id}`); } }; const currentYear = new Date().getFullYear(); const isCurrentYear = (y) => y === currentYear; const isCurrentMonth = (m) => { const now = new Date(); return year === now.getFullYear() && m === now.getMonth(); }; // 연도 선택 범위 (2025년부터 시작) const MIN_YEAR = 2025; const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR); const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i); const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; const canGoPrevYearRange = yearRangeStart > MIN_YEAR; const prevYearRange = () => canGoPrevYearRange && setYearRangeStart(prev => Math.max(MIN_YEAR, prev - 12)); const nextYearRange = () => setYearRangeStart(prev => prev + 12); // 선택된 카테고리 이름 const getSelectedCategoryNames = () => { if (selectedCategories.length === 0) return '전체'; const names = selectedCategories.map(id => { const cat = categories.find(c => c.id === id); return cat?.name || ''; }).filter(Boolean); if (names.length <= 2) return names.join(', '); return `${names.slice(0, 2).join(', ')} 외 ${names.length - 2}개`; }; // 카테고리 색상 가져오기 (schedule 객체에서 직접 가져오거나 categories에서 조회) const getCategoryColor = useCallback((categoryId, schedule = null) => { if (schedule?.category_color) return schedule.category_color; const cat = categories.find(c => c.id === categoryId); return cat?.color || '#808080'; }, [categories]); const getCategoryName = useCallback((categoryId, schedule = null) => { if (schedule?.category_name) return schedule.category_name; const cat = categories.find(c => c.id === categoryId); return cat?.name || ''; }, [categories]); // 정렬된 카테고리 목록 (메모이제이션으로 깜빡임 방지) const sortedCategories = useMemo(() => { return categories .map(category => ({ ...category, count: categoryCounts.get(category.id) || 0 })) .filter(category => category.count > 0) .sort((a, b) => { if (a.name === '기타') return 1; if (b.name === '기타') return -1; return b.count - a.count; }); }, [categories, categoryCounts]); return (
{/* 헤더 */}
일정 프로미스나인의 다가오는 일정을 확인하세요
{/* 왼쪽: 달력 + 카테고리 */}
{/* 달력 */}
{/* 달력 헤더 */}
{/* 년/월 선택 팝업 */} {showYearMonthPicker && (
{viewMode === 'yearMonth' ? `${yearRange[0]} - ${yearRange[yearRange.length - 1]}` : `${year}년`}
{viewMode === 'yearMonth' && (
년도
{yearRange.map((y) => ( ))}
{monthNames.map((m, i) => ( ))}
)} {viewMode === 'months' && (
월 선택
{monthNames.map((m, i) => ( ))}
)}
)}
{/* 요일 헤더 + 날짜 그리드 */}
{days.map((day, i) => (
{day}
))}
{/* 전달 날짜 */} {Array.from({ length: firstDay }).map((_, i) => { const prevMonthDays = getDaysInMonth(year, month - 1); const day = prevMonthDays - firstDay + i + 1; return (
{day}
); })} {/* 현재 달 날짜 */} {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 hasEvent = hasSchedule(day); const eventColor = getScheduleColor(day); const dayOfWeek = (firstDay + i) % 7; const isToday = new Date().toDateString() === new Date(year, month, day).toDateString(); // 해당 날짜의 일정 목록 (점 표시용, 최대 3개) const daySchedules = schedules.filter(s => { const scheduleDate = s.date ? s.date.split('T')[0] : ''; const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; return scheduleDate === dateStr; }).slice(0, 3); return ( ); })} {/* 다음달 날짜 */} {(() => { const totalCells = firstDay + daysInMonth; const remainder = totalCells % 7; const nextDays = remainder === 0 ? 0 : 7 - remainder; return Array.from({ length: nextDays }).map((_, i) => (
{i + 1}
)); })()}
{/* 범례 */}
일정 있음
{/* 카테고리 필터 */}

카테고리

{/* 전체 */} {/* 개별 카테고리 - useMemo로 정렬됨 */} {sortedCategories.map(category => { const isSelected = selectedCategories.includes(category.id); return ( ); })}
{/* 스케줄 리스트 */}
{/* 헤더 */}
{isSearchMode ? ( /* 검색 모드 - 밑줄 스타일 */ {/* 검색창 컨테이너 - 화살표와 검색창 일체형 */}
{/* 뒤로가기 영역 */} {/* 검색 입력 영역 */}
{ setSearchInput(e.target.value); setOriginalSearchQuery(e.target.value); // 원본 쿼리도 업데이트 setShowSuggestions(true); setSelectedSuggestionIndex(-1); }} onFocus={() => setShowSuggestions(true)} onKeyDown={(e) => { if (e.key === 'ArrowDown') { e.preventDefault(); const newIndex = selectedSuggestionIndex < suggestions.length - 1 ? selectedSuggestionIndex + 1 : 0; setSelectedSuggestionIndex(newIndex); if (suggestions[newIndex]) { setSearchInput(suggestions[newIndex]); } } else if (e.key === 'ArrowUp') { e.preventDefault(); const newIndex = selectedSuggestionIndex > 0 ? selectedSuggestionIndex - 1 : suggestions.length - 1; setSelectedSuggestionIndex(newIndex); if (suggestions[newIndex]) { setSearchInput(suggestions[newIndex]); } } else if (e.key === 'Enter') { if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) { setSearchInput(suggestions[selectedSuggestionIndex]); setSearchTerm(suggestions[selectedSuggestionIndex]); } else if (searchInput.trim()) { setSearchTerm(searchInput); } setShowSuggestions(false); setSelectedSuggestionIndex(-1); } else if (e.key === 'Escape') { setIsSearchMode(false); setSearchInput(''); setOriginalSearchQuery(''); setSearchTerm(''); setShowSuggestions(false); setSelectedSuggestionIndex(-1); // 스크롤 위치 초기화 if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = 0; } } }} className="flex-1 bg-transparent focus:outline-none text-gray-700 placeholder-gray-400 text-sm" /> {/* 입력 지우기 버튼 - 항상 공간 차지, 입력 있을 때만 보임 */}
{/* 검색 버튼 영역 */}
{/* 검색어 추천 드롭다운 */} {showSuggestions && !isLoadingSuggestions && suggestions.length > 0 && (
{suggestions.map((suggestion, index) => ( ))}
)}
) : ( /* 일반 모드 */

{selectedDate ? (() => { const d = new Date(selectedDate); const dayNames = ['일', '월', '화', '수', '목', '금', '토']; return `${d.getMonth() + 1}월 ${d.getDate()}일 ${dayNames[d.getDay()]}요일`; })() : `${month + 1}월 전체 일정` }

{selectedCategories.length > 0 && (
{showCategoryTooltip && ( {selectedCategories.map(id => { const cat = categories.find(c => c.id === id); if (!cat) return null; return (
{cat.name}
); })}
)}
)}
)}
{!isSearchMode && ( {filteredSchedules.length}개 일정 )}
{loading ? (
로딩 중...
) : filteredSchedules.length > 0 ? ( isSearchMode && searchTerm ? ( /* 검색 모드: 가상 스크롤 */ <>
{virtualizer.getVirtualItems().map((virtualItem) => { const schedule = filteredSchedules[virtualItem.index]; if (!schedule) return null; const formatted = formatDate(schedule.date); const categoryColor = getCategoryColor(schedule.category_id, schedule); const categoryName = getCategoryName(schedule.category_id, schedule); return (
{schedule.is_birthday ? ( handleScheduleClick(schedule)} /> ) : (
handleScheduleClick(schedule)} className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer min-h-[100px]" > {/* 날짜 영역 */}
{new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1} {formatted.day} {formatted.weekday}
{/* 스케줄 내용 */}

{decodeHtmlEntities(schedule.title)}

{schedule.time && ( {schedule.time.slice(0, 5)} )} {categoryName} {schedule.source?.name && ( {schedule.source?.name} )}
{(() => { const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || ''; const memberList = memberNames.split(',').filter(name => name.trim()); if (memberList.length === 0) return null; if (memberList.length === 5) { return (
프로미스나인
); } return (
{memberList.map((name, i) => ( {name} ))}
); })()}
)}
); })}
{/* 무한 스크롤 트리거 & 로딩 인디케이터 */}
{isFetchingNextPage && (
)} {!hasNextPage && filteredSchedules.length > 0 && (
{filteredSchedules.length}개 표시 (모두 로드됨)
)}
) : ( /* 일반 모드: 기존 렌더링 */ filteredSchedules.map((schedule, index) => { const formatted = formatDate(schedule.date); const categoryColor = getCategoryColor(schedule.category_id, schedule); const categoryName = getCategoryName(schedule.category_id, schedule); return ( {schedule.is_birthday ? ( handleScheduleClick(schedule)} /> ) : (
handleScheduleClick(schedule)} className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer" >
{formatted.day} {formatted.weekday}

{decodeHtmlEntities(schedule.title)}

{schedule.time && ( {schedule.time.slice(0, 5)} )} {categoryName} {schedule.source?.name && ( {schedule.source?.name} )}
{(() => { const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || ''; const memberList = memberNames.split(',').filter(name => name.trim()); if (memberList.length === 0) return null; if (memberList.length === 5) { return (
프로미스나인
); } return (
{memberList.map((name, i) => ( {name} ))}
); })()}
)}
); }) ) ) : ( !isSearchMode && (

{selectedDate ? '일정이 없습니다' : '예정된 일정이 없습니다'}

{selectedDate ? '다른 날짜를 선택해 보세요' : '다른 달을 확인해 보세요'}

) )}
); } export default Schedule;