Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 67 additions & 14 deletions src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -69,6 +79,7 @@ const CustomCalendar = ({ onSelectedDateChange }: CustomCalendarProps) => {
const { mutate: patchCompleteTodoMutate } = usePatchCompleteTodo()
const { mutate: patchTodoMutate } = usePatchTodo()
const { mutate: deleteTodoMutate } = useDeleteTodo()
const recurringTodoPatchSeqRef = useRef<Map<string, number>>(new Map())
const [deleteConfirm, setDeleteConfirm] = useState<{
isOpen: boolean
eventId: CalendarEvent['id'] | null
Expand Down Expand Up @@ -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(
(
Expand All @@ -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],
)

// 반응형 레이아웃 판단
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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])
Expand Down
20 changes: 18 additions & 2 deletions src/features/Calendar/components/CustomEvent/CustomEvent.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
`
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -33,17 +35,71 @@ 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<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<S.MonthEventContainer
backgroundColor={baseColor}
pointColor={pointColor}
isSelected={isSelected}
onClick={(eventMouse) => {
eventMouse.stopPropagation()
hideTooltip()
onEventClick(event)
}}
onDoubleClick={(eventMouse) => {
eventMouse.stopPropagation()
hideTooltip()
onEventDoubleClick(event)
}}
>
Expand All @@ -63,9 +119,24 @@ const CustomMonthEvent = ({
) : (
<S.Circle backgroundColor={pointColor} />
)}
<S.EventTitle>{event.title}</S.EventTitle>
<S.EventTitle
ref={titleRef}
onMouseEnter={showTooltip}
onMouseMove={updateTooltipPosition}
onMouseLeave={hideTooltip}
>
{event.title}
</S.EventTitle>
</S.EventRow>
<S.EventMeta>{formatTimeRange(event)}</S.EventMeta>
{tooltip.visible &&
typeof document !== 'undefined' &&
createPortal(
<S.MonthEventTooltip style={{ left: tooltip.x, top: tooltip.y }}>
{tooltipText}
</S.MonthEventTooltip>,
document.body,
)}
</S.MonthEventContainer>
)
}
Expand Down
74 changes: 72 additions & 2 deletions src/features/Calendar/components/CustomEvent/CustomWeekEvent.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -34,6 +35,58 @@ const CustomWeekEvent: React.FC<CustomWeekEventProps> = ({
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<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<S.WeekEventContainer
Expand All @@ -42,10 +95,12 @@ const CustomWeekEvent: React.FC<CustomWeekEventProps> = ({
isSelected={isSelected}
onClick={(eventMouse) => {
eventMouse.stopPropagation()
hideTooltip()
onEventClick(event)
}}
onDoubleClick={(eventMouse) => {
eventMouse.stopPropagation()
hideTooltip()
onEventDoubleClick(event)
}}
>
Expand All @@ -65,10 +120,25 @@ const CustomWeekEvent: React.FC<CustomWeekEventProps> = ({
) : (
<S.Circle backgroundColor={pointColor} />
)}
<S.EventTitle>{event.title}</S.EventTitle>
<S.EventTitle
ref={titleRef}
onMouseEnter={showTooltip}
onMouseMove={updateTooltipPosition}
onMouseLeave={hideTooltip}
>
{event.title}
</S.EventTitle>
</S.WeekEventRow>
<S.EventWeekMeta>{formatTimeRange(event)}</S.EventWeekMeta>
{event.location && <S.EventLocation>{event.location}</S.EventLocation>}
{tooltip.visible &&
typeof document !== 'undefined' &&
createPortal(
<S.MonthEventTooltip style={{ left: tooltip.x, top: tooltip.y }}>
{tooltipText}
</S.MonthEventTooltip>,
document.body,
)}
</S.WeekEventContainer>
)
}
Expand Down
Loading