diff --git a/packages/constants/src/profile.ts b/packages/constants/src/profile.ts index f7765a0cfc..032e4526a8 100644 --- a/packages/constants/src/profile.ts +++ b/packages/constants/src/profile.ts @@ -71,3 +71,53 @@ export const PROFILE_ADMINS_TAB = [ selected: "/activity/", }, ]; + +/** + * @description The start of the week for the user + * @enum {number} + */ +export enum EStartOfTheWeek { + SUNDAY = 0, + MONDAY = 1, + TUESDAY = 2, + WEDNESDAY = 3, + THURSDAY = 4, + FRIDAY = 5, + SATURDAY = 6, +} + +/** + * @description The options for the start of the week + * @type {Array<{value: EStartOfTheWeek, label: string}>} + * @constant + */ +export const START_OF_THE_WEEK_OPTIONS = [ + { + value: EStartOfTheWeek.SUNDAY, + label: "Sunday", + }, + { + value: EStartOfTheWeek.MONDAY, + label: "Monday", + }, + { + value: EStartOfTheWeek.TUESDAY, + label: "Tuesday", + }, + { + value: EStartOfTheWeek.WEDNESDAY, + label: "Wednesday", + }, + { + value: EStartOfTheWeek.THURSDAY, + label: "Thursday", + }, + { + value: EStartOfTheWeek.FRIDAY, + label: "Friday", + }, + { + value: EStartOfTheWeek.SATURDAY, + label: "Saturday", + }, +]; diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index e5140fdef1..9f6ac49055 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -1,3 +1,4 @@ +import { EStartOfTheWeek } from "@plane/constants"; import { IIssueActivity, TIssuePriorities, TStateGroups } from "."; import { TUserPermissions } from "./enums"; @@ -64,6 +65,7 @@ export type TUserProfile = { language: string; created_at: Date | string; updated_at: Date | string; + start_of_the_week: EStartOfTheWeek; }; export interface IInstanceAdminStatus { @@ -155,14 +157,7 @@ export interface IUserProfileProjectSegregation { id: string; pending_issues: number; }[]; - user_data: Pick< - IUser, - | "avatar_url" - | "cover_image_url" - | "display_name" - | "first_name" - | "last_name" - > & { + user_data: Pick & { date_joined: Date; user_timezone: string; }; diff --git a/packages/ui/src/calendar.tsx b/packages/ui/src/calendar.tsx index 9fdbab1766..80b160cc1b 100644 --- a/packages/ui/src/calendar.tsx +++ b/packages/ui/src/calendar.tsx @@ -17,6 +17,7 @@ export const Calendar = ({ className, classNames, showOutsideDays = true, ...pro { const { t } = useTranslation(); const { setTheme } = useTheme(); @@ -75,6 +75,7 @@ const ProfileAppearancePage = observer(() => { {userProfile?.theme?.theme === "custom" && } + ) : (
diff --git a/web/core/components/dropdowns/date.tsx b/web/core/components/dropdowns/date.tsx index 83a5e7b368..684d6f3ef0 100644 --- a/web/core/components/dropdowns/date.tsx +++ b/web/core/components/dropdowns/date.tsx @@ -1,15 +1,18 @@ import React, { useRef, useState } from "react"; +import { observer } from "mobx-react"; import { Matcher } from "react-day-picker"; import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { CalendarDays, X } from "lucide-react"; import { Combobox } from "@headlessui/react"; // ui +import { EStartOfTheWeek } from "@plane/constants"; import { ComboDropDown, Calendar } from "@plane/ui"; // helpers import { cn } from "@/helpers/common.helper"; import { renderFormattedDate, getDate } from "@/helpers/date-time.helper"; // hooks +import { useUserProfile } from "@/hooks/store"; import { useDropdown } from "@/hooks/use-dropdown"; // components import { DropdownButton } from "./buttons"; @@ -33,7 +36,7 @@ type Props = TDropdownProps & { renderByDefault?: boolean; }; -export const DateDropdown: React.FC = (props) => { +export const DateDropdown: React.FC = observer((props) => { const { buttonClassName = "", buttonContainerClassName, @@ -62,6 +65,9 @@ export const DateDropdown: React.FC = (props) => { const [isOpen, setIsOpen] = useState(false); // refs const dropdownRef = useRef(null); + // hooks + const { data } = useUserProfile(); + const startOfWeek = data?.start_of_the_week; // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -186,6 +192,7 @@ export const DateDropdown: React.FC = (props) => { disabled={disabledDays} mode="single" fixedWeeks + weekStartsOn={startOfWeek} />
, @@ -193,4 +200,4 @@ export const DateDropdown: React.FC = (props) => { )} ); -}; +}); diff --git a/web/core/components/gantt-chart/chart/root.tsx b/web/core/components/gantt-chart/chart/root.tsx index 03e63e7a5b..16604d8817 100644 --- a/web/core/components/gantt-chart/chart/root.tsx +++ b/web/core/components/gantt-chart/chart/root.tsx @@ -1,10 +1,13 @@ import { FC, useEffect, useState } from "react"; import { observer } from "mobx-react"; +// plane imports +import { EStartOfTheWeek } from "@plane/constants"; // components import { GanttChartHeader, GanttChartMainContent } from "@/components/gantt-chart"; // helpers import { cn } from "@/helpers/common.helper"; // hooks +import { useUserProfile } from "@/hooks/store"; import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; // import { SIDEBAR_WIDTH } from "../constants"; @@ -87,6 +90,8 @@ export const ChartViewRoot: FC = observer((props) => { updateRenderView, updateAllBlocksOnChartChangeWhileDragging, } = useTimeLineChartStore(); + const { data } = useUserProfile(); + const startOfWeek = data?.start_of_the_week; const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews, targetDate?: Date) => { const selectedCurrentView: TGanttViews = view; @@ -98,7 +103,7 @@ export const ChartViewRoot: FC = observer((props) => { if (selectedCurrentViewData === undefined) return; const currentViewHelpers = timelineViewHelpers[selectedCurrentView]; - const currentRender = currentViewHelpers.generateChart(selectedCurrentViewData, side, targetDate); + const currentRender = currentViewHelpers.generateChart(selectedCurrentViewData, side, targetDate, startOfWeek); const mergeRenderPayloads = currentViewHelpers.mergeRenderPayloads as ( a: IWeekBlock[] | IMonthView | IMonthBlock[], b: IWeekBlock[] | IMonthView | IMonthBlock[] diff --git a/web/core/components/gantt-chart/data/index.ts b/web/core/components/gantt-chart/data/index.ts index 6db8dda659..2e72810d87 100644 --- a/web/core/components/gantt-chart/data/index.ts +++ b/web/core/components/gantt-chart/data/index.ts @@ -1,7 +1,13 @@ // types +import { EStartOfTheWeek } from "@plane/constants"; import { WeekMonthDataType, ChartDataType, TGanttViews } from "../types"; // constants +export const generateWeeks = (startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY): WeekMonthDataType[] => [ + ...weeks.slice(startOfWeek), + ...weeks.slice(0, startOfWeek), +]; + export const weeks: WeekMonthDataType[] = [ { key: 0, shortTitle: "sun", title: "sunday", abbreviation: "Su" }, { key: 1, shortTitle: "mon", title: "monday", abbreviation: "M" }, diff --git a/web/core/components/gantt-chart/views/week-view.ts b/web/core/components/gantt-chart/views/week-view.ts index 65915274c5..ea3d75f91b 100644 --- a/web/core/components/gantt-chart/views/week-view.ts +++ b/web/core/components/gantt-chart/views/week-view.ts @@ -1,5 +1,6 @@ // -import { weeks, months } from "../data"; +import { EStartOfTheWeek } from "@plane/constants"; +import { months, generateWeeks } from "../data"; import { ChartDataType } from "../types"; import { getNumberOfDaysBetweenTwoDates, getWeekNumberByDate } from "./helpers"; export interface IDayBlock { @@ -38,7 +39,12 @@ export interface IWeekBlock { * @param side * @returns */ -const generateWeekChart = (weekPayload: ChartDataType, side: null | "left" | "right", targetDate?: Date) => { +const generateWeekChart = ( + weekPayload: ChartDataType, + side: null | "left" | "right", + targetDate?: Date, + startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY +) => { let renderState = weekPayload; const range: number = renderState.data.approxFilterRange || 6; @@ -56,7 +62,7 @@ const generateWeekChart = (weekPayload: ChartDataType, side: null | "left" | "ri minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); - if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate); + if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate, true, startOfWeek); startDate = filteredDates[0].startDate; endDate = filteredDates[filteredDates.length - 1].endDate; @@ -77,7 +83,7 @@ const generateWeekChart = (weekPayload: ChartDataType, side: null | "left" | "ri minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, 1); plusDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth(), chartStartDate.getDate() - 1); - if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate); + if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate, true, startOfWeek); startDate = filteredDates[0].startDate; endDate = new Date(chartStartDate.getFullYear(), chartStartDate.getMonth(), chartStartDate.getDate() - 1); @@ -94,7 +100,7 @@ const generateWeekChart = (weekPayload: ChartDataType, side: null | "left" | "ri minusDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth(), chartEndDate.getDate() + 1); plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, 1); - if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate); + if (minusDate && plusDate) filteredDates = getWeeksBetweenTwoDates(minusDate, plusDate, true, startOfWeek); startDate = new Date(chartEndDate.getFullYear(), chartEndDate.getMonth(), chartEndDate.getDate() + 1); endDate = filteredDates[filteredDates.length - 1].endDate; @@ -120,14 +126,18 @@ const generateWeekChart = (weekPayload: ChartDataType, side: null | "left" | "ri export const getWeeksBetweenTwoDates = ( startDate: Date, endDate: Date, - shouldPopulateDaysForWeek: boolean = true + shouldPopulateDaysForWeek: boolean = true, + startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY ): IWeekBlock[] => { const weeks: IWeekBlock[] = []; const currentDate = new Date(startDate.getTime()); const today = new Date(); - currentDate.setDate(currentDate.getDate() - currentDate.getDay()); + // Adjust the current date to the start of the week + const day = currentDate.getDay(); + const diff = (day + 7 - startOfWeek) % 7; // Calculate days to subtract to get to startOfWeek + currentDate.setDate(currentDate.getDate() - diff); while (currentDate <= endDate) { const weekStartDate = new Date(currentDate.getTime()); @@ -141,7 +151,7 @@ export const getWeeksBetweenTwoDates = ( const weekNumber = getWeekNumberByDate(currentDate); weeks.push({ - children: shouldPopulateDaysForWeek ? populateDaysForWeek(weekStartDate) : undefined, + children: shouldPopulateDaysForWeek ? populateDaysForWeek(weekStartDate, startOfWeek) : undefined, weekNumber, weekData: { shortTitle: `w${weekNumber}`, @@ -171,17 +181,18 @@ export const getWeeksBetweenTwoDates = ( * @param startDate * @returns */ -const populateDaysForWeek = (startDate: Date): IDayBlock[] => { +const populateDaysForWeek = (startDate: Date, startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY): IDayBlock[] => { const currentDate = new Date(startDate); const days: IDayBlock[] = []; const today = new Date(); + const weekDays = generateWeeks(startOfWeek); for (let i = 0; i < 7; i++) { days.push({ date: new Date(currentDate), day: currentDate.getDay(), - dayData: weeks[currentDate.getDay()], - title: `${weeks[currentDate.getDay()].abbreviation} ${currentDate.getDate()}`, + dayData: weekDays[i], + title: `${weekDays[i].abbreviation} ${currentDate.getDate()}`, today: today.setHours(0, 0, 0, 0) == currentDate.setHours(0, 0, 0, 0), }); currentDate.setDate(currentDate.getDate() + 1); diff --git a/web/core/components/issues/issue-layouts/calendar/week-days.tsx b/web/core/components/issues/issue-layouts/calendar/week-days.tsx index 9aaa132294..c5ba104ee5 100644 --- a/web/core/components/issues/issue-layouts/calendar/week-days.tsx +++ b/web/core/components/issues/issue-layouts/calendar/week-days.tsx @@ -1,9 +1,14 @@ import { observer } from "mobx-react"; +import { EStartOfTheWeek } from "@plane/constants"; import { TGroupedIssues, TIssue, TIssueMap, TPaginationData } from "@plane/types"; +import { cn } from "@plane/utils"; // components import { CalendarDayTile } from "@/components/issues"; // helpers +import { getOrderedDays } from "@/helpers/calendar.helper"; import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +// hooks +import { useUserProfile } from "@/hooks/store"; // types import { IProjectEpicsFilter } from "@/plane-web/store/issue/epic"; import { ICycleIssuesFilter } from "@/store/issue/cycle"; @@ -65,20 +70,33 @@ export const CalendarWeekDays: React.FC = observer((props) => { canEditProperties, isEpic = false, } = props; + // hooks + const { data } = useUserProfile(); + const startOfWeek = data?.start_of_the_week; const calendarLayout = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.layout ?? "month"; const showWeekends = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.show_weekends ?? false; if (!week) return null; + const shouldShowDay = (dayDate: Date) => { + if (showWeekends) return true; + const day = dayDate.getDay(); + return !(day === 0 || day === 6); + }; + + const sortedWeekDays = getOrderedDays(Object.values(week), (item) => item.date.getDay(), startOfWeek); + return (
- {Object.values(week).map((date: ICalendarDate) => { - if (!showWeekends && (date.date.getDay() === 0 || date.date.getDay() === 6)) return null; + {sortedWeekDays.map((date: ICalendarDate) => { + if (!shouldShowDay(date.date)) return null; return ( = observer((props) => { const { isLoading, showWeekends } = props; + // hooks + const { data } = useUserProfile(); + const startOfWeek = data?.start_of_the_week; + + // derived + const orderedDays = getOrderedDays(Object.values(DAYS_LIST), (item) => item.value, startOfWeek); return (
= observer((props) => { {isLoading && (
)} - {Object.values(DAYS_LIST).map((day) => { - if (!showWeekends && (day.shortTitle === "Sat" || day.shortTitle === "Sun")) return null; + {orderedDays.map((day) => { + if (!showWeekends && (day.value === EStartOfTheWeek.SUNDAY || day.value === EStartOfTheWeek.SATURDAY)) + return null; return (
+ START_OF_THE_WEEK_OPTIONS.find((option) => option.value === startOfWeek)?.label; + +export const StartOfWeekPreference = observer(() => { + // hooks + const { data: userProfile, updateUserProfile } = useUserProfile(); + + return ( +
+
+

First day of the week

+

This will change how all calendars in your app look.

+
+
+ { + updateUserProfile({ start_of_the_week: val }) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "First day of the week updated successfully", + }); + }) + .catch(() => { + setToast({ type: TOAST_TYPE.ERROR, title: "Update failed", message: "Please try again later." }); + }); + }} + input + maxHeight="lg" + > + <> + {START_OF_THE_WEEK_OPTIONS.map((day) => ( + + {day.label} + + ))} + + +
+
+ ); +}); diff --git a/web/core/constants/calendar.ts b/web/core/constants/calendar.ts index b2d0624c56..79db73ef7f 100644 --- a/web/core/constants/calendar.ts +++ b/web/core/constants/calendar.ts @@ -1,3 +1,4 @@ +import { EStartOfTheWeek } from "@plane/constants"; import { TCalendarLayouts } from "@plane/types"; export const MONTHS_LIST: { @@ -60,35 +61,43 @@ export const DAYS_LIST: { [dayIndex: number]: { shortTitle: string; title: string; + value: EStartOfTheWeek; }; } = { 1: { shortTitle: "Sun", title: "Sunday", + value: EStartOfTheWeek.SUNDAY, }, 2: { shortTitle: "Mon", title: "Monday", + value: EStartOfTheWeek.MONDAY, }, 3: { shortTitle: "Tue", title: "Tuesday", + value: EStartOfTheWeek.TUESDAY, }, 4: { shortTitle: "Wed", title: "Wednesday", + value: EStartOfTheWeek.WEDNESDAY, }, 5: { shortTitle: "Thu", title: "Thursday", + value: EStartOfTheWeek.THURSDAY, }, 6: { shortTitle: "Fri", title: "Friday", + value: EStartOfTheWeek.FRIDAY, }, 7: { shortTitle: "Sat", title: "Saturday", + value: EStartOfTheWeek.SATURDAY, }, }; diff --git a/web/core/store/issue/issue_calendar_view.store.ts b/web/core/store/issue/issue_calendar_view.store.ts index c84fe956bc..4757fb5b3b 100644 --- a/web/core/store/issue/issue_calendar_view.store.ts +++ b/web/core/store/issue/issue_calendar_view.store.ts @@ -68,7 +68,29 @@ export class CalendarStore implements ICalendarStore { const { activeMonthDate } = this.calendarFilters; - return this.calendarPayload[`y-${activeMonthDate.getFullYear()}`][`m-${activeMonthDate.getMonth()}`]; + const year = activeMonthDate.getFullYear(); + const month = activeMonthDate.getMonth(); + + // Get the weeks for the current month + const weeks = this.calendarPayload[`y-${year}`][`m-${month}`]; + + // If no weeks exist, return undefined + if (!weeks) return undefined; + + // Create a new object to store the reordered weeks + const reorderedWeeks: { [weekNumber: string]: ICalendarWeek } = {}; + + // Get all week numbers and sort them + const weekNumbers = Object.keys(weeks).map((key) => parseInt(key.replace("w-", ""))); + weekNumbers.sort((a, b) => a - b); + + // Reorder weeks based on start_of_week + weekNumbers.forEach((weekNumber) => { + const weekKey = `w-${weekNumber}`; + reorderedWeeks[weekKey] = weeks[weekKey]; + }); + + return reorderedWeeks; } get activeWeekNumber() { diff --git a/web/core/store/user/profile.store.ts b/web/core/store/user/profile.store.ts index 08cbe1fc70..d5e796be66 100644 --- a/web/core/store/user/profile.store.ts +++ b/web/core/store/user/profile.store.ts @@ -2,6 +2,7 @@ import cloneDeep from "lodash/cloneDeep"; import set from "lodash/set"; import { action, makeObservable, observable, runInAction } from "mobx"; // types +import { EStartOfTheWeek } from "@plane/constants"; import { IUserTheme, TUserProfile } from "@plane/types"; // services import { UserService } from "@/services/user.service"; @@ -58,7 +59,8 @@ export class ProfileStore implements IUserProfileStore { has_billing_address: false, created_at: "", updated_at: "", - language: "" + language: "", + start_of_the_week: EStartOfTheWeek.SUNDAY, }; // services diff --git a/web/helpers/calendar.helper.ts b/web/helpers/calendar.helper.ts index 5b89d8625f..709cf9c961 100644 --- a/web/helpers/calendar.helper.ts +++ b/web/helpers/calendar.helper.ts @@ -1,5 +1,7 @@ +import { EStartOfTheWeek } from "@plane/constants"; // helpers import { ICalendarDate, ICalendarPayload } from "@/components/issues"; +import { DAYS_LIST } from "@/constants/calendar"; import { getWeekNumberOfDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; // types @@ -92,3 +94,21 @@ export const generateCalendarData = (currentStructure: ICalendarPayload | null, return calendarData; }; + +/** + * Returns a new array sorted by the startOfWeek. + * @param items Array of items to sort. + * @param getDayIndex Function to get the day index (0-6) from an item. + * @param startOfWeek The day to start the week on. + */ +export function getOrderedDays( + items: T[], + getDayIndex: (item: T) => number, + startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY +): T[] { + return [...items].sort((a, b) => { + const dayA = (7 + getDayIndex(a) - startOfWeek) % 7; + const dayB = (7 + getDayIndex(b) - startOfWeek) % 7; + return dayA - dayB; + }); +}