From e6055da150451d1a42806d494ecf1ef504c6932e Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Thu, 30 Mar 2023 02:01:53 +0530 Subject: [PATCH] feat: calendar view (#561) * feat:start and last day of month helper function * feat: start and last day of week helper function * feat: weekday and everyday interval helper function * feat: calendar date formater helper function * feat: monthly calendar view , feat: weekend date toggle, feat: calendar month and year picker * feat: monthly , weekly view and weekend toggle * feat: drag and drop added in calendar view * fix: drag and drop mutation fix * chore: refactoring , feat: calendar view option added * fix: calendar view menu fix * style: calendar view style improvement * style: drag and drop styling * fix:week day format fix * chore: calendar constant added * feat: calendar helper function added * feat: month and year picker, month navigator, jump to today funtionality added * feat: weekly navigation and jump to today fix, style: month year picker fix --------- Co-authored-by: Aaryan Khandelwal --- .../core/calendar-view/calendar.tsx | 419 ++++++++++++++++++ .../components/core/calendar-view/index.ts | 1 + .../components/core/issues-view-filter.tsx | 18 +- apps/app/components/core/issues-view.tsx | 5 +- apps/app/constants/calendar.ts | 22 + apps/app/constants/fetch-keys.ts | 3 + apps/app/contexts/issue-view.context.tsx | 95 ++-- apps/app/helpers/calendar.helper.ts | 161 +++++++ apps/app/hooks/use-issues-view.tsx | 6 +- apps/app/types/issues.d.ts | 2 + apps/app/types/projects.d.ts | 5 +- 11 files changed, 661 insertions(+), 76 deletions(-) create mode 100644 apps/app/components/core/calendar-view/calendar.tsx create mode 100644 apps/app/components/core/calendar-view/index.ts create mode 100644 apps/app/constants/calendar.ts create mode 100644 apps/app/helpers/calendar.helper.ts diff --git a/apps/app/components/core/calendar-view/calendar.tsx b/apps/app/components/core/calendar-view/calendar.tsx new file mode 100644 index 000000000..c61236edb --- /dev/null +++ b/apps/app/components/core/calendar-view/calendar.tsx @@ -0,0 +1,419 @@ +import React, { useState } from "react"; +import useSWR, { mutate } from "swr"; +import Link from "next/link"; +import { useRouter } from "next/router"; + +// helper +import { renderDateFormat } from "helpers/date-time.helper"; +import { + startOfWeek, + lastDayOfWeek, + eachDayOfInterval, + weekDayInterval, + formatDate, + getCurrentWeekStartDate, + getCurrentWeekEndDate, + subtractMonths, + addMonths, + updateDateWithYear, + updateDateWithMonth, + isSameMonth, + isSameYear, + subtract7DaysToDate, + addSevenDaysToDate, +} from "helpers/calendar.helper"; +// ui +import { Popover, Transition } from "@headlessui/react"; +import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd"; +import StrictModeDroppable from "components/dnd/StrictModeDroppable"; +import { CustomMenu } from "components/ui"; +// icon +import { + CheckIcon, + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "@heroicons/react/24/outline"; +// services +import issuesService from "services/issues.service"; +// fetch key +import { CALENDAR_ISSUES, ISSUE_DETAILS } from "constants/fetch-keys"; +// type +import { IIssue } from "types"; +// constant +import { monthOptions, yearOptions } from "constants/calendar"; + +interface ICalendarRange { + startDate: Date; + endDate: Date; +} + +export const CalendarView = () => { + const [showWeekEnds, setShowWeekEnds] = useState(false); + const [currentDate, setCurrentDate] = useState(new Date()); + const [isMonthlyView, setIsMonthlyView] = useState(true); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const [calendarDateRange, setCalendarDateRange] = useState({ + startDate: startOfWeek(currentDate), + endDate: lastDayOfWeek(currentDate), + }); + + const { data: calendarIssues } = useSWR( + workspaceSlug && projectId ? CALENDAR_ISSUES(projectId as string) : null, + workspaceSlug && projectId + ? () => + issuesService.getIssuesWithParams(workspaceSlug as string, projectId as string, { + target_date: `${renderDateFormat(calendarDateRange.startDate)};after,${renderDateFormat( + calendarDateRange.endDate + )};before`, + }) + : null + ); + + const totalDate = eachDayOfInterval({ + start: calendarDateRange.startDate, + end: calendarDateRange.endDate, + }); + + const onlyWeekDays = weekDayInterval({ + start: calendarDateRange.startDate, + end: calendarDateRange.endDate, + }); + + const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays; + + const currentViewDaysData = currentViewDays.map((date: Date) => { + const filterIssue = + calendarIssues && calendarIssues.length > 0 + ? (calendarIssues as IIssue[]).filter( + (issue) => + issue.target_date && renderDateFormat(issue.target_date) === renderDateFormat(date) + ) + : []; + return { + date: renderDateFormat(date), + issues: filterIssue, + }; + }); + + const weeks = ((date: Date[]) => { + const weeks = []; + if (showWeekEnds) { + for (let day = 0; day <= 6; day++) { + weeks.push(date[day]); + } + } else { + for (let day = 0; day <= 4; day++) { + weeks.push(date[day]); + } + } + + return weeks; + })(currentViewDays); + + const onDragEnd = (result: DropResult) => { + const { source, destination, draggableId } = result; + + if (!destination || !workspaceSlug || !projectId) return; + if (source.droppableId === destination.droppableId) return; + + mutate( + CALENDAR_ISSUES(projectId as string), + (prevData) => + (prevData ?? []).map((p) => { + if (p.id === draggableId) + return { + ...p, + target_date: destination.droppableId, + }; + return p; + }), + false + ); + + issuesService.patchIssue(workspaceSlug as string, projectId as string, draggableId, { + target_date: destination?.droppableId, + }); + }; + + const updateDate = (date: Date) => { + setCurrentDate(date); + setCalendarDateRange({ + startDate: startOfWeek(date), + endDate: lastDayOfWeek(date), + }); + }; + + return ( + +
+
+
+ + {({ open }) => ( + <> + +
+ {formatDate(currentDate, "Month")}{" "} + {formatDate(currentDate, "yyyy")} +
+
+ + + +
+ {yearOptions.map((year) => ( + + ))} +
+
+ {monthOptions.map((month) => ( + + ))} +
+
+
+ + )} +
+ +
+ + +
+
+ +
+ + + {isMonthlyView ? "Monthly" : "Weekly"} +
+ } + > + { + setIsMonthlyView(true); + setCalendarDateRange({ + startDate: startOfWeek(currentDate), + endDate: lastDayOfWeek(currentDate), + }); + }} + className="w-52 text-sm text-gray-600" + > +
+ Monthly View + +
+
+ { + setIsMonthlyView(false); + setCalendarDateRange({ + startDate: getCurrentWeekStartDate(currentDate), + endDate: getCurrentWeekEndDate(currentDate), + }); + }} + className="w-52 text-sm text-gray-600" + > +
+ Weekly View + +
+
+
+

Show weekends

+ +
+ +
+
+ +
+ {weeks.map((date, index) => ( +
+ + {isMonthlyView ? formatDate(date, "eee").substring(0, 3) : formatDate(date, "eee")} + + {!isMonthlyView && {formatDate(date, "d")}} +
+ ))} +
+ +
+ {currentViewDaysData.map((date, index) => ( + + {(provided, snapshot) => ( +
+ {isMonthlyView && {formatDate(new Date(date.date), "d")}} + {date.issues.length > 0 && + date.issues.map((issue: IIssue, index) => ( + + {(provided, snapshot) => ( +
+ + {issue.name} + +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+ ))} +
+ +
+ ); +}; diff --git a/apps/app/components/core/calendar-view/index.ts b/apps/app/components/core/calendar-view/index.ts new file mode 100644 index 000000000..55608c7e8 --- /dev/null +++ b/apps/app/components/core/calendar-view/index.ts @@ -0,0 +1 @@ +export * from "./calendar" \ No newline at end of file diff --git a/apps/app/components/core/issues-view-filter.tsx b/apps/app/components/core/issues-view-filter.tsx index cde9e6fe9..bf35baa2a 100644 --- a/apps/app/components/core/issues-view-filter.tsx +++ b/apps/app/components/core/issues-view-filter.tsx @@ -12,7 +12,7 @@ import { SelectFilters } from "components/views"; // ui import { CustomMenu } from "components/ui"; // icons -import { ChevronDownIcon, ListBulletIcon } from "@heroicons/react/24/outline"; +import { ChevronDownIcon, ListBulletIcon, CalendarDaysIcon } from "@heroicons/react/24/outline"; import { Squares2X2Icon } from "@heroicons/react/20/solid"; // helpers import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; @@ -27,8 +27,7 @@ export const IssuesFilterView: React.FC = () => { const { issueView, - setIssueViewToList, - setIssueViewToKanban, + setIssueView, groupByProperty, setGroupByProperty, orderBy, @@ -54,7 +53,7 @@ export const IssuesFilterView: React.FC = () => { className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${ issueView === "list" ? "bg-gray-200" : "" }`} - onClick={() => setIssueViewToList()} + onClick={() => setIssueView("list")} > @@ -63,10 +62,19 @@ export const IssuesFilterView: React.FC = () => { className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${ issueView === "kanban" ? "bg-gray-200" : "" }`} - onClick={() => setIssueViewToKanban()} + onClick={() => setIssueView("kanban")} > + = ({ isCompleted={isCompleted} userAuth={userAuth} /> - ) : ( + ) : issueView === "kanban" ? ( = ({ isCompleted={isCompleted} userAuth={userAuth} /> + ) : ( + )} ) : ( diff --git a/apps/app/constants/calendar.ts b/apps/app/constants/calendar.ts new file mode 100644 index 000000000..9b25acec0 --- /dev/null +++ b/apps/app/constants/calendar.ts @@ -0,0 +1,22 @@ +export const monthOptions = [ + { value: "1", label: "January" }, + { value: "2", label: "February" }, + { value: "3", label: "March" }, + { value: "4", label: "April" }, + { value: "5", label: "May" }, + { value: "6", label: "June" }, + { value: "7", label: "July" }, + { value: "8", label: "August" }, + { value: "9", label: "September" }, + { value: "10", label: "October" }, + { value: "11", label: "November" }, + { value: "12", label: "December" }, +]; + +export const yearOptions = [ + { value: "2021", label: "2021" }, + { value: "2022", label: "2022" }, + { value: "2023", label: "2023" }, + { value: "2024", label: "2024" }, + { value: "2025", label: "2025" }, +]; diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index 0da34739d..bd9ee3416 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -121,6 +121,9 @@ export const SUB_ISSUES = (issueId: string) => `SUB_ISSUES_${issueId.toUpperCase // integrations +// Calendar +export const CALENDAR_ISSUES = (projectId: string) => `CALENDAR_ISSUES_${projectId}`; + // Pages export const RECENT_PAGES_LIST = (projectId: string) => `RECENT_PAGES_LIST_${projectId.toUpperCase()}`; diff --git a/apps/app/contexts/issue-view.context.tsx b/apps/app/contexts/issue-view.context.tsx index 88dff42cc..29b1a5ea9 100644 --- a/apps/app/contexts/issue-view.context.tsx +++ b/apps/app/contexts/issue-view.context.tsx @@ -12,6 +12,7 @@ import viewsService from "services/views.service"; // types import { IIssueFilterOptions, + TIssueViewOptions, IProjectMember, TIssueGroupByOptions, TIssueOrderByOptions, @@ -22,7 +23,7 @@ import { USER_PROJECT_VIEW, VIEW_DETAILS } from "constants/fetch-keys"; export const issueViewContext = createContext({} as ContextType); type IssueViewProps = { - issueView: "list" | "kanban"; + issueView: TIssueViewOptions; groupByProperty: TIssueGroupByOptions; orderBy: TIssueOrderByOptions; showEmptyGroups: boolean; @@ -48,12 +49,11 @@ type ContextType = IssueViewProps & { setFilters: (filters: Partial, saveToServer?: boolean) => void; resetFilterToDefault: () => void; setNewFilterDefaultView: () => void; - setIssueViewToKanban: () => void; - setIssueViewToList: () => void; + setIssueView: (property: TIssueViewOptions) => void; }; type StateType = { - issueView: "list" | "kanban"; + issueView: TIssueViewOptions; groupByProperty: TIssueGroupByOptions; orderBy: TIssueOrderByOptions; showEmptyGroups: boolean; @@ -227,66 +227,34 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> = : null ); - const setIssueViewToKanban = useCallback(() => { - dispatch({ - type: "SET_ISSUE_VIEW", - payload: { - issueView: "kanban", - }, - }); - - dispatch({ - type: "SET_GROUP_BY_PROPERTY", - payload: { - groupByProperty: "state", - }, - }); - - if (!workspaceSlug || !projectId) return; - - saveDataToServer(workspaceSlug as string, projectId as string, { - ...state, - issueView: "kanban", - groupByProperty: "state", - }); - }, [workspaceSlug, projectId, state]); - - const setIssueViewToList = useCallback(() => { - dispatch({ - type: "SET_ISSUE_VIEW", - payload: { - issueView: "list", - }, - }); - - dispatch({ - type: "SET_GROUP_BY_PROPERTY", - payload: { - groupByProperty: null, - }, - }); - - if (!workspaceSlug || !projectId) return; - - mutateMyViewProps((prevData) => { - if (!prevData) return prevData; - - return { - ...prevData, - view_props: { - ...state, - issueView: "list", - groupByProperty: null, + const setIssueView = useCallback( + (property: TIssueViewOptions) => { + dispatch({ + type: "SET_ISSUE_VIEW", + payload: { + issueView: property, }, - }; - }, false); + }); - saveDataToServer(workspaceSlug as string, projectId as string, { - ...state, - issueView: "list", - groupByProperty: null, - }); - }, [workspaceSlug, projectId, state, mutateMyViewProps]); + if (property === "kanban") { + dispatch({ + type: "SET_GROUP_BY_PROPERTY", + payload: { + groupByProperty: "state", + }, + }); + } + + if (!workspaceSlug || !projectId) return; + + saveDataToServer(workspaceSlug as string, projectId as string, { + ...state, + issueView: property, + groupByProperty: "state", + }); + }, + [workspaceSlug, projectId, state] + ); const setGroupByProperty = useCallback( (property: TIssueGroupByOptions) => { @@ -492,8 +460,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> = setFilters, resetFilterToDefault: resetToDefault, setNewFilterDefaultView: setNewDefaultView, - setIssueViewToKanban, - setIssueViewToList, + setIssueView, }} > diff --git a/apps/app/helpers/calendar.helper.ts b/apps/app/helpers/calendar.helper.ts new file mode 100644 index 000000000..436fa237e --- /dev/null +++ b/apps/app/helpers/calendar.helper.ts @@ -0,0 +1,161 @@ +export const startOfWeek = (date: Date) => { + const startOfMonthDate = new Date(date.getFullYear(), date.getMonth(), 1); + const dayOfWeek = startOfMonthDate.getDay() % 7; + const startOfWeekDate = new Date( + startOfMonthDate.getFullYear(), + startOfMonthDate.getMonth(), + startOfMonthDate.getDate() - dayOfWeek + ); + const timezoneOffset = startOfMonthDate.getTimezoneOffset(); + const timezoneOffsetMilliseconds = timezoneOffset * 60 * 1000; + const startOfWeekAdjusted = new Date(startOfWeekDate.getTime() - timezoneOffsetMilliseconds); + return startOfWeekAdjusted; +}; + +export const lastDayOfWeek = (date: Date) => { + const lastDayOfPreviousMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0); + const dayOfWeek = lastDayOfPreviousMonth.getDay() % 7; + const daysUntilEndOfWeek = 6 - dayOfWeek; + const lastDayOfWeekDate = new Date( + lastDayOfPreviousMonth.getFullYear(), + lastDayOfPreviousMonth.getMonth(), + lastDayOfPreviousMonth.getDate() + daysUntilEndOfWeek + ); + const timezoneOffset = lastDayOfPreviousMonth.getTimezoneOffset(); + const timezoneOffsetMilliseconds = timezoneOffset * 60 * 1000; + const lastDayOfWeekAdjusted = new Date(lastDayOfWeekDate.getTime() - timezoneOffsetMilliseconds); + return lastDayOfWeekAdjusted; +}; + +export const getCurrentWeekStartDate = (date: Date) => { + const today = new Date(date); + const dayOfWeek = today.getDay(); + const startOfWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() - dayOfWeek); + const timezoneOffset = startOfWeek.getTimezoneOffset(); + const timezoneOffsetMilliseconds = timezoneOffset * 60 * 1000; + const startOfWeekAdjusted = new Date(startOfWeek.getTime() - timezoneOffsetMilliseconds); + return startOfWeekAdjusted; +}; + +export const getCurrentWeekEndDate = (date: Date) => { + const today = new Date(date); + const dayOfWeek = today.getDay(); + const endOfWeek = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + (6 - dayOfWeek) + ); + const timezoneOffset = endOfWeek.getTimezoneOffset(); + const timezoneOffsetMilliseconds = timezoneOffset * 60 * 1000; + const endOfWeekAdjusted = new Date(endOfWeek.getTime() - timezoneOffsetMilliseconds); + return endOfWeekAdjusted; +}; + +export const eachDayOfInterval = ({ start, end }: { start: Date; end: Date }) => { + const days = []; + const current = new Date(start); + while (current <= end) { + days.push(new Date(current)); + current.setDate(current.getDate() + 1); + } + return days; +}; + +export const weekDayInterval = ({ start, end }: { start: Date; end: Date }) => { + const dates = []; + const currentDate = new Date(start); + const endDate = new Date(end); + while (currentDate <= endDate) { + const dayOfWeek = currentDate.getDay(); + if (dayOfWeek !== 0 && dayOfWeek !== 6) { + dates.push(new Date(currentDate)); + } + currentDate.setDate(currentDate.getDate() + 1); + } + return dates; +}; + +export const formatDate = (date: Date, format: string): string => { + const day = date.getDate(); + const month = date.getMonth() + 1; + const year = date.getFullYear(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const seconds = date.getSeconds(); + const daysOfWeek = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + const monthsOfYear = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + + const formattedDate = format + .replace("dd", day.toString().padStart(2, "0")) + .replace("d", day.toString()) + .replace("eee", daysOfWeek[date.getDay()]) + .replace("Month", monthsOfYear[month - 1]) + .replace("yyyy", year.toString()) + .replace("yyy", year.toString().slice(-3)) + .replace("hh", hours.toString().padStart(2, "0")) + .replace("mm", minutes.toString().padStart(2, "0")) + .replace("ss", seconds.toString().padStart(2, "0")); + + return formattedDate; +}; + +export const subtractMonths = (date: Date, numMonths: number) => { + const result = new Date(date); + result.setMonth(result.getMonth() - numMonths); + return result; +}; + +export const addMonths = (date: Date, numMonths: number) => { + const result = new Date(date); + result.setMonth(result.getMonth() + numMonths); + return result; +}; + +export const updateDateWithYear = (yearString: string, date: Date) => { + const year = parseInt(yearString); + const month = date.getMonth(); + const day = date.getDate(); + return new Date(year, month, day); +}; + +export const updateDateWithMonth = (monthString: string, date: Date) => { + const month = parseInt(monthString) - 1; + const year = date.getFullYear(); + const day = date.getDate(); + return new Date(year, month, day); +}; + +export const isSameMonth = (monthString: string, date: Date) => { + const month = parseInt(monthString) - 1; + return month === date.getMonth(); +}; + +export const isSameYear = (yearString: string, date: Date) => { + const year = parseInt(yearString); + return year === date.getFullYear(); +}; + +export const addSevenDaysToDate = (date: Date) => { + const currentDate = date; + const newDate = new Date(currentDate.setDate(currentDate.getDate() + 7)); + return newDate; +}; + +export const subtract7DaysToDate = (date: Date) => { + const currentDate = date; + const newDate = new Date(currentDate.getTime() - 7 * 24 * 60 * 60 * 1000); + return newDate; +}; diff --git a/apps/app/hooks/use-issues-view.tsx b/apps/app/hooks/use-issues-view.tsx index dadc5d6b4..789123c58 100644 --- a/apps/app/hooks/use-issues-view.tsx +++ b/apps/app/hooks/use-issues-view.tsx @@ -36,8 +36,7 @@ const useIssuesView = () => { setFilters, resetFilterToDefault, setNewFilterDefaultView, - setIssueViewToKanban, - setIssueViewToList, + setIssueView } = useContext(issueViewContext); const router = useRouter(); @@ -147,8 +146,7 @@ const useIssuesView = () => { isNotEmpty: !isEmpty, resetFilterToDefault, setNewFilterDefaultView, - setIssueViewToKanban, - setIssueViewToList, + setIssueView, } as const; }; diff --git a/apps/app/types/issues.d.ts b/apps/app/types/issues.d.ts index ca95269c1..edf7eb7dc 100644 --- a/apps/app/types/issues.d.ts +++ b/apps/app/types/issues.d.ts @@ -240,6 +240,8 @@ export interface IIssueFilterOptions { created_by: string[] | null; } +export type TIssueViewOptions = "list" | "kanban" | "calendar"; + export type TIssueGroupByOptions = "state" | "priority" | "labels" | "created_by" | null; export type TIssueOrderByOptions = "-created_at" | "updated_at" | "priority" | "sort_order"; diff --git a/apps/app/types/projects.d.ts b/apps/app/types/projects.d.ts index 1ed22f201..9bc56343e 100644 --- a/apps/app/types/projects.d.ts +++ b/apps/app/types/projects.d.ts @@ -5,7 +5,8 @@ import type { IWorkspaceLite, TIssueGroupByOptions, TIssueOrderByOptions, -} from "types"; + TIssueViewOptions, +} from "./"; export interface IProject { cover_image: string | null; @@ -50,7 +51,7 @@ export interface IFavoriteProject { } type ProjectViewTheme = { - issueView: "list" | "kanban"; + issueView: TIssueViewOptions; groupByProperty: TIssueGroupByOptions; orderBy: TIssueOrderByOptions; filters: IIssueFilterOptions;