diff --git a/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx b/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx index b9bcf4a..a24da37 100644 --- a/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx +++ b/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx @@ -3,7 +3,15 @@ import 'moment/locale/ko' import 'react-big-calendar/lib/css/react-big-calendar.css' import moment from 'moment' -import { cloneElement, type MouseEvent, useCallback, useEffect, useMemo, useState } from 'react' +import { + cloneElement, + type MouseEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { Calendar, type DateCellWrapperProps, momentLocalizer } from 'react-big-calendar' import type { EventInteractionArgs } from 'react-big-calendar/lib/addons/dragAndDrop' import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop' @@ -23,11 +31,13 @@ import { useDayViewHandlers, useStoredCalendarView, } from '@/features/Calendar/hooks' +import { buildRecurringGroupForFutureDrop } from '@/features/Calendar/hooks/useCalendarDragDrop' import { useCalendarEvents } from '@/features/Calendar/hooks/useCalendarEvents' import { getEventOccurrenceKey, resolveOccurrenceDateTime, } from '@/features/Calendar/utils/helpers/dayViewHelpers' +import { getDetailTodo } from '@/shared/api/todo/api' import Plus from '@/shared/assets/icons/plus.svg?react' import { useCalendarMutation } from '@/shared/hooks/query/useCalendarMutation' import { useTodoMutations } from '@/shared/hooks/query/useTodoMutations' @@ -69,6 +79,7 @@ const CustomCalendar = ({ onSelectedDateChange }: CustomCalendarProps) => { const { mutate: patchCompleteTodoMutate } = usePatchCompleteTodo() const { mutate: patchTodoMutate } = usePatchTodo() const { mutate: deleteTodoMutate } = useDeleteTodo() + const recurringTodoPatchSeqRef = useRef>(new Map()) const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean eventId: CalendarEvent['id'] | null @@ -116,6 +127,21 @@ const CustomCalendar = ({ onSelectedDateChange }: CustomCalendarProps) => { [events, patchCompleteTodoMutate, toggleEventDone], ) + const resolveFutureTodoRecurrenceGroup = useCallback( + async (todoId: number, occurrenceDate: string, nextStart: Date) => { + try { + // "이후 일정만 변경"일 때는 현재 occurrence 기준의 상세 recurrence를 받아 + // 드롭한 날짜(nextStart)에 맞는 recurrenceGroup으로 재계산해 patch에 포함합니다. + const { result } = await getDetailTodo(todoId, occurrenceDate) + return buildRecurringGroupForFutureDrop(result?.recurrenceGroup ?? null, nextStart) + } catch (error) { + console.error('[CustomCalendar] failed to resolve todo recurrenceGroup', error) + return undefined + } + }, + [], + ) + // Todo 일정 이동 시 시간 패치 const patchTodoTiming = useCallback( ( @@ -127,19 +153,40 @@ const CustomCalendar = ({ onSelectedDateChange }: CustomCalendarProps) => { const occurrenceDate = options?.occurrenceDate ?? moment(todoEvent.occurrenceDate ?? todoEvent.start).format('YYYY-MM-DD') + const patchScope = options?.scope ?? (todoEvent.isRecurring ? 'THIS_TODO' : undefined) const dueTime = todoEvent.isAllDay ? undefined : moment(start).format('HH:mm') - patchTodoMutate({ - todoId: todoEvent.id, - occurrenceDate, - ...(options?.scope ? { scope: options.scope } : {}), - requestBody: { - startDate, - dueTime, - isAllDay: todoEvent.isAllDay, - }, - }) + const submitPatch = (recurrenceGroup?: CalendarEvent['recurrenceGroup']) => { + patchTodoMutate({ + todoId: todoEvent.id, + occurrenceDate, + ...(patchScope ? { scope: patchScope } : {}), + requestBody: { + startDate, + dueTime, + isAllDay: todoEvent.isAllDay, + ...(recurrenceGroup ? { recurrenceGroup } : {}), + }, + }) + } + + if (patchScope === 'THIS_AND_FOLLOWING') { + const requestKey = `${todoEvent.id}-${occurrenceDate}` + const nextSequence = (recurringTodoPatchSeqRef.current.get(requestKey) ?? 0) + 1 + recurringTodoPatchSeqRef.current.set(requestKey, nextSequence) + // 반복 할 일을 "이후 항목" 범위로 이동한 경우 recurrenceGroup 보정이 필요합니다. + void resolveFutureTodoRecurrenceGroup(todoEvent.id, occurrenceDate, start).then( + (recurrenceGroup) => { + // 가장 마지막 드롭 요청만 반영해, 비동기 응답 역전으로 인한 역패치를 방지합니다. + const latestSequence = recurringTodoPatchSeqRef.current.get(requestKey) + if (latestSequence !== nextSequence) return + submitPatch(recurrenceGroup) + }, + ) + return + } + submitPatch() }, - [patchTodoMutate], + [patchTodoMutate, resolveFutureTodoRecurrenceGroup], ) // 반응형 레이아웃 판단 @@ -252,9 +299,13 @@ const CustomCalendar = ({ onSelectedDateChange }: CustomCalendarProps) => { targetEvent?.occurrenceDate, targetEvent?.start ?? start, ) + const patchScope = targetEvent?.recurrenceGroup != null ? ('THIS_EVENT' as const) : undefined patchEventMutate({ eventId, - params: { occurrenceDate }, + params: { + occurrenceDate, + ...(patchScope ? { scope: patchScope } : {}), + }, eventData: { startTime: moment(start).format('YYYY-MM-DDTHH:mm:ss'), endTime: nextEnd, @@ -434,7 +485,9 @@ const CustomCalendar = ({ onSelectedDateChange }: CustomCalendarProps) => { // 모달에 넘길 이벤트 조회 const modalEvent = useMemo(() => { if (selectedEventKey) { - return events.find((item) => getEventOccurrenceKey(item) === selectedEventKey) ?? null + const selectedOccurrenceEvent = + events.find((item) => getEventOccurrenceKey(item) === selectedEventKey) ?? null + if (selectedOccurrenceEvent) return selectedOccurrenceEvent } return modal.eventId == null ? null : (events.find((item) => item.id === modal.eventId) ?? null) }, [events, modal.eventId, selectedEventKey]) diff --git a/src/features/Calendar/components/CustomEvent/CustomEvent.style.ts b/src/features/Calendar/components/CustomEvent/CustomEvent.style.ts index 06101a2..98ade74 100644 --- a/src/features/Calendar/components/CustomEvent/CustomEvent.style.ts +++ b/src/features/Calendar/components/CustomEvent/CustomEvent.style.ts @@ -98,7 +98,7 @@ export const WeekEventContainer = styled.div<{ export const EventTitle = styled.div` font-weight: 400; font-size: 12px; - width: 100%; + width: 45px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -108,7 +108,7 @@ export const EventMeta = styled.div` font-size: 8px; color: ${({ theme }) => theme.colors.black}; white-space: nowrap; - width: fit-content; + min-width: fit-content; ` export const EventWeekMeta = styled.div` font-size: 10px; @@ -146,3 +146,19 @@ export const MonthShowMore = styled.div` color: ${theme.colors.textColor2}; background-color: ${theme.colors.lightGray}; ` + +export const MonthEventTooltip = styled.div` + position: fixed; + z-index: 10010; + pointer-events: none; + padding: 6px 8px; + border-radius: 6px; + max-width: 240px; + font-size: 11px; + line-height: 1.35; + color: ${theme.colors.white}; + background-color: rgba(25, 25, 25, 0.92); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); + white-space: normal; + word-break: break-word; +` diff --git a/src/features/Calendar/components/CustomEvent/CustomMonthEvent.tsx b/src/features/Calendar/components/CustomEvent/CustomMonthEvent.tsx index b9d2ba2..960d96c 100644 --- a/src/features/Calendar/components/CustomEvent/CustomMonthEvent.tsx +++ b/src/features/Calendar/components/CustomEvent/CustomMonthEvent.tsx @@ -1,5 +1,7 @@ import moment from 'moment' +import { type MouseEvent, useRef, useState } from 'react' import type { EventProps } from 'react-big-calendar' +import { createPortal } from 'react-dom' import { getColorPalette } from '../../utils/colorPalette' import type { CalendarEvent } from '../CustomView/CustomDayView' @@ -33,6 +35,58 @@ const CustomMonthEvent = ({ const pointColor = palette?.point const isTodo = 'type' in event && (event as { type?: string }).type === 'todo' const isDone = 'isDone' in event && (event as { isDone?: boolean }).isDone + const titleRef = useRef(null) + const [tooltip, setTooltip] = useState({ + visible: false, + x: 0, + y: 0, + }) + + // 제목이 말줄임으로 잘린 경우에만 툴팁을 보여주기 위한 검사다. + const isTitleOverflowed = () => { + const titleElement = titleRef.current + if (!titleElement) { + return false + } + return titleElement.scrollWidth > titleElement.clientWidth + } + + // 마우스 위치 기준으로 툴팁 좌표를 계산해 셀 경계 밖에서도 읽을 수 있게 한다. + const updateTooltipPosition = (eventMouse: MouseEvent) => { + if (!tooltip.visible) { + return + } + + const horizontalOffset = 12 + const verticalOffset = 14 + + setTooltip((previous) => ({ + ...previous, + x: eventMouse.clientX + horizontalOffset, + y: eventMouse.clientY + verticalOffset, + })) + } + + const showTooltip = (eventMouse: MouseEvent) => { + if (!isTitleOverflowed()) { + return + } + + setTooltip({ + visible: true, + x: eventMouse.clientX + 12, + y: eventMouse.clientY + 14, + }) + } + + const hideTooltip = () => { + setTooltip((previous) => ({ + ...previous, + visible: false, + })) + } + + const tooltipText = typeof event.title === 'string' ? event.title : '' return ( { eventMouse.stopPropagation() + hideTooltip() onEventClick(event) }} onDoubleClick={(eventMouse) => { eventMouse.stopPropagation() + hideTooltip() onEventDoubleClick(event) }} > @@ -63,9 +119,24 @@ const CustomMonthEvent = ({ ) : ( )} - {event.title} + + {event.title} + {formatTimeRange(event)} + {tooltip.visible && + typeof document !== 'undefined' && + createPortal( + + {tooltipText} + , + document.body, + )} ) } diff --git a/src/features/Calendar/components/CustomEvent/CustomWeekEvent.tsx b/src/features/Calendar/components/CustomEvent/CustomWeekEvent.tsx index 007c806..b39e45c 100644 --- a/src/features/Calendar/components/CustomEvent/CustomWeekEvent.tsx +++ b/src/features/Calendar/components/CustomEvent/CustomWeekEvent.tsx @@ -1,5 +1,6 @@ import moment from 'moment' -import React from 'react' +import React, { type MouseEvent, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { getColorPalette } from '../../utils/colorPalette' import type { CalendarEvent } from '../CustomView/CustomDayView' @@ -34,6 +35,58 @@ const CustomWeekEvent: React.FC = ({ const pointColor = palette?.point const isTodo = 'type' in event && (event as { type?: string }).type === 'todo' const isDone = 'isDone' in event && (event as { isDone?: boolean }).isDone + const titleRef = useRef(null) + const [tooltip, setTooltip] = useState({ + visible: false, + x: 0, + y: 0, + }) + + // 제목이 말줄임 처리된 경우에만 툴팁을 띄워 불필요한 노출을 막는다. + const isTitleOverflowed = () => { + const titleElement = titleRef.current + if (!titleElement) { + return false + } + return titleElement.scrollWidth > titleElement.clientWidth + } + + // 주간 셀 영역에 잘리지 않도록 마우스 기준 좌표로 포털 툴팁 위치를 갱신한다. + const updateTooltipPosition = (eventMouse: MouseEvent) => { + if (!tooltip.visible) { + return + } + + const horizontalOffset = 12 + const verticalOffset = 14 + + setTooltip((previous) => ({ + ...previous, + x: eventMouse.clientX + horizontalOffset, + y: eventMouse.clientY + verticalOffset, + })) + } + + const showTooltip = (eventMouse: MouseEvent) => { + if (!isTitleOverflowed()) { + return + } + + setTooltip({ + visible: true, + x: eventMouse.clientX + 12, + y: eventMouse.clientY + 14, + }) + } + + const hideTooltip = () => { + setTooltip((previous) => ({ + ...previous, + visible: false, + })) + } + + const tooltipText = typeof event.title === 'string' ? event.title : '' return ( = ({ isSelected={isSelected} onClick={(eventMouse) => { eventMouse.stopPropagation() + hideTooltip() onEventClick(event) }} onDoubleClick={(eventMouse) => { eventMouse.stopPropagation() + hideTooltip() onEventDoubleClick(event) }} > @@ -65,10 +120,25 @@ const CustomWeekEvent: React.FC = ({ ) : ( )} - {event.title} + + {event.title} + {formatTimeRange(event)} {event.location && {event.location}} + {tooltip.visible && + typeof document !== 'undefined' && + createPortal( + + {tooltipText} + , + document.body, + )} ) } diff --git a/src/features/Calendar/hooks/useCalendarDragDrop.ts b/src/features/Calendar/hooks/useCalendarDragDrop.ts index b8833bf..954957b 100644 --- a/src/features/Calendar/hooks/useCalendarDragDrop.ts +++ b/src/features/Calendar/hooks/useCalendarDragDrop.ts @@ -8,8 +8,61 @@ import { resolveOccurrenceDateTime } from '@/features/Calendar/utils/helpers/day import type { CalendarEvent } from '@/shared/types/calendar/types' import type { RecurrenceEventScope, + RecurrenceGroup, RecurrenceTodoScope, } from '@/shared/types/recurrence/recurrence' +import { + normalizeRecurrenceGroupPayload, + toWeekday, + toWeekOfMonth, +} from '@/shared/utils/recurrencePattern' + +export const buildRecurringGroupForFutureDrop = ( + recurrenceGroup: CalendarEvent['recurrenceGroup'], + nextStart: Date, +): RecurrenceGroup | undefined => { + const source = normalizeRecurrenceGroupPayload(recurrenceGroup) + if (!source) return undefined + const weekday = toWeekday(nextStart) + const nextWeekOfMonth = toWeekOfMonth(nextStart) + const nextDayOfMonth = nextStart.getDate() + + const nextGroup: RecurrenceGroup = { + ...source, + } + + if (source.frequency === 'WEEKLY') { + nextGroup.daysOfWeek = [weekday] + } + + if (source.frequency === 'MONTHLY') { + if (source.monthlyType === 'DAY_OF_WEEK') { + nextGroup.weekOfMonth = nextWeekOfMonth + nextGroup.weekdayRule = 'SINGLE' + nextGroup.dayOfWeekInMonth = weekday + } else { + nextGroup.daysOfMonth = [nextDayOfMonth] + nextGroup.weekOfMonth = undefined + nextGroup.weekdayRule = undefined + nextGroup.dayOfWeekInMonth = undefined + } + } + + if (source.frequency === 'YEARLY') { + nextGroup.monthOfYear = nextStart.getMonth() + 1 + if (source.weekOfMonth != null || source.weekdayRule != null) { + nextGroup.weekOfMonth = nextWeekOfMonth + if (source.weekdayRule && source.weekdayRule !== 'SINGLE') { + nextGroup.dayOfWeekInMonth = null + } else { + nextGroup.weekdayRule = 'SINGLE' + nextGroup.dayOfWeekInMonth = weekday + } + } + } + + return nextGroup +} type UseCalendarDragDropArgs = { view: View @@ -18,11 +71,13 @@ type UseCalendarDragDropArgs = { eventId: number params: { occurrenceDate: string + scope?: RecurrenceEventScope } eventData: { startTime: string endTime: string isAllDay: boolean + recurrenceGroup?: RecurrenceGroup } }) => void patchTodoTiming: ( @@ -77,6 +132,14 @@ export const useCalendarDragDrop = ({ startTime: nextStart, endTime: nextEnd, isAllDay: event.isAllDay ?? false, + ...(options?.eventScope === 'THIS_AND_FOLLOWING_EVENTS' + ? { + recurrenceGroup: buildRecurringGroupForFutureDrop( + event.recurrenceGroup, + start as Date, + ), + } + : {}), }, }) }, diff --git a/src/features/Calendar/utils/colorPalette.ts b/src/features/Calendar/utils/colorPalette.ts index 8c836fa..d38ef4e 100644 --- a/src/features/Calendar/utils/colorPalette.ts +++ b/src/features/Calendar/utils/colorPalette.ts @@ -1,6 +1,7 @@ import { theme } from '@/shared/styles/theme' import type { EventColorType } from '@/shared/types/event/event' +// 서버에서 내려오는 일정 색상 키를 실제 테마 팔레트 값으로 매핑한다. export const getColorPalette = (name: EventColorType) => { return theme.colors[name] } diff --git a/src/features/Calendar/utils/formatters.ts b/src/features/Calendar/utils/formatters.ts index 4f404fc..1da4027 100644 --- a/src/features/Calendar/utils/formatters.ts +++ b/src/features/Calendar/utils/formatters.ts @@ -2,11 +2,14 @@ import moment from 'moment' import { WEEK_DAYS } from '@/shared/constants/event' +// 주 단위 헤더에서 사용할 요일(일~토) 문자열을 반환한다. export const formatWeekday = (date: Date) => WEEK_DAYS[moment(date).day()] +// 월간/일간 헤더에서 날짜가 1일이면 "M/D", 아니면 "D" 형태로 표시한다. export const formatDayHeaderLabel = (date: Date) => { const dayMoment = moment(date) return dayMoment.date() === 1 ? dayMoment.format('M/D') : dayMoment.format('D') } +// 월간 셀의 숫자 라벨을 한 자리 또는 두 자리 날짜로 정규화한다. export const formatDayNumber = (date: Date) => moment(date).format('D') diff --git a/src/features/Calendar/utils/helpers/calendarPageHelpers.ts b/src/features/Calendar/utils/helpers/calendarPageHelpers.ts index 9837a94..df5be70 100644 --- a/src/features/Calendar/utils/helpers/calendarPageHelpers.ts +++ b/src/features/Calendar/utils/helpers/calendarPageHelpers.ts @@ -13,6 +13,7 @@ import { export const normalizeDate = (value: Date | string): Date => typeof value === 'string' ? new Date(value) : value +// 로컬에서 임시로 생성하는 이벤트 id 충돌을 줄이기 위해 timestamp + index를 사용한다. const buildEventId = (prevCount: number, date: Date) => date.valueOf() + prevCount /** 기본 제목/기간을 가지는 새 캘린더 이벤트를 생성합니다. */ diff --git a/src/features/Calendar/utils/helpers/dayViewHelpers.ts b/src/features/Calendar/utils/helpers/dayViewHelpers.ts index 7e2fd5c..1002e0d 100644 --- a/src/features/Calendar/utils/helpers/dayViewHelpers.ts +++ b/src/features/Calendar/utils/helpers/dayViewHelpers.ts @@ -19,26 +19,32 @@ export type TimedSlotEvent = { overflowBottom?: boolean } +// "2026-02-24" 같은 날짜 전용 문자열인지 판별해 시간 파싱 분기를 단순화한다. export const isDateOnlyString = (value?: stringOrDate) => typeof value === 'string' && !value.includes('T') +// day view 렌더링 전 이벤트를 시작 시각 기준으로 정렬한다. export const compareByStart = (a: CalendarEvent, b: CalendarEvent) => moment(a.start).diff(moment(b.start)) +// 반복 일정의 각 occurrence를 안정적으로 식별하기 위한 키를 생성한다. export const getEventOccurrenceKey = (event: CalendarEvent) => `${event.id}_${moment(event.start).format('YYYY-MM-DDTHH:mm')}` +// occurrenceDate가 있으면 우선 사용하고, 없으면 start를 사용해 일관된 datetime 문자열을 만든다. export const resolveOccurrenceDateTime = ( occurrenceDate: CalendarEvent['occurrenceDate'] | undefined, fallbackStart: CalendarEvent['start'] | Date, ) => moment(occurrenceDate ?? fallbackStart).format('YYYY-MM-DDTHH:mm:ss') +// 며칠짜리 일정이 특정 날짜를 포함하는지 판별한다. export const eventCoversDate = (event: CalendarEvent, date: Date) => { const start = moment(event.start) const end = moment(event.end) return moment(date).isBetween(start.startOf('day'), end.endOf('day'), undefined, '[]') } +// 이벤트를 오전/오후 컬럼 단위 시각 슬롯으로 변환하고 겹침 레이아웃 정보를 계산한다. export const buildTimedSlots = (events: CalendarEvent[], date: Date) => { const { SLOT_HEIGHT, MIN_HEIGHT, MAX_VISUAL_HOURS, COLUMNS } = TIMED_SLOT_CONFIG const columns: TimedSlotEvent[][] = COLUMNS.map(() => []) @@ -58,6 +64,7 @@ export const buildTimedSlots = (events: CalendarEvent[], date: Date) => { return } + // 표시 구간(한 컬럼 내 시작/종료)을 실제 픽셀 좌표(top/height)로 변환한다. const pushSegment = (segmentStart: moment.Moment, segmentEnd: moment.Moment) => { const columnIndex = segmentStart.hour() < 12 ? 0 : 1 const columnStart = dayStart.clone().add(COLUMNS[columnIndex], 'hours') @@ -88,12 +95,14 @@ export const buildTimedSlots = (events: CalendarEvent[], date: Date) => { pushSegment(clampedStart, clampedEnd) }) + // 같은 시간대에 겹치는 이벤트를 lane으로 분배해 겹침 표시 폭을 계산한다. const assignLanes = (columnEvents: TimedSlotEvent[]) => { const sorted = [...columnEvents].sort((a, b) => a.start.getTime() - b.start.getTime()) const active: Array<{ end: Date; lane: number }> = [] let cluster: TimedSlotEvent[] = [] let maxLane = 0 + // 하나의 겹침 클러스터가 끝나면 laneCount를 일괄 확정한다. const finalizeCluster = () => { if (cluster.length === 0) return cluster.forEach((item) => { diff --git a/src/features/Calendar/utils/viewConfig.ts b/src/features/Calendar/utils/viewConfig.ts index 7984214..c4cdfb1 100644 --- a/src/features/Calendar/utils/viewConfig.ts +++ b/src/features/Calendar/utils/viewConfig.ts @@ -18,10 +18,12 @@ type ViewConfig = { allDayAccessor?: (event: CalendarEvent) => boolean } +// react-big-calendar format 콜백을 앱 공통 포맷으로 연결한다. const weekdayFormat = (date: Date) => formatWeekday(date) const dayHeaderFormat = (date: Date) => formatDayHeaderLabel(date) const timeGutterFormat = (date: Date) => moment(date).format('HH:00') +// 주간 헤더에서만 "+" 액션을 주입할 수 있도록 동적 헤더 컴포넌트를 만든다. const createWeekHeader = (options?: ViewConfigOptions): ComponentType => { if (options?.onAddHeader) { return (props: HeaderProps) => @@ -33,6 +35,7 @@ const createWeekHeader = (options?: ViewConfigOptions): ComponentType> = { month: { formats: { @@ -65,6 +68,7 @@ const viewConfigMap: Partial> = { }, } +// 선택된 뷰에 맞는 캘린더 설정을 반환하고, 주간뷰는 헤더 옵션을 합성한다. export const getViewConfig = (view: View, options?: ViewConfigOptions): ViewConfig => { const config = viewConfigMap[view] ?? viewConfigMap.month! if (view === Views.WEEK) { diff --git a/src/features/Todo/components/TodoCard/TodoCard.tsx b/src/features/Todo/components/TodoCard/TodoCard.tsx index 4e0da44..153b585 100644 --- a/src/features/Todo/components/TodoCard/TodoCard.tsx +++ b/src/features/Todo/components/TodoCard/TodoCard.tsx @@ -1,29 +1,16 @@ -import { type MouseEventHandler, useCallback, useEffect, useState } from 'react' +import type { MouseEventHandler } from 'react' import Repeat from '@/shared/assets/icons/rotate.svg?react' import Trash from '@/shared/assets/icons/trash-2.svg?react' -import { useTodoMutations } from '@/shared/hooks/query/useTodoMutations' import { theme } from '@/shared/styles/theme' import { DeleteConfirmModal } from '@/shared/ui/modal' +import { useTodoCardActions } from '../../hooks/useTodoCardActions' import PriorityBadge from '../ImportantBadge/PriorityBadge' import TodoCheckbox from '../TodoCheckbox/TodoCheckbox' import * as S from './TodoCard.style' -const TodoCard = ({ - id, - title, - date, - occurrenceDate, - isHighlight, - isOverdue, - time, - priority, - isRecurring, - repeatInfo, - onDoubleClick, - isEditing, - isCompleted, -}: { + +type TodoCardProps = { id: number title: string date: string @@ -37,44 +24,37 @@ const TodoCard = ({ onDoubleClick?: MouseEventHandler isEditing?: boolean isCompleted?: boolean -}) => { - const [selected, setSelected] = useState(isCompleted ?? false) - const [openModal, setOpenModal] = useState(false) - const isRecurringTodo = Boolean(isRecurring) - const { useDeleteTodo, usePatchCompleteTodo } = useTodoMutations() - const { mutate: deleteTodoMutate } = useDeleteTodo() - const { mutate: patchCompleteTodoMutate } = usePatchCompleteTodo() - useEffect(() => { - setSelected(isCompleted ?? false) - }, [isCompleted]) - const handleDelete = useCallback(() => { - if (isRecurringTodo) { - setOpenModal(true) - return - } - deleteTodoMutate({ - todoId: id, - occurrenceDate, - }) - }, [deleteTodoMutate, id, isRecurringTodo, occurrenceDate]) +} - const handleToggleComplete = () => { - const previousSelected = selected - const nextSelected = !selected - setSelected(nextSelected) - patchCompleteTodoMutate( - { - todoId: id, - occurrenceDate, - isCompleted: nextSelected, - }, - { - onError: () => { - setSelected(previousSelected) - }, - }, - ) - } +const TodoCard = ({ + id, + title, + date, + occurrenceDate, + isHighlight, + isOverdue, + time, + priority, + isRecurring, + repeatInfo, + onDoubleClick, + isEditing, + isCompleted, +}: TodoCardProps) => { + const { + selected, + openDeleteModal, + isRecurringTodo, + deleteTodoMutate, + handleDelete, + handleToggleComplete, + closeDeleteModal, + } = useTodoCardActions({ + id, + occurrenceDate, + isCompleted, + isRecurring, + }) return ( - {openModal && isRecurringTodo && ( + {openDeleteModal && isRecurringTodo && ( setOpenModal(false)} + onClose={closeDeleteModal} title={title} target={{ type: 'todo', id, occurrenceDate }} mutate={deleteTodoMutate} diff --git a/src/features/Todo/components/TodoSection/TodoFilterTabs.tsx b/src/features/Todo/components/TodoSection/TodoFilterTabs.tsx new file mode 100644 index 0000000..3e05dd3 --- /dev/null +++ b/src/features/Todo/components/TodoSection/TodoFilterTabs.tsx @@ -0,0 +1,33 @@ +import type { TodoFilter } from '@/shared/types/todo/types' + +import * as S from './TodoSection.style' + +const TODO_FILTER_OPTIONS: Array<{ value: TodoFilter; label: string }> = [ + { value: 'ALL', label: '전체' }, + { value: 'TODAY', label: '오늘' }, + { value: 'PRIORITY', label: '중요도' }, + { value: 'COMPLETED', label: '완료' }, +] + +type TodoFilterTabsProps = { + value: TodoFilter + onChange: (value: TodoFilter) => void +} + +const TodoFilterTabs = ({ value, onChange }: TodoFilterTabsProps) => { + return ( + + {TODO_FILTER_OPTIONS.map((option) => ( + onChange(option.value)} + $isActive={value === option.value} + > + {option.label} + + ))} + + ) +} + +export default TodoFilterTabs diff --git a/src/features/Todo/components/TodoSection/TodoSection.tsx b/src/features/Todo/components/TodoSection/TodoSection.tsx index 72a58f8..f4689c4 100644 --- a/src/features/Todo/components/TodoSection/TodoSection.tsx +++ b/src/features/Todo/components/TodoSection/TodoSection.tsx @@ -1,167 +1,43 @@ -import { useEffect, useState } from 'react' - import Plus from '@/shared/assets/icons/plus.svg?react' import { useGetTodoQuery } from '@/shared/hooks/query/useTodoQueries' -import { theme } from '@/shared/styles/theme' -import type { TodoFilter } from '@/shared/types/todo/types' import AddTodoModal from '@/shared/ui/modal/AddTodo' +import { useTodoSectionState } from '../../hooks/useTodoSectionState' +import { + formatYmd, + getIsoDateWithOffset, + getTodoDateLabel, + getTodoDueDateTime, +} from '../../utils/todoDate' import TodoCard from '../TodoCard/TodoCard' +import TodoFilterTabs from './TodoFilterTabs' import * as S from './TodoSection.style' -const getIsoDateWithOffset = (offset: number) => { - const date = new Date() - date.setHours(0, 0, 0, 0) - date.setDate(date.getDate() + offset) - return date.toISOString() -} - -const formatYmd = (date: Date) => - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( - date.getDate(), - ).padStart(2, '0')}` - -const parseYmd = (value: string) => { - const [year, month, day] = value.split('-').map((part) => Number.parseInt(part, 10)) - return new Date(year, (month || 1) - 1, day || 1) -} - -type DueTimeLike = - | string - | { hour: number; minute: number; second: number; nano: number } - | undefined - -const formatTime = (value?: DueTimeLike) => { - if (!value) return '' - if (typeof value === 'string') return value.slice(0, 5) - const hour = String(value.hour ?? 0).padStart(2, '0') - const minute = String(value.minute ?? 0).padStart(2, '0') - return `${hour}:${minute}` -} - -const getWeekLabel = (date: Date) => { - const names = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'] - return names[date.getDay()] ?? '' -} - -const getTodoDateLabel = (occurrenceDate: string, dueTime?: DueTimeLike) => { - const targetDate = parseYmd(occurrenceDate) - const today = new Date() - const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()) - const diffDays = Math.floor((targetDate.getTime() - todayStart.getTime()) / (1000 * 60 * 60 * 24)) - const timeLabel = formatTime(dueTime) - if (diffDays === 0) return `오늘 ${timeLabel}`.trim() - if (diffDays === 1) return `내일 ${timeLabel}`.trim() - if (diffDays >= 2 && diffDays <= 6) - return `이번주 ${getWeekLabel(targetDate)} ${timeLabel}`.trim() - if (diffDays >= 7 && diffDays <= 13) - return `다음주 ${getWeekLabel(targetDate)} ${timeLabel}`.trim() - const ymd = `${targetDate.getFullYear()}.${String(targetDate.getMonth() + 1).padStart( - 2, - '0', - )}.${String(targetDate.getDate()).padStart(2, '0')}` - return timeLabel ? `${ymd} ${timeLabel}` : ymd -} - -const getTodoDueDateTime = (occurrenceDate: string, dueTime?: DueTimeLike, isAllDay?: boolean) => { - const base = parseYmd(occurrenceDate) - if (isAllDay || !dueTime) { - return new Date(base.getFullYear(), base.getMonth(), base.getDate(), 23, 59, 59, 999) - } - if (typeof dueTime !== 'string') { - return new Date( - base.getFullYear(), - base.getMonth(), - base.getDate(), - dueTime.hour || 0, - dueTime.minute || 0, - dueTime.second || 0, - 0, - ) - } - const [hour, minute, second] = dueTime.split(':').map((part) => Number.parseInt(part, 10)) - return new Date( - base.getFullYear(), - base.getMonth(), - base.getDate(), - hour || 0, - minute || 0, - second || 0, - 0, - ) -} - const TodoSection = () => { - const [todoState, setTodoState] = useState('ALL') - const [isDesktop, setIsDesktop] = useState(() => { - if (typeof window === 'undefined') return false - return window.matchMedia(`(min-width: ${theme.breakPoints.desktop})`).matches - }) - - const [isAddTodoOpen, setIsAddTodoOpen] = useState<{ - id: number | undefined - open: boolean - isEdit: boolean - }>({ - id: undefined, - open: false, - isEdit: false, - }) - const [addTodoDate, setAddTodoDate] = useState(() => getIsoDateWithOffset(0)) - const editingCardId = isAddTodoOpen.open && isAddTodoOpen.isEdit ? isAddTodoOpen.id : undefined - - const { data } = useGetTodoQuery(todoState) - - const handleCardDoubleClick = (date?: string, isEditing = true, id?: number) => { - setAddTodoDate(date ?? getIsoDateWithOffset(0)) - setIsAddTodoOpen({ id, open: true, isEdit: isEditing }) - } - - const closeAddTodoModal = () => { - setIsAddTodoOpen({ id: undefined, open: false, isEdit: false }) - } + const { + todoFilter, + setTodoFilter, + isDesktop, + addTodoDate, + todoModalState, + editingCardKey, + openAddTodoModal, + closeAddTodoModal, + } = useTodoSectionState() + const { data } = useGetTodoQuery(todoFilter) const todayIso = getIsoDateWithOffset(0) const todayYmd = formatYmd(new Date()) - useEffect(() => { - if (typeof window === 'undefined') return undefined - const mediaQuery = window.matchMedia(`(min-width: ${theme.breakPoints.desktop})`) - const handler = (event: MediaQueryListEvent) => { - setIsDesktop(event.matches) - } - mediaQuery.addEventListener('change', handler) - return () => mediaQuery.removeEventListener('change', handler) - }, []) - return ( - - setTodoState('ALL')} $isActive={todoState === 'ALL'}> - 전체 - - setTodoState('TODAY')} $isActive={todoState === 'TODAY'}> - 오늘 - - setTodoState('PRIORITY')} - $isActive={todoState === 'PRIORITY'} - > - 중요도 - - setTodoState('COMPLETED')} - $isActive={todoState === 'COMPLETED'} - > - 완료 - - + @@ -182,19 +58,26 @@ const TodoSection = () => { } priority={todo.priority} isRecurring={todo.isRecurring} - onDoubleClick={() => handleCardDoubleClick(todo.occurrenceDate, true, todo.todoId)} - isEditing={editingCardId === todo.todoId} + onDoubleClick={() => + openAddTodoModal({ + date: todo.occurrenceDate, + isEditing: true, + id: todo.todoId, + occurrenceDate: todo.occurrenceDate, + }) + } + isEditing={editingCardKey === `${todo.todoId}-${todo.occurrenceDate}`} /> ))} - {isAddTodoOpen.open && ( + {todoModalState.open && ( )} diff --git a/src/features/Todo/hooks/useTodoCardActions.ts b/src/features/Todo/hooks/useTodoCardActions.ts new file mode 100644 index 0000000..51d4534 --- /dev/null +++ b/src/features/Todo/hooks/useTodoCardActions.ts @@ -0,0 +1,72 @@ +import { useCallback, useState } from 'react' + +import { + useDeleteTodoMutation, + usePatchCompleteTodoMutation, +} from '@/shared/hooks/query/useTodoMutations' + +type UseTodoCardActionsArgs = { + id: number + occurrenceDate: string + isCompleted?: boolean + isRecurring?: boolean +} + +export const useTodoCardActions = ({ + id, + occurrenceDate, + isCompleted, + isRecurring, +}: UseTodoCardActionsArgs) => { + const [optimisticSelected, setOptimisticSelected] = useState(null) + const [openDeleteModal, setOpenDeleteModal] = useState(false) + const isRecurringTodo = Boolean(isRecurring) + const { mutate: deleteTodoMutate } = useDeleteTodoMutation() + const { mutate: patchCompleteTodoMutate } = usePatchCompleteTodoMutation() + const selected = optimisticSelected ?? isCompleted ?? false + + // 반복 할 일은 단순 삭제하지 않고 확인 모달을 먼저 노출합니다. + const handleDelete = useCallback(() => { + if (isRecurringTodo) { + setOpenDeleteModal(true) + return + } + deleteTodoMutate({ + todoId: id, + occurrenceDate, + }) + }, [deleteTodoMutate, id, isRecurringTodo, occurrenceDate]) + + // 체크 토글은 즉시 UI 반영(낙관적 업데이트) 후 실패 시 이전 값으로 되돌립니다. + const handleToggleComplete = () => { + const nextSelected = !selected + setOptimisticSelected(nextSelected) + patchCompleteTodoMutate( + { + todoId: id, + occurrenceDate, + isCompleted: nextSelected, + }, + { + onSuccess: () => { + // 성공 이후에는 서버 데이터(isCompleted)를 단일 진실 원천으로 사용합니다. + setOptimisticSelected(null) + }, + onError: () => { + // 실패 시에는 서버 값(isCompleted)으로 다시 보여주기 위해 낙관 상태를 해제합니다. + setOptimisticSelected(null) + }, + }, + ) + } + + return { + selected, + openDeleteModal, + isRecurringTodo, + deleteTodoMutate, + handleDelete, + handleToggleComplete, + closeDeleteModal: () => setOpenDeleteModal(false), + } +} diff --git a/src/features/Todo/hooks/useTodoSectionState.ts b/src/features/Todo/hooks/useTodoSectionState.ts new file mode 100644 index 0000000..cb71411 --- /dev/null +++ b/src/features/Todo/hooks/useTodoSectionState.ts @@ -0,0 +1,89 @@ +import { useEffect, useMemo, useState } from 'react' + +import { theme } from '@/shared/styles/theme' +import type { TodoFilter } from '@/shared/types/todo/types' + +import { getIsoDateWithOffset } from '../utils/todoDate' + +type OpenTodoModalArgs = { + date?: string + isEditing?: boolean + id?: number + occurrenceDate?: string +} + +type TodoModalState = { + id: number | undefined + open: boolean + isEdit: boolean + occurrenceDate?: string +} + +export const useTodoSectionState = () => { + const [todoFilter, setTodoFilter] = useState('ALL') + const [isDesktop, setIsDesktop] = useState(() => { + if (typeof window === 'undefined') return false + return window.matchMedia(`(min-width: ${theme.breakPoints.desktop})`).matches + }) + + const [todoModalState, setTodoModalState] = useState({ + id: undefined, + open: false, + isEdit: false, + occurrenceDate: undefined, + }) + const [addTodoDate, setAddTodoDate] = useState(() => getIsoDateWithOffset(0)) + + // 반복 할 일은 같은 id라도 occurrenceDate가 다를 수 있어서, + // 카드 편집 강조 기준을 id + occurrenceDate 조합으로 고정합니다. + const editingCardKey = useMemo(() => { + if (!todoModalState.open || !todoModalState.isEdit) return undefined + if (todoModalState.id == null || !todoModalState.occurrenceDate) return undefined + return `${todoModalState.id}-${todoModalState.occurrenceDate}` + }, [todoModalState.id, todoModalState.isEdit, todoModalState.occurrenceDate, todoModalState.open]) + + const openAddTodoModal = ({ + date, + isEditing = true, + id, + occurrenceDate, + }: OpenTodoModalArgs = {}) => { + setAddTodoDate(date ?? getIsoDateWithOffset(0)) + setTodoModalState({ + id, + open: true, + isEdit: isEditing, + occurrenceDate, + }) + } + + const closeAddTodoModal = () => { + setTodoModalState({ + id: undefined, + open: false, + isEdit: false, + occurrenceDate: undefined, + }) + } + + useEffect(() => { + if (typeof window === 'undefined') return undefined + const mediaQuery = window.matchMedia(`(min-width: ${theme.breakPoints.desktop})`) + const handler = (event: MediaQueryListEvent) => { + setIsDesktop(event.matches) + } + mediaQuery.addEventListener('change', handler) + return () => mediaQuery.removeEventListener('change', handler) + }, []) + + return { + todoFilter, + setTodoFilter, + isDesktop, + addTodoDate, + todoModalState, + editingCardKey, + openAddTodoModal, + closeAddTodoModal, + } +} diff --git a/src/features/Todo/utils/colorPalette.ts b/src/features/Todo/utils/colorPalette.ts index 61044b1..5ab75dd 100644 --- a/src/features/Todo/utils/colorPalette.ts +++ b/src/features/Todo/utils/colorPalette.ts @@ -2,6 +2,7 @@ import { theme } from '@/shared/styles/theme' import type { EventColorType } from '@/shared/types/event/event' import type { PriorityColorType } from '@/shared/types/event/priority' +// 우선순위 타입을 Todo 카드에 사용할 색상 팔레트로 변환한다. export const getColorPalette = (name: PriorityColorType) => { return theme.colors.priority[name] } diff --git a/src/features/Todo/utils/todoDate.ts b/src/features/Todo/utils/todoDate.ts new file mode 100644 index 0000000..b4c0cbf --- /dev/null +++ b/src/features/Todo/utils/todoDate.ts @@ -0,0 +1,98 @@ +type DueTimeObject = { + hour: number + minute: number + second: number + nano: number +} + +export type DueTimeLike = string | DueTimeObject | undefined + +// 오늘(00:00)을 기준으로 offset일 만큼 이동한 ISO 문자열을 만든다. +export const getIsoDateWithOffset = (offset: number) => { + const date = new Date() + date.setHours(0, 0, 0, 0) + date.setDate(date.getDate() + offset) + return date.toISOString() +} + +// Date 객체를 API 파라미터에서 사용하는 YYYY-MM-DD 문자열로 변환한다. +export const formatYmd = (date: Date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( + date.getDate(), + ).padStart(2, '0')}` + +// Todo 전용 날짜 문자열(YYYY-MM-DD)을 로컬 Date로 안전하게 파싱한다. +const parseYmd = (value: string) => { + const [year, month, day] = value.split('-').map((part) => Number.parseInt(part, 10)) + return new Date(year, (month || 1) - 1, day || 1) +} + +// dueTime이 문자열/객체 어느 형태로 오더라도 표시용 HH:mm 문자열로 통일한다. +const formatTime = (value?: DueTimeLike) => { + if (!value) return '' + if (typeof value === 'string') return value.slice(0, 5) + const hour = String(value.hour ?? 0).padStart(2, '0') + const minute = String(value.minute ?? 0).padStart(2, '0') + return `${hour}:${minute}` +} + +// 상대 날짜 문구(이번주/다음주)에 들어갈 요일 한글 라벨을 반환한다. +const getWeekLabel = (date: Date) => { + const names = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'] + return names[date.getDay()] ?? '' +} + +// Todo 카드에 표시할 상대 날짜 텍스트를 계산합니다. +// 예: 오늘/내일/이번주 월요일/다음주 화요일 혹은 YYYY.MM.DD +export const getTodoDateLabel = (occurrenceDate: string, dueTime?: DueTimeLike) => { + const targetDate = parseYmd(occurrenceDate) + const today = new Date() + const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()) + const diffDays = Math.floor((targetDate.getTime() - todayStart.getTime()) / (1000 * 60 * 60 * 24)) + const timeLabel = formatTime(dueTime) + if (diffDays === 0) return `오늘 ${timeLabel}`.trim() + if (diffDays === 1) return `내일 ${timeLabel}`.trim() + if (diffDays >= 2 && diffDays <= 6) + return `이번주 ${getWeekLabel(targetDate)} ${timeLabel}`.trim() + if (diffDays >= 7 && diffDays <= 13) + return `다음주 ${getWeekLabel(targetDate)} ${timeLabel}`.trim() + const ymd = `${targetDate.getFullYear()}.${String(targetDate.getMonth() + 1).padStart( + 2, + '0', + )}.${String(targetDate.getDate()).padStart(2, '0')}` + return timeLabel ? `${ymd} ${timeLabel}` : ymd +} + +// 마감 시각 비교를 위해 occurrenceDate + dueTime을 실제 Date 객체로 변환합니다. +// 종일 할 일은 해당 날짜 23:59:59.999를 마감 시각으로 사용합니다. +export const getTodoDueDateTime = ( + occurrenceDate: string, + dueTime?: DueTimeLike, + isAllDay?: boolean, +) => { + const base = parseYmd(occurrenceDate) + if (isAllDay || !dueTime) { + return new Date(base.getFullYear(), base.getMonth(), base.getDate(), 23, 59, 59, 999) + } + if (typeof dueTime !== 'string') { + return new Date( + base.getFullYear(), + base.getMonth(), + base.getDate(), + dueTime.hour || 0, + dueTime.minute || 0, + dueTime.second || 0, + 0, + ) + } + const [hour, minute, second] = dueTime.split(':').map((part) => Number.parseInt(part, 10)) + return new Date( + base.getFullYear(), + base.getMonth(), + base.getDate(), + hour || 0, + minute || 0, + second || 0, + 0, + ) +} diff --git a/src/features/Todo/utils/todoPage.ts b/src/features/Todo/utils/todoPage.ts new file mode 100644 index 0000000..dcebb9b --- /dev/null +++ b/src/features/Todo/utils/todoPage.ts @@ -0,0 +1,14 @@ +import { formatYmd } from './todoDate' + +// 할 일 진행 조회 API에서 사용하는 기준 날짜 파라미터를 생성한다. +export const getTodoProgressDateParam = (baseDate: Date = new Date()) => formatYmd(baseDate) + +// 월의 첫 요일을 기준으로 현재 날짜가 몇 번째 주인지 계산합니다. +// 예: 2026-02-23이면 "2026년 2월 4주차" 형태로 반환됩니다. +export const getTodoWeekTitle = (baseDate: Date = new Date()) => { + const year = baseDate.getFullYear() + const month = baseDate.getMonth() + 1 + const firstDay = new Date(year, baseDate.getMonth(), 1).getDay() + const weekOfMonth = Math.ceil((baseDate.getDate() + firstDay) / 7) + return `${year}년 ${month}월 ${weekOfMonth}주차` +} diff --git a/src/pages/main/CalendarPage/CalendarPage.styles.ts b/src/pages/main/CalendarPage/CalendarPage.styles.ts index 6b67ef9..31c7693 100644 --- a/src/pages/main/CalendarPage/CalendarPage.styles.ts +++ b/src/pages/main/CalendarPage/CalendarPage.styles.ts @@ -22,7 +22,7 @@ export const PageWrapper = styled.div` gap: 16px; flex-direction: column; align-items: center; - margin-bottom: 32px; + justify-content: flex-start; .mobile-custom-view-button { display: block; } diff --git a/src/pages/main/TodoListPage/TodoListPage.tsx b/src/pages/main/TodoListPage/TodoListPage.tsx index d3d189f..2ad21f5 100644 --- a/src/pages/main/TodoListPage/TodoListPage.tsx +++ b/src/pages/main/TodoListPage/TodoListPage.tsx @@ -1,28 +1,16 @@ /** @jsxImportSource @emotion/react */ -import { useMemo, useRef } from 'react' +import { useMemo } from 'react' import TodoSection from '@/features/Todo/components/TodoSection/TodoSection' import TodoStatus from '@/features/Todo/components/TodoStatus/TodoStatus' +import { getTodoProgressDateParam, getTodoWeekTitle } from '@/features/Todo/utils/todoPage' import { useGetTodoProgressQuery } from '@/shared/hooks/query/useTodoQueries' import * as S from './TodoListPage.styles' export default function TodoListPage() { - const cardAreaRef = useRef(null) - const titleLabel = useMemo(() => { - const now = new Date() - const year = now.getFullYear() - const month = now.getMonth() + 1 - const firstDay = new Date(year, now.getMonth(), 1).getDay() - const weekOfMonth = Math.ceil((now.getDate() + firstDay) / 7) - return `${year}년 ${month}월 ${weekOfMonth}주차` - }, []) - const todayParam = useMemo(() => { - const today = new Date() - return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String( - today.getDate(), - ).padStart(2, '0')}` - }, []) + const titleLabel = useMemo(() => getTodoWeekTitle(new Date()), []) + const todayParam = useMemo(() => getTodoProgressDateParam(new Date()), []) const { data } = useGetTodoProgressQuery(todayParam) return ( @@ -34,7 +22,6 @@ export default function TodoListPage() {
+ group?.frequency === 'MONTHLY' && + group.monthlyType === 'DAY_OF_WEEK' && + group.weekdayRule != null && + group.weekdayRule !== 'SINGLE' + +const buildMonthlySinglePatternFromDate = ( + group: RecurrenceGroup, + targetDate: Date, +): RecurrenceGroup => ({ + ...group, + monthlyType: 'DAY_OF_WEEK', + weekOfMonth: toWeekOfMonth(targetDate), + weekdayRule: 'SINGLE', + dayOfWeekInMonth: toWeekday(targetDate), + daysOfMonth: undefined, +}) type PatchEventMutate = (params: { eventId: number @@ -60,10 +83,21 @@ export const useSchedulePatch = ({ const isRecurring = values.repeatConfig.repeatType !== 'none' || initialEvent?.recurrenceGroup != null - const shouldSendRecurrenceGroup = !areRepeatConfigsEqual( - values.repeatConfig, - initialRepeatConfig, - ) + const initialRecurrenceGroupPayload = + normalizeRecurrenceGroupPayload(initialEvent?.recurrenceGroup) ?? + mapRepeatConfigToRecurrenceGroup(initialRepeatConfig) + const nextRecurrenceGroupPayload = + values.repeatConfig.repeatType === 'none' + ? null + : mapRepeatConfigToRecurrenceGroup(values.repeatConfig) + const shouldSendRecurrenceGroup = + JSON.stringify(nextRecurrenceGroupPayload ?? null) !== + JSON.stringify(initialRecurrenceGroupPayload ?? null) + const patchScope = shouldSendRecurrenceGroup + ? 'THIS_AND_FOLLOWING_EVENTS' + : isRecurring + ? (scope ?? 'THIS_EVENT') + : undefined const initialTitle = initialEvent?.title ?? '' const initialContent = initialEvent?.content ?? '' @@ -78,6 +112,20 @@ export const useSchedulePatch = ({ const nextContent = values.eventDescription ?? '' const nextStart = formatDateTime(start) const nextEnd = formatDateTime(end) + const hasStartDateChanged = + initialEvent?.start != null && !isSameYmd(new Date(initialEvent.start), start) + const shouldSendMonthlySinglePattern = + patchScope === 'THIS_AND_FOLLOWING_EVENTS' && + !shouldSendRecurrenceGroup && + hasStartDateChanged && + isMonthlyPatternWithFlexibleWeekdayRule(initialRecurrenceGroupPayload) + const monthlySinglePatternPayload = + shouldSendMonthlySinglePattern && initialRecurrenceGroupPayload + ? buildMonthlySinglePatternFromDate(initialRecurrenceGroupPayload, start) + : undefined + const recurrenceGroupPayload = shouldSendRecurrenceGroup + ? nextRecurrenceGroupPayload + : monthlySinglePatternPayload // occurrenceDate 우선순위: // 1) 호출자가 명시적으로 전달한 occurrenceDate // 2) 기존 이벤트가 가진 태생 occurrenceDate @@ -95,12 +143,9 @@ export const useSchedulePatch = ({ ...(initialEnd && nextEnd !== initialEnd ? { endTime: nextEnd } : {}), ...(initialColor && values.eventColor !== initialColor ? { color: values.eventColor } : {}), ...(values.isAllday !== initialIsAllday ? { isAllDay: values.isAllday } : {}), - ...(shouldSendRecurrenceGroup + ...(recurrenceGroupPayload !== undefined ? { - recurrenceGroup: - values.repeatConfig.repeatType === 'none' - ? null - : mapRepeatConfigToRecurrenceGroup(values.repeatConfig), + recurrenceGroup: recurrenceGroupPayload, } : {}), } @@ -110,7 +155,7 @@ export const useSchedulePatch = ({ eventId, params: { occurrenceDate: nextOccurrenceDate, - ...(isRecurring && scope ? { scope } : {}), + ...(patchScope ? { scope: patchScope } : {}), }, eventData, }) diff --git a/src/shared/hooks/addSchedule/useScheduleSubmitFlow.ts b/src/shared/hooks/addSchedule/useScheduleSubmitFlow.ts index bd6bb69..8c312e7 100644 --- a/src/shared/hooks/addSchedule/useScheduleSubmitFlow.ts +++ b/src/shared/hooks/addSchedule/useScheduleSubmitFlow.ts @@ -74,62 +74,41 @@ export const useScheduleSubmitFlow = ({ setIsApplyConfirmOpen(false) }, []) - // 폼 제출 처리(일반/반복 분기) - const handleFormSubmit = handleSubmit(async (values) => { - if (isExistingRecurring && requestConfirmation()) { - setPendingScheduleValues(values) - return - } - if (isExistingRecurring) { - openApplyConfirm(values) - return - } - if (eventId != null && eventId !== 0) { + const confirmTitle = useCallback( + (values: AddScheduleFormValues) => { + if (eventId == null || eventId === 0) return const nextTitle = values.eventTitle ?? '' if (nextTitle) { handleTitleConfirm(nextTitle) } - } - syncEventTiming(values) - try { - if (isEditing) { - await patchSchedule(values) - } else { - await createSchedule(values) - } - onClose() - } catch (error) { - console.error('[AddScheduleForm] submit failed', error) - const message = - error instanceof Error - ? error.message - : '일정 저장 중 오류가 발생했습니다. 다시 시도해주세요.' - alert(message) - } - }) + }, + [eventId, handleTitleConfirm], + ) - // 반복 일정 수정 범위를 확인 후 제출 처리 - const handleConfirmedSubmit = useCallback( - async (option: EditConfirmOption) => { - void option - if (!pendingScheduleValues) return - if (isEditConfirmOpen) { + // 일반 생성/일반 수정/반복 수정(범위 선택) 모두 이 경로를 통해 제출합니다. + // 분기마다 흩어진 try/catch를 모아 에러 처리 정책을 일관되게 유지합니다. + const submitScheduleValues = useCallback( + async ( + values: AddScheduleFormValues, + options: { + mode: 'create' | 'patch' + scope?: RecurrenceEventScope + occurrenceDate?: string + shouldConfirmChange?: boolean + }, + ) => { + if (options.shouldConfirmChange) { confirmChange() } - if (eventId != null && eventId !== 0) { - const nextTitle = pendingScheduleValues.eventTitle ?? '' - if (nextTitle) { - handleTitleConfirm(nextTitle) - } - } - const startDate = pendingScheduleValues.eventStartDate ?? new Date(date) - const occurrenceDate = formatDateTime( - buildDateTime(startDate, pendingScheduleValues.eventStartTime), - ) - const scope = option === 'future' ? 'THIS_AND_FOLLOWING_EVENTS' : 'THIS_EVENT' - syncEventTiming(pendingScheduleValues) + confirmTitle(values) + syncEventTiming(values) + try { - await patchSchedule(pendingScheduleValues, scope, occurrenceDate) + if (options.mode === 'patch') { + await patchSchedule(values, options.scope, options.occurrenceDate) + } else { + await createSchedule(values) + } onClose() clearApplyConfirm() } catch (error) { @@ -142,18 +121,60 @@ export const useScheduleSubmitFlow = ({ } }, [ - buildDateTime, clearApplyConfirm, confirmChange, + confirmTitle, + createSchedule, + onClose, + patchSchedule, + syncEventTiming, + ], + ) + + // 폼 제출 처리(일반/반복 분기) + const handleFormSubmit = handleSubmit(async (values) => { + if (isExistingRecurring && requestConfirmation()) { + setPendingScheduleValues(values) + return + } + if (isExistingRecurring) { + openApplyConfirm(values) + return + } + await submitScheduleValues(values, { + mode: isEditing ? 'patch' : 'create', + }) + }) + + // 반복 일정 수정 범위를 확인 후 제출 처리 + const handleConfirmedSubmit = useCallback( + async (option: EditConfirmOption) => { + if (!pendingScheduleValues) return + const fallbackStartDate = pendingScheduleValues.eventStartDate ?? new Date(date) + const fallbackOccurrenceDate = formatDateTime( + buildDateTime(fallbackStartDate, pendingScheduleValues.eventStartTime), + ) + // occurrenceDate를 현재 수정 중인 인스턴스로 고정해, + // 반복 일정에서도 사용자가 연 occurrence를 기준으로 patch 요청을 보냅니다. + const occurrenceDate = initialEvent?.occurrenceDate + ? formatDateTime(new Date(initialEvent.occurrenceDate)) + : fallbackOccurrenceDate + const scope = option === 'future' ? 'THIS_AND_FOLLOWING_EVENTS' : 'THIS_EVENT' + await submitScheduleValues(pendingScheduleValues, { + mode: 'patch', + scope, + occurrenceDate, + shouldConfirmChange: isEditConfirmOpen, + }) + }, + [ + buildDateTime, date, - eventId, formatDateTime, - handleTitleConfirm, + initialEvent, isEditConfirmOpen, - onClose, - patchSchedule, pendingScheduleValues, - syncEventTiming, + submitScheduleValues, ], ) diff --git a/src/shared/hooks/addTodo/useTodoSubmitFlow.ts b/src/shared/hooks/addTodo/useTodoSubmitFlow.ts new file mode 100644 index 0000000..28b63af --- /dev/null +++ b/src/shared/hooks/addTodo/useTodoSubmitFlow.ts @@ -0,0 +1,157 @@ +import { useCallback, useState } from 'react' +import type { UseFormHandleSubmit, UseFormSetValue } from 'react-hook-form' + +import { useRepeatChangeGuard } from '@/shared/hooks/repeat/useRepeatChangeGuard' +import type { CalendarEvent } from '@/shared/types/calendar/types' +import type { AddTodoFormValues } from '@/shared/types/event/event' +import type { RecurrenceTodoScope } from '@/shared/types/recurrence/recurrence' +import type { EditConfirmOption } from '@/shared/ui/modal' + +type UseTodoSubmitFlowProps = { + eventId: CalendarEvent['id'] + hasExistingRecurrence: boolean + repeatGuardEnabled: boolean + isDetailReady: boolean + repeatConfig: AddTodoFormValues['repeatConfig'] + setValue: UseFormSetValue + handleSubmit: UseFormHandleSubmit + patchOccurrenceDate: string + onSubmit: ( + values: AddTodoFormValues, + options?: { occurrenceDate?: string; scope?: RecurrenceTodoScope }, + ) => Promise + onClose: () => void + syncEventTiming: (values: AddTodoFormValues) => void + onEventTitleConfirm?: (eventId: CalendarEvent['id'], title: string) => void +} + +export const useTodoSubmitFlow = ({ + eventId, + hasExistingRecurrence, + repeatGuardEnabled, + isDetailReady, + repeatConfig, + setValue, + handleSubmit, + patchOccurrenceDate, + onSubmit, + onClose, + syncEventTiming, + onEventTitleConfirm, +}: UseTodoSubmitFlowProps) => { + const { + isOpen: isEditConfirmOpen, + confirmChange, + revertChange, + requestConfirmation, + } = useRepeatChangeGuard({ + repeatConfig, + isEditing: repeatGuardEnabled, + setValue, + }) + const [isApplyConfirmOpen, setIsApplyConfirmOpen] = useState(false) + const [pendingTodoValues, setPendingTodoValues] = useState(null) + + const confirmTitle = useCallback( + (values: AddTodoFormValues) => { + if (eventId == null || eventId === 0) return + const nextTitle = values.todoTitle ?? '' + if (nextTitle) { + onEventTitleConfirm?.(eventId, nextTitle) + } + }, + [eventId, onEventTitleConfirm], + ) + + const clearApplyConfirm = useCallback(() => { + setPendingTodoValues(null) + setIsApplyConfirmOpen(false) + }, []) + + // 제출 경로를 하나로 모아 반복/단건 모두 동일한 에러 처리 정책을 사용합니다. + const submitTodoValues = useCallback( + async ( + values: AddTodoFormValues, + options?: { scope?: RecurrenceTodoScope; shouldConfirmChange?: boolean }, + ) => { + if (options?.shouldConfirmChange) { + confirmChange() + } + confirmTitle(values) + syncEventTiming(values) + + const submitOptions = { + occurrenceDate: patchOccurrenceDate, + ...(options?.scope ? { scope: options.scope } : {}), + } + + try { + await onSubmit(values, submitOptions) + onClose() + clearApplyConfirm() + } catch (error) { + console.error('[AddTodoForm] submit failed', error) + const message = + error instanceof Error + ? error.message + : '할 일 저장 중 오류가 발생했습니다. 다시 시도해주세요.' + alert(message) + } + }, + [ + clearApplyConfirm, + confirmChange, + confirmTitle, + onClose, + onSubmit, + patchOccurrenceDate, + syncEventTiming, + ], + ) + + const handleFormSubmit = handleSubmit(async (values) => { + // 편집 모달에서 상세 데이터가 아직 hydrate 되지 않았다면 + // recurrence/occurrenceDate 판단이 틀어질 수 있어 제출을 잠시 막습니다. + if (!isDetailReady) { + alert('할 일 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요.') + return + } + if (requestConfirmation()) { + setPendingTodoValues(values) + return + } + if (hasExistingRecurrence) { + setPendingTodoValues(values) + setIsApplyConfirmOpen(true) + return + } + await submitTodoValues(values) + }) + + const handleConfirmedSubmit = useCallback( + async (option: EditConfirmOption) => { + if (!pendingTodoValues) return + const scope: RecurrenceTodoScope = option === 'future' ? 'THIS_AND_FOLLOWING' : 'THIS_TODO' + await submitTodoValues(pendingTodoValues, { + scope, + shouldConfirmChange: isEditConfirmOpen, + }) + }, + [isEditConfirmOpen, pendingTodoValues, submitTodoValues], + ) + + const handleCancelRepeat = useCallback(() => { + if (isEditConfirmOpen) { + revertChange() + } + clearApplyConfirm() + }, [clearApplyConfirm, isEditConfirmOpen, revertChange]) + + return { + isEditConfirmOpen, + isApplyConfirmOpen, + handleFormSubmit, + handleConfirmedSubmit, + handleCancelRepeat, + } +} diff --git a/src/shared/hooks/common/useUnsavedCloseGuard.ts b/src/shared/hooks/common/useUnsavedCloseGuard.ts new file mode 100644 index 0000000..6bd0e41 --- /dev/null +++ b/src/shared/hooks/common/useUnsavedCloseGuard.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +type UseUnsavedCloseGuardArgs = { + isDirty: boolean + onClose: () => void + registerCloseGuard?: (guard?: (() => boolean) | null) => void +} + +export const useUnsavedCloseGuard = ({ + isDirty, + onClose, + registerCloseGuard, +}: UseUnsavedCloseGuardArgs) => { + const allowCloseRef = useRef(false) + const [isUnsavedConfirmOpen, setIsUnsavedConfirmOpen] = useState(false) + + const requestClose = useCallback( + (force?: boolean) => { + if (force) { + allowCloseRef.current = true + } + onClose() + }, + [onClose], + ) + + // 모달 레이아웃에서 닫기 클릭 시 이 가드가 실행됩니다. + // isDirty 상태면 즉시 닫지 않고 확인 모달을 띄워 사용자 선택을 받습니다. + const closeGuard = useCallback(() => { + if (allowCloseRef.current) { + allowCloseRef.current = false + return true + } + if (!isDirty) return true + setIsUnsavedConfirmOpen(true) + return false + }, [isDirty]) + + const handleCloseUnsavedConfirm = useCallback(() => { + setIsUnsavedConfirmOpen(false) + }, []) + + const handleLeaveUnsavedForm = useCallback(() => { + setIsUnsavedConfirmOpen(false) + requestClose(true) + }, [requestClose]) + + useEffect(() => { + if (!registerCloseGuard) return + registerCloseGuard(closeGuard) + return () => registerCloseGuard() + }, [closeGuard, registerCloseGuard]) + + return { + isUnsavedConfirmOpen, + requestClose, + handleCloseUnsavedConfirm, + handleLeaveUnsavedForm, + } +} diff --git a/src/shared/hooks/form/useAddTodoForm.ts b/src/shared/hooks/form/useAddTodoForm.ts index 6b86ef3..4cb6a9b 100644 --- a/src/shared/hooks/form/useAddTodoForm.ts +++ b/src/shared/hooks/form/useAddTodoForm.ts @@ -10,9 +10,12 @@ import type { RepeatConfigSchema, TimePickerField, } from '@/shared/types/event/event' +import type { PriorityType } from '@/shared/types/event/priority' +import type { RecurrenceTodoScope } from '@/shared/types/recurrence/recurrence' import type { CustomRepeatBasis, RepeatConfig, RepeatType } from '@/shared/types/recurrence/repeat' import { formatIsoDate } from '@/shared/utils/date' import { mapRepeatConfigToRecurrenceGroup } from '@/shared/utils/recurrenceGroup' +import { isSameYmd, toWeekday, toWeekOfMonth } from '@/shared/utils/recurrencePattern' import { useTodoFormFields } from './useTodoFormFields' @@ -32,15 +35,20 @@ export type UseAddTodoFormResult = { todoEndTime: string | undefined repeatConfig: RepeatConfigSchema eventColor: EventColorType + todoPriority: PriorityType handleCalendarOpen: (field: DatePickerField) => void handleDateSelect: (selectedDate: Date) => void handleTimeChange: (field: TimePickerField, value: string) => void handleRepeatType: (value: RepeatType) => void updateConfig: (changes: Partial) => void handleSubmit: UseFormReturn['handleSubmit'] - onSubmit: (values: AddTodoFormValues) => Promise + onSubmit: ( + values: AddTodoFormValues, + options?: { occurrenceDate?: string; scope?: RecurrenceTodoScope }, + ) => Promise setIsAllday: React.Dispatch> setEventColor: (value: EventColorType) => void + setTodoPriority: (value: PriorityType) => void todoTitle: string | undefined repeatEndDate: Date | null } @@ -48,6 +56,13 @@ export type UseAddTodoFormResult = { const isCustomBasis = (value: RepeatType): value is CustomRepeatBasis => value !== 'none' && value !== 'custom' +const parseYmd = (value?: string) => { + if (!value) return null + const [year, month, day] = value.split('-').map((item) => Number.parseInt(item, 10)) + if (!year || !month || !day) return null + return new Date(year, month - 1, day, 0, 0, 0, 0) +} + export const useAddTodoForm = ({ date, id, @@ -68,6 +83,7 @@ export const useAddTodoForm = ({ repeatConfig, todoTitle, eventColor, + todoPriority, } = useTodoFormFields({ date, isAllday }) const calendarRef = useRef(null) @@ -167,27 +183,59 @@ export const useAddTodoForm = ({ [setValue], ) - const onSubmit = (values: AddTodoFormValues) => { - const recurrenceGroup = + const setTodoPriority = useCallback( + (value: PriorityType) => { + setValue('todoPriority', value, { shouldValidate: true, shouldDirty: true }) + }, + [setValue], + ) + + const onSubmit = ( + values: AddTodoFormValues, + options?: { occurrenceDate?: string; scope?: RecurrenceTodoScope }, + ) => { + const mappedRecurrenceGroup = values.repeatConfig.repeatType === 'none' ? undefined : (mapRepeatConfigToRecurrenceGroup(values.repeatConfig) ?? undefined) + const currentDate = values.todoDate ? new Date(values.todoDate) : null + const targetOccurrenceDate = parseYmd(options?.occurrenceDate) + const shouldAdjustFutureMonthlyPattern = + options?.scope === 'THIS_AND_FOLLOWING' && + mappedRecurrenceGroup?.frequency === 'MONTHLY' && + mappedRecurrenceGroup?.monthlyType === 'DAY_OF_WEEK' && + mappedRecurrenceGroup?.weekdayRule != null && + mappedRecurrenceGroup.weekdayRule !== 'SINGLE' && + currentDate != null && + targetOccurrenceDate != null && + !isSameYmd(currentDate, targetOccurrenceDate) + const recurrenceGroup = shouldAdjustFutureMonthlyPattern + ? { + ...mappedRecurrenceGroup, + weekOfMonth: toWeekOfMonth(currentDate), + weekdayRule: 'SINGLE' as const, + dayOfWeekInMonth: toWeekday(currentDate), + daysOfMonth: undefined, + } + : mappedRecurrenceGroup const payload = { title: values.todoTitle?.trim() || '새로운 할 일', startDate: formatIsoDate(values.todoDate), dueTime: values.isAllday ? undefined : values.todoEndTime, isAllDay: values.isAllday ? true : false, color: values.eventColor, - priority: 'MEDIUM' as const, + priority: values.todoPriority, memo: values.todoDescription ?? '', recurrenceGroup, } const isPersistedTodoId = typeof id === 'number' && id > 0 if (isEditing && isPersistedTodoId) { + const occurrenceDate = options?.occurrenceDate ?? formatIsoDate(date) return patchTodoMutate({ todoId: id, - occurrenceDate: formatIsoDate(date), + occurrenceDate, + ...(options?.scope ? { scope: options.scope } : {}), requestBody: payload, }) } @@ -207,6 +255,7 @@ export const useAddTodoForm = ({ todoEndTime, repeatConfig, eventColor, + todoPriority, handleCalendarOpen, handleDateSelect, handleTimeChange, @@ -214,6 +263,7 @@ export const useAddTodoForm = ({ onSubmit, setIsAllday, setEventColor, + setTodoPriority, todoTitle, repeatEndDate, } diff --git a/src/shared/hooks/form/useTodoFormFields.ts b/src/shared/hooks/form/useTodoFormFields.ts index fa541ab..58cc79b 100644 --- a/src/shared/hooks/form/useTodoFormFields.ts +++ b/src/shared/hooks/form/useTodoFormFields.ts @@ -8,6 +8,7 @@ import { type EventColorType, type RepeatConfigSchema, } from '@/shared/types/event/event' +import type { PriorityType } from '@/shared/types/event/priority' import { defaultRepeatConfig } from '@/shared/types/recurrence/repeat' type UseTodoFormFieldsProps = { @@ -25,6 +26,7 @@ export type UseTodoFormFieldsResult = { repeatConfig: RepeatConfigSchema todoTitle: string | undefined eventColor: EventColorType + todoPriority: PriorityType } export const useTodoFormFields = ({ @@ -41,6 +43,7 @@ export const useTodoFormFields = ({ todoEndTime: '10:00', isAllday, eventColor: 'GRAY', + todoPriority: 'MEDIUM', repeatConfig: defaultRepeatConfig as RepeatConfigSchema, }, }) @@ -52,12 +55,14 @@ export const useTodoFormFields = ({ (defaultRepeatConfig as RepeatConfigSchema)) as RepeatConfigSchema const todoTitle = useWatch({ control, name: 'todoTitle' }) const eventColor = (useWatch({ control, name: 'eventColor' }) ?? 'GRAY') as EventColorType + const todoPriority = (useWatch({ control, name: 'todoPriority' }) ?? 'MEDIUM') as PriorityType useEffect(() => { register('todoDate') register('todoEndTime') register('isAllday') register('eventColor') + register('todoPriority') register('repeatConfig') }, [register]) @@ -80,5 +85,6 @@ export const useTodoFormFields = ({ repeatConfig, todoTitle, eventColor, + todoPriority, } } diff --git a/src/shared/hooks/query/useTodoMutations.ts b/src/shared/hooks/query/useTodoMutations.ts index c79f91c..f9a416f 100644 --- a/src/shared/hooks/query/useTodoMutations.ts +++ b/src/shared/hooks/query/useTodoMutations.ts @@ -6,68 +6,90 @@ import type { PatchTodoRequestDTO } from '@/shared/types/todo/types' import { useCustomMutation } from '../common/customQuery' -export function useTodoMutations() { +type DeleteTodoVariables = { + todoId: number + occurrenceDate?: string + scope?: RecurrenceTodoScope +} + +type PatchTodoVariables = { + todoId: number + requestBody: PatchTodoRequestDTO + occurrenceDate?: string + scope?: RecurrenceTodoScope +} + +type PatchCompleteTodoVariables = { + todoId: number + occurrenceDate: string + isCompleted: boolean +} + +const useInvalidateTodoQueries = () => { const queryClient = useQueryClient() - const invalidateCalendarQueries = () => { + + // Todo 수정/삭제/완료 토글 이후에는 목록/상세/진행률/캘린더 조회를 함께 무효화합니다. + return () => { queryClient.invalidateQueries({ queryKey: ['todo', 'list'] }) queryClient.invalidateQueries({ queryKey: ['todo', 'detail'] }) queryClient.invalidateQueries({ queryKey: ['todo', 'progress'] }) queryClient.invalidateQueries({ queryKey: ['calendar', 'todos'] }) } +} + +export function usePostTodoMutation() { + const invalidateTodoQueries = useInvalidateTodoQueries() + return useCustomMutation(postTodo, { + onSuccess: invalidateTodoQueries, + }) +} + +export function useDeleteTodoMutation() { + const invalidateTodoQueries = useInvalidateTodoQueries() + return useCustomMutation( + ({ todoId, occurrenceDate, scope }: DeleteTodoVariables) => + deleteTodo(todoId, occurrenceDate, scope), + { + onSuccess: invalidateTodoQueries, + }, + ) +} + +export function usePatchTodoMutation() { + const invalidateTodoQueries = useInvalidateTodoQueries() + return useCustomMutation( + ({ todoId, requestBody, occurrenceDate, scope }: PatchTodoVariables) => + patchTodo(todoId, requestBody, occurrenceDate, scope), + { + onSuccess: invalidateTodoQueries, + }, + ) +} + +export function usePatchCompleteTodoMutation() { + const invalidateTodoQueries = useInvalidateTodoQueries() + return useCustomMutation( + ({ todoId, occurrenceDate, isCompleted }: PatchCompleteTodoVariables) => + patchTodoComplete(todoId, occurrenceDate, isCompleted), + { + onSuccess: invalidateTodoQueries, + }, + ) +} + +// 기존 호출부(useTodoMutations().usePatchTodo())와의 호환을 위해 래퍼 형태를 유지합니다. +export function useTodoMutations() { function usePostTodo() { - return useCustomMutation(postTodo, { - onSuccess: invalidateCalendarQueries, - }) + return usePostTodoMutation() } function useDeleteTodo() { - return useCustomMutation( - ({ - todoId, - occurrenceDate, - scope, - }: { - todoId: number - occurrenceDate?: string - scope?: RecurrenceTodoScope - }) => deleteTodo(todoId, occurrenceDate, scope), - { - onSuccess: invalidateCalendarQueries, - }, - ) + return useDeleteTodoMutation() } function usePatchTodo() { - return useCustomMutation( - ({ - todoId, - requestBody, - occurrenceDate, - scope, - }: { - todoId: number - requestBody: PatchTodoRequestDTO - occurrenceDate?: string - scope?: RecurrenceTodoScope - }) => patchTodo(todoId, requestBody, occurrenceDate, scope), - { - onSuccess: invalidateCalendarQueries, - }, - ) + return usePatchTodoMutation() } function usePatchCompleteTodo() { - return useCustomMutation( - ({ - todoId, - occurrenceDate, - isCompleted, - }: { - todoId: number - occurrenceDate: string - isCompleted: boolean - }) => patchTodoComplete(todoId, occurrenceDate, isCompleted), - { - onSuccess: invalidateCalendarQueries, - }, - ) + return usePatchCompleteTodoMutation() } return { usePostTodo, useDeleteTodo, usePatchTodo, usePatchCompleteTodo } } diff --git a/src/shared/schemas/schedule.ts b/src/shared/schemas/schedule.ts index 9b8a636..1511973 100644 --- a/src/shared/schemas/schedule.ts +++ b/src/shared/schemas/schedule.ts @@ -23,6 +23,8 @@ const eventEndTime = yup .test('is-greater', '종료 시간은 시작 시간보다 늦어야 합니다.', function (value) { const { eventStartTime, isAllday } = this.parent if (isAllday) return true + // 스키마 평가 시점에는 value/eventStartTime이 undefined일 수 있어 안전하게 분기합니다. + if (typeof value !== 'string' || typeof eventStartTime !== 'string') return false return value > eventStartTime }) diff --git a/src/shared/schemas/todo.ts b/src/shared/schemas/todo.ts index c87cf6f..20a0044 100644 --- a/src/shared/schemas/todo.ts +++ b/src/shared/schemas/todo.ts @@ -2,6 +2,7 @@ import * as yup from 'yup' import { EVENT_COLORS } from '@/shared/constants/event' import type { EventColorType } from '@/shared/types/event/event' +import type { PriorityType } from '@/shared/types/event/priority' import { DateSchema, description, isAllday, repeatConfigSchema } from './common' @@ -10,6 +11,11 @@ const eventColor = yup .oneOf(EVENT_COLORS, '유효하지 않은 색상입니다.') .required('색상을 선택하세요.') +const todoPriority = yup + .mixed() + .oneOf(['HIGH', 'MEDIUM', 'LOW'], '유효하지 않은 중요도입니다.') + .required('중요도를 선택하세요.') + export const addTodoSchema = yup.object().shape({ todoTitle: yup.string().max(50, '제목은 최대 50자까지 입력 가능합니다.'), todoDescription: description, @@ -17,6 +23,7 @@ export const addTodoSchema = yup.object().shape({ todoEndTime: yup.string().required('종료 시간은 필수 입력 사항입니다.'), isAllday, eventColor, + todoPriority, repeatConfig: repeatConfigSchema, }) diff --git a/src/shared/ui/modal/AddSchedule/form/AddScheduleFormContent.tsx b/src/shared/ui/modal/AddSchedule/form/AddScheduleFormContent.tsx index d48d596..39f6e8e 100644 --- a/src/shared/ui/modal/AddSchedule/form/AddScheduleFormContent.tsx +++ b/src/shared/ui/modal/AddSchedule/form/AddScheduleFormContent.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @emotion/react */ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback } from 'react' import { createPortal } from 'react-dom' import { useFormContext } from 'react-hook-form' @@ -10,6 +10,7 @@ import { useSchedulePatchController, useScheduleSubmitFlow, } from '@/shared/hooks/addSchedule' +import { useUnsavedCloseGuard } from '@/shared/hooks/common/useUnsavedCloseGuard' import { useSyncEventTiming } from '@/shared/hooks/form' import type { UseAddScheduleFormResult } from '@/shared/hooks/form/useAddScheduleForm' import type { AddScheduleFormValues } from '@/shared/types/event/event' @@ -63,43 +64,12 @@ const AddScheduleFormContent = ({ } = schedule const { setValue, getValues, formState } = useFormContext() const { isDirty } = formState - const allowCloseRef = useRef(false) - const [isUnsavedConfirmOpen, setIsUnsavedConfirmOpen] = useState(false) - - const requestClose = useCallback( - (force?: boolean) => { - if (force) { - allowCloseRef.current = true - } - onClose() - }, - [onClose], - ) - - const closeGuard = useCallback(() => { - if (allowCloseRef.current) { - allowCloseRef.current = false - return true - } - if (!isDirty) return true - setIsUnsavedConfirmOpen(true) - return false - }, [isDirty]) - - const handleCloseUnsavedConfirm = useCallback(() => { - setIsUnsavedConfirmOpen(false) - }, []) - - const handleLeaveUnsavedForm = useCallback(() => { - setIsUnsavedConfirmOpen(false) - requestClose(true) - }, [requestClose]) - - useEffect(() => { - if (!registerCloseGuard) return - registerCloseGuard(closeGuard) - return () => registerCloseGuard() - }, [closeGuard, registerCloseGuard]) + const { isUnsavedConfirmOpen, requestClose, handleCloseUnsavedConfirm, handleLeaveUnsavedForm } = + useUnsavedCloseGuard({ + isDirty, + onClose, + registerCloseGuard, + }) // 패치 요청에 필요한 포맷/빌더/함수 묶음 const { formatDateTime, buildDateTime, patchSchedule, createSchedule } = @@ -130,19 +100,31 @@ const AddScheduleFormContent = ({ // 종일 토글 처리 (시간 필드 초기화 포함) const handleAllDayToggle = useCallback(() => { const nextIsAllDay = !isAllday + const isExistingRecurring = initialEvent?.recurrenceGroup != null setIsAllday(nextIsAllDay) if (nextIsAllDay) { setValue('eventStartTime', undefined, { shouldValidate: true }) setValue('eventEndTime', undefined, { shouldValidate: true }) } if (isEditing) { - void patchSchedule({ - ...getValues(), - isAllday: nextIsAllDay, - ...(nextIsAllDay ? { eventStartTime: undefined, eventEndTime: undefined } : {}), - }) + void patchSchedule( + { + ...getValues(), + isAllday: nextIsAllDay, + ...(nextIsAllDay ? { eventStartTime: undefined, eventEndTime: undefined } : {}), + }, + isExistingRecurring ? 'THIS_EVENT' : undefined, + ) } - }, [getValues, isAllday, isEditing, patchSchedule, setIsAllday, setValue]) + }, [ + getValues, + initialEvent?.recurrenceGroup, + isAllday, + isEditing, + patchSchedule, + setIsAllday, + setValue, + ]) const isInlineMode = mode === 'inline' const shouldShowModalOverlay = !isInlineMode && (activeCalendarField || isSearchPlaceOpen) diff --git a/src/shared/ui/modal/AddTodo/components/AddTodoForm.tsx b/src/shared/ui/modal/AddTodo/components/AddTodoForm.tsx index 4873c8f..1f1d7af 100644 --- a/src/shared/ui/modal/AddTodo/components/AddTodoForm.tsx +++ b/src/shared/ui/modal/AddTodo/components/AddTodoForm.tsx @@ -12,31 +12,29 @@ import { import { createPortal } from 'react-dom' import { FormProvider } from 'react-hook-form' +import { useTodoSubmitFlow } from '@/shared/hooks/addTodo/useTodoSubmitFlow' +import { useUnsavedCloseGuard } from '@/shared/hooks/common/useUnsavedCloseGuard' import { useSyncEventTiming } from '@/shared/hooks/form' import { useAddTodoForm } from '@/shared/hooks/form/useAddTodoForm' import { useTodoMutations } from '@/shared/hooks/query/useTodoMutations' import { useGetDetailTodoQuery } from '@/shared/hooks/query/useTodoQueries' -import { useRepeatChangeGuard } from '@/shared/hooks/repeat/useRepeatChangeGuard' import { theme } from '@/shared/styles/theme' import type { CalendarEvent } from '@/shared/types/calendar/types' import { type AddTodoFormValues, type RepeatConfigSchema } from '@/shared/types/event/event' +import type { PriorityType } from '@/shared/types/event/priority' import { defaultRepeatConfig } from '@/shared/types/recurrence/repeat' import Checkbox from '@/shared/ui/common/Checkbox/Checkbox' import RepeatTypeGroup from '@/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup' import TerminationPanel from '@/shared/ui/common/TerminationPanel/TerminationPanel' import TitleSuggestionInput from '@/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput' -import { - DeleteConfirmModal, - EditConfirmModal, - type EditConfirmOption, - UnsavedChangesConfirmModal, -} from '@/shared/ui/modal' +import { DeleteConfirmModal, EditConfirmModal, UnsavedChangesConfirmModal } from '@/shared/ui/modal' import * as S from '@/shared/ui/modal/AddTodo/index.style' import CustomBasisPanel from '@/shared/ui/modal/common/CustomBasisPanel/CustomBasisPanel' import CustomDatePicker from '@/shared/ui/modal/common/CustomDatePicker/CustomDatePicker' import CustomTimePicker from '@/shared/ui/modal/common/CustomTimePicker/CustomTimePicker' import SelectColor from '@/shared/ui/modal/common/SelectColor/SelectColor' import { formatDisplayDate } from '@/shared/utils/date' +import { getPriorityColor } from '@/shared/utils/priority' import { mapRecurrenceGroupToRepeatConfig } from '@/shared/utils/recurrenceGroup' type AddTodoFormProps = { @@ -59,6 +57,12 @@ type AddTodoFormProps = { ) => void } +const PRIORITY_OPTIONS: Array<{ value: PriorityType; label: string }> = [ + { value: 'HIGH', label: '높음' }, + { value: 'MEDIUM', label: '중간' }, + { value: 'LOW', label: '낮음' }, +] + const AddTodoForm = ({ date, mode = 'modal', @@ -93,14 +97,23 @@ const AddTodoForm = ({ todoTitle, eventColor, setEventColor, + todoPriority, + setTodoPriority, } = useAddTodoForm({ date, id: eventId, isEditing }) const { register, setValue, formState } = formMethods const { isDirty } = formState - const allowCloseRef = useRef(false) - const [isUnsavedConfirmOpen, setIsUnsavedConfirmOpen] = useState(false) - const occurrenceDate = useMemo(() => moment(date).format('YYYY-MM-DD'), [date]) + const [detailOccurrenceDate, setDetailOccurrenceDate] = useState(() => + moment(date).format('YYYY-MM-DD'), + ) + const detailQueryAnchorRef = useRef<{ eventId: CalendarEvent['id']; date: string } | null>(null) + const hydratedDetailKeyRef = useRef(null) const shouldFetchDetail = isEditing && eventId != null && eventId !== 0 - const { data: detailData } = useGetDetailTodoQuery(eventId, occurrenceDate, shouldFetchDetail) + const { data: detailData } = useGetDetailTodoQuery( + eventId, + detailOccurrenceDate, + shouldFetchDetail, + ) + const isPersistedTodo = isEditing && eventId != null && eventId !== 0 const { useDeleteTodo, usePatchTodo } = useTodoMutations() const { mutate: deleteTodoMutate } = useDeleteTodo() const { mutate: patchTodoMutate } = usePatchTodo() @@ -111,13 +124,33 @@ const AddTodoForm = ({ return window.matchMedia(`(max-width: ${theme.breakPoints.tablet})`).matches }) const startDate = formatDisplayDate(todoDate) + const { isUnsavedConfirmOpen, requestClose, handleCloseUnsavedConfirm, handleLeaveUnsavedForm } = + useUnsavedCloseGuard({ + isDirty, + onClose, + registerCloseGuard, + }) + + useEffect(() => { + if (!isEditing || eventId == null || eventId === 0) return + const nextDate = moment(date).format('YYYY-MM-DD') + const anchor = detailQueryAnchorRef.current + if (!anchor || anchor.eventId !== eventId) { + detailQueryAnchorRef.current = { eventId, date: nextDate } + hydratedDetailKeyRef.current = null + setDetailOccurrenceDate(nextDate) + } + }, [date, eventId, isEditing]) useEffect(() => { if (!isEditing) return const detail = detailData?.result if (!detail) return + const detailKey = `${detail.todoId}-${detail.occurrenceDate ?? ''}` + if (hydratedDetailKeyRef.current === detailKey) return + hydratedDetailKeyRef.current = detailKey - const baseDate = detail.occurrenceDate ? new Date(detail.occurrenceDate) : new Date(date) + const baseDate = detail.occurrenceDate ? new Date(detail.occurrenceDate) : new Date() const dueTime = detail.dueTime const parsedTime = typeof dueTime === 'string' @@ -132,6 +165,7 @@ const AddTodoForm = ({ setValue('todoDate', baseDate, { shouldValidate: true }) setValue('todoEndTime', parsedTime, { shouldValidate: true }) setValue('eventColor', detail.color ?? 'GRAY', { shouldValidate: true }) + setValue('todoPriority', detail.priority ?? 'MEDIUM', { shouldValidate: true }) setIsAllday(detail.isAllDay) const mappedRepeatConfig = mapRecurrenceGroupToRepeatConfig(detail.recurrenceGroup) @@ -149,7 +183,7 @@ const AddTodoForm = ({ customYearlyMonths: mappedRepeatConfig.customYearlyMonths ?? [], } setValue('repeatConfig', nextRepeatConfig, { shouldValidate: true }) - }, [date, detailData, isEditing, setIsAllday, setValue]) + }, [detailData, isEditing, setIsAllday, setValue]) const handleCalendarButtonClick = (field: 'start' | 'end') => (event: ReactMouseEvent) => { @@ -189,48 +223,12 @@ const AddTodoForm = ({ const isInlineMode = mode === 'inline' const shouldShowModalOverlay = !isInlineMode && activeCalendarField - const isPersistedTodo = isEditing && eventId != null && eventId !== 0 const deleteOccurrenceDate = useMemo(() => { if (detailData?.result?.occurrenceDate) { return moment(detailData.result.occurrenceDate).format('YYYY-MM-DD') } return moment(todoDate ?? date).format('YYYY-MM-DD') }, [date, detailData?.result?.occurrenceDate, todoDate]) - const requestClose = useCallback( - (force?: boolean) => { - if (force) { - allowCloseRef.current = true - } - onClose() - }, - [onClose], - ) - - const closeGuard = useCallback(() => { - if (allowCloseRef.current) { - allowCloseRef.current = false - return true - } - if (!isDirty) return true - setIsUnsavedConfirmOpen(true) - return false - }, [isDirty]) - - const handleCloseUnsavedConfirm = useCallback(() => { - setIsUnsavedConfirmOpen(false) - }, []) - - const handleLeaveUnsavedForm = useCallback(() => { - setIsUnsavedConfirmOpen(false) - requestClose(true) - }, [requestClose]) - - useEffect(() => { - if (!registerCloseGuard) return - registerCloseGuard(closeGuard) - return () => registerCloseGuard() - }, [closeGuard, registerCloseGuard]) - const renderTitleInput = () => ( (null) + const patchOccurrenceDate = useMemo(() => { + if (detailData?.result?.occurrenceDate) { + return moment(detailData.result.occurrenceDate).format('YYYY-MM-DD') + } + return moment(todoDate ?? date).format('YYYY-MM-DD') + }, [date, detailData?.result?.occurrenceDate, todoDate]) const buildDateTime = useCallback((dateValue: Date | null, timeValue?: string) => { const nextDate = dateValue ? new Date(dateValue) : new Date() @@ -290,73 +282,27 @@ const AddTodoForm = ({ }, [buildDateTime, date, eventId, onEventTimingChange], ) - - const handleFormSubmit = handleSubmit(async (values) => { - if (requestConfirmation()) { - setPendingTodoValues(values) - return - } - if (eventId != null && eventId !== 0) { - const nextTitle = values.todoTitle ?? '' - if (nextTitle) { - onEventTitleConfirm?.(eventId, nextTitle) - } - } - syncEventTiming(values) - try { - await onSubmit(values) - requestClose(true) - } catch (error) { - console.error('[AddTodoForm] submit failed', error) - const message = - error instanceof Error - ? error.message - : '할 일 저장 중 오류가 발생했습니다. 다시 시도해주세요.' - alert(message) - } + const { + isEditConfirmOpen, + isApplyConfirmOpen, + handleFormSubmit, + handleConfirmedSubmit, + handleCancelRepeat, + } = useTodoSubmitFlow({ + eventId, + hasExistingRecurrence, + repeatGuardEnabled, + isDetailReady: !isPersistedTodo || !shouldFetchDetail || Boolean(detailData?.result), + repeatConfig, + setValue, + handleSubmit, + patchOccurrenceDate, + onSubmit, + onClose: () => requestClose(true), + syncEventTiming, + onEventTitleConfirm, }) - const handleConfirmedSubmit = useCallback( - async (option: EditConfirmOption) => { - void option - if (!pendingTodoValues) return - confirmChange() - if (eventId != null && eventId !== 0) { - const nextTitle = pendingTodoValues.todoTitle ?? '' - if (nextTitle) { - onEventTitleConfirm?.(eventId, nextTitle) - } - } - syncEventTiming(pendingTodoValues) - try { - await onSubmit(pendingTodoValues) - requestClose(true) - setPendingTodoValues(null) - } catch (error) { - console.error('[AddTodoForm] submit failed', error) - const message = - error instanceof Error - ? error.message - : '할 일 저장 중 오류가 발생했습니다. 다시 시도해주세요.' - alert(message) - } - }, - [ - confirmChange, - eventId, - onEventTitleConfirm, - onSubmit, - pendingTodoValues, - requestClose, - syncEventTiming, - ], - ) - - const handleCancelRepeat = useCallback(() => { - revertChange() - setPendingTodoValues(null) - }, [revertChange]) - const handleDelete = useCallback(() => { if (!isPersistedTodo) { requestClose(true) @@ -493,6 +439,27 @@ const AddTodoForm = ({ document.getElementById('modal-root')!, )} + + 중요도 + + {PRIORITY_OPTIONS.map((option) => { + const token = getPriorityColor(option.value) + const palette = theme.colors.priority[token] + return ( + setTodoPriority(option.value)} + > + {option.label} + + ) + })} + + setIsAllday((prev) => !prev)} @@ -554,7 +521,7 @@ const AddTodoForm = ({ mutate={deleteTodoMutate} /> )} - {isEditConfirmOpen && ( + {(isEditConfirmOpen || isApplyConfirmOpen) && ( )} {isUnsavedConfirmOpen && ( diff --git a/src/shared/ui/modal/AddTodo/index.style.ts b/src/shared/ui/modal/AddTodo/index.style.ts index dbb0508..496cabf 100644 --- a/src/shared/ui/modal/AddTodo/index.style.ts +++ b/src/shared/ui/modal/AddTodo/index.style.ts @@ -47,6 +47,51 @@ export const Selection = styled.div` flex-direction: column; gap: 12px; ` +export const PrioritySection = styled.div` + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +` +export const PriorityLabel = styled.span` + font-size: 14px; + color: ${theme.colors.textColor3}; + font-weight: 500; +` +export const PriorityOptions = styled.div` + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +` +export const PriorityOptionButton = styled.button<{ + isActive: boolean + baseColor: string + pointColor: string +}>` + border: none; + border-radius: 999px; + min-width: 64px; + padding: 8px 14px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: + background-color 0.2s ease, + color 0.2s ease, + transform 0.1s ease; + background-color: ${({ isActive, baseColor }) => (isActive ? baseColor : theme.colors.lightGray)}; + color: ${({ isActive, pointColor }) => (isActive ? pointColor : theme.colors.textColor3)}; + + &:active { + transform: scale(0.97); + } + + &:focus-visible { + outline: 2px solid ${({ pointColor }) => pointColor}; + outline-offset: 2px; + } +` export const SelectionColumn = styled.div` display: flex; gap: 12px; diff --git a/src/shared/utils/date.ts b/src/shared/utils/date.ts index f726ab9..1a16220 100644 --- a/src/shared/utils/date.ts +++ b/src/shared/utils/date.ts @@ -1,13 +1,16 @@ import type { MonthlyPatternDay, MonthlyPatternWeek } from '../types/recurrence/repeat' +// Date/string/null 혼합 입력을 Date 또는 null로 정규화한다. export const toDateValue = (value: Date | string | null | undefined) => value instanceof Date ? value : value ? new Date(value) : null +// 캘린더 입력 필드에서 사용하는 로케일 날짜 포맷으로 변환한다. export const formatCalendarDate = (value: Date | string | null | undefined) => { const dateValue = toDateValue(value) return dateValue ? dateValue.toLocaleDateString('ko-KR') : '날짜 선택' } +// API 송수신에 사용할 ISO 형식(YYYY-MM-DD) 문자열을 생성한다. export const formatIsoDate = (value: Date | string | null | undefined) => { const dateValue = toDateValue(value) if (!dateValue) return '날짜 선택' @@ -17,6 +20,7 @@ export const formatIsoDate = (value: Date | string | null | undefined) => { return `${year}-${month}-${day}` } +// 화면 표시용 날짜 포맷(YYYY. MM. DD)으로 변환한다. export const formatDisplayDate = (value: Date | string | null | undefined) => { const dateValue = toDateValue(value) if (!dateValue) return '날짜 선택' @@ -26,8 +30,11 @@ export const formatDisplayDate = (value: Date | string | null | undefined) => { return `${year}. ${month}. ${day}` } +// 반복 설정의 주차 값을 UI 라벨 문자열로 변환한다. export const weekLabel = (value: MonthlyPatternWeek) => value === 'last' ? '마지막주' : `${value}주` + +// 반복 설정의 요일/규칙 키를 사용자에게 보여줄 한글 라벨로 변환한다. export const dayLabel = (value: MonthlyPatternDay) => ({ mon: '월요일', diff --git a/src/shared/utils/priority.ts b/src/shared/utils/priority.ts index 1e0609f..dc43557 100644 --- a/src/shared/utils/priority.ts +++ b/src/shared/utils/priority.ts @@ -1,5 +1,6 @@ import type { PriorityColorType } from '../types/event/priority' +// 우선순위 enum(HIGH/MEDIUM/LOW)을 한국어 라벨로 변환한다. export function translatePriority(priority: 'HIGH' | 'MEDIUM' | 'LOW'): string { switch (priority) { case 'HIGH': @@ -13,6 +14,7 @@ export function translatePriority(priority: 'HIGH' | 'MEDIUM' | 'LOW'): string { } } +// 우선순위 enum을 배지 색상 토큰으로 변환한다. export function getPriorityColor(priority: 'HIGH' | 'MEDIUM' | 'LOW'): PriorityColorType { switch (priority) { case 'HIGH': diff --git a/src/shared/utils/recurrenceGroup.ts b/src/shared/utils/recurrenceGroup.ts index a83c3bf..364b295 100644 --- a/src/shared/utils/recurrenceGroup.ts +++ b/src/shared/utils/recurrenceGroup.ts @@ -18,6 +18,7 @@ type RecurrenceLike = RecurrenceGroup & { isCustom?: boolean } +// 서버 요일 enum과 폼 요일 키 간 양방향 변환 테이블이다. const WEEKDAY_MAP: Record = { SUNDAY: 'sun', MONDAY: 'mon', @@ -38,12 +39,15 @@ const WEEKDAY_REVERSE_MAP: Record = { sat: 'SATURDAY', } +// 서버 요일 값을 반복 설정 UI에서 사용하는 키로 변환한다. const toWeekday = (value?: Week | null): WeekdayName | undefined => value ? WEEKDAY_MAP[value] : undefined +// 주간 반복 요일 배열을 UI의 요일 배열 포맷으로 바꾼다. const toWeekdays = (values?: Week[] | null) => values ? values.map((value) => WEEKDAY_MAP[value]) : [] +// API의 weekOfMonth 숫자(-1, 1~5)를 UI 패턴 값('last', '1'~'5')으로 변환한다. const toPatternWeek = (value?: number | null): MonthlyPatternWeek | undefined => { if (!value) return undefined if (value <= 0) return 'last' @@ -51,11 +55,13 @@ const toPatternWeek = (value?: number | null): MonthlyPatternWeek | undefined => return String(normalized) as MonthlyPatternWeek } +// 월간 규칙의 단일 요일 값을 배열 기반 처리 로직과 맞추기 위해 배열로 감싼다. const toWeekArray = (value?: RecurrenceGroup['dayOfWeekInMonth']): Week[] => { if (!value) return [] return [value] } +// 월간/연간 규칙(평일/주말/단일요일)을 UI 패턴 day 값으로 통합 변환한다. const toPatternDayFromRule = ( rule?: MonthlyWeekDayRule | null, value?: RecurrenceGroup['dayOfWeekInMonth'], @@ -68,18 +74,22 @@ const toPatternDayFromRule = ( return toWeekday(weekdays[0]) } +// UI 요일 키를 서버 요일 enum으로 역변환한다. const toWeek = (value?: WeekdayName | null): Week | undefined => value ? WEEKDAY_REVERSE_MAP[value] : undefined +// pattern day 값 중 단일 요일만 서버 요일 enum으로 변환한다. const toWeekFromPatternDay = (value?: MonthlyPatternDay | null): Week | undefined => { if (!value) return undefined if (value === 'weekday' || value === 'weekend' || value === 'allweek') return undefined return toWeek(value as WeekdayName) } +// UI 요일 배열에서 빈 값을 제거한 뒤 서버 요일 enum 배열로 변환한다. const toWeeks = (values?: (WeekdayName | undefined)[] | null) => values?.filter(Boolean).map((value) => WEEKDAY_REVERSE_MAP[value as WeekdayName]) ?? [] +// 서버 recurrenceGroup을 반복 설정 폼의 RepeatConfig로 변환한다. export const mapRecurrenceGroupToRepeatConfig = (group?: RecurrenceGroup | null): RepeatConfig => { if (!group) { return { @@ -164,6 +174,7 @@ export const mapRecurrenceGroupToRepeatConfig = (group?: RecurrenceGroup | null) return base } +// 반복 설정 폼 값을 서버 전송용 recurrenceGroup DTO로 변환한다. export const mapRepeatConfigToRecurrenceGroup = ( config?: RepeatConfig | null, ): RecurrenceGroup | null => { diff --git a/src/shared/utils/recurrencePattern.ts b/src/shared/utils/recurrencePattern.ts new file mode 100644 index 0000000..0925aa9 --- /dev/null +++ b/src/shared/utils/recurrencePattern.ts @@ -0,0 +1,69 @@ +import type { CalendarEvent } from '@/shared/types/calendar/types' +import type { Week } from '@/shared/types/event/event' +import type { RecurrenceGroup } from '@/shared/types/recurrence/recurrence' + +const WEEKDAY_BY_INDEX: Week[] = [ + 'SUNDAY', + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', +] + +export type RecurrenceLike = RecurrenceGroup & { + interval?: number + dayOfWeekInMonth?: RecurrenceGroup['dayOfWeekInMonth'] | Week[] +} + +// Date에서 서버 반복 규칙에 쓰는 요일 enum을 계산한다. +export const toWeekday = (value: Date): Week => WEEKDAY_BY_INDEX[value.getDay()] + +// 1~5주차 또는 마지막주(-1) 값을 반환합니다. +// 월 경계를 넘기는 다음 주 날짜를 확인해 "마지막 주"를 판정합니다. +export const toWeekOfMonth = (value: Date) => { + const weekNumber = Math.floor((value.getDate() - 1) / 7) + 1 + const nextWeek = new Date(value) + nextWeek.setDate(value.getDate() + 7) + return nextWeek.getMonth() === value.getMonth() ? weekNumber : -1 +} + +export const isSameYmd = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + +// dayOfWeekInMonth가 배열로 들어오는 케이스를 단일 요일 값으로 정규화한다. +export const normalizeDayOfWeekInMonth = ( + value: RecurrenceLike['dayOfWeekInMonth'], +): RecurrenceGroup['dayOfWeekInMonth'] => { + if (value == null) return value + if (Array.isArray(value)) return value[0] ?? null + return value +} + +// recurrenceGroup은 API/로컬 상태에서 모양이 조금씩 달라질 수 있어, +// patch 전에는 공통 DTO 형태(intervalValue/dayOfWeekInMonth 등)로 맞춰서 사용합니다. +export const normalizeRecurrenceGroupPayload = ( + group: CalendarEvent['recurrenceGroup'] | RecurrenceGroup | null | undefined, +): RecurrenceGroup | null => { + if (!group) return null + const source = group as RecurrenceLike + return { + frequency: source.frequency, + endType: source.endType, + intervalValue: source.intervalValue ?? source.interval ?? 1, + ...(source.endDate ? { endDate: source.endDate } : {}), + ...(source.occurrenceCount != null ? { occurrenceCount: source.occurrenceCount } : {}), + ...(source.daysOfWeek ? { daysOfWeek: [...source.daysOfWeek] } : {}), + ...(source.monthlyType ? { monthlyType: source.monthlyType } : {}), + ...(source.weekOfMonth != null ? { weekOfMonth: source.weekOfMonth } : {}), + ...(source.daysOfMonth ? { daysOfMonth: [...source.daysOfMonth] } : {}), + ...(source.weekdayRule ? { weekdayRule: source.weekdayRule } : {}), + ...(source.dayOfWeekInMonth !== undefined + ? { dayOfWeekInMonth: normalizeDayOfWeekInMonth(source.dayOfWeekInMonth) } + : {}), + ...(source.monthOfYear != null ? { monthOfYear: source.monthOfYear } : {}), + } +} diff --git a/src/shared/utils/repeatConfig.ts b/src/shared/utils/repeatConfig.ts index d2ca6ca..747870c 100644 --- a/src/shared/utils/repeatConfig.ts +++ b/src/shared/utils/repeatConfig.ts @@ -1,7 +1,9 @@ import { type RepeatConfigSchema } from '@/shared/types/event/event' +// 깊은 비교를 위해 반복 설정 객체를 직렬화 가능한 문자열로 변환한다. const serializeRepeatConfig = (config: RepeatConfigSchema) => JSON.stringify(config) +// 동일한 반복 설정인지 판단할 때 필드 단위 비교 대신 직렬화 결과를 비교한다. export const areRepeatConfigsEqual = ( left: RepeatConfigSchema, right: RepeatConfigSchema, diff --git a/src/shared/utils/timeUtils.ts b/src/shared/utils/timeUtils.ts index 25dabf4..aa22018 100644 --- a/src/shared/utils/timeUtils.ts +++ b/src/shared/utils/timeUtils.ts @@ -1,9 +1,11 @@ +// HH:mm 문자열을 분(minute) 단위 정수로 변환한다. export const toMinutes = (time?: string) => { if (!time) return 0 const [hours, minutes] = time.split(':').map((part) => Number(part)) return hours * 60 + minutes } +// 분 단위 값을 HH:mm 형식 문자열로 포맷한다. export const formatMinutes = (total: number) => { const normalized = Math.max(total, 0) const hours = Math.floor(normalized / 60)