-
Notifications
You must be signed in to change notification settings - Fork 3
feat: 캘린더 기능 구현 #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: 캘린더 기능 구현 #35
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { CalendarPage } from '@/views/calendar'; | ||
|
|
||
| interface WorkspaceCalendarPageProps { | ||
| params: Promise<{ | ||
| workspaceId: string; | ||
| }>; | ||
| } | ||
|
|
||
| export default async function WorkspaceCalendarPage({ | ||
| params, | ||
| }: WorkspaceCalendarPageProps) { | ||
| const { workspaceId } = await params; | ||
|
|
||
| return <CalendarPage workspaceId={workspaceId} />; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| export { getMockCalendarEventsByWorkspaceId } from './model/mock-calendar-events-by-workspace'; | ||
| export type { | ||
| CalendarEvent, | ||
| CalendarEventColor, | ||
| CalendarEventFormValues, | ||
| } from './model/calendar-event.types'; | ||
| export { CalendarEventChip } from './ui/CalendarEventChip'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| export type CalendarEventColor = 'violet' | 'purple' | 'blue' | 'green' | 'amber' | 'coral' | 'pink'; | ||
|
|
||
| export interface CalendarEvent { | ||
| id: string; | ||
| workspaceId: string; | ||
| title: string; | ||
| date: string; | ||
| time: string | null; | ||
| color: CalendarEventColor; | ||
| } | ||
|
|
||
| export interface CalendarEventFormValues { | ||
| title: string; | ||
| time: string; | ||
| color: CalendarEventColor; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import type { CalendarEvent } from './calendar-event.types'; | ||
|
|
||
| const mockCalendarEventsByWorkspaceId: Record<string, CalendarEvent[]> = { | ||
| test: [ | ||
| { | ||
| id: 'calendar-event-1', | ||
| workspaceId: 'test', | ||
| title: 'API 명세 마감', | ||
| date: '2025-07-02', | ||
| time: null, | ||
| color: 'violet', | ||
| }, | ||
| { | ||
| id: 'calendar-event-2', | ||
| workspaceId: 'test', | ||
| title: '백로그 정리', | ||
| date: '2025-07-02', | ||
| time: '오후 1:00', | ||
| color: 'blue', | ||
| }, | ||
| { | ||
| id: 'calendar-event-3', | ||
| workspaceId: 'test', | ||
| title: '와이어프레임', | ||
| date: '2025-07-03', | ||
| time: null, | ||
| color: 'purple', | ||
| }, | ||
| { | ||
| id: 'calendar-event-4', | ||
| workspaceId: 'test', | ||
| title: '스프린트', | ||
| date: '2025-07-03', | ||
| time: null, | ||
| color: 'blue', | ||
| }, | ||
| { | ||
| id: 'calendar-event-5', | ||
| workspaceId: 'test', | ||
| title: '사용자 리서치', | ||
| date: '2025-07-05', | ||
| time: null, | ||
| color: 'blue', | ||
| }, | ||
| { | ||
| id: 'calendar-event-6', | ||
| workspaceId: 'test', | ||
| title: '회의 안건 정리', | ||
| date: '2025-07-05', | ||
| time: '오전 10:00', | ||
| color: 'amber', | ||
| }, | ||
| { | ||
| id: 'calendar-event-7', | ||
| workspaceId: 'test', | ||
| title: '랜딩 디자인', | ||
| date: '2025-07-08', | ||
| time: null, | ||
| color: 'violet', | ||
| }, | ||
| { | ||
| id: 'calendar-event-8', | ||
| workspaceId: 'test', | ||
| title: '카피 문구 검토', | ||
| date: '2025-07-08', | ||
| time: '오후 2:00', | ||
| color: 'purple', | ||
| }, | ||
| { | ||
| id: 'calendar-event-9', | ||
| workspaceId: 'test', | ||
| title: 'MVP 완성', | ||
| date: '2025-07-14', | ||
| time: null, | ||
| color: 'green', | ||
| }, | ||
| { | ||
| id: 'calendar-event-10', | ||
| workspaceId: 'test', | ||
| title: '중간 점검 회의', | ||
| date: '2025-07-14', | ||
| time: '오후 4:00', | ||
| color: 'blue', | ||
| }, | ||
| { | ||
| id: 'calendar-event-11', | ||
| workspaceId: 'test', | ||
| title: 'QA 시작', | ||
| date: '2025-07-22', | ||
| time: null, | ||
| color: 'coral', | ||
| }, | ||
| { | ||
| id: 'calendar-event-12', | ||
| workspaceId: 'test', | ||
| title: '버그 우선순위 정리', | ||
| date: '2025-07-22', | ||
| time: '오전 11:00', | ||
| color: 'pink', | ||
| }, | ||
| { | ||
| id: 'calendar-event-13', | ||
| workspaceId: 'test', | ||
| title: '배포 준비', | ||
| date: '2025-07-28', | ||
| time: null, | ||
| color: 'violet', | ||
| }, | ||
| { | ||
| id: 'calendar-event-14', | ||
| workspaceId: 'test', | ||
| title: '릴리즈 체크리스트', | ||
| date: '2025-07-28', | ||
| time: '오후 1:00', | ||
| color: 'green', | ||
| }, | ||
| { | ||
| id: 'calendar-event-15', | ||
| workspaceId: 'test', | ||
| title: '최종 발표 리허설', | ||
| date: '2025-07-30', | ||
| time: '오후 3:00', | ||
| color: 'violet', | ||
| }, | ||
| { | ||
| id: 'calendar-event-16', | ||
| workspaceId: 'test', | ||
| title: '발표 자료 검수', | ||
| date: '2025-07-30', | ||
| time: '오후 5:00', | ||
| color: 'amber', | ||
| }, | ||
| ], | ||
| }; | ||
|
|
||
| export function getMockCalendarEventsByWorkspaceId(workspaceId: string): CalendarEvent[] { | ||
| return ( | ||
| mockCalendarEventsByWorkspaceId[workspaceId]?.map((event) => ({ | ||
| ...event, | ||
| })) ?? [] | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import { cn } from '@/shared/lib/utils'; | ||
| import type { CalendarEvent, CalendarEventColor } from '../model/calendar-event.types'; | ||
|
|
||
| const colorClassNames: Record<CalendarEventColor, string> = { | ||
| violet: 'bg-[#6b5cff]', | ||
| purple: 'bg-[#7f5cff]', | ||
| blue: 'bg-[#2f7df6]', | ||
| green: 'bg-[#10b74a]', | ||
| amber: 'bg-[#ff9f0a]', | ||
| coral: 'bg-[#ff6565]', | ||
| pink: 'bg-[#eb2f96]', | ||
| }; | ||
|
|
||
| interface CalendarEventChipProps { | ||
| event: CalendarEvent; | ||
| } | ||
|
|
||
| export function CalendarEventChip({ event }: CalendarEventChipProps) { | ||
| return ( | ||
| <div | ||
| className={cn( | ||
| 'h-5 truncate rounded-full px-2 text-[10px] leading-5 font-semibold text-white', | ||
| colorClassNames[event.color], | ||
| )} | ||
| title={event.title} | ||
| > | ||
| {event.title} | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { CalendarView } from './ui/CalendarView'; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,40 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface CalendarDayCell { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isoDate: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dayNumber: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isCurrentMonth: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function formatIsoDate(date: Date) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const year = date.getFullYear(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const month = String(date.getMonth() + 1).padStart(2, '0'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const day = String(date.getDate()).padStart(2, '0'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return `${year}-${month}-${day}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function createCalendarMonthLabel(currentMonth: Date) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return `${currentMonth.getFullYear()}년 ${currentMonth.getMonth() + 1}월`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function createCalendarMonthGrid(currentMonth: Date): CalendarDayCell[] { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const year = currentMonth.getFullYear(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const monthIndex = currentMonth.getMonth(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const firstDayOfMonth = new Date(year, monthIndex, 1); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const startDayIndex = firstDayOfMonth.getDay(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const gridStartDate = new Date(year, monthIndex, 1 - startDayIndex); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Array.from({ length: 35 }, (_, index) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const date = new Date(gridStartDate); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| date.setDate(gridStartDate.getDate() + index); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isoDate: formatIsoDate(date), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dayNumber: date.getDate(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isCurrentMonth: date.getMonth() === monthIndex, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+19
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 월별 셀 개수를 35개로 고정하면 일부 월의 마지막 날짜가 그리드에서 잘립니다.
🐛 셀 개수를 동적으로 계산하는 수정안 export function createCalendarMonthGrid(currentMonth: Date): CalendarDayCell[] {
const year = currentMonth.getFullYear();
const monthIndex = currentMonth.getMonth();
const firstDayOfMonth = new Date(year, monthIndex, 1);
const startDayIndex = firstDayOfMonth.getDay();
const gridStartDate = new Date(year, monthIndex, 1 - startDayIndex);
+ const daysInMonth = new Date(year, monthIndex + 1, 0).getDate();
+ const totalCells = Math.ceil((startDayIndex + daysInMonth) / 7) * 7;
- return Array.from({ length: 35 }, (_, index) => {
+ return Array.from({ length: totalCells }, (_, index) => {
const date = new Date(gridStartDate);
date.setDate(gridStartDate.getDate() + index);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 혹시 모를 상황에 대비하여 동적으로 관리하는 것으로 수정하면 좋을 거 같습니다~ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The PR description image (Image 1) shows the Syncly calendar page for "2025년 7월" with a monthly grid (일~토), colored event chips in various day cells, and a right-side detail panel for "2025년 7월 30일" showing an empty-state message ("등록된 일정이 없어요"). The PR description image (Image 2) shows the same calendar with a modal overlay titled "2025년 7월 30일 일정 추가", containing fields for 일정 이름, 시간 (선택), and 색상 (color picker dots), with 취소/추가 buttons at the bottom.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 월 시작 요일과 말일 조합에 따라 5주(35칸)로는 월 전체를 표현할 수 없는 경우가 있어서, 그리드 셀 수를 동적으로 계산하도록 수정했습니다. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function getInitialCalendarDate() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return new Date(2025, 6, 1); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| 'use client'; | ||
|
|
||
| import { create } from 'zustand'; | ||
| import type { CalendarEvent } from '@/entities/calendar-event'; | ||
|
|
||
| interface CalendarEventsStore { | ||
| calendarEventsByWorkspaceId: Record<string, CalendarEvent[]>; | ||
| initializeWorkspace: (workspaceId: string, events: CalendarEvent[]) => void; | ||
| addCalendarEvent: (workspaceId: string, event: CalendarEvent) => void; | ||
| removeCalendarEvent: (workspaceId: string, eventId: string) => void; | ||
| } | ||
|
|
||
| export const useCalendarEventsStore = create<CalendarEventsStore>((set) => ({ | ||
| calendarEventsByWorkspaceId: {}, | ||
| initializeWorkspace: (workspaceId, events) => | ||
| set((state) => { | ||
| if (state.calendarEventsByWorkspaceId[workspaceId]) { | ||
| return state; | ||
| } | ||
|
|
||
| return { | ||
| calendarEventsByWorkspaceId: { | ||
| ...state.calendarEventsByWorkspaceId, | ||
| [workspaceId]: events, | ||
| }, | ||
| }; | ||
| }), | ||
| addCalendarEvent: (workspaceId, event) => | ||
| set((state) => ({ | ||
| calendarEventsByWorkspaceId: { | ||
| ...state.calendarEventsByWorkspaceId, | ||
| [workspaceId]: [...(state.calendarEventsByWorkspaceId[workspaceId] ?? []), event], | ||
| }, | ||
| })), | ||
| removeCalendarEvent: (workspaceId, eventId) => | ||
| set((state) => ({ | ||
| calendarEventsByWorkspaceId: { | ||
| ...state.calendarEventsByWorkspaceId, | ||
| [workspaceId]: (state.calendarEventsByWorkspaceId[workspaceId] ?? []).filter( | ||
| (event) => event.id !== eventId, | ||
| ), | ||
| }, | ||
| })), | ||
| })); |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
색상 매핑 불일치: 칩 색상이 선택 UI와 다릅니다.
colorClassNames의purple(#7f5cff)과blue(#2f7df6) hex 값이CalendarView.tsx의colorButtonClassNames/colorDotClassNames에서 사용하는 값(#8b5cf6,#3b82f6)과 다릅니다. 사용자가 모달에서 색상을 고르면 실제 칩에는 다른 색으로 렌더링됩니다. 색상 상수를 한 곳(예:calendar-event.types.ts또는 공용 constants 파일)으로 통합하는 것을 권장합니다.🎨 제안: 공유 색상 상수로 통합
📝 Committable suggestion
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CalendarEventColor 색상 매핑이 CalendarView와 CalendarEventChip에 중복 정의되어 있고, purple/blue의 hex 값이 서로 달라 선택 UI와 실제 칩 색상이 다르게 보일 수 있을 것 같습니다.
색상 매핑을 공용 constants로 분리해서 두 컴포넌트가 동일한 값을 참조하도록 통일하면 좋겠습니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Length of output: 5819
🏁 Script executed:
rg -n "colorButtonClassNames\[|colorDotClassNames\[|colorClassNames\[" src/features/manage-calendar/ui/CalendarView.tsx src/entities/calendar-event/ui/CalendarEventChip.tsxLength of output: 721
Confirmed — there are exactly 3 usage sites to update:
src/entities/calendar-event/ui/CalendarEventChip.tsx:23→colorClassNames[event.color]src/features/manage-calendar/ui/CalendarView.tsx:267→colorDotClassNames[calendarEvent.color]src/features/manage-calendar/ui/CalendarView.tsx:369→colorButtonClassNames[color]The plan already covers all three: replace each with
CALENDAR_EVENT_COLOR_CLASS_NAMES[...]after importing the new shared constant from theentities/calendar-eventlayer (respecting the FSDfeature → entityimport direction). No other usages were found, so the migration is contained to these two files plus the new constants file and barrel export.✏️ Learnings added
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
칩, 색상 선택 버튼, 오른쪽 패널 점 표시가 각각 다른 hex 값을 보던 상태여서 색상 매핑을
calendar-event-colors.ts로 공통화했습니다.이제
CalendarEventChip,CalendarView모두 동일한 색상 상수를 사용합니다.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.