Merge pull request #43 from linagora/37-month-view-distinguish-display-time-and-today

month view distinguish today and selected week
This commit is contained in:
Camille Moussu
2025-07-23 08:58:06 +02:00
committed by GitHub
3 changed files with 294 additions and 87 deletions
@@ -0,0 +1,201 @@
import { renderWithProviders } from "../utils/Renderwithproviders";
import { fireEvent, screen } from "@testing-library/react";
import { jest } from "@jest/globals";
import CalendarApp from "../../src/components/Calendar/Calendar";
import * as appHooks from "../../src/app/hooks";
import { ThunkDispatch } from "@reduxjs/toolkit";
import preview from "jest-preview";
describe("MiniCalendar", () => {
const day = new Date();
beforeEach(() => {
jest.clearAllMocks();
const dispatch = jest.fn() as ThunkDispatch<any, any, any>;
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch);
});
const renderCalendar = () => {
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
},
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
name: "Calendar 1",
color: "#FF0000",
events: {
event1: {
id: "event1",
title: "Test Event",
start: day.toISOString(),
},
},
},
},
pending: false,
},
};
renderWithProviders(<CalendarApp />, preloadedState);
};
it("renders mini calendar with today in orange", async () => {
renderCalendar();
const today = new Date().getDate().toString();
const todayTile = screen
.getAllByText(today)
.find((el) => el.tagName.toLowerCase() === "abbr");
expect(todayTile?.parentElement).toHaveClass("today");
});
it("renders mini calendar with the week in gray (except for today) when full calendar in week view", async () => {
renderCalendar();
preview.debug();
const today = new Date();
const sunday = new Date(today);
sunday.setDate(today.getDate() - today.getDay());
for (let i = 0; i < 7; i++) {
const date = new Date(sunday);
date.setDate(sunday.getDate() + i);
const tile = (await screen.findAllByText(date.getDate())).find(
(el) => el.tagName.toLowerCase() === "abbr"
);
if (date.getTime() !== today.setHours(0, 0, 0, 0)) {
expect(tile?.parentElement).toHaveClass("selectedWeek");
}
}
});
it("renders mini calendar with the day in gray (except for today) when full calendar in day view", async () => {
renderCalendar();
// Simulate switching to day view
const dayViewButton = await screen.findByTitle(/day view/i);
fireEvent.click(dayViewButton);
const today = new Date().getDate().toString();
const todayTile = screen
.getAllByText(today)
.find((el) => el.tagName.toLowerCase() === "abbr");
expect(todayTile?.parentElement).toHaveClass("selectedWeek");
});
it("renders mini calendar with nothing colored (except for today) when full calendar in month view", async () => {
renderCalendar();
// Switch to month view
const monthButton = await screen.findByRole("button", { name: /month/i });
fireEvent.click(monthButton);
const tiles = await screen.findAllByRole("gridcell");
tiles.forEach((tile) => {
if (!tile.classList.contains("today")) {
expect(tile.className).not.toContain("selectedWeek");
}
});
});
it("renders mini calendar with dots on days with personal events", async () => {
renderCalendar();
const dot = document.querySelector(".event-dot");
expect(dot?.parentElement?.children[0].innerHTML).toBe(
day.getDate().toString()
);
expect(dot).toBeInTheDocument();
});
});
describe("Found Bugs", () => {
const day = new Date();
beforeEach(() => {
jest.clearAllMocks();
const dispatch = jest.fn() as ThunkDispatch<any, any, any>;
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch);
});
const renderCalendar = () => {
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
},
},
calendars: {
list: {
"667037022b752d0026472254/cal1": {
name: "Calendar 1",
color: "#FF0000",
events: {
event1: {
id: "event1",
title: "Test Event",
start: day.toISOString(),
},
},
},
},
pending: false,
},
};
renderWithProviders(<CalendarApp />, preloadedState);
};
it("gray day stays when day mode, click today, then change the month bar to august and come back to july", async () => {
renderCalendar();
const dayViewButton = await screen.findByTitle(/day view/i);
fireEvent.click(dayViewButton);
const nextMonthButton = screen.getByText(">");
const previousMonthButton = screen.getByText("<");
fireEvent.click(nextMonthButton);
fireEvent.click(previousMonthButton);
preview.debug();
const shownDay = screen.getByText((content, element) => {
return (
element?.className.toLowerCase().includes("fc-daygrid-day-number") ??
false
);
});
const selectedTile = screen.getByText((content, element) => {
return element?.className.includes("selectedWeek") ?? false;
});
const supposedSelectedTile = screen
.getAllByText((content, element) => {
return element?.tagName.toLowerCase() === "abbr";
})
.find((el) => el.innerHTML === shownDay.innerHTML);
expect(selectedTile.children[0].innerHTML).toBe(
supposedSelectedTile?.innerHTML
);
expect(supposedSelectedTile?.parentElement).toHaveClass("selectedWeek");
});
it("in month view going to next month, side panel is not updated on second click to following month both components are updated with the side panel view jumping 2 months", async () => {
renderCalendar();
const monthViewButton = await screen.findByTitle(/month view/i);
fireEvent.click(monthViewButton);
const nextMonthButton = await screen.findByTitle(/Next month/i);
const previousMonthButton = await screen.findByTitle(/Previous month/i);
fireEvent.click(nextMonthButton);
const miniCalMonth = await screen.findByTitle(/mini calendar month/i);
const fullCalMonth = screen.getByText((content, element) => {
return element?.className.includes("fc-toolbar-title") ?? false;
});
expect(miniCalMonth.innerHTML).toBe(fullCalMonth.innerHTML);
fireEvent.click(nextMonthButton);
expect(miniCalMonth.innerHTML).toBe(fullCalMonth.innerHTML);
});
});
+16 -51
View File
@@ -41,7 +41,6 @@ main {
align-items: center; align-items: center;
padding: 0.5rem 0; padding: 0.5rem 0;
font-family: "Inter", sans-serif; font-family: "Inter", sans-serif;
color: #1e293b; /* dark navy */
} }
/* Day number */ /* Day number */
@@ -74,7 +73,7 @@ main {
border: none; border: none;
} }
/* Container de la sidebar */ /* sidebar*/
.sidebar-calendar { .sidebar-calendar {
width: 280px; width: 280px;
background-color: #fff; background-color: #fff;
@@ -87,7 +86,7 @@ main {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
} }
/* Style du mini calendrier */ /* mini calendar style */
.react-calendar { .react-calendar {
border: none; border: none;
font-size: 14px; font-size: 14px;
@@ -95,29 +94,31 @@ main {
user-select: none; user-select: none;
} }
/* Jours sélectionnés et hover */ /* selected day hover */
.react-calendar__tile { .react-calendar__tile {
transition: background-color 0.3s ease, color 0.3s ease; transition: background-color 0.3s ease, color 0.3s ease;
border-radius: 50%;
border: none; border: none;
background-color: white; background-color: white;
width: 12px; width: 12px;
height: 37.52px; height: 37.52px;
} }
.react-calendar__tile:hover { .react-calendar__tile--active {
background-color: rgb(224, 224, 224); background-color: rgb(255, 255, 255);
border: none;
border-radius: 50%;
cursor: pointer;
} }
.react-calendar__tile--active { .today {
background-color: orange; background: orange !important;
color: white; color: white;
} }
.selectedWeek {
background-color: lightgrey;
}
.react-calendar__month-view__days__day--neighboringMonth {
color: gray;
}
/* Titre ou header de la sidebar */ /* sidebar header */
.sidebar-calendar h2 { .sidebar-calendar h2 {
font-size: 1.3rem; font-size: 1.3rem;
font-weight: 700; font-weight: 700;
@@ -125,7 +126,7 @@ main {
margin: 0 0 10px 0; margin: 0 0 10px 0;
} }
/* Liste des calendriers */ /* Calendar list */
.calendar-list { .calendar-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
@@ -147,47 +148,11 @@ main {
cursor: pointer; cursor: pointer;
} }
/* Couleurs distinctes pour chaque type de calendrier */
.calendar-list li.work {
background-color: #d0e8ff;
color: #1a73e8;
}
.calendar-list li.work:hover {
background-color: #b0d4ff;
}
.calendar-list li.personal {
background-color: #ffe0e0;
color: #d93025;
}
.calendar-list li.personal:hover {
background-color: #ffb3b3;
}
/* Petit indicateur couleur à gauche */
.calendar-list li::before {
content: "";
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.calendar-list li.work::before {
background-color: #1a73e8;
}
.calendar-list li.personal::before {
background-color: #d93025;
}
.event-dot { .event-dot {
margin-top: 2px; margin-top: 2px;
width: 6px; width: 6px;
height: 6px; height: 6px;
background-color: #d93025; /* adjust to your theme */ background-color: #d93025;
border-radius: 50%; border-radius: 50%;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
+77 -36
View File
@@ -24,6 +24,7 @@ import { push } from "redux-first-history";
export default function CalendarApp() { export default function CalendarApp() {
const calendarRef = useRef<CalendarApi | null>(null); const calendarRef = useRef<CalendarApi | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date()); const [selectedDate, setSelectedDate] = useState(new Date());
const [selectedMiniDate, setSelectedMiniDate] = useState(new Date());
const tokens = useAppSelector((state) => state.user.tokens); const tokens = useAppSelector((state) => state.user.tokens);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -56,7 +57,6 @@ export default function CalendarApp() {
const [selectedCalendars, setSelectedCalendars] = useState<string[]>( const [selectedCalendars, setSelectedCalendars] = useState<string[]>(
Object.keys(calendars).filter((id) => id.split("/")[0] === userId) Object.keys(calendars).filter((id) => id.split("/")[0] === userId)
); );
const fetchedIdsRef = useRef<Set<string>>(new Set());
const calendarRange = getCalendarRange(selectedDate); const calendarRange = getCalendarRange(selectedDate);
@@ -108,59 +108,82 @@ export default function CalendarApp() {
setSelectedRange(null); setSelectedRange(null);
}; };
const handleMonthUp = () => {
setSelectedMiniDate(
new Date(selectedMiniDate.getFullYear(), selectedMiniDate.getMonth() - 1)
);
};
const handleMonthDown = () => {
setSelectedMiniDate(
new Date(selectedMiniDate.getFullYear(), selectedMiniDate.getMonth() + 1)
);
};
return ( return (
<main> <main>
<div className="sidebar"> <div className="sidebar">
<div className="calendar-label"> <div className="calendar-label">
<div className="calendar-label"> <div className="calendar-label">
<span> <span title="mini calendar month">
{selectedDate.toLocaleDateString("us-us", { {selectedMiniDate.toLocaleDateString("en-us", {
month: "long", month: "long",
year: "numeric", year: "numeric",
})} })}
</span> </span>
</div> </div>
<button <button onClick={handleMonthUp}>&lt;</button>
onClick={() => <button onClick={handleMonthDown}>&gt;</button>
setSelectedDate(
new Date(
selectedDate.getFullYear(),
selectedDate.getMonth() - 1
)
)
}
>
&lt;
</button>
<button
onClick={() =>
setSelectedDate(
new Date(
selectedDate.getFullYear(),
selectedDate.getMonth() + 1
)
)
}
>
&gt;
</button>
</div> </div>
<ReactCalendar <ReactCalendar
key={selectedDate.toDateString()} key={selectedMiniDate.toDateString()}
showNeighboringMonth={false} calendarType="gregory"
calendarType="hebrew"
formatShortWeekday={(locale, date) => formatShortWeekday={(locale, date) =>
date.toLocaleDateString(locale, { weekday: "narrow" }) date.toLocaleDateString(locale, { weekday: "narrow" })
} }
value={selectedDate} value={selectedMiniDate}
onClickDay={(date) => { onClickDay={(date) => {
setSelectedDate(date); setSelectedDate(date);
setSelectedMiniDate(date);
calendarRef.current?.gotoDate(date); calendarRef.current?.gotoDate(date);
}} }}
prevLabel={null} prevLabel={null}
showNeighboringMonth={true}
nextLabel={null} nextLabel={null}
showNavigation={false} showNavigation={false}
tileClassName={({ date }) => {
const classNames: string[] = [];
const today = new Date().setHours(0, 0, 0, 0);
if (date.getTime() === today) {
classNames.push("today");
}
const selected = new Date(selectedDate);
selected.setHours(0, 0, 0, 0);
if (
calendarRef.current?.view.type === "timeGridWeek" ||
calendarRef.current?.view.type === undefined
) {
const startOfWeek = new Date(selected);
startOfWeek.setDate(selected.getDate() - selected.getDay()); // Sunday
startOfWeek.setHours(0, 0, 0, 0);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6); // Saturday
endOfWeek.setHours(23, 59, 59, 999);
if (date <= endOfWeek && date >= startOfWeek) {
classNames.push("selectedWeek");
}
}
if (
calendarRef.current?.view.type === "timeGridDay" &&
date.getTime() === selected.getTime()
) {
classNames.push("selectedWeek");
}
return classNames;
}}
tileContent={({ date }) => { tileContent={({ date }) => {
const classNames: string[] = [];
const hasEvents = personnalEvents.some((event) => { const hasEvents = personnalEvents.some((event) => {
const eventDate = new Date(event.start); const eventDate = new Date(event.start);
return ( return (
@@ -169,7 +192,11 @@ export default function CalendarApp() {
eventDate.getDate() === date.getDate() eventDate.getDate() === date.getDate()
); );
}); });
return hasEvents ? <div className="event-dot" /> : null; if (hasEvents) {
classNames.push("event-dot");
}
return <div className={classNames.join(" ")}></div>;
}} }}
/> />
<CalendarSelection <CalendarSelection
@@ -211,10 +238,24 @@ export default function CalendarApp() {
hour12: false, hour12: false,
}} }}
datesSet={(arg) => { datesSet={(arg) => {
const today = new Date(); if (arg.view.type === "timeGridDay") {
setSelectedDate( setSelectedDate(new Date(arg.start));
today > arg.start && today < arg.end ? today : arg.start setSelectedMiniDate(new Date(arg.start));
); } else if (arg.view.type === "timeGridWeek") {
// In week view, retain selectedDate if it's in current range, otherwise set to start
if (selectedDate < arg.start || selectedDate > arg.end) {
setSelectedDate(new Date(arg.start));
setSelectedMiniDate(new Date(arg.start));
}
} else if (arg.view.type === "dayGridMonth") {
setSelectedDate(new Date(arg.start));
const midTimestamp =
(arg.start.getTime() + arg.end.getTime()) / 2;
setSelectedMiniDate(new Date(midTimestamp));
} else {
setSelectedDate(new Date(arg.start));
setSelectedMiniDate(new Date(arg.start));
}
}} }}
dayHeaderContent={(arg) => { dayHeaderContent={(arg) => {
const date = arg.date.getDate(); const date = arg.date.getDate();