[WEB-3523] feat: start of week preference (#7033)

* chore: startOfWeek constant and types updated

* chore: startOfWeek updated in profile store

* chore: StartOfWeekPreference added to profile appearance settings

* chore: calendar layout startOfWeek implementation

* chore: date picker startOfWeek implementation

* chore: gantt layout startOfWeek implementation

* chore: code refactor

* chore: code refactor

* chore: code refactor
This commit is contained in:
Anmol Singh Bhatia 2025-05-12 19:13:39 +05:30 committed by GitHub
parent dc16f2862e
commit 8613a80b16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 251 additions and 36 deletions

View file

@ -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",
},
];

View file

@ -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<IUser, "avatar_url" | "cover_image_url" | "display_name" | "first_name" | "last_name"> & {
date_joined: Date;
user_timezone: string;
};

View file

@ -17,6 +17,7 @@ export const Calendar = ({ className, classNames, showOutsideDays = true, ...pro
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
weekStartsOn={props.weekStartsOn}
// classNames={{
// months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
// month: "space-y-4",

View file

@ -1,6 +1,7 @@
import set from "lodash/set";
import { action, makeObservable, observable, runInAction } from "mobx";
// plane imports
import { EStartOfTheWeek } from "@plane/constants";
import { UserService } from "@plane/services";
import { TUserProfile } from "@plane/types";
// store
@ -54,6 +55,7 @@ export class ProfileStore implements IProfileStore {
created_at: "",
updated_at: "",
language: "",
start_of_the_week: EStartOfTheWeek.SUNDAY,
};
// services

View file

@ -10,13 +10,13 @@ import { IUserTheme } from "@plane/types";
import { setPromiseToast } from "@plane/ui";
// components
import { LogoSpinner } from "@/components/common";
import { CustomThemeSelector, ThemeSwitch, PageHead } from "@/components/core";
import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile";
// constants
import { ThemeSwitch, PageHead, CustomThemeSelector } from "@/components/core";
import { ProfileSettingContentHeader, ProfileSettingContentWrapper, StartOfWeekPreference } from "@/components/profile";
// helpers
import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
// hooks
import { useUserProfile } from "@/hooks/store";
const ProfileAppearancePage = observer(() => {
const { t } = useTranslation();
const { setTheme } = useTheme();
@ -75,6 +75,7 @@ const ProfileAppearancePage = observer(() => {
</div>
</div>
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
<StartOfWeekPreference />
</ProfileSettingContentWrapper>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">

View file

@ -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> = (props) => {
export const DateDropdown: React.FC<Props> = observer((props) => {
const {
buttonClassName = "",
buttonContainerClassName,
@ -62,6 +65,9 @@ export const DateDropdown: React.FC<Props> = (props) => {
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// hooks
const { data } = useUserProfile();
const startOfWeek = data?.start_of_the_week;
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -186,6 +192,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
disabled={disabledDays}
mode="single"
fixedWeeks
weekStartsOn={startOfWeek}
/>
</div>
</Combobox.Options>,
@ -193,4 +200,4 @@ export const DateDropdown: React.FC<Props> = (props) => {
)}
</ComboDropDown>
);
};
});

View file

@ -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<ChartViewRootProps> = 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<ChartViewRootProps> = 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[]

View file

@ -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" },

View file

@ -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);

View file

@ -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<Props> = 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 (
<div
className={`grid divide-custom-border-200 md:divide-x-[0.5px] ${showWeekends ? "grid-cols-7" : "grid-cols-5"} ${
calendarLayout === "month" ? "" : "h-full"
}`}
className={cn("grid divide-custom-border-200 md:divide-x-[0.5px]", {
"grid-cols-7": showWeekends,
"grid-cols-5": !showWeekends,
"h-full": calendarLayout !== "month",
})}
>
{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 (
<CalendarDayTile

View file

@ -1,7 +1,10 @@
import { observer } from "mobx-react";
// constants
import { EStartOfTheWeek } from "@plane/constants";
import { DAYS_LIST } from "@/constants/calendar";
// helpers
import { getOrderedDays } from "@/helpers/calendar.helper";
// hooks
import { useUserProfile } from "@/hooks/store";
type Props = {
isLoading: boolean;
@ -10,6 +13,12 @@ type Props = {
export const CalendarWeekHeader: React.FC<Props> = 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 (
<div
@ -20,8 +29,9 @@ export const CalendarWeekHeader: React.FC<Props> = observer((props) => {
{isLoading && (
<div className="absolute h-[1.5px] w-3/4 animate-[bar-loader_2s_linear_infinite] bg-custom-primary-100" />
)}
{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 (
<div

View file

@ -6,3 +6,4 @@ export * from "./time";
export * from "./profile-setting-content-wrapper";
export * from "./profile-setting-content-header";
export * from "./form";
export * from "./start-of-week-preference";

View file

@ -0,0 +1,55 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { EStartOfTheWeek, START_OF_THE_WEEK_OPTIONS } from "@plane/constants";
import { CustomSelect, setToast, TOAST_TYPE } from "@plane/ui";
// hooks
import { useUserProfile } from "@/hooks/store";
const getStartOfWeekLabel = (startOfWeek: EStartOfTheWeek) =>
START_OF_THE_WEEK_OPTIONS.find((option) => option.value === startOfWeek)?.label;
export const StartOfWeekPreference = observer(() => {
// hooks
const { data: userProfile, updateUserProfile } = useUserProfile();
return (
<div className="grid grid-cols-12 gap-4 py-6 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-custom-text-100">First day of the week</h4>
<p className="text-sm text-custom-text-200">This will change how all calendars in your app look.</p>
</div>
<div className="col-span-12 sm:col-span-6">
<CustomSelect
value={userProfile.start_of_the_week}
label={getStartOfWeekLabel(userProfile.start_of_the_week)}
onChange={(val: number) => {
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) => (
<CustomSelect.Option key={day.value} value={day.value}>
{day.label}
</CustomSelect.Option>
))}
</>
</CustomSelect>
</div>
</div>
);
});

View file

@ -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,
},
};

View file

@ -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() {

View file

@ -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

View file

@ -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<T>(
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;
});
}