From 27c41b0af09e7a9fa325961e0386da6cf36e6b2a Mon Sep 17 00:00:00 2001 From: caadiq Date: Wed, 21 Jan 2026 17:22:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Phase=205=20-=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=ED=9B=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useMediaQuery, useIsMobile, useIsDesktop: 반응형 레이아웃 - useScheduleData, useCategories: 스케줄/카테고리 데이터 조회 - useScheduleSearch: 무한 스크롤 검색 - useScheduleFiltering, useCategoryCounts: 필터링 및 정렬 - useCalendar: 캘린더 로직 (월 이동, 날짜 선택) - useAdminAuth: 토큰 검증 및 리다이렉트 - utils/schedule.js: 스케줄 유틸리티 함수 추가 - constants: SEARCH_LIMIT, MIN_YEAR, MONTH_NAMES 추가 Co-Authored-By: Claude Opus 4.5 --- frontend-temp/src/App.jsx | 150 +++++++++--------- frontend-temp/src/constants/index.js | 12 ++ frontend-temp/src/hooks/.gitkeep | 0 frontend-temp/src/hooks/index.js | 26 +++ frontend-temp/src/hooks/useAdminAuth.js | 72 +++++++++ frontend-temp/src/hooks/useCalendar.js | 104 ++++++++++++ frontend-temp/src/hooks/useMediaQuery.js | 41 +++++ frontend-temp/src/hooks/useScheduleData.js | 49 ++++++ .../src/hooks/useScheduleFiltering.js | 71 +++++++++ frontend-temp/src/hooks/useScheduleSearch.js | 75 +++++++++ frontend-temp/src/utils/index.js | 12 ++ frontend-temp/src/utils/schedule.js | 122 ++++++++++++++ 12 files changed, 659 insertions(+), 75 deletions(-) delete mode 100644 frontend-temp/src/hooks/.gitkeep create mode 100644 frontend-temp/src/hooks/index.js create mode 100644 frontend-temp/src/hooks/useAdminAuth.js create mode 100644 frontend-temp/src/hooks/useCalendar.js create mode 100644 frontend-temp/src/hooks/useMediaQuery.js create mode 100644 frontend-temp/src/hooks/useScheduleData.js create mode 100644 frontend-temp/src/hooks/useScheduleFiltering.js create mode 100644 frontend-temp/src/hooks/useScheduleSearch.js create mode 100644 frontend-temp/src/utils/schedule.js diff --git a/frontend-temp/src/App.jsx b/frontend-temp/src/App.jsx index e7e70d3..767b4a6 100644 --- a/frontend-temp/src/App.jsx +++ b/frontend-temp/src/App.jsx @@ -1,50 +1,37 @@ -import { useState } from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom"; -import { isMobile } from "react-device-detect"; -import { useQuery } from "@tanstack/react-query"; import { cn, getTodayKST, formatFullDate } from "@/utils"; import { useAuthStore, useUIStore } from "@/stores"; -import { memberApi, scheduleApi } from "@/api"; +import { useIsMobile, useCategories, useCalendar } from "@/hooks"; +import { memberApi } from "@/api"; +import { useQuery } from "@tanstack/react-query"; /** * 프로미스나인 팬사이트 메인 앱 * - * Phase 4: API 계층 완료 - * - api/client.js: 기본 fetch 클라이언트 (공개/인증) - * - api/schedules.js: 스케줄 API - * - api/albums.js: 앨범 API - * - api/members.js: 멤버 API - * - api/auth.js: 인증 API + * Phase 5: 커스텀 훅 완료 + * - useMediaQuery, useIsMobile, useIsDesktop + * - useScheduleData, useScheduleDetail, useCategories + * - useScheduleSearch (무한 스크롤) + * - useScheduleFiltering, useCategoryCounts + * - useCalendar + * - useAdminAuth */ function App() { const today = getTodayKST(); + const isMobile = useIsMobile(); const { isAuthenticated } = useAuthStore(); - const { showSuccess, showError, toasts } = useUIStore(); - const [apiTest, setApiTest] = useState(null); + const { showSuccess, toasts } = useUIStore(); - // React Query로 멤버 데이터 조회 + // 커스텀 훅 사용 + const { data: categories, isLoading: categoriesLoading } = useCategories(); + const calendar = useCalendar(); + + // 멤버 데이터 (기존 방식 유지) const { data: members, isLoading: membersLoading } = useQuery({ queryKey: ["members"], queryFn: memberApi.getMembers, }); - // React Query로 카테고리 데이터 조회 - const { data: categories, isLoading: categoriesLoading } = useQuery({ - queryKey: ["categories"], - queryFn: scheduleApi.getCategories, - }); - - const handleTestApi = async () => { - try { - const data = await memberApi.getMembers(); - setApiTest(`멤버 ${data.length}명 조회 성공!`); - showSuccess("API 호출 성공!"); - } catch (error) { - setApiTest(`에러: ${error.message}`); - showError("API 호출 실패"); - } - }; - return ( @@ -56,9 +43,9 @@ function App() {

fromis_9 Frontend Refactoring

-

Phase 4 완료 - API 계층

+

Phase 5 완료 - 커스텀 훅

- 디바이스: {isMobile ? "모바일" : "PC"} + 디바이스: {isMobile ? "모바일" : "PC"} (useIsMobile 훅)

@@ -66,54 +53,67 @@ function App() {

인증: {isAuthenticated ? "✅" : "❌"}

-

API 테스트 (React Query)

+

useCategories 훅

+

+ {categoriesLoading ? "로딩 중..." : `${categories?.length || 0}개 카테고리`} +

+ {categories && ( +
+ {categories.map((c) => ( + + {c.name} + + ))} +
+ )} +
-
-

- 멤버:{" "} - {membersLoading ? "로딩 중..." : `${members?.length || 0}명`} -

- {members && ( -
- {members.map((m) => ( - - {m.name} - - ))} -
- )} -
- -
-

- 카테고리:{" "} - {categoriesLoading ? "로딩 중..." : `${categories?.length || 0}개`} -

- {categories && ( -
- {categories.map((c) => ( - - {c.name} - - ))} -
- )} +
+

useCalendar 훅

+

현재: {calendar.year}년 {calendar.monthName}

+

선택: {calendar.selectedDate}

+
+ + +
-

직접 API 호출 테스트

- - {apiTest &&

{apiTest}

} +

멤버 데이터

+

{membersLoading ? "로딩 중..." : `${members?.length || 0}명`}

+ {members && ( +
+ {members.map((m) => ( + + {m.name} + + ))} +
+ )}
diff --git a/frontend-temp/src/constants/index.js b/frontend-temp/src/constants/index.js index e1a043a..7b52724 100644 --- a/frontend-temp/src/constants/index.js +++ b/frontend-temp/src/constants/index.js @@ -43,5 +43,17 @@ export const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']; /** 기본 페이지 크기 */ export const DEFAULT_PAGE_SIZE = 20; +/** 검색 결과 페이지 크기 */ +export const SEARCH_LIMIT = 20; + /** API 기본 URL */ export const API_BASE_URL = '/api'; + +/** 캘린더 최소 년도 */ +export const MIN_YEAR = 2017; + +/** 월 이름 */ +export const MONTH_NAMES = [ + '1월', '2월', '3월', '4월', '5월', '6월', + '7월', '8월', '9월', '10월', '11월', '12월', +]; diff --git a/frontend-temp/src/hooks/.gitkeep b/frontend-temp/src/hooks/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend-temp/src/hooks/index.js b/frontend-temp/src/hooks/index.js new file mode 100644 index 0000000..c0447fe --- /dev/null +++ b/frontend-temp/src/hooks/index.js @@ -0,0 +1,26 @@ +/** + * 훅 통합 export + */ + +// 미디어 쿼리 +export { useMediaQuery, useIsMobile, useIsDesktop, useIsTablet } from './useMediaQuery'; + +// 스케줄 데이터 +export { + useScheduleData, + useScheduleDetail, + useUpcomingSchedules, + useCategories, +} from './useScheduleData'; + +// 스케줄 검색 +export { useScheduleSearch } from './useScheduleSearch'; + +// 스케줄 필터링 +export { useScheduleFiltering, useCategoryCounts } from './useScheduleFiltering'; + +// 캘린더 +export { useCalendar } from './useCalendar'; + +// 인증 +export { useAdminAuth, useRedirectIfAuthenticated } from './useAdminAuth'; diff --git a/frontend-temp/src/hooks/useAdminAuth.js b/frontend-temp/src/hooks/useAdminAuth.js new file mode 100644 index 0000000..54f5236 --- /dev/null +++ b/frontend-temp/src/hooks/useAdminAuth.js @@ -0,0 +1,72 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { useAuthStore } from '@/stores'; +import { authApi } from '@/api'; + +/** + * 어드민 인증 훅 + * 토큰 유효성 검증 및 미인증 시 리다이렉트 + * @param {object} options - 옵션 + * @param {string} options.redirectTo - 미인증 시 리다이렉트 경로 (기본: /admin) + * @param {boolean} options.required - 인증 필수 여부 (기본: true) + */ +export function useAdminAuth(options = {}) { + const { redirectTo = '/admin', required = true } = options; + const navigate = useNavigate(); + const { token, user, logout, isAuthenticated } = useAuthStore(); + + // 토큰 검증 쿼리 + const { data, isLoading, isError } = useQuery({ + queryKey: ['admin', 'auth'], + queryFn: authApi.verifyToken, + enabled: !!token, + retry: false, + staleTime: 1000 * 60 * 5, // 5분 캐시 + }); + + // 토큰 없거나 검증 실패 시 처리 + // 리다이렉트는 DOM 조작이므로 useEffect 사용 허용 + useEffect(() => { + if (required && (!token || isError)) { + logout(); + navigate(redirectTo); + } + }, [token, isError, required, logout, navigate, redirectTo]); + + return { + user: data?.user || user, + isLoading: !token ? false : isLoading, + isAuthenticated: !!data?.valid || isAuthenticated, + isError, + }; +} + +/** + * 로그인 페이지에서 사용하는 훅 + * 이미 인증된 경우 리다이렉트 + * @param {string} redirectTo - 인증된 경우 리다이렉트 경로 + */ +export function useRedirectIfAuthenticated(redirectTo = '/admin/dashboard') { + const navigate = useNavigate(); + const { isAuthenticated, token } = useAuthStore(); + + // 토큰 검증 + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'auth'], + queryFn: authApi.verifyToken, + enabled: !!token, + retry: false, + }); + + useEffect(() => { + if (data?.valid) { + navigate(redirectTo); + } + }, [data, navigate, redirectTo]); + + return { + isLoading: !!token && isLoading, + isAuthenticated: !!data?.valid, + }; +} diff --git a/frontend-temp/src/hooks/useCalendar.js b/frontend-temp/src/hooks/useCalendar.js new file mode 100644 index 0000000..8a2e190 --- /dev/null +++ b/frontend-temp/src/hooks/useCalendar.js @@ -0,0 +1,104 @@ +import { useState, useMemo, useCallback } from 'react'; +import { MIN_YEAR, WEEKDAYS, MONTH_NAMES } from '@/constants'; +import { getTodayKST } from '@/utils'; + +/** + * 캘린더 훅 + * 날짜 선택, 월 이동 등 캘린더 로직 제공 + * @param {Date} initialDate - 초기 날짜 + */ +export function useCalendar(initialDate = new Date()) { + const [currentDate, setCurrentDate] = useState(initialDate); + const [selectedDate, setSelectedDate] = useState(getTodayKST()); + + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + + // 캘린더 데이터 계산 + const calendarData = useMemo(() => { + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const prevMonthDays = new Date(year, month, 0).getDate(); + + return { + year, + month, + monthName: MONTH_NAMES[month], + firstDay, + daysInMonth, + prevMonthDays, + weekdays: WEEKDAYS, + }; + }, [year, month]); + + // 이전 월로 이동 가능 여부 + const canGoPrevMonth = !(year === MIN_YEAR && month === 0); + + // 선택 날짜 업데이트 헬퍼 + const updateSelectedDate = useCallback((newDate) => { + 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 goToPrevMonth = useCallback(() => { + if (!canGoPrevMonth) return; + const newDate = new Date(year, month - 1, 1); + setCurrentDate(newDate); + updateSelectedDate(newDate); + }, [year, month, canGoPrevMonth, updateSelectedDate]); + + // 다음 월로 이동 + const goToNextMonth = useCallback(() => { + const newDate = new Date(year, month + 1, 1); + setCurrentDate(newDate); + updateSelectedDate(newDate); + }, [year, month, updateSelectedDate]); + + // 특정 월로 이동 + const goToMonth = useCallback( + (newYear, newMonth) => { + const newDate = new Date(newYear, newMonth, 1); + setCurrentDate(newDate); + updateSelectedDate(newDate); + }, + [updateSelectedDate] + ); + + // 오늘로 이동 + const goToToday = useCallback(() => { + const today = new Date(); + setCurrentDate(today); + setSelectedDate(getTodayKST()); + }, []); + + // 날짜 선택 + const selectDate = useCallback( + (day) => { + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + setSelectedDate(dateStr); + }, + [year, month] + ); + + return { + ...calendarData, + currentDate, + selectedDate, + canGoPrevMonth, + goToPrevMonth, + goToNextMonth, + goToMonth, + goToToday, + selectDate, + setSelectedDate, + }; +} diff --git a/frontend-temp/src/hooks/useMediaQuery.js b/frontend-temp/src/hooks/useMediaQuery.js new file mode 100644 index 0000000..09a4986 --- /dev/null +++ b/frontend-temp/src/hooks/useMediaQuery.js @@ -0,0 +1,41 @@ +import { useState, useEffect } from 'react'; + +/** + * 미디어 쿼리 훅 + * @param {string} query - CSS 미디어 쿼리 문자열 + * @returns {boolean} 쿼리 매칭 여부 + */ +export function useMediaQuery(query) { + const [matches, setMatches] = useState(() => { + if (typeof window !== 'undefined') { + return window.matchMedia(query).matches; + } + return false; + }); + + // DOM 이벤트 리스너이므로 useEffect 사용 허용 + useEffect(() => { + const mediaQuery = window.matchMedia(query); + const handler = (e) => setMatches(e.matches); + + mediaQuery.addEventListener('change', handler); + return () => mediaQuery.removeEventListener('change', handler); + }, [query]); + + return matches; +} + +/** + * 모바일 여부 확인 훅 + */ +export const useIsMobile = () => useMediaQuery('(max-width: 768px)'); + +/** + * 데스크탑 여부 확인 훅 + */ +export const useIsDesktop = () => useMediaQuery('(min-width: 769px)'); + +/** + * 태블릿 여부 확인 훅 + */ +export const useIsTablet = () => useMediaQuery('(min-width: 769px) and (max-width: 1024px)'); diff --git a/frontend-temp/src/hooks/useScheduleData.js b/frontend-temp/src/hooks/useScheduleData.js new file mode 100644 index 0000000..56bc9e6 --- /dev/null +++ b/frontend-temp/src/hooks/useScheduleData.js @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query'; +import { scheduleApi } from '@/api'; + +/** + * 스케줄 목록 조회 훅 (월별) + * @param {number} year - 년도 + * @param {number} month - 월 (1-12) + */ +export function useScheduleData(year, month) { + return useQuery({ + queryKey: ['schedules', year, month], + queryFn: () => scheduleApi.getSchedules(year, month), + enabled: !!year && !!month, + }); +} + +/** + * 스케줄 상세 조회 훅 + * @param {number} id - 스케줄 ID + */ +export function useScheduleDetail(id) { + return useQuery({ + queryKey: ['schedule', id], + queryFn: () => scheduleApi.getSchedule(id), + enabled: !!id, + }); +} + +/** + * 다가오는 스케줄 조회 훅 + * @param {number} limit - 조회 개수 + */ +export function useUpcomingSchedules(limit = 3) { + return useQuery({ + queryKey: ['schedules', 'upcoming', limit], + queryFn: () => scheduleApi.getUpcomingSchedules(limit), + }); +} + +/** + * 카테고리 목록 조회 훅 + */ +export function useCategories() { + return useQuery({ + queryKey: ['categories'], + queryFn: scheduleApi.getCategories, + staleTime: 1000 * 60 * 10, // 10분 캐시 + }); +} diff --git a/frontend-temp/src/hooks/useScheduleFiltering.js b/frontend-temp/src/hooks/useScheduleFiltering.js new file mode 100644 index 0000000..f12ddc4 --- /dev/null +++ b/frontend-temp/src/hooks/useScheduleFiltering.js @@ -0,0 +1,71 @@ +import { useMemo } from 'react'; +import { formatDate } from '@/utils'; +import { getCategoryId, getScheduleDate, isBirthdaySchedule } from '@/utils/schedule'; + +/** + * 스케줄 필터링 훅 + * @param {Array} schedules - 스케줄 배열 + * @param {object} options - 필터 옵션 + * @param {number[]} options.categoryIds - 선택된 카테고리 ID 배열 + * @param {string} options.selectedDate - 선택된 날짜 (YYYY-MM-DD) + * @param {boolean} options.birthdayFirst - 생일 우선 정렬 여부 + * @returns {Array} 필터링된 스케줄 배열 + */ +export function useScheduleFiltering(schedules, options = {}) { + const { + categoryIds = [], + selectedDate = null, + birthdayFirst = true, + } = options; + + return useMemo(() => { + if (!schedules || schedules.length === 0) return []; + + let result = schedules.filter((schedule) => { + // 카테고리 필터 + if (categoryIds.length > 0) { + const catId = getCategoryId(schedule); + if (!categoryIds.includes(catId)) return false; + } + + // 날짜 필터 + if (selectedDate) { + const scheduleDate = getScheduleDate(schedule); + if (scheduleDate !== selectedDate) return false; + } + + return true; + }); + + // 생일 우선 정렬 + if (birthdayFirst) { + result = [...result].sort((a, b) => { + const aIsBirthday = isBirthdaySchedule(a); + const bIsBirthday = isBirthdaySchedule(b); + if (aIsBirthday && !bIsBirthday) return -1; + if (!aIsBirthday && bIsBirthday) return 1; + return 0; + }); + } + + return result; + }, [schedules, categoryIds, selectedDate, birthdayFirst]); +} + +/** + * 카테고리별 개수 계산 훅 + * @param {Array} schedules - 스케줄 배열 + * @returns {object} 카테고리 ID별 개수 객체 + */ +export function useCategoryCounts(schedules) { + return useMemo(() => { + if (!schedules || schedules.length === 0) return {}; + + const counts = {}; + for (const schedule of schedules) { + const categoryId = getCategoryId(schedule); + counts[categoryId] = (counts[categoryId] || 0) + 1; + } + return counts; + }, [schedules]); +} diff --git a/frontend-temp/src/hooks/useScheduleSearch.js b/frontend-temp/src/hooks/useScheduleSearch.js new file mode 100644 index 0000000..88c563e --- /dev/null +++ b/frontend-temp/src/hooks/useScheduleSearch.js @@ -0,0 +1,75 @@ +import { useState, useMemo } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { fetchApi } from '@/api'; +import { SEARCH_LIMIT } from '@/constants'; + +/** + * 스케줄 검색 훅 + * 무한 스크롤, 검색어 추천 지원 + */ +export function useScheduleSearch() { + const [searchInput, setSearchInput] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [isSearchMode, setIsSearchMode] = useState(false); + + // 검색 결과 (무한 스크롤) + const searchQuery = useInfiniteQuery({ + queryKey: ['scheduleSearch', searchTerm], + queryFn: async ({ pageParam = 0, signal }) => { + const response = await fetch( + `/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}`, + { signal } + ); + if (!response.ok) throw new Error('Search failed'); + return response.json(); + }, + getNextPageParam: (lastPage) => { + return lastPage.hasMore + ? lastPage.offset + lastPage.schedules.length + : undefined; + }, + enabled: !!searchTerm && isSearchMode, + }); + + // 검색 결과 평탄화 + const searchResults = useMemo(() => { + return searchQuery.data?.pages?.flatMap((page) => page.schedules) || []; + }, [searchQuery.data]); + + const searchTotal = searchQuery.data?.pages?.[0]?.total || 0; + + // 검색 실행 + const executeSearch = () => { + if (searchInput.trim()) { + setSearchTerm(searchInput.trim()); + setIsSearchMode(true); + } + }; + + // 검색 초기화 + const clearSearch = () => { + setSearchInput(''); + setSearchTerm(''); + setIsSearchMode(false); + }; + + return { + // 상태 + searchInput, + searchTerm, + isSearchMode, + searchResults, + searchTotal, + isLoading: searchQuery.isLoading, + isFetching: searchQuery.isFetching, + hasNextPage: searchQuery.hasNextPage, + isFetchingNextPage: searchQuery.isFetchingNextPage, + + // 액션 + setSearchInput, + setIsSearchMode, + executeSearch, + clearSearch, + fetchNextPage: searchQuery.fetchNextPage, + }; +} diff --git a/frontend-temp/src/utils/index.js b/frontend-temp/src/utils/index.js index 5fedaa1..38957df 100644 --- a/frontend-temp/src/utils/index.js +++ b/frontend-temp/src/utils/index.js @@ -32,3 +32,15 @@ export { formatDuration, truncateText, } from './format'; + +// 스케줄 관련 +export { + getCategoryId, + getCategoryInfo, + getScheduleDate, + getScheduleTime, + getMemberList, + isBirthdaySchedule, + groupSchedulesByDate, + countByCategory, +} from './schedule'; diff --git a/frontend-temp/src/utils/schedule.js b/frontend-temp/src/utils/schedule.js new file mode 100644 index 0000000..b84947f --- /dev/null +++ b/frontend-temp/src/utils/schedule.js @@ -0,0 +1,122 @@ +/** + * 스케줄 관련 유틸리티 함수 + */ + +/** + * 스케줄에서 카테고리 ID 추출 + * 검색 결과와 일반 데이터의 형식 차이를 처리 + * @param {object} schedule - 스케줄 객체 + * @returns {number|null} 카테고리 ID + */ +export function getCategoryId(schedule) { + return schedule.category?.id ?? schedule.category_id ?? schedule.categoryId ?? null; +} + +/** + * 스케줄에서 카테고리 정보 추출 + * @param {object} schedule - 스케줄 객체 + * @returns {{ id: number, name: string, color: string }} + */ +export function getCategoryInfo(schedule) { + return { + id: getCategoryId(schedule), + name: schedule.category?.name ?? schedule.category_name ?? schedule.categoryName ?? '미분류', + color: schedule.category?.color ?? schedule.category_color ?? schedule.categoryColor ?? '#9CA3AF', + }; +} + +/** + * 스케줄에서 날짜 추출 + * datetime 형식과 date 형식 모두 처리 + * @param {object} schedule - 스케줄 객체 + * @returns {string} YYYY-MM-DD 형식 날짜 + */ +export function getScheduleDate(schedule) { + const datetime = schedule.datetime || schedule.date; + if (!datetime) return ''; + return datetime.split(' ')[0].split('T')[0]; +} + +/** + * 스케줄에서 시간 추출 + * @param {object} schedule - 스케줄 객체 + * @returns {string|null} HH:mm 형식 시간 또는 null + */ +export function getScheduleTime(schedule) { + if (schedule.time) { + return schedule.time.slice(0, 5); + } + const datetime = schedule.datetime; + if (datetime && (datetime.includes(' ') || datetime.includes('T'))) { + const timePart = datetime.includes(' ') + ? datetime.split(' ')[1] + : datetime.split('T')[1]; + return timePart?.slice(0, 5) || null; + } + return null; +} + +/** + * 스케줄에서 멤버 목록 추출 + * 다양한 형식 처리 (문자열 배열, 객체 배열) + * @param {object} schedule - 스케줄 객체 + * @returns {Array<{ id: number, name: string }>} + */ +export function getMemberList(schedule) { + const members = schedule.members || []; + + if (members.length === 0) return []; + + // 문자열 배열인 경우 (검색 결과) + if (typeof members[0] === 'string') { + return members.map((name, idx) => ({ id: idx, name })); + } + + // 객체 배열인 경우 + return members; +} + +/** + * 생일 스케줄인지 확인 + * @param {object} schedule - 스케줄 객체 + * @returns {boolean} + */ +export function isBirthdaySchedule(schedule) { + const title = schedule.title || ''; + return title.includes('생일') || title.includes('birthday'); +} + +/** + * 날짜별로 스케줄 그룹화 + * @param {Array} schedules - 스케줄 배열 + * @returns {Map} 날짜별 그룹화된 맵 + */ +export function groupSchedulesByDate(schedules) { + const groups = new Map(); + + for (const schedule of schedules) { + const date = getScheduleDate(schedule); + if (!groups.has(date)) { + groups.set(date, []); + } + groups.get(date).push(schedule); + } + + return groups; +} + +/** + * 카테고리별 스케줄 수 계산 + * @param {Array} schedules - 스케줄 배열 + * @returns {Map} 카테고리 ID별 개수 + */ +export function countByCategory(schedules) { + const counts = new Map(); + + for (const schedule of schedules) { + const categoryId = getCategoryId(schedule); + counts.set(categoryId, (counts.get(categoryId) || 0) + 1); + } + + return counts; +}