From 8797d85be00dcffe6a8fbe05083bdbd74146f13b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=B4=EB=A6=AC=28=EC=B5=9C=ED=98=84=EC=9B=85=29?= Date: Tue, 24 Sep 2024 21:13:05 +0900 Subject: [PATCH] =?UTF-8?q?[FE]=20=EB=9F=B0=EC=B9=AD=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B0=9B=EC=9D=80=20=EB=8B=AC=EB=A0=A5=20?= =?UTF-8?q?UI=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81=20(#346)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 한국 공휴일을 계산해 주는 라이브러리 추가 * chore: 이전 달력 컴포넌트 & 로직 레거시 폴더로 이동 * chore: 이전에 사용했던 달력 데이터 계산 로직 커스텀 훅 삭제 * feat: 재사용할 수 있는 달력 공통 컴포넌트 구현 - useCalendar 커스텀 훅이 반환하는 달력 데이터를 각 컴포넌트가 뽑아서 사용할 수 있도록 Context API 활용 - Header, Weekdays, Body 컴포넌트로 달력을 구성하는 컴포넌트들을 합성 컴포넌트 구조로 구현 * feat: 공통 달력 컴포넌트, 헤더 컴포넌트 구현 - render props 패턴을 활용하여 useCalendar 컴포넌트가 반환하는 월, 일 데이터를 사용해서 UI를 그리는 컴포넌트를 호출할 수 있도록 구현 * feat: 공통 달력 컴포넌트, 일주일 날짜를 표현하는 컴포넌트 구현 - render props 패턴을 활용하여 useCalendar 컴포넌트가 반환하는 일주일 날짜 데이터를 사용해서 UI를 그리는 컴포넌트를 호출할 수 있도록 구현 * feat: 공통 달력 컴포넌트, 한 달을 구성하는 모든 날짜를 표현하는 컴포넌트 구현 - render props 패턴을 활용하여 useCalendar 컴포넌트가 반환하는 날짜 데이터를 사용해서 UI를 그리는 컴포넌트를 호출할 수 있도록 구현 * feat: 약속을 생성할 때 필요한 달력 컴포넌트 - 헤더 컴포넌트 구현 * feat: 약속을 생성할 때 필요한 달력 컴포넌트 - 일주일 날짜 컴포넌트 구현 * feat: 하나씩, 기간으로 날짜를 선택할 수 있는 인터페이스를 제공하는 useDateSelect 커스텀 훅 구현 - 날짜를 선택할 수 있는 모드인 single, range를 상태로 관리 - 시작/끝 날짜가 모두 선택되면 그 사이 모든 날짜가 선택되도록 getDatesInRange 유틸 함수 구현 - 끝 날짜가 시작 날짜보다 이른 경우 해당 끝 날짜를 다시 시작 날짜로 변경하도록 예외 처리 - 모든 범위 날짜가 선택되었을 때, 다른 날짜를 선택한 경우 해당 다른 날짜를 시작 날짜로 변경하도록 예외 처리 * feat: 한 달의 날짜 데이터, 다음/이전 달로 이동시킬 수 있는 인터페이스를 제공하는 useCalendar 커스텀 훅 구현 * chore: 폴더명 변경에 따른 import 경로 수정 * chore: 공통 달력 컴포넌트, useCalendar 커스텀 훅에서 필요한 타입 정의 * feat: 달, 년(year), 일, 날짜, 전체 날짜를 계산하는 유틸 함수 구현 * feat: 하나씩, 범위로 날짜를 선택하는 경우 UI를 구성하는 컴포넌트 구현 - 기존에는 하나의 CalendarDate로 구현을 하려고 했으나, 하나씩 선택하는 것과 범위로 선택하는 것의 UI 책임이 너무 다르다고 판단해 따로 구현하는 것으로 결정 * feat: 달력을 구성하는 각각의 컴포넌트들이 Context API로 공유되는 달력 데이터를 뽑아서 사용할 수 있도록 커스텀 훅 구현 * refactor: 변경된 달력 컴포넌트 반영 * feat: 날짜 추가 정보를 보여주는 컴포넌트 구현 * refactor: 날짜 추가 정보를 보여주는 컴포넌트를 사용하는 것으로 수정 * chore: Fragment 제거, 리액트 모듈 import 구문 제거 * chore: 현재 날짜 정보를 구할 때, 더 의미있는 변수명을 사용하는 것으로 개선 * design: 특정 날짜의 상태를 구하기 위해서 연속된 if문을 사용하는 것이 아닌 객체를 활용해서 구하는 것으로 수정 * feat: 유효한 배열의 타입인지 확인하는 타입 가드 함수 구현 * chore: 날짜 아래에 위치하는 텍스트 컴포넌트명을 더 의미있게 수정 * chore: 현재 달의 상태를 나타내는 변수명 수정 사항 반영, 날짜 상태 props 순서 수정 * chore: 달(Month) 상태 타입 네이밍 수정 * chore: 달력 정보를 Context API를 활용해서 내려주는 프로바이더 컴포넌트 위치 변경 반영 * chore: 달력 정보를 Context API를 활용해서 내려주는 프로바이더 컴포넌트 위치 변경 반영 * chore: 유효한 배열의 타입인지 확인하는 타입 가드 함수 위치 변경 반영 * refactor: 현재 달(Month)의 상태를 나타내는 로직을 함수로 분리 * chore: 함수 개행 추가 * chore: aria-label을 추가하기 위한 TabButton 컴포넌트 props 타입 변경 * chore: type import 구문 컨벤션에 맞게 수정 * chore: 실수로 지웠던 HTML Entities 복구 * chore: 필요없는 날짜 계산 로직 제거 --- frontend/legacy/Calendar/Calendar.stories.tsx | 64 +++++++ frontend/legacy/Calendar/Calendar.styles.ts | 93 ++++++++++ .../legacy/Calendar/CalendarDate.styles.ts | 159 +++++++++++++++++ .../Calendar/Date/RangeCalendarDate.tsx | 74 ++++++++ .../Calendar/Date/SingleCalendarDate.tsx | 62 +++++++ frontend/legacy/Calendar/index.tsx | 98 +++++++++++ .../hooks/useCalendarInfo/useCalendarInfo.ts | 0 .../useCalendarInfo/useCalendarInfo.utils.ts | 164 +++++++++++++++++ frontend/package-lock.json | 10 ++ frontend/package.json | 1 + .../MeetingCalendar/Date/Date.styles.ts | 166 ++++++++++++++++++ .../MeetingCalendar/Date/Date.utils.ts | 38 ++++ .../Date/DateAdditionalText.tsx | 36 ++++ .../MeetingCalendar/Date/RangeDate.tsx | 84 +++++++++ .../MeetingCalendar/Date/SingleDate.tsx | 64 +++++++ .../Header/MeetingCalendarHeader.styles.ts | 47 +++++ .../MeetingCalendar/Header/index.tsx | 69 ++++++++ .../MeetingCalendarWeekdays.styles.ts | 67 +++++++ .../MeetingCalendar/Weekdays/index.tsx | 22 +++ .../_common/Buttons/TabButton/index.tsx | 13 +- .../_common/Calendar/Body/index.tsx | 23 +++ .../_common/Calendar/Calendar.stories.tsx | 119 ++++++++----- .../_common/Calendar/Calendar.styles.ts | 155 ---------------- .../_common/Calendar/Header/index.tsx | 20 +++ .../_common/Calendar/Weekdays/index.tsx | 13 ++ .../src/components/_common/Calendar/index.tsx | 98 ++--------- frontend/src/contexts/CalendarProvider.tsx | 16 ++ frontend/src/hooks/useCalendar/useCalendar.ts | 62 +++++++ .../hooks/useCalendar/useCalendar.utils.ts | 115 ++++++++++++ .../useCalendarContext/useCalendarContext.ts | 13 ++ .../useCalendarInfo/useCalendarInfo.utils.ts | 55 ------ .../src/hooks/useDateSelect/useDateSelect.ts | 89 ++++++++++ .../useDateSelect/useDateSelect.utils.ts | 37 ++++ .../src/pages/CreateMeetingPage/index.tsx | 75 ++++++-- frontend/src/types/calendar.ts | 14 ++ frontend/src/utils/date.ts | 12 ++ frontend/src/utils/typeGuards.ts | 6 + 37 files changed, 1906 insertions(+), 347 deletions(-) create mode 100644 frontend/legacy/Calendar/Calendar.stories.tsx create mode 100644 frontend/legacy/Calendar/Calendar.styles.ts create mode 100644 frontend/legacy/Calendar/CalendarDate.styles.ts create mode 100644 frontend/legacy/Calendar/Date/RangeCalendarDate.tsx create mode 100644 frontend/legacy/Calendar/Date/SingleCalendarDate.tsx create mode 100644 frontend/legacy/Calendar/index.tsx rename frontend/{src => legacy}/hooks/useCalendarInfo/useCalendarInfo.ts (100%) create mode 100644 frontend/legacy/hooks/useCalendarInfo/useCalendarInfo.utils.ts create mode 100644 frontend/src/components/MeetingCalendar/Date/Date.styles.ts create mode 100644 frontend/src/components/MeetingCalendar/Date/Date.utils.ts create mode 100644 frontend/src/components/MeetingCalendar/Date/DateAdditionalText.tsx create mode 100644 frontend/src/components/MeetingCalendar/Date/RangeDate.tsx create mode 100644 frontend/src/components/MeetingCalendar/Date/SingleDate.tsx create mode 100644 frontend/src/components/MeetingCalendar/Header/MeetingCalendarHeader.styles.ts create mode 100644 frontend/src/components/MeetingCalendar/Header/index.tsx create mode 100644 frontend/src/components/MeetingCalendar/Weekdays/MeetingCalendarWeekdays.styles.ts create mode 100644 frontend/src/components/MeetingCalendar/Weekdays/index.tsx create mode 100644 frontend/src/components/_common/Calendar/Body/index.tsx create mode 100644 frontend/src/components/_common/Calendar/Header/index.tsx create mode 100644 frontend/src/components/_common/Calendar/Weekdays/index.tsx create mode 100644 frontend/src/contexts/CalendarProvider.tsx create mode 100644 frontend/src/hooks/useCalendar/useCalendar.ts create mode 100644 frontend/src/hooks/useCalendar/useCalendar.utils.ts create mode 100644 frontend/src/hooks/useCalendarContext/useCalendarContext.ts delete mode 100644 frontend/src/hooks/useCalendarInfo/useCalendarInfo.utils.ts create mode 100644 frontend/src/hooks/useDateSelect/useDateSelect.ts create mode 100644 frontend/src/hooks/useDateSelect/useDateSelect.utils.ts create mode 100644 frontend/src/types/calendar.ts create mode 100644 frontend/src/utils/typeGuards.ts diff --git a/frontend/legacy/Calendar/Calendar.stories.tsx b/frontend/legacy/Calendar/Calendar.stories.tsx new file mode 100644 index 000000000..258dfb42e --- /dev/null +++ b/frontend/legacy/Calendar/Calendar.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; + +import Calendar from './index'; + +const meta = { + title: 'Components/Calendar', + component: Calendar, + tags: ['autodocs'], + + parameters: { + layout: 'centered', + }, + argTypes: { + hasDate: { + description: '선택된 날짜들', + type: 'function', + control: { + disable: true, + }, + }, + onDateClick: { + description: '선택된 날짜 리스트에 특정 날짜를 추가하거나 제거할 수 있는 함수', + }, + }, + decorators: [ + (Story, context) => { + const [selectedDates, setSelectedDates] = useState([]); + + const hasDate = (date: string) => selectedDates.includes(date); + + const handleDateClick = (date: string) => { + setSelectedDates((prevDates) => + hasDate(date) ? prevDates.filter((d) => d !== date) : [...prevDates, date], + ); + }; + + return ( + + ); + }, + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + hasDate: () => false, + onDateClick: () => {}, + }, + render: (args) => { + return ; + }, +}; +export default {}; diff --git a/frontend/legacy/Calendar/Calendar.styles.ts b/frontend/legacy/Calendar/Calendar.styles.ts new file mode 100644 index 000000000..95cc429ad --- /dev/null +++ b/frontend/legacy/Calendar/Calendar.styles.ts @@ -0,0 +1,93 @@ +import { css } from '@emotion/react'; + +import theme from '@styles/theme'; + +import CALENDAR_PROPERTIES from '@constants/calendar'; + +export const s_calendarContainer = css` + display: flex; + flex-direction: column; + align-items: center; +`; + +export const s_calendarContent = css` + display: grid; + grid-template-columns: repeat(7, 1fr); + width: 100%; +`; + +export const s_dayOfWeekContainer = css` + margin-bottom: 2rem; +`; + +export const s_baseDayOfWeek = css` + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + min-width: 4rem; + height: 4rem; + min-height: 4rem; + + ${theme.typography.bodyMedium} +`; + +export const s_dayOfWeek = (index: number) => { + if (index === CALENDAR_PROPERTIES.sundayNumber) + return css` + ${DAY_SLOT_TEXT_STYLES.holiday} + `; + + if (index === CALENDAR_PROPERTIES.saturdayNumber) + return css` + ${DAY_SLOT_TEXT_STYLES.saturday} + `; + + return css` + ${DAY_SLOT_TEXT_STYLES.default} + `; +}; + +export const s_monthHeader = css` + display: flex; + align-items: center; + justify-content: space-between; + + width: 100%; + margin-bottom: 2rem; + padding: 0 1rem; +`; + +export const s_monthNavigation = css` + cursor: pointer; + background-color: transparent; + border: none; + + ${theme.typography.titleMedium} + + &:disabled { + color: ${theme.colors.grey.primary}; + } +`; + +const DAY_SLOT_TEXT_STYLES = { + selected: css` + color: ${theme.colors.calendar.color.selected}; + `, + holiday: css` + color: ${theme.colors.calendar.color.holiday}; + `, + today: css` + color: ${theme.colors.calendar.color.today}; + `, + saturday: css` + color: #8c9eff; + `, + prevDay: css` + color: ${theme.colors.grey.primary}; + `, + default: css` + color: ${theme.colors.black}; + `, +}; diff --git a/frontend/legacy/Calendar/CalendarDate.styles.ts b/frontend/legacy/Calendar/CalendarDate.styles.ts new file mode 100644 index 000000000..86cb330b4 --- /dev/null +++ b/frontend/legacy/Calendar/CalendarDate.styles.ts @@ -0,0 +1,159 @@ +import { css } from '@emotion/react'; +import type { FlagObject } from 'types/utility'; + +import theme from '@styles/theme'; + +export const s_dateContainer = css` + width: 100%; + min-width: 4.8rem; + height: 4.8rem; +`; + +export const s_baseDateButton = () => css` + cursor: pointer; + + position: relative; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + border: none; + + &:disabled { + cursor: default; + } +`; + +export const s_singleDateButton = (isSelectedDate: boolean) => css` + background-color: ${isSelectedDate ? theme.colors.pink.medium : 'transparent'}; + border-radius: 0.8rem; +`; + +export const s_rangeDateButton = (isSelectedDate: boolean) => css` + background-color: ${isSelectedDate ? theme.colors.pink.light : 'transparent'}; +`; + +export const s_baseDaySlotText = css` + ${theme.typography.bodyLight} +`; + +type DaySlotStatus = + | 'isSelectedFullDate' + | 'isPrevDate' + | 'isSunday' + | 'isSaturday' + | 'isHoliday' + | 'isToday'; + +export const s_daySlotText = ({ + isSelectedFullDate, + isPrevDate, + isSunday, + isSaturday, + isHoliday, + isToday, +}: FlagObject) => { + if (isSelectedFullDate) return DAY_SLOT_TEXT_STYLES.selected; + if (isHoliday) return DAY_SLOT_TEXT_STYLES.holiday; + if (isPrevDate) return DAY_SLOT_TEXT_STYLES.prevDay; + if (isToday) return DAY_SLOT_TEXT_STYLES.today; + if (isSunday) return DAY_SLOT_TEXT_STYLES.holiday; + if (isSaturday) return DAY_SLOT_TEXT_STYLES.saturday; + + return DAY_SLOT_TEXT_STYLES.default; +}; + +const DAY_SLOT_TEXT_STYLES = { + selected: css` + color: ${theme.colors.calendar.color.selected}; + `, + holiday: css` + color: ${theme.colors.calendar.color.holiday}; + `, + today: css` + color: ${theme.colors.calendar.color.today}; + `, + saturday: css` + color: #8c9eff; + `, + prevDay: css` + color: ${theme.colors.grey.primary}; + `, + default: css` + color: ${theme.colors.black}; + `, +}; + +export const s_holidayText = css` + font-size: 1rem; + font-weight: 300; + line-height: 1.2; +`; + +export const s_rangeStart = (isAllRangeSelected: boolean) => css` + background-color: ${theme.colors.pink.medium}; + + &::before { + content: ''; + + position: absolute; + top: 0; + right: 0; + bottom: 0; + + width: 20%; + height: 100%; + + background-color: ${isAllRangeSelected ? theme.colors.pink.light : theme.colors.white}; + } + + &::after { + content: ''; + + position: absolute; + top: 0; + right: 0.4px; + bottom: 0; + + width: 20%; + height: 100%; + + background-color: ${theme.colors.pink.medium}; + clip-path: polygon(0 0, 100% 50%, 0 100%); + } +`; + +export const s_rangeEnd = (isAllRangeSelected: boolean) => css` + background-color: ${theme.colors.pink.medium}; + + &::before { + content: ''; + + position: absolute; + top: 0; + bottom: 0; + left: 0; + + width: 20%; + height: 100%; + + background-color: ${isAllRangeSelected ? theme.colors.pink.light : theme.colors.white}; + } + + &::after { + content: ''; + + position: absolute; + top: 0; + bottom: 0; + left: 0.4px; + + width: 20%; + height: 100%; + + background-color: ${theme.colors.pink.medium}; + clip-path: polygon(100% 0, 0 50%, 100% 100%); + } +`; diff --git a/frontend/legacy/Calendar/Date/RangeCalendarDate.tsx b/frontend/legacy/Calendar/Date/RangeCalendarDate.tsx new file mode 100644 index 000000000..7aa17b25a --- /dev/null +++ b/frontend/legacy/Calendar/Date/RangeCalendarDate.tsx @@ -0,0 +1,74 @@ +import type { DateInfo } from '@hooks/useCalendarInfo/useCalendar.type'; +import { getDateInfo2 } from '@hooks/useCalendarInfo/useCalendarInfo.utils'; + +import { + s_baseDateButton, + s_baseDaySlotText, + s_dateContainer, + s_daySlotText, + s_holidayText, + s_rangeDateButton, + s_rangeEnd, + s_rangeStart, +} from '../CalendarDate.styles'; + +interface RangeCalendarDateProps { + dateInfo: DateInfo; + hasDate: (date: string) => boolean; + onDateClick: (date: string) => void; + isRangeStart: boolean; + isRangeEnd: boolean; + isAllRangeSelected: boolean; +} + +export default function RangeCalendarDate({ + dateInfo, + hasDate, + onDateClick, + isRangeStart, + isRangeEnd, + isAllRangeSelected, +}: RangeCalendarDateProps) { + const { key, value, status } = dateInfo; + const { + date, + currentFullDate, + isHoliday, + isToday, + isSaturday, + isSunday, + isPrevDate, + holidayName, + } = getDateInfo2(value, new Date()); + const isSelectedFullDate = hasDate(currentFullDate); + + return status === 'currentMonth' ? ( + + ) : ( +
+ ); +} diff --git a/frontend/legacy/Calendar/Date/SingleCalendarDate.tsx b/frontend/legacy/Calendar/Date/SingleCalendarDate.tsx new file mode 100644 index 000000000..1ea709c37 --- /dev/null +++ b/frontend/legacy/Calendar/Date/SingleCalendarDate.tsx @@ -0,0 +1,62 @@ +import type { DateInfo } from '@hooks/useCalendarInfo/useCalendar.type'; +import { getDateInfo2 } from '@hooks/useCalendarInfo/useCalendarInfo.utils'; + +import { + s_baseDateButton, + s_baseDaySlotText, + s_dateContainer, + s_daySlotText, + s_holidayText, + s_singleDateButton, +} from '../CalendarDate.styles'; + +interface SingleCalendarDateProps { + dateInfo: DateInfo; + hasDate: (date: string) => boolean; + onDateClick: (date: string) => void; +} + +export default function SingleCalendarDate({ + dateInfo, + hasDate, + onDateClick, +}: SingleCalendarDateProps) { + const { key, value, status } = dateInfo; + const { + date, + currentFullDate, + isHoliday, + isToday, + isSaturday, + isSunday, + isPrevDate, + holidayName, + } = getDateInfo2(value, new Date()); + const isSelectedFullDate = hasDate(currentFullDate); + + return status === 'currentMonth' ? ( + + ) : ( +
+ ); +} diff --git a/frontend/legacy/Calendar/index.tsx b/frontend/legacy/Calendar/index.tsx new file mode 100644 index 000000000..da59f1931 --- /dev/null +++ b/frontend/legacy/Calendar/index.tsx @@ -0,0 +1,98 @@ +import useCalendar from '@hooks/useCalendarInfo/useCalendar'; +import useDateSelect from '@hooks/useDateSelect/useDateSelect'; + +import TabButton from '../Buttons/TabButton'; +import { + s_baseDayOfWeek, + s_calendarContainer, + s_calendarContent, + s_dayOfWeek, + s_dayOfWeekContainer, + s_monthHeader, + s_monthNavigation, +} from './Calendar.styles'; +import RangeCalendarDate from './Date/RangeCalendarDate'; +import SingleCalendarDate from './Date/SingleCalendarDate'; + +interface CalendarProps { + onDateClick: (date: string) => void; +} + +export default function Calendar({ onDateClick }: CalendarProps) { + const { headers, body, view, isCurrentMonth } = useCalendar(); + const { currentYear, currentMonth, weekDays } = headers; + const { moveToNextMonth, moveToPrevMonth } = view; + const { + dateSelectMode, + toggleDateSelectMode, + handleSelectedDates, + hasDate, + checkIsRangeStartDate, + checkIsRangeEndDate, + isAllRangeSelected, + } = useDateSelect(); + + return ( +
+
+ + + {currentYear}년 {currentMonth + 1}월 + + + toggleDateSelectMode('single')} + > + 하나씩 + + toggleDateSelectMode('range')} + > + 기간 + +
+
+ {weekDays.map((day, index) => ( +
+ {day} +
+ ))} +
+
+ {body.value.map(({ key, value: onWeekDays }) => + onWeekDays.map((dateInfo) => { + return dateSelectMode === 'single' ? ( + + ) : ( + + ); + }), + )} +
+
+ ); +} diff --git a/frontend/src/hooks/useCalendarInfo/useCalendarInfo.ts b/frontend/legacy/hooks/useCalendarInfo/useCalendarInfo.ts similarity index 100% rename from frontend/src/hooks/useCalendarInfo/useCalendarInfo.ts rename to frontend/legacy/hooks/useCalendarInfo/useCalendarInfo.ts diff --git a/frontend/legacy/hooks/useCalendarInfo/useCalendarInfo.utils.ts b/frontend/legacy/hooks/useCalendarInfo/useCalendarInfo.utils.ts new file mode 100644 index 000000000..2bf770754 --- /dev/null +++ b/frontend/legacy/hooks/useCalendarInfo/useCalendarInfo.utils.ts @@ -0,0 +1,164 @@ +import { getHolidayNames } from '@hyunbinseo/holidays-kr'; +import type { DateInfo, MonthStatus, MonthlyDays } from 'types/calendar'; + +import CALENDAR_PROPERTIES from '@constants/calendar'; + +export const getCurrentDateInfo = () => { + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth() + 1; + + return { + currentDate, + currentYear, + currentMonth, + } as const; +}; + +export const generateMonthDaySlots = (year: number, month: number) => { + const startDate = new Date(year, month - 1, 1); + const firstDayIndex = startDate.getDay(); + + const lastDateOfMonth = new Date(year, month, 0); + const lastDayNumber = lastDateOfMonth.getDate(); + + const daySlotCount = firstDayIndex + lastDayNumber; + + return { firstDayIndex, daySlotCount } as const; +}; + +const getDateBaseInfo = (date: Date) => { + const currentYear = getYear(date); + const currentMonth = getMonth(date); + const currentDate = getDate(date); + const currentDay = getDay(date); + + return { currentYear, currentMonth, currentDate, currentDay }; +}; + +export const getDateInfo2 = (date: Date, today: Date) => { + const { currentYear, currentMonth, currentDate, currentDay } = getDateBaseInfo(date); + + const currentFullDate = getFullDate(date); + const todayFullDate = getFullDate(today); + + const holidayName = getHolidayNames(date); + const formattedHolidayName = holidayName ? holidayName[0] : null; + const isHoliday = formattedHolidayName !== null; + + const isSaturday = currentDay === CALENDAR_PROPERTIES.saturdayNumber; + const isSunday = currentDay === CALENDAR_PROPERTIES.sundayNumber; + const isPrevDate = currentFullDate < todayFullDate; + + const isToday = currentFullDate === todayFullDate; + + return { + date: currentDate, + currentFullDate, + isHoliday, + isToday, + isSunday, + isSaturday, + isPrevDate, + holidayName: formattedHolidayName, + } as const; +}; + +export const getDateInfo = ({ + year, + month, + firstDayIndex, + index, + currentDate, +}: { + year: number; + month: number; + firstDayIndex: number; + index: number; + currentDate: Date; +}) => { + const date = index - firstDayIndex + 1; + const todayDate = currentDate.getDate(); + const formattedMonth = String(month).padStart(2, '0'); + const formattedCurrentMonth = String(currentDate.getMonth() + 1).padStart(2, '0'); + + const fullDate = `${year}-${formattedMonth}-${String(date).padStart(2, '0')}`; + const todayFullDate = `${year}-${formattedCurrentMonth}-${String(todayDate).padStart(2, '0')}`; + + const isValidDate = index >= firstDayIndex; + const isHoliday = index % CALENDAR_PROPERTIES.daysInOneWeek === 0; + const isSaturday = index % CALENDAR_PROPERTIES.daysInOneWeek === 6; + const isPrevDate = formattedMonth === formattedCurrentMonth && date < todayDate; + const isToday = fullDate === todayFullDate; + + return { date, fullDate, isValidDate, isToday, isSaturday, isHoliday, isPrevDate } as const; +}; + +export const getMonth = (date: Date) => date.getMonth(); +export const getYear = (date: Date) => date.getFullYear(); +export const getDay = (date: Date) => date.getDay(); +export const getDate = (date: Date) => date.getDate(); +export const getFullDate = (date: Date) => { + const year = getYear(date); + const month = String(getMonth(date) + 1).padStart(2, '0'); + const day = String(getDate(date)).padStart(2, '0'); + + return `${year}-${month}-${day}`; +}; + +export const setFirstDate = (date: Date) => { + const newDate = new Date(date); + newDate.setDate(1); + + return newDate; +}; + +export const getNumberOfWeeks = (date: Date) => { + const firstOfMonth = setFirstDate(date); + const daysInMonth = new Date(getYear(date), getMonth(date) + 1, 0).getDate(); + const dayOfWeek = firstOfMonth.getDay(); + + return Math.ceil((daysInMonth + ((dayOfWeek + 7) % 7)) / 7); +}; + +export const getMonthlyStartIndex = (date: Date) => { + const firstOfMonth = setFirstDate(date); + return (firstOfMonth.getDay() + 7) % 7; +}; + +export const getWeeklyDate = (startDate: Date, currentMonth: number): DateInfo[] => + Array.from({ length: CALENDAR_PROPERTIES.daysInOneWeek }, (_, i) => { + const date = new Date(startDate); + date.setDate(getDate(startDate) + i); + + let status: MonthStatus; + if (getMonth(date) < currentMonth || (getMonth(date) === 11 && currentMonth === 0)) { + status = 'prevMonth'; + } else if (getMonth(date) > currentMonth || (getMonth(date) === 0 && currentMonth === 11)) { + status = 'nextMonth'; + } else { + status = 'currentMonth'; + } + + return { + key: `${date}`, + value: date, + status, + }; + }); + +export const getMonthlyDate = (date: Date): MonthlyDays => { + const numberOfWeeks = getNumberOfWeeks(date); + const monthlyStartDate = setFirstDate(new Date(date)); + monthlyStartDate.setDate(1 - getMonthlyStartIndex(date)); + + return Array.from({ length: numberOfWeeks }, (_, i) => { + const newDate = new Date(monthlyStartDate); + newDate.setDate(getDate(monthlyStartDate) + 7 * i); + + return { + key: getYear(date) * getMonth(date) + i, + value: getWeeklyDate(newDate, getMonth(date)), + }; + }); +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 87b4789fe..f8cbb67f6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "@babel/preset-typescript": "7.24.7", "@chromatic-com/storybook": "1.6.1", "@emotion/babel-plugin": "11.11.0", + "@hyunbinseo/holidays-kr": "3.2025.1", "@storybook/addon-essentials": "8.2.4", "@storybook/addon-interactions": "8.2.4", "@storybook/addon-links": "8.2.4", @@ -2929,6 +2930,15 @@ "dev": true, "peer": true }, + "node_modules/@hyunbinseo/holidays-kr": { + "version": "3.2025.1", + "resolved": "https://registry.npmjs.org/@hyunbinseo/holidays-kr/-/holidays-kr-3.2025.1.tgz", + "integrity": "sha512-5tdF8VoWzFz9r79d6yglq4V+A5DMYcBe4tM6fwQ4+BkOkdkHPAvplSp94EAilmQmGtFjVL2+ckRcvy3gqKf1Vw==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@inquirer/confirm": { "version": "3.1.15", "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.1.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9360a024d..d02c373a1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,6 +45,7 @@ "@babel/preset-typescript": "7.24.7", "@chromatic-com/storybook": "1.6.1", "@emotion/babel-plugin": "11.11.0", + "@hyunbinseo/holidays-kr": "3.2025.1", "@storybook/addon-essentials": "8.2.4", "@storybook/addon-interactions": "8.2.4", "@storybook/addon-links": "8.2.4", diff --git a/frontend/src/components/MeetingCalendar/Date/Date.styles.ts b/frontend/src/components/MeetingCalendar/Date/Date.styles.ts new file mode 100644 index 000000000..7c0371498 --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Date/Date.styles.ts @@ -0,0 +1,166 @@ +import type { SerializedStyles } from '@emotion/react'; +import { css } from '@emotion/react'; +import type { FlagObject } from 'types/utility'; + +import { isValidArrayType } from '@utils/typeGuards'; + +import theme from '@styles/theme'; + +export const s_dateContainer = css` + width: 100%; + min-width: 4.8rem; + height: 4.8rem; +`; + +export const s_baseDateButton = css` + cursor: pointer; + + position: relative; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + border: none; + + &:disabled { + cursor: default; + } +`; + +// 하나씩 선택하는 경우와, 시작/끝 기간으로 선택하는 경우 스타일을 구분. +// 하나로 합쳐서 스타일 함수에서 분기 처리 하는 것 보다, 이름으로 스타일 책임을 구분할 수 있는 방법을 선택. (@해리) + +export const s_singleDateButton = (isSelectedDate: boolean) => css` + background-color: ${isSelectedDate ? theme.colors.pink.medium : 'transparent'}; + border-radius: 0.8rem; +`; + +export const s_rangeDateButton = (isSelectedDate: boolean) => css` + background-color: ${isSelectedDate ? theme.colors.pink.light : 'transparent'}; +`; + +export const s_baseDateText = css` + ${theme.typography.bodyLight} +`; + +const DAY_SLOT_TEXT_STYLES = { + selected: css` + color: ${theme.colors.calendar.color.selected}; + `, + holiday: css` + color: ${theme.colors.calendar.color.holiday}; + `, + today: css` + color: ${theme.colors.calendar.color.today}; + `, + saturday: css` + color: #8c9eff; + `, + prevDay: css` + color: ${theme.colors.grey.primary}; + `, + default: css` + color: ${theme.colors.black}; + `, +}; + +type DateStatus = + | 'isSelectedDate' + | 'isPrevDate' + | 'isSunday' + | 'isSaturday' + | 'isHoliday' + | 'isToday'; + +const dateStatusStyleMap: Record = { + isSelectedDate: DAY_SLOT_TEXT_STYLES.selected, + isToday: DAY_SLOT_TEXT_STYLES.today, + isPrevDate: DAY_SLOT_TEXT_STYLES.prevDay, + isHoliday: DAY_SLOT_TEXT_STYLES.holiday, + isSunday: DAY_SLOT_TEXT_STYLES.holiday, + isSaturday: DAY_SLOT_TEXT_STYLES.saturday, +}; + +export const s_dateText = (dateStatusMap: FlagObject) => { + // key가 dateStatusMap에 속하는 타입인지 확정할 수 없는 문제 발생 -> type guard 함수로 해결(@해리) + const dateStatusArray = Object.keys(dateStatusMap); + if (!isValidArrayType(Object.keys(dateStatusMap), dateStatusArray)) return; + + const status = dateStatusArray.find((key) => dateStatusMap[key]); + + return status ? dateStatusStyleMap[status] : DAY_SLOT_TEXT_STYLES.default; +}; + +export const s_dateExtraInfoText = css` + font-size: 1rem; + font-weight: 300; + line-height: 1.2; +`; + +export const s_rangeStart = (isAllRangeSelected: boolean) => css` + background-color: ${theme.colors.pink.medium}; + + &::before { + content: ''; + + position: absolute; + top: 0; + right: 0; + bottom: 0; + + width: 20%; + height: 100%; + + background-color: ${isAllRangeSelected ? theme.colors.pink.light : theme.colors.white}; + } + + &::after { + content: ''; + + position: absolute; + top: 0; + right: 0.4px; + bottom: 0; + + width: 20%; + height: 100%; + + background-color: ${theme.colors.pink.medium}; + clip-path: polygon(0 0, 100% 50%, 0 100%); + } +`; + +export const s_rangeEnd = (isAllRangeSelected: boolean) => css` + background-color: ${theme.colors.pink.medium}; + + &::before { + content: ''; + + position: absolute; + top: 0; + bottom: 0; + left: 0; + + width: 20%; + height: 100%; + + background-color: ${isAllRangeSelected ? theme.colors.pink.light : theme.colors.white}; + } + + &::after { + content: ''; + + position: absolute; + top: 0; + bottom: 0; + left: 0.4px; + + width: 20%; + height: 100%; + + background-color: ${theme.colors.pink.medium}; + clip-path: polygon(100% 0, 0 50%, 100% 100%); + } +`; diff --git a/frontend/src/components/MeetingCalendar/Date/Date.utils.ts b/frontend/src/components/MeetingCalendar/Date/Date.utils.ts new file mode 100644 index 000000000..93a14dad8 --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Date/Date.utils.ts @@ -0,0 +1,38 @@ +import { getHolidayNames } from '@hyunbinseo/holidays-kr'; + +import { getDate, getDay, getFullDate } from '@utils/date'; + +import CALENDAR_PROPERTIES from '@constants/calendar'; + +export const getDateInfo = (targetDate: Date, today: Date) => { + const targetDateNumber = getDate(targetDate); + const targetDayOfWeek = getDay(targetDate); + const targetFullDate = getFullDate(targetDate); + + const todayFullDate = getFullDate(today); + + const holidayNames = getHolidayNames(targetDate); + /* + 현재 사용하고 있는 라이브러리는 하루에 공휴일이 겹칠 수 있는 경우를 대비하기 위해서 string[] 형태로 공휴일들을 관리하고 있다. + -> 그래서, 공휴일이 있는 경우 첫 번째 공휴일을 반환하도록 했다. + -> 네이버나 다른 서비스의 달력들을 참고했을 때 하루에 공휴일이 겹치는 경우 하나만 보여주는 것을 확인했다. + -> 여러개를 모두 보여주는 경우, 레이아웃이 달라질 수 있기 때문에 하나만 보여주는 것으로 결정. (@해리) + */ + const formattedHolidayName = holidayNames ? holidayNames[0] : null; + const isHoliday = formattedHolidayName !== null; + const isSaturday = targetDayOfWeek === CALENDAR_PROPERTIES.saturdayNumber; + const isSunday = targetDayOfWeek === CALENDAR_PROPERTIES.sundayNumber; + const isPrevDate = targetFullDate < todayFullDate; + const isToday = targetFullDate === todayFullDate; + + return { + date: targetDateNumber, + targetFullDate, + isHoliday, + isToday, + isSunday, + isSaturday, + isPrevDate, + holidayName: formattedHolidayName, + } as const; +}; diff --git a/frontend/src/components/MeetingCalendar/Date/DateAdditionalText.tsx b/frontend/src/components/MeetingCalendar/Date/DateAdditionalText.tsx new file mode 100644 index 000000000..8d7352b86 --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Date/DateAdditionalText.tsx @@ -0,0 +1,36 @@ +import { s_dateExtraInfoText } from './Date.styles'; + +const DATE_INFO_TEXTS = { + today: '오늘', + rangeStart: '시작', + rangeEnd: '끝', + default: '\u00A0', +} as const; + +interface DateInfoProps { + isToday?: boolean; + isRangeStart?: boolean; + isRangeEnd?: boolean; + holidayName: string | null; +} + +const getDateInfoText = ({ isToday, isRangeStart, isRangeEnd, holidayName }: DateInfoProps) => { + if (isRangeStart) return DATE_INFO_TEXTS.rangeStart; + if (isRangeEnd) return DATE_INFO_TEXTS.rangeEnd; + if (isToday) return DATE_INFO_TEXTS.today; + if (holidayName) return holidayName; + return DATE_INFO_TEXTS.default; +}; + +export default function DateAdditionalText({ + isToday, + isRangeStart, + isRangeEnd, + holidayName, +}: DateInfoProps) { + return ( + + {getDateInfoText({ isToday, isRangeStart, isRangeEnd, holidayName })} + + ); +} diff --git a/frontend/src/components/MeetingCalendar/Date/RangeDate.tsx b/frontend/src/components/MeetingCalendar/Date/RangeDate.tsx new file mode 100644 index 000000000..2ffee066c --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Date/RangeDate.tsx @@ -0,0 +1,84 @@ +import type { DateInfo } from 'types/calendar'; + +import { + s_baseDateButton, + s_baseDateText, + s_dateContainer, + s_dateText, + s_rangeDateButton, + s_rangeEnd, + s_rangeStart, +} from './Date.styles'; +import { getDateInfo } from './Date.utils'; +import DateAdditionalText from './DateAdditionalText'; + +interface RangeDateProps { + dateInfo: DateInfo; + today: Date; + hasDate: (date: string) => boolean; + onDateClick: (date: string) => void; + isRangeStart: boolean; + isRangeEnd: boolean; + isAllRangeSelected: boolean; +} + +export default function RangeDate({ + dateInfo, + today, + hasDate, + onDateClick, + isRangeStart, + isRangeEnd, + isAllRangeSelected, +}: RangeDateProps) { + const { key, value, status } = dateInfo; + /* + - 오늘인지 아닌지 판단하기 위해서, new Date()를 어디서 호출해야 할지 고민. + -> getDateInfo를 호출할 때마다 인자로 new Date() 생성자 함수를 호출하든, getDateInfo 내부에서 호출하든 '오늘' 정보를 가지고 있는 인스턴스를 중복해서 생성한다고 판단. + -> 따라서 한 번만 호출하기 위해서 날짜 데이터 관련 책임을 가지는 useCalendar 커스텀 훅에서 '오늘' 인스턴스를 생성 후 반환하고, props로 전달받아서 재사용하는 것으로 결정.(@해리) + */ + const { + date, + targetFullDate, + isHoliday, + isToday, + isSaturday, + isSunday, + isPrevDate, + holidayName, + } = getDateInfo(value, today); + const isSelectedDate = hasDate(targetFullDate); + + return status === 'current' ? ( + + ) : ( +
+ ); +} diff --git a/frontend/src/components/MeetingCalendar/Date/SingleDate.tsx b/frontend/src/components/MeetingCalendar/Date/SingleDate.tsx new file mode 100644 index 000000000..8dfb38284 --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Date/SingleDate.tsx @@ -0,0 +1,64 @@ +import type { DateInfo } from 'types/calendar'; + +import { + s_baseDateButton, + s_baseDateText, + s_dateContainer, + s_dateText, + s_singleDateButton, +} from './Date.styles'; +import { getDateInfo } from './Date.utils'; +import DateAdditionalText from './DateAdditionalText'; + +interface SingleCalendarDateProps { + dateInfo: DateInfo; + today: Date; + hasDate: (date: string) => boolean; + onDateClick: (date: string) => void; +} + +export default function SingleDate({ + dateInfo, + today, + hasDate, + onDateClick, +}: SingleCalendarDateProps) { + const { key, value, status } = dateInfo; + const { + date, + targetFullDate, + isHoliday, + isToday, + isSaturday, + isSunday, + isPrevDate, + holidayName, + } = getDateInfo(value, today); + const isSelectedDate = hasDate(targetFullDate); + + return status === 'current' ? ( + + ) : ( +
+ ); +} diff --git a/frontend/src/components/MeetingCalendar/Header/MeetingCalendarHeader.styles.ts b/frontend/src/components/MeetingCalendar/Header/MeetingCalendarHeader.styles.ts new file mode 100644 index 000000000..46e8070af --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Header/MeetingCalendarHeader.styles.ts @@ -0,0 +1,47 @@ +import { css } from '@emotion/react'; + +import theme from '@styles/theme'; + +export const s_monthHeader = css` + display: flex; + align-items: center; + justify-content: space-between; + + width: 100%; + height: 3.6rem; + margin-bottom: 1rem; +`; + +export const s_monthNavigationContainer = css` + display: flex; + gap: 2.4rem; + align-items: center; + height: 100%; + + ${theme.typography.bodyMedium} +`; + +export const s_monthNavigation = css` + cursor: pointer; + background-color: transparent; + border: none; + + ${theme.typography.titleMedium} + + &:disabled { + color: ${theme.colors.grey.primary}; + } +`; + +export const s_dateSelectModeTabButtonContainer = css` + display: flex; + gap: 0.4rem; + align-items: center; +`; + +// 2024 1월, 2024 12월을 그릴 때 텍스트의 너비가 달라져서 양 옆 버튼의 위치가 변경되는 문제를 해결하기 위해 고정 너비 적용. (@해리) +export const s_yearMonthText = css` + display: inline-block; + min-width: 12rem; + text-align: center; +`; diff --git a/frontend/src/components/MeetingCalendar/Header/index.tsx b/frontend/src/components/MeetingCalendar/Header/index.tsx new file mode 100644 index 000000000..52a6d313c --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Header/index.tsx @@ -0,0 +1,69 @@ +import type { DateSelectMode } from 'types/calendar'; + +import TabButton from '@components/_common/Buttons/TabButton'; + +import { s_monthNavigation } from './MeetingCalendarHeader.styles'; +import { + s_dateSelectModeTabButtonContainer, + s_monthHeader, + s_monthNavigationContainer, + s_yearMonthText, +} from './MeetingCalendarHeader.styles'; + +interface MeetingCalendarHeaderProps { + currentYear: number; + currentMonth: number; + moveToNextMonth: () => void; + moveToPrevMonth: () => void; + isCurrentMonth?: boolean; + dateSelectMode: DateSelectMode; + toggleDateSelectMode: (mode: DateSelectMode) => void; +} + +export default function MeetingCalendarHeader({ + currentYear, + currentMonth, + moveToNextMonth, + moveToPrevMonth, + isCurrentMonth, + dateSelectMode, + toggleDateSelectMode, +}: MeetingCalendarHeaderProps) { + return ( +
+
+ + + {currentYear}년 {currentMonth + 1}월 + + +
+
+ toggleDateSelectMode('single')} + aria-label="하나씩 선택하기" + > + 하나씩 + +

/

+ toggleDateSelectMode('range')} + aria-label="기간으로 선택하기" + > + 기간 + +
+
+ ); +} diff --git a/frontend/src/components/MeetingCalendar/Weekdays/MeetingCalendarWeekdays.styles.ts b/frontend/src/components/MeetingCalendar/Weekdays/MeetingCalendarWeekdays.styles.ts new file mode 100644 index 000000000..716b7ee21 --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Weekdays/MeetingCalendarWeekdays.styles.ts @@ -0,0 +1,67 @@ +import { css } from '@emotion/react'; + +import theme from '@styles/theme'; + +import CALENDAR_PROPERTIES from '@constants/calendar'; + +export const s_calendarContent = css` + display: grid; + grid-template-columns: repeat(7, 1fr); + width: 100%; +`; + +export const s_dayOfWeekContainer = css` + margin-bottom: 2rem; +`; + +export const s_baseDayOfWeek = css` + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + min-width: 4rem; + height: 4rem; + min-height: 4rem; + + ${theme.typography.bodyMedium} +`; + +export const s_dayOfWeek = (index: number) => { + if (index === CALENDAR_PROPERTIES.sundayNumber) + return css` + ${DAY_SLOT_TEXT_STYLES.holiday} + `; + + if (index === CALENDAR_PROPERTIES.saturdayNumber) + return css` + ${DAY_SLOT_TEXT_STYLES.saturday} + `; + + return css` + ${DAY_SLOT_TEXT_STYLES.default} + `; +}; + +// 현재 DAY_SLOT_TEXT_STYLES를 중복해서 정의하는 곳이 두 곳이 있음. +// MeetingCalendar.common.styles.ts 을 생성하고, 약속 달력 컴포넌트에서 중복해서 사용하는 스타일을 정의할까 고민 중.(@해리) +const DAY_SLOT_TEXT_STYLES = { + selected: css` + color: ${theme.colors.calendar.color.selected}; + `, + holiday: css` + color: ${theme.colors.calendar.color.holiday}; + `, + today: css` + color: ${theme.colors.calendar.color.today}; + `, + saturday: css` + color: #8c9eff; + `, + prevDay: css` + color: ${theme.colors.grey.primary}; + `, + default: css` + color: ${theme.colors.black}; + `, +}; diff --git a/frontend/src/components/MeetingCalendar/Weekdays/index.tsx b/frontend/src/components/MeetingCalendar/Weekdays/index.tsx new file mode 100644 index 000000000..138795c41 --- /dev/null +++ b/frontend/src/components/MeetingCalendar/Weekdays/index.tsx @@ -0,0 +1,22 @@ +import { + s_baseDayOfWeek, + s_calendarContent, + s_dayOfWeek, + s_dayOfWeekContainer, +} from './MeetingCalendarWeekdays.styles'; + +interface MeetingCalendarWeekdaysProps { + weekdays: string[]; +} + +export default function MeetingCalendarWeekdays({ weekdays }: MeetingCalendarWeekdaysProps) { + return ( +
+ {weekdays.map((day, index) => ( +
+ {day} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/_common/Buttons/TabButton/index.tsx b/frontend/src/components/_common/Buttons/TabButton/index.tsx index 1efb1bb9b..3df41157e 100644 --- a/frontend/src/components/_common/Buttons/TabButton/index.tsx +++ b/frontend/src/components/_common/Buttons/TabButton/index.tsx @@ -1,13 +1,14 @@ -import type { PropsWithChildren } from 'react'; +import type { ButtonHTMLAttributes, ReactNode } from 'react'; import { Button } from '../Button'; import { s_tabButton } from './TabButton.styles'; import type { TabButtonVariants } from './TabButton.types'; -interface TabButtonProps extends PropsWithChildren { +interface TabButtonProps extends ButtonHTMLAttributes { isActive: boolean; onClick: () => void; tabButtonVariants?: TabButtonVariants; + children: ReactNode; } export default function TabButton({ @@ -15,9 +16,15 @@ export default function TabButton({ onClick, tabButtonVariants = 'default', children, + ...props }: TabButtonProps) { return ( - ); diff --git a/frontend/src/components/_common/Calendar/Body/index.tsx b/frontend/src/components/_common/Calendar/Body/index.tsx new file mode 100644 index 000000000..d46cb1a12 --- /dev/null +++ b/frontend/src/components/_common/Calendar/Body/index.tsx @@ -0,0 +1,23 @@ +// import 하지 않으면 스토리북에서 캘린더 컴포넌트가 렌더링 되지 않아 일단 추가(@해리) +import React from 'react'; +import type { DateInfo } from 'types/calendar'; + +import { useCalendarContext } from '@hooks/useCalendarContext/useCalendarContext'; + +import { s_calendarContent } from '../Calendar.styles'; + +interface BodyProps { + renderDate: (dateInfo: DateInfo, today: Date) => JSX.Element; +} + +export default function Body({ renderDate }: BodyProps) { + const { body } = useCalendarContext(); + + return ( +
+ {body.value.map(({ value: onWeekDays }) => + onWeekDays.map((dateInfo) => renderDate(dateInfo, body.today)), + )} +
+ ); +} diff --git a/frontend/src/components/_common/Calendar/Calendar.stories.tsx b/frontend/src/components/_common/Calendar/Calendar.stories.tsx index a180f2594..301c9b0fe 100644 --- a/frontend/src/components/_common/Calendar/Calendar.stories.tsx +++ b/frontend/src/components/_common/Calendar/Calendar.stories.tsx @@ -1,63 +1,100 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { useState } from 'react'; +import type { DateInfo } from 'types/calendar'; + +import { CalendarContext } from '@contexts/CalendarProvider'; + +import RangeDate from '@components/MeetingCalendar/Date/RangeDate'; +import SingleDate from '@components/MeetingCalendar/Date/SingleDate'; +import MeetingCalendarHeader from '@components/MeetingCalendar/Header'; +import MeetingCalendarWeekdays from '@components/MeetingCalendar/Weekdays'; + +import useCalendar from '@hooks/useCalendar/useCalendar'; +import useDateSelect from '@hooks/useDateSelect/useDateSelect'; import Calendar from './index'; -const meta = { +const meta: Meta = { title: 'Components/Calendar', component: Calendar, tags: ['autodocs'], - parameters: { layout: 'centered', }, - argTypes: { - hasDate: { - description: '선택된 날짜들', - type: 'function', - control: { - disable: true, - }, - }, - onDateClick: { - description: '선택된 날짜 리스트에 특정 날짜를 추가하거나 제거할 수 있는 함수', - }, - }, decorators: [ - (Story, context) => { - const [selectedDates, setSelectedDates] = useState([]); - - const hasDate = (date: string) => selectedDates.includes(date); - - const handleDateClick = (date: string) => { - setSelectedDates((prevDates) => - hasDate(date) ? prevDates.filter((d) => d !== date) : [...prevDates, date], - ); - }; + (Story) => { + const calendarData = useCalendar(); return ( - + + + ); }, ], -} satisfies Meta; +}; export default meta; -type Story = StoryObj; +type Story = StoryObj; -export const Playground: Story = { - args: { - hasDate: () => false, - onDateClick: () => {}, - }, - render: (args) => { - return ; +export const Default: Story = { + render: () => { + const { + handleSelectedDates, + hasDate, + dateSelectMode, + checkIsRangeStartDate, + checkIsRangeEndDate, + isAllRangeSelected, + toggleDateSelectMode, + } = useDateSelect(); + + const renderDate = (dateInfo: DateInfo, today: Date) => + dateSelectMode === 'single' ? ( + + ) : ( + + ); + + return ( + + ( + + )} + /> + } /> + + + ); }, }; diff --git a/frontend/src/components/_common/Calendar/Calendar.styles.ts b/frontend/src/components/_common/Calendar/Calendar.styles.ts index 65942d34a..a7ba79d56 100644 --- a/frontend/src/components/_common/Calendar/Calendar.styles.ts +++ b/frontend/src/components/_common/Calendar/Calendar.styles.ts @@ -1,9 +1,4 @@ import { css } from '@emotion/react'; -import type { FlagObject } from 'types/utility'; - -import theme from '@styles/theme'; - -import CALENDAR_PROPERTIES from '@constants/calendar'; export const s_calendarContainer = css` display: flex; @@ -13,156 +8,6 @@ export const s_calendarContainer = css` export const s_calendarContent = css` display: grid; - grid-auto-rows: 4rem; grid-template-columns: repeat(7, 1fr); width: 100%; `; - -export const s_dayOfWeekContainer = css` - margin-bottom: 2rem; -`; - -export const s_baseDayOfWeek = css` - display: flex; - align-items: center; - justify-content: center; - - width: 100%; - min-width: 4rem; - height: 4rem; - min-height: 4rem; - - ${theme.typography.bodyMedium} -`; - -export const s_dayOfWeek = (index: number) => { - if (index === CALENDAR_PROPERTIES.sundayNumber) - return css` - ${DAY_SLOT_TEXT_STYLES.holiday} - `; - - if (index === CALENDAR_PROPERTIES.saturdayNumber) - return css` - ${DAY_SLOT_TEXT_STYLES.saturday} - `; - - return css` - ${DAY_SLOT_TEXT_STYLES.default} - `; -}; - -export const s_monthHeader = css` - display: flex; - align-items: center; - justify-content: space-between; - - width: 100%; - margin-bottom: 2rem; - padding: 0 1rem; - - ${theme.typography.bodyMedium} -`; - -export const s_monthNavigation = css` - cursor: pointer; - background-color: transparent; - border: none; - - ${theme.typography.titleMedium} - - &:disabled { - color: ${theme.colors.grey.primary}; - } -`; - -export const s_baseDaySlot = css` - display: flex; - align-items: center; - justify-content: center; - - width: 100%; - min-width: 3.6rem; - height: 3.6rem; -`; - -export const s_daySlotButton = css` - cursor: pointer; - background-color: transparent; - border: none; - ${theme.typography.bodyLight} - - &:disabled { - cursor: default; - } -`; - -export const s_baseDaySlotText = css` - position: relative; - - display: flex; - align-items: center; - justify-content: center; - - width: 3.6rem; - height: 3.6rem; -`; - -type DaySlotStatus = 'isSelectedFullDate' | 'isPrevDate' | 'isHoliday' | 'isSaturday' | 'isToday'; - -export const s_daySlotText = ({ - isSelectedFullDate, - isPrevDate, - isHoliday, - isSaturday, - isToday, -}: FlagObject) => { - /* 덕지덕지 if문인데 어쩔 수 없다고 생각하기는 했습니다. 가독성을 위해서 switch문을 사용하는 것도 고려해보면 좋을 것 같은데, 코멘트로 의견 부탁드려요(@해리) */ - /* if문 위에서부터 아래로, 스타일이 적용되어야 하는 우선순위입니다. 선택된 날짜의 스타일이 가장 우선적으로 고려되어야 하고, 그 다음은 지난날짜,,,순 입니다. 그래서 early return 패턴을 활용했어요(@해리) */ - if (isSelectedFullDate) return DAY_SLOT_TEXT_STYLES.selected; - if (isPrevDate) return DAY_SLOT_TEXT_STYLES.prevDay; - if (isHoliday) return DAY_SLOT_TEXT_STYLES.holiday; - if (isSaturday) return DAY_SLOT_TEXT_STYLES.saturday; - if (isToday) return DAY_SLOT_TEXT_STYLES.today; - - return DAY_SLOT_TEXT_STYLES.default; -}; - -// background-color: ${theme.colors.calendar.backgroundColor.today}; -const DAY_SLOT_TEXT_STYLES = { - selected: css` - color: ${theme.colors.calendar.color.selected}; - background-color: ${theme.colors.calendar.backgroundColor.selected}; - border-radius: 50%; - `, - holiday: css` - color: ${theme.colors.calendar.color.holiday}; - `, - today: css` - color: ${theme.colors.calendar.color.today}; - border-radius: 50%; - - &::after { - content: ''; - - position: absolute; - bottom: 0.4rem; - left: 50%; - transform: translateX(-50%); - - width: 0.4rem; - height: 0.4rem; - - background-color: ${theme.colors.calendar.color.today}; - border-radius: 50%; - } - `, - saturday: css` - color: #8c9eff; - `, - prevDay: css` - color: ${theme.colors.grey.primary}; - `, - default: css` - color: ${theme.colors.black}; - `, -}; diff --git a/frontend/src/components/_common/Calendar/Header/index.tsx b/frontend/src/components/_common/Calendar/Header/index.tsx new file mode 100644 index 000000000..a394a6e90 --- /dev/null +++ b/frontend/src/components/_common/Calendar/Header/index.tsx @@ -0,0 +1,20 @@ +// import 하지 않으면 스토리북에서 캘린더 컴포넌트가 렌더링 되지 않아 일단 추가(@해리) +import { useCalendarContext } from '@hooks/useCalendarContext/useCalendarContext'; + +interface HeaderProps { + render: (props: { + currentYear: number; + currentMonth: number; + moveToNextMonth: () => void; + moveToPrevMonth: () => void; + isCurrentMonth?: boolean; + }) => JSX.Element; +} + +export function Header({ render }: HeaderProps) { + const { headers, view, isCurrentMonth } = useCalendarContext(); + const { currentYear, currentMonth } = headers; + const { moveToNextMonth, moveToPrevMonth } = view; + + return render({ currentYear, currentMonth, moveToNextMonth, moveToPrevMonth, isCurrentMonth }); +} diff --git a/frontend/src/components/_common/Calendar/Weekdays/index.tsx b/frontend/src/components/_common/Calendar/Weekdays/index.tsx new file mode 100644 index 000000000..6e50aff4c --- /dev/null +++ b/frontend/src/components/_common/Calendar/Weekdays/index.tsx @@ -0,0 +1,13 @@ +// import 하지 않으면 스토리북에서 캘린더 컴포넌트가 렌더링 되지 않아 일단 추가(@해리) +import { useCalendarContext } from '@hooks/useCalendarContext/useCalendarContext'; + +interface WeekDaysProps { + render: (weekDays: string[]) => JSX.Element; +} + +export default function WeekDays({ render }: WeekDaysProps) { + const { headers } = useCalendarContext(); + const { weekDays } = headers; + + return render(weekDays); +} diff --git a/frontend/src/components/_common/Calendar/index.tsx b/frontend/src/components/_common/Calendar/index.tsx index 6b7b7f243..dd84190f5 100644 --- a/frontend/src/components/_common/Calendar/index.tsx +++ b/frontend/src/components/_common/Calendar/index.tsx @@ -1,88 +1,22 @@ -import useCalendarInfo from '@hooks/useCalendarInfo/useCalendarInfo'; +import type { PropsWithChildren } from 'react'; +// import 하지 않으면 스토리북에서 캘린더 컴포넌트가 렌더링 되지 않아 일단 추가(@해리) +import React from 'react'; -import { - s_baseDayOfWeek, - s_baseDaySlot, - s_baseDaySlotText, - s_calendarContainer, - s_calendarContent, - s_dayOfWeek, - s_dayOfWeekContainer, - s_daySlotButton, - s_daySlotText, - s_monthHeader, - s_monthNavigation, -} from './Calendar.styles'; +import CalendarProvider from '@contexts/CalendarProvider'; -const DAY_OF_WEEK = ['일', '월', '화', '수', '목', '금', '토'] as const; - -interface CalendarProps { - hasDate: (date: string) => boolean; - onDateClick: (date: string) => void; -} - -export default function Calendar({ hasDate, onDateClick }: CalendarProps) { - const { - yearMonthInfo, - handleGetDateInfo, - handlePrevMonthMove, - handleNextMonthMove, - isCurrentDate, - } = useCalendarInfo(); - const { year, month, daySlotCount } = yearMonthInfo; +import Body from './Body'; +import { s_calendarContainer } from './Calendar.styles'; +import { Header } from './Header'; +import WeekDays from './Weekdays'; +function CalendarMain({ children }: PropsWithChildren) { return ( -
-
- - - {year}년 {month}월 - - -
-
- {DAY_OF_WEEK.map((day, index) => ( -
- {day} -
- ))} -
-
- {Array.from({ length: daySlotCount }, (_, index) => { - const { date, fullDate, isValidDate, isToday, isHoliday, isSaturday, isPrevDate } = - handleGetDateInfo(index); - const isSelectedFullDate = hasDate(fullDate); - - return isValidDate ? ( - - ) : ( -
- ); - })} -
-
+ +
{children}
+
); } + +const Calender = Object.assign(CalendarMain, { Header, WeekDays, Body }); + +export default Calender; diff --git a/frontend/src/contexts/CalendarProvider.tsx b/frontend/src/contexts/CalendarProvider.tsx new file mode 100644 index 000000000..9f35cb50c --- /dev/null +++ b/frontend/src/contexts/CalendarProvider.tsx @@ -0,0 +1,16 @@ +import type { PropsWithChildren } from 'react'; +import { createContext } from 'react'; + +import useCalendar from '@hooks/useCalendar/useCalendar'; + +type CalendarContextType = ReturnType; + +export const CalendarContext = createContext(null); + +export default function CalendarProvider({ children }: PropsWithChildren) { + const calendarData = useCalendar(); + + return ( + {children} + ); +} diff --git a/frontend/src/hooks/useCalendar/useCalendar.ts b/frontend/src/hooks/useCalendar/useCalendar.ts new file mode 100644 index 000000000..1daa482f9 --- /dev/null +++ b/frontend/src/hooks/useCalendar/useCalendar.ts @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import type { MonthlyDays } from 'types/calendar'; + +import { getMonth, getYear } from '@utils/date'; + +import { getMonthlyDate } from './useCalendar.utils'; + +interface useCalendarReturn { + headers: { + currentYear: number; + currentMonth: number; + weekDays: string[]; + }; + body: { + value: MonthlyDays; + today: Date; + }; + view: { + moveToNextMonth: () => void; + moveToPrevMonth: () => void; + }; + isCurrentMonth: boolean; +} + +const TODAY = new Date(); + +const useCalendar = (): useCalendarReturn => { + const [currentFullDate, setCurrentFullDate] = useState(new Date()); + + const currentYear = getYear(currentFullDate); + const currentMonth = getMonth(currentFullDate); + const isCurrentMonth = getYear(TODAY) === currentYear && getMonth(TODAY) === currentMonth; + + const moveToPrevMonth = () => { + setCurrentFullDate(new Date(currentYear, currentMonth - 1)); + }; + + const moveToNextMonth = () => { + setCurrentFullDate(new Date(currentYear, currentMonth + 1)); + }; + + const monthlyDates = getMonthlyDate(currentFullDate); + + return { + headers: { + currentYear, + currentMonth, + weekDays: ['일', '월', '화', '수', '목', '금', '토'], + }, + body: { + today: TODAY, + value: monthlyDates, + }, + view: { + moveToNextMonth, + moveToPrevMonth, + }, + isCurrentMonth, + }; +}; + +export default useCalendar; diff --git a/frontend/src/hooks/useCalendar/useCalendar.utils.ts b/frontend/src/hooks/useCalendar/useCalendar.utils.ts new file mode 100644 index 000000000..acc5bc4d2 --- /dev/null +++ b/frontend/src/hooks/useCalendar/useCalendar.utils.ts @@ -0,0 +1,115 @@ +import type { DateInfo, MonthStatus, MonthlyDays } from 'types/calendar'; + +import { getDate, getDay, getMonth, getYear } from '@utils/date'; + +import CALENDAR_PROPERTIES from '@constants/calendar'; + +export const setFirstDate = (date: Date) => { + const newDate = new Date(date); + newDate.setDate(1); + + return newDate; +}; + +export const getDaysInMonth = (date: Date) => { + const lastDateOfMonth = getDate(new Date(getYear(date), getMonth(date) + 1, 0)); + + return lastDateOfMonth; +}; + +export const getNumberOfWeeks = (date: Date) => { + const firstOfMonth = setFirstDate(date); + const daysInMonth = getDaysInMonth(date); + const dayOfWeek = getDay(firstOfMonth); + + return Math.ceil((daysInMonth + dayOfWeek) / 7); +}; + +export const getMonthlyStartIndex = (date: Date) => { + const firstOfMonth = setFirstDate(date); + + return firstOfMonth.getDay(); +}; + +const getMonthStatus = (targetDate: Date, currentMonthIndex: number): MonthStatus => { + const month = getMonth(targetDate); + + if (month < currentMonthIndex) return 'prev'; + + if (month > currentMonthIndex) return 'next'; + + return 'current'; +}; + +export const getWeeklyDate = (startDate: Date, currentMonthIndex: number): DateInfo[] => + Array.from({ length: CALENDAR_PROPERTIES.daysInOneWeek }, (_, i) => { + const date = new Date(startDate); + date.setDate(getDate(startDate) + i); + + const monthStatus = getMonthStatus(date, currentMonthIndex); + + return { + key: `${date}`, + value: date, + status: monthStatus, + }; + }); + +/** + * 주어진 날짜를 기준으로 해당 월의 달력 데이터를 계산하여 반환. + * 이 함수는 해당 월이 몇 주에 걸쳐 있는지 계산하고, 주마다 날짜 정보를 생성하여 반환. + * 월의 첫 번째 주와 마지막 주가 이전 달 또는 다음 달의 날짜로 채워질 수 있다. + * 이렇게 한 이유는 현재 모모 서비스에서는, 이전 달 또는 다음 달의 데이터를 활용하고 있지 않지만 요구 사항이 변경되어 필요하게 되면 필요에 따라 유연하게 뽑아서 사용할 수 있도록 하기 위해서이다. + * + * @param {Date} date - 해당 월을 나타내는 JavaScript `Date` 객체. (해당 월의 아무 날짜나 가능합니다) + * + * @returns {MonthlyDays} 해당 월의 주 단위 데이터를 포함하는 배열을 반환합니다. 각 주는 객체로 표현된다. 아래는 객체에 대한 설명. + * - `key`: 해당 주의 고유 식별자. + * - `value`: `DateInfo` 객체 배열로, 각 날짜의 정보를 포함. + * - `key`: 특정 날짜를 나타내는 문자열 (예: `${date}` 형태). + * - `value`: 해당 날짜의 `Date` 객체. + * - `status`: 해당 날짜가 'prevMonth' (이전 달), 'currentMonth' (현재 달), 'nextMonth' (다음 달) 중 어디에 속하는지 나타내는 값. + * + * 함수 실행 과정: + * 1. `getNumberOfWeeks` 함수를 사용하여 해당 월이 몇 주에 걸쳐 있는지 계산합니다. + * 2. `getMonthlyStartIndex` 함수를 사용하여 해당 월의 첫 번째 주에 이전 달의 날짜가 필요한지 계산합니다. + * 3. `getWeeklyDate` 함수를 사용하여 주별로 날짜 데이터를 생성하며, 각 날짜가 현재 달, 이전 달, 또는 다음 달에 속하는지 판단하여 `status` 값을 설정합니다. + * + * 반환 데이터 예시: + * [ + * { + * key: 2023090, // 2023-09의 첫 번째 주 + * value: [ + * { key: '2023-08-27', value: Date, status: 'prevMonth' }, 이전 달 데이터인 8월 데이터가 포함되고, prevMonth 상태를 가짐. + * { key: '2023-08-28', value: Date, status: 'prevMonth' }, + * ... + * { key: '2023-09-03', value: Date, status: 'currentMonth' } + * ] + * }, + * ... + * { + * key: 2023094, + * value: [ + * { key: '2023-09-25', value: Date, status: 'currentMonth' }, + * { key: '2023-09-26', value: Date, status: 'currentMonth' }, + * ... + * { key: '2023-10-01', value: Date, status: 'nextMonth' } // 다음 달 데이터인 10월 데이터가 포함되고, nextMonth 상태를 가짐. + * ] + * } + * ] + */ +export const getMonthlyDate = (date: Date): MonthlyDays => { + const numberOfWeeks = getNumberOfWeeks(date); + const monthlyStartDate = setFirstDate(new Date(date)); + monthlyStartDate.setDate(1 - getMonthlyStartIndex(date)); + + return Array.from({ length: numberOfWeeks }, (_, i) => { + const newDate = new Date(monthlyStartDate); + newDate.setDate(getDate(monthlyStartDate) + CALENDAR_PROPERTIES.daysInOneWeek * i); + + return { + key: getYear(date) * getMonth(date) + i, + value: getWeeklyDate(newDate, getMonth(date)), + }; + }); +}; diff --git a/frontend/src/hooks/useCalendarContext/useCalendarContext.ts b/frontend/src/hooks/useCalendarContext/useCalendarContext.ts new file mode 100644 index 000000000..ce3a2b5c6 --- /dev/null +++ b/frontend/src/hooks/useCalendarContext/useCalendarContext.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react'; + +import { CalendarContext } from '@contexts/CalendarProvider'; + +export const useCalendarContext = () => { + const context = useContext(CalendarContext); + + if (!context) { + throw new Error('useCalendarContext 커스텀 훅은 캘린더 컴포넌트 내부에서만 사용할 수 있어요^^'); + } + + return context; +}; diff --git a/frontend/src/hooks/useCalendarInfo/useCalendarInfo.utils.ts b/frontend/src/hooks/useCalendarInfo/useCalendarInfo.utils.ts deleted file mode 100644 index 14d46ee64..000000000 --- a/frontend/src/hooks/useCalendarInfo/useCalendarInfo.utils.ts +++ /dev/null @@ -1,55 +0,0 @@ -import CALENDAR_PROPERTIES from '@constants/calendar'; - -export const getCurrentDateInfo = () => { - const currentDate = new Date(); - const currentYear = currentDate.getFullYear(); - const currentMonth = currentDate.getMonth() + 1; - - return { - currentDate, - currentYear, - currentMonth, - } as const; -}; - -export const generateMonthDaySlots = (year: number, month: number) => { - const startDate = new Date(year, month - 1, 1); - const firstDayIndex = startDate.getDay(); - - const lastDateOfMonth = new Date(year, month, 0); - const lastDayNumber = lastDateOfMonth.getDate(); - - const daySlotCount = firstDayIndex + lastDayNumber; - - return { firstDayIndex, daySlotCount } as const; -}; - -export const getDateInfo = ({ - year, - month, - firstDayIndex, - index, - currentDate, -}: { - year: number; - month: number; - firstDayIndex: number; - index: number; - currentDate: Date; -}) => { - const date = index - firstDayIndex + 1; - const todayDate = currentDate.getDate(); - const formattedMonth = String(month).padStart(2, '0'); - const formattedCurrentMonth = String(currentDate.getMonth() + 1).padStart(2, '0'); - - const fullDate = `${year}-${formattedMonth}-${String(date).padStart(2, '0')}`; - const todayFullDate = `${year}-${formattedCurrentMonth}-${String(todayDate).padStart(2, '0')}`; - - const isValidDate = index >= firstDayIndex; - const isHoliday = index % CALENDAR_PROPERTIES.daysInOneWeek === 0; - const isSaturday = index % CALENDAR_PROPERTIES.daysInOneWeek === 6; - const isPrevDate = formattedMonth === formattedCurrentMonth && date < todayDate; - const isToday = fullDate === todayFullDate; - - return { date, fullDate, isValidDate, isToday, isSaturday, isHoliday, isPrevDate } as const; -}; diff --git a/frontend/src/hooks/useDateSelect/useDateSelect.ts b/frontend/src/hooks/useDateSelect/useDateSelect.ts new file mode 100644 index 000000000..d29b4d9c3 --- /dev/null +++ b/frontend/src/hooks/useDateSelect/useDateSelect.ts @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import type { DateSelectMode } from 'types/calendar'; + +import { getFullDate } from '@utils/date'; + +import { getDatesInRange } from './useDateSelect.utils'; + +const useDateSelect = () => { + const [selectedDates, setSelectedDates] = useState([]); + const [dateSelectMode, setDateSelectMode] = useState('single'); + const [rangeStartDate, setRangeStartDate] = useState(null); + const [rangeEndDate, setRangeEndDate] = useState(null); + + const toggleDateSelectMode = (mode: DateSelectMode) => { + if (mode === dateSelectMode) return; + + setDateSelectMode(mode); + setSelectedDates([]); + setRangeStartDate(null); + setRangeEndDate(null); + }; + + const handleSelectedDateBySingleMode = (date: string) => { + setSelectedDates((prev) => + prev.includes(date) ? prev.filter((d) => d !== date) : [...prev, date], + ); + }; + + const handleRangeStartDatePick = (date: string) => { + setRangeStartDate(date); + setRangeEndDate(null); + setSelectedDates([date]); + }; + + const handleRangeEndDatePick = (date: string) => { + if (!rangeStartDate || rangeStartDate === date) return; + + const start = new Date(rangeStartDate); + const end = new Date(date); + + if (end < start) { + setRangeStartDate(date); + setSelectedDates([date]); + return; + } else { + setRangeEndDate(date); + const range = getDatesInRange(start, end); + setSelectedDates(range); + } + }; + + const handleSelectedDateByRangeMode = (date: string) => { + if (isAllRangeSelected || !rangeStartDate) { + handleRangeStartDatePick(date); + return; + } + + handleRangeEndDatePick(date); + }; + + const handleSelectedDates = (date: string) => { + if (dateSelectMode === 'single') { + handleSelectedDateBySingleMode(date); + return; + } + + handleSelectedDateByRangeMode(date); + }; + + const hasDate = (date: string) => { + return selectedDates.includes(date); + }; + const checkIsRangeStartDate = (date: Date) => getFullDate(date) === rangeStartDate; + const checkIsRangeEndDate = (date: Date) => getFullDate(date) === rangeEndDate; + const isAllRangeSelected = rangeStartDate !== null && rangeEndDate != null; + + return { + selectedDates, + dateSelectMode, + toggleDateSelectMode, + checkIsRangeStartDate, + checkIsRangeEndDate, + handleSelectedDates, + hasDate, + isAllRangeSelected, + }; +}; + +export default useDateSelect; diff --git a/frontend/src/hooks/useDateSelect/useDateSelect.utils.ts b/frontend/src/hooks/useDateSelect/useDateSelect.utils.ts new file mode 100644 index 000000000..184242f1e --- /dev/null +++ b/frontend/src/hooks/useDateSelect/useDateSelect.utils.ts @@ -0,0 +1,37 @@ +import { getFullDate } from '@utils/date'; + +/** + * 주어진 시작 날짜와 끝 날짜 사이의 모든 날짜를 'YYYY-MM-DD' 형식의 문자열 배열로 반환. + * 시작 날짜와 끝 날짜를 포함하여 날짜 범위를 계산. + * + * @param {Date} start - 범위의 시작 날짜 (포함). + * @param {Date} end - 범위의 끝 날짜 (포함). + * + * @returns {string[]} 'YYYY-MM-DD' 형식의 날짜 문자열 배열. 시작 날짜부터 끝 날짜까지의 모든 날짜가 포함. + * + * 동작 과정: + * 1. `getTime()`을 사용하여 시작 날짜와 끝 날짜 사이의 일 수를 계산합니다. + * - 시작 날짜의 시간과 끝 날짜의 시간을 빼면 두 날짜 사이의 시간 차이를 밀리초 단위로 구할 수 있다. + * - `1000 * 60 * 60 * 24`로 나누어 밀리초를 일 단위로 변환한다. + * - `Math.ceil`을 사용하여 소수점 이하의 값을 올림 처리하고, 범위에 끝 날짜까지 포함하기 위해 `+1`을 더한다. + * + * 2. `Array.from`을 사용하여 계산된 일 수만큼 배열을 생성하고, 각 인덱스에 해당하는 날짜를 계산하여 배열에 추가합니다. + * - 각 날짜는 시작 날짜에 인덱스를 더해 생성되며, getFullDate 함수를 사용하여 'YYYY-MM-DD' 형식으로 변환됩니다. + * + * 예시: + * ```ts + * const start = new Date('2023-09-01'); + * const end = new Date('2023-09-03'); + * 출력: ['2023-09-01', '2023-09-02', '2023-09-03'] + * ``` + */ +export const getDatesInRange = (start: Date, end: Date): string[] => { + const daysBetween = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1; + + return Array.from({ length: daysBetween }, (_, index) => { + const date = new Date(start); + date.setDate(date.getDate() + index); + + return getFullDate(date); + }); +}; diff --git a/frontend/src/pages/CreateMeetingPage/index.tsx b/frontend/src/pages/CreateMeetingPage/index.tsx index e08f4e895..2ba68ef83 100644 --- a/frontend/src/pages/CreateMeetingPage/index.tsx +++ b/frontend/src/pages/CreateMeetingPage/index.tsx @@ -1,11 +1,16 @@ -import { useState } from 'react'; +import type { DateInfo } from 'types/calendar'; +import RangeDate from '@components/MeetingCalendar/Date/RangeDate'; +import SingleDate from '@components/MeetingCalendar/Date/SingleDate'; +import MeetingCalendarHeader from '@components/MeetingCalendar/Header'; +import MeetingCalendarWeekdays from '@components/MeetingCalendar/Weekdays'; import TimeRangeSelector from '@components/TimeRangeSelector'; import { Button } from '@components/_common/Buttons/Button'; import Calendar from '@components/_common/Calendar'; import Field from '@components/_common/Field'; import Input from '@components/_common/Input'; +import useDateSelect from '@hooks/useDateSelect/useDateSelect'; import useInput from '@hooks/useInput/useInput'; import { INITIAL_END_TIME, INITIAL_START_TIME } from '@hooks/useTimeRangeDropdown/constants'; import useTimeRangeDropdown from '@hooks/useTimeRangeDropdown/useTimeRangeDropdown'; @@ -46,18 +51,19 @@ export default function CreateMeetingPage() { errorMessage: FIELD_DESCRIPTIONS.password, }); - const [selectedDates, setSelectedDates] = useState([]); + const { + selectedDates, + handleSelectedDates, + hasDate, + dateSelectMode, + checkIsRangeStartDate, + checkIsRangeEndDate, + isAllRangeSelected, + toggleDateSelectMode, + } = useDateSelect(); const { startTime, endTime, handleStartTimeChange, handleEndTimeChange } = useTimeRangeDropdown(); - const hasDate = (date: string) => selectedDates.includes(date); - - const handleDateClick = (date: string) => { - setSelectedDates((prevDates) => - hasDate(date) ? prevDates.filter((d) => d !== date) : [...prevDates, date], - ); - }; - const isFormValid = () => { const errorMessages = [meetingNameErrorMessage, hostNameErrorMessage, hostPasswordError]; const hasErrors = errorMessages.some((errorMessage) => errorMessage !== null); @@ -86,6 +92,29 @@ export default function CreateMeetingPage() { }); }; + const renderDate = (dateInfo: DateInfo, today: Date) => { + return dateSelectMode === 'single' ? ( + + ) : ( + + ); + }; + return (
{/* 추후 form 태그로 수정 예정 (@Largopie) */} @@ -125,7 +154,31 @@ export default function CreateMeetingPage() { - + + ( + + )} + /> + } + /> + + diff --git a/frontend/src/types/calendar.ts b/frontend/src/types/calendar.ts new file mode 100644 index 000000000..db690cd74 --- /dev/null +++ b/frontend/src/types/calendar.ts @@ -0,0 +1,14 @@ +export type DateSelectMode = 'single' | 'range'; + +export type MonthStatus = 'prev' | 'current' | 'next'; + +export interface DateInfo { + key: string; + value: Date; + status: MonthStatus; +} + +export type MonthlyDays = { + key: number; + value: DateInfo[]; +}[]; diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index ee0878ea6..a3236a82a 100644 --- a/frontend/src/utils/date.ts +++ b/frontend/src/utils/date.ts @@ -20,3 +20,15 @@ export const formatTime = (time: string): string => { return `${hourPrefix} ${formattedHour}시`; }; + +export const getMonth = (date: Date) => date.getMonth(); +export const getYear = (date: Date) => date.getFullYear(); +export const getDay = (date: Date) => date.getDay(); +export const getDate = (date: Date) => date.getDate(); +export const getFullDate = (date: Date) => { + const year = getYear(date); + const month = String(getMonth(date) + 1).padStart(2, '0'); + const day = String(getDate(date)).padStart(2, '0'); + + return `${year}-${month}-${day}`; +}; diff --git a/frontend/src/utils/typeGuards.ts b/frontend/src/utils/typeGuards.ts new file mode 100644 index 000000000..a8ef2900d --- /dev/null +++ b/frontend/src/utils/typeGuards.ts @@ -0,0 +1,6 @@ +export const isValidArrayType = ( + validArray: readonly K[], + candidateArray: readonly K[], +): candidateArray is T[] => { + return candidateArray.every((element) => validArray.includes(element)); +};