diff --git a/__test__/components/Menubar.test.tsx b/__test__/components/Menubar.test.tsx index 896d43e..55a5d6c 100644 --- a/__test__/components/Menubar.test.tsx +++ b/__test__/components/Menubar.test.tsx @@ -55,6 +55,139 @@ describe("Calendar App Component Display Tests", () => { expect(screen.getByText("t")).toBeInTheDocument(); }); + // Edge cases for avatar display logic + it("handles user with only family_name", () => { + (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + const preloadedState = { + user: { + userData: { + sub: "test", + email: "test@test.com", + family_name: "Smith", + name: null, + sid: "mockSid", + openpaasId: "667037022b752d0026472254", + }, + }, + }; + renderWithProviders(, preloadedState); + expect(screen.getByText("t")).toBeInTheDocument(); + }); + + it("handles user with only name", () => { + (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + const preloadedState = { + user: { + userData: { + sub: "test", + email: "test@test.com", + family_name: null, + name: "John", + sid: "mockSid", + openpaasId: "667037022b752d0026472254", + }, + }, + }; + renderWithProviders(, preloadedState); + expect(screen.getByText("t")).toBeInTheDocument(); + }); + + it("handles user with both name and family_name", () => { + (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + const preloadedState = { + user: { + userData: { + sub: "test", + email: "test@test.com", + family_name: "Doe", + name: "John", + sid: "mockSid", + openpaasId: "667037022b752d0026472254", + }, + }, + }; + renderWithProviders(, preloadedState); + expect(screen.getByText("JD")).toBeInTheDocument(); + }); + + it("handles user with no name and family_name", () => { + (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + const preloadedState = { + user: { + userData: { + sub: "test", + email: "test@test.com", + family_name: null, + name: null, + sid: "mockSid", + openpaasId: "667037022b752d0026472254", + }, + }, + }; + renderWithProviders(, preloadedState); + expect(screen.getByText("t")).toBeInTheDocument(); + }); + + it("handles user with empty email", () => { + (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + const preloadedState = { + user: { + userData: { + sub: "test", + email: "", + family_name: null, + name: null, + sid: "mockSid", + openpaasId: "667037022b752d0026472254", + }, + }, + }; + renderWithProviders(, preloadedState); + // Should not crash and show empty string + const avatar = screen.getByRole("img", { hidden: true }); + expect(avatar).toBeInTheDocument(); + }); + + it("handles user with null email", () => { + (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + const preloadedState = { + user: { + userData: { + sub: "test", + email: null, + family_name: null, + name: null, + sid: "mockSid", + openpaasId: "667037022b752d0026472254", + }, + }, + }; + renderWithProviders(, preloadedState); + // Should not crash and show empty string + const avatar = screen.getByRole("img", { hidden: true }); + expect(avatar).toBeInTheDocument(); + }); + + it("handles user with undefined email", () => { + (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; + const preloadedState = { + user: { + userData: { + sub: "test", + email: undefined, + family_name: null, + name: null, + sid: "mockSid", + openpaasId: "667037022b752d0026472254", + }, + }, + }; + renderWithProviders(, preloadedState); + // Should not crash and show empty string + const avatar = screen.getByRole("img", { hidden: true }); + expect(avatar).toBeInTheDocument(); + }); + it("shows AppsIcon when applist is not empty", () => { (window as any).appList = [{ name: "test", icon: "test", link: "test" }]; renderWithProviders(, preloadedState); diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts index 4cb01b1..fca445f 100644 --- a/__test__/features/Events/eventUtils.test.ts +++ b/__test__/features/Events/eventUtils.test.ts @@ -465,4 +465,202 @@ describe("calendarEventToJCal", () => { ]) ); }); + + // Edge cases for timezone fallback + it("handles invalid timezone with UTC fallback", () => { + const mockEvent = { + uid: "event-invalid-tz", + title: "Invalid Timezone Event", + start: new Date("2025-07-23T10:00:00"), + end: new Date("2025-07-23T11:00:00"), + timezone: "Invalid/Timezone", + allday: false, + attendee: [], + }; + + const result = calendarEventToJCal(mockEvent as CalendarEvent); + + expect(result[0]).toBe("vcalendar"); + const [vevent, vtimezone] = result[2]; + expect(vevent[0]).toBe("vevent"); + expect(vtimezone[0]).toBe("vtimezone"); + + // Should use UTC timezone as fallback - check for UTC standard timezone + expect(vtimezone[2]).toEqual( + expect.arrayContaining([ + [ + "standard", + [ + ["tzoffsetfrom", {}, "utc-offset", "+00:00"], + ["tzoffsetto", {}, "utc-offset", "+00:00"], + ["tzname", {}, "text", "UTC"], + ["dtstart", {}, "date-time", "1970-01-01T00:00:00"], + ], + [], + ], + ]) + ); + }); + + it("handles null timezone with UTC fallback", () => { + const mockEvent = { + uid: "event-null-tz", + title: "Null Timezone Event", + start: new Date("2025-07-23T10:00:00"), + end: new Date("2025-07-23T11:00:00"), + timezone: null, + allday: false, + attendee: [], + }; + + const result = calendarEventToJCal(mockEvent as CalendarEvent); + + expect(result[0]).toBe("vcalendar"); + const [vevent, vtimezone] = result[2]; + expect(vevent[0]).toBe("vevent"); + expect(vtimezone[0]).toBe("vtimezone"); + + // Should use UTC timezone as fallback + expect(vtimezone[2]).toEqual( + expect.arrayContaining([ + [ + "standard", + [ + ["tzoffsetfrom", {}, "utc-offset", "+00:00"], + ["tzoffsetto", {}, "utc-offset", "+00:00"], + ["tzname", {}, "text", "UTC"], + ["dtstart", {}, "date-time", "1970-01-01T00:00:00"], + ], + [], + ], + ]) + ); + }); + + it("handles undefined timezone with UTC fallback", () => { + const mockEvent = { + uid: "event-undefined-tz", + title: "Undefined Timezone Event", + start: new Date("2025-07-23T10:00:00"), + end: new Date("2025-07-23T11:00:00"), + timezone: undefined, + allday: false, + attendee: [], + }; + + const result = calendarEventToJCal(mockEvent as CalendarEvent); + + expect(result[0]).toBe("vcalendar"); + const [vevent, vtimezone] = result[2]; + expect(vevent[0]).toBe("vevent"); + expect(vtimezone[0]).toBe("vtimezone"); + + // Should use UTC timezone as fallback + expect(vtimezone[2]).toEqual( + expect.arrayContaining([ + [ + "standard", + [ + ["tzoffsetfrom", {}, "utc-offset", "+00:00"], + ["tzoffsetto", {}, "utc-offset", "+00:00"], + ["tzname", {}, "text", "UTC"], + ["dtstart", {}, "date-time", "1970-01-01T00:00:00"], + ], + [], + ], + ]) + ); + }); + + it("handles empty string timezone with UTC fallback", () => { + const mockEvent = { + uid: "event-empty-tz", + title: "Empty Timezone Event", + start: new Date("2025-07-23T10:00:00"), + end: new Date("2025-07-23T11:00:00"), + timezone: "", + allday: false, + attendee: [], + }; + + const result = calendarEventToJCal(mockEvent as CalendarEvent); + + expect(result[0]).toBe("vcalendar"); + const [vevent, vtimezone] = result[2]; + expect(vevent[0]).toBe("vevent"); + expect(vtimezone[0]).toBe("vtimezone"); + + // Should use UTC timezone as fallback + expect(vtimezone[2]).toEqual( + expect.arrayContaining([ + [ + "standard", + [ + ["tzoffsetfrom", {}, "utc-offset", "+00:00"], + ["tzoffsetto", {}, "utc-offset", "+00:00"], + ["tzname", {}, "text", "UTC"], + ["dtstart", {}, "date-time", "1970-01-01T00:00:00"], + ], + [], + ], + ]) + ); + }); + + it("handles valid timezone correctly", () => { + const mockEvent = { + uid: "event-valid-tz", + title: "Valid Timezone Event", + start: new Date("2025-07-23T10:00:00"), + end: new Date("2025-07-23T11:00:00"), + timezone: "Europe/Paris", + allday: false, + attendee: [], + }; + + const result = calendarEventToJCal(mockEvent as CalendarEvent); + + expect(result[0]).toBe("vcalendar"); + const [vevent, vtimezone] = result[2]; + expect(vevent[0]).toBe("vevent"); + expect(vtimezone[0]).toBe("vtimezone"); + + // Should use the specified timezone - check for Europe/Paris timezone components + expect(vtimezone[2]).toEqual( + expect.arrayContaining([ + [ + "daylight", + [ + ["tzoffsetfrom", {}, "utc-offset", "+01:00"], + ["tzoffsetto", {}, "utc-offset", "+02:00"], + ["tzname", {}, "text", "CEST"], + ["dtstart", {}, "date-time", "1970-03-29T02:00:00"], + [ + "rrule", + {}, + "recur", + { byday: "-1SU", bymonth: 3, freq: "YEARLY" }, + ], + ], + [], + ], + [ + "standard", + [ + ["tzoffsetfrom", {}, "utc-offset", "+02:00"], + ["tzoffsetto", {}, "utc-offset", "+01:00"], + ["tzname", {}, "text", "CET"], + ["dtstart", {}, "date-time", "1970-10-25T03:00:00"], + [ + "rrule", + {}, + "recur", + { byday: "-1SU", bymonth: 10, freq: "YEARLY" }, + ], + ], + [], + ], + ]) + ); + }); }); diff --git a/src/components/Calendar/Calendar.css b/src/components/Calendar/Calendar.css index 704c32d..87e7712 100644 --- a/src/components/Calendar/Calendar.css +++ b/src/components/Calendar/Calendar.css @@ -1,12 +1,21 @@ -main { +.App { + background-color: #f3f6f9; display: flex; + flex-direction: column; + justify-content: space-between; +} +.main-layout { + background-color: #fff; flex-direction: row; + height: calc(100vh - 90px); + display: flex; } .calendar { flex-grow: 1; - overflow: "hidden"; - height: 90vh; + height: 100%; + overflow: hidden; + padding: 0 25px 0 10px; } .calendarListHeader { @@ -16,38 +25,22 @@ main { margin-left: 25%; } .sidebar { - border-right: 2px gray; + border-right: 2px solid #f3f4f6; width: 20vw; + padding: 16px 25px; } - -.fc .fc-daygrid-day { - min-height: 10px !important; /* Default is ~40px */ - height: 10px !important; - font-size: 0.75rem; -} -.fc .fc-timegrid-slot { - height: 60px !important; /* Default is ~40px */ -} - -.fc-timegrid-slot { - position: relative; -} - .declined-event { opacity: 0.7; } -.tentative-event { -} - .needs-action-event { opacity: 0.7; border: dashed 1px #ffffff; } .fc .fc-timegrid-slot { - height: 30px !important; - min-height: 30px !important; + height: auto; + min-height: 54px !important; } .fc .fc-timegrid-slot-label { @@ -74,14 +67,6 @@ main { overflow: hidden; /* Important: let FullCalendar handle internal scroll */ } -.fc-scroller-liquid-absolute { - overflow-y: auto !important; /* This targets the scrollable part */ - max-height: calc(100vh - 100px); /* Adjust based on your header size */ -} -.scrollgrid-section-header { - border: white; -} - /* Base container */ .fc-daygrid-day-top { display: flex; @@ -91,36 +76,6 @@ main { font-family: "Inter", sans-serif; } -/* Day number */ -.fc-daygrid-day-top .fc-daygrid-day-number { - font-size: 1rem; - font-weight: 600; - line-height: 2; -} - -/* Day name (e.g., MON) */ -.fc-daygrid-day-top small { - font-size: 0.75rem; - letter-spacing: 0.05em; - color: #94a3b8; /* slate gray */ - text-transform: uppercase; -} - -/* Today’s date highlight */ -.current-date { - background-color: orange; - color: white; - border-radius: 50%; - padding: 50px; -} - -.fc .fc-col-header-cell, -.fc .fc-scrollgrid table, -.fc-theme-standard .fc-scrollgrid { - background: none; - border: none; -} - /* sidebar*/ .sidebar-calendar { width: 280px; @@ -207,3 +162,112 @@ main { margin-left: auto; margin-right: auto; } + +/* Custom main calendar view */ +.fc-header-toolbar.fc-toolbar.fc-toolbar-ltr { + /* display: none; */ +} +.fc-theme-standard td, +.fc-theme-standard th { + border: 1px solid #b8c1cc; + border-left: 0; +} +a.fc-timegrid-axis-cushion { + color: #243b55; + text-align: center; + font-family: Roboto; + font-size: 12.507px; + font-style: normal; + font-weight: 400; + line-height: 18.76px; + letter-spacing: 0.391px; +} +.fc-daygrid-day-top small { + color: #8c9caf; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; +} +.fc-daygrid-day-top .fc-daygrid-day-number, +span.fc-daygrid-day-number { + color: #243b55; + margin: 0 8px 0 0; + font-family: Roboto; + font-size: 28px; + font-style: normal; + font-weight: 400; + line-height: 36px; + position: relative; + width: 48px; + height: 48px; + line-height: 39px; +} +.fc-daygrid-day-top .fc-daygrid-day-number:after, +span.fc-daygrid-day-number:after { + content: ""; + z-index: -1; + border-radius: 50%; + width: 48px; + height: 48px; + position: absolute; + left: 0; + top: -1px; +} +span.fc-daygrid-day-number.current-date { + color: #fff; +} +span.fc-daygrid-day-number.current-date:after { + background-color: #f67e35; +} +td.fc-timegrid-slot.fc-timegrid-slot-label.fc-scrollgrid-shrink, +td.fc-timegrid-slot.fc-timegrid-slot-label.fc-timegrid-slot-minor { + border: 0; +} +th.fc-timegrid-axis.fc-scrollgrid-shrink { + border: 0; + border-right: 1px solid #b8c1cc; +} +.fc .fc-timegrid-axis-cushion { + min-width: 70px; + max-width: 70px; + padding: 0; + text-align: left; +} +.fc .fc-timegrid-divider { + display: none; + height: 0; +} +.fc-timegrid-slot-label-cushion { + color: #aea9b1; + font-family: Roboto; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.25px; +} +th.fc-col-header-cell.fc-day { + border-right: 0; +} +.fc .fc-scrollgrid { + border: 0; +} +.fc .fc-timegrid-slot-label { + justify-content: flex-end; +} +.fc .fc-timegrid-slot-label .fc-timegrid-slot-label-frame { + position: relative; + transform: translate(-15px, -12px); +} +.fc .fc-timegrid-slot-label .fc-timegrid-slot-label-frame::after { + content: ""; + z-index: -1; + background-color: #b8c1cc; + width: 10px; + height: 1px; + position: absolute; + top: 9.5px; + right: -15px; +} diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index cfef63e..771218b 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -56,7 +56,8 @@ export default function CalendarApp() { const calendars = useAppSelector((state) => state.calendars.list); const pending = useAppSelector((state) => state.calendars.pending); - const userId = useAppSelector((state) => state.user.userData.openpaasId); + const userId = + useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; const selectPersonnalCalendars = createSelector( (state) => state.calendars, (calendars) => @@ -80,9 +81,21 @@ export default function CalendarApp() { ); } }); - const [selectedCalendars, setSelectedCalendars] = useState( - Object.keys(calendars).filter((id) => id.split("/")[0] === userId) - ); + const [selectedCalendars, setSelectedCalendars] = useState([]); + + // Auto-select personal calendars when first loaded + useEffect(() => { + if ( + Object.keys(calendars).length > 0 && + userId && + selectedCalendars.length === 0 + ) { + const personalCalendarIds = Object.keys(calendars).filter( + (id) => id.split("/")[0] === userId + ); + setSelectedCalendars(personalCalendarIds); + } + }, [calendars, userId, selectedCalendars.length]); const calendarRange = getCalendarRange(selectedDate); @@ -163,7 +176,7 @@ export default function CalendarApp() { } return ( -
+
@@ -304,8 +317,10 @@ export default function CalendarApp() { weekNumbers weekNumberFormat={{ week: "long" }} slotDuration={"00:30:00"} - slotLabelInterval={"01:00:00"} - scrollTime={"08:00:00"} + slotLabelInterval={"00:30:00"} + scrollTime={new Date(Date.now() - 2 * 60 * 60 * 1000) + .toTimeString() + .slice(0, 5)} unselectAuto={false} allDayText="" slotLabelFormat={{ diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx index d526ed1..1734226 100644 --- a/src/components/Calendar/CalendarSelection.tsx +++ b/src/components/Calendar/CalendarSelection.tsx @@ -10,7 +10,6 @@ import { Calendars } from "../../features/Calendars/CalendarTypes"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import IconButton from "@mui/material/IconButton"; import Checkbox from "@mui/material/Checkbox"; -import Button from "@mui/material/Button"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; function CalendarAccordion({ @@ -34,8 +33,8 @@ function CalendarAccordion({ }) { const allCalendars = useAppSelector((state) => state.calendars.list); - if (calendars.length === 0) return null; const [expended, setExpended] = useState(defaultExpanded); + if (calendars.length === 0) return null; return ( @@ -81,7 +80,8 @@ export default function CalendarSelection({ selectedCalendars: string[]; setSelectedCalendars: Function; }) { - const userId = useAppSelector((state) => state.user.userData.openpaasId); + const userId = + useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; const calendars = useAppSelector((state) => state.calendars.list); const personnalCalendars = Object.keys(calendars).filter( diff --git a/src/components/Menubar/Menubar.css b/src/components/Menubar/Menubar.css index aafc6df..2fcffb0 100644 --- a/src/components/Menubar/Menubar.css +++ b/src/components/Menubar/Menubar.css @@ -42,7 +42,8 @@ padding: 0.5rem; width: 100%; z-index: 1000; - height: 7vh; + height: 80px; + background-color: #fff; } .menubar-item { diff --git a/src/components/Menubar/Menubar.tsx b/src/components/Menubar/Menubar.tsx index cc3ee09..3850f71 100644 --- a/src/components/Menubar/Menubar.tsx +++ b/src/components/Menubar/Menubar.tsx @@ -48,14 +48,18 @@ export function Menubar() { - {user.name && user.family_name + {user?.name && user?.family_name ? `${user.name[0]}${user.family_name[0]}` - : user.email[0]} + : (user?.email?.[0] ?? "")}
diff --git a/src/features/Calendars/CalendarModal.tsx b/src/features/Calendars/CalendarModal.tsx index 623716b..d53c3c1 100644 --- a/src/features/Calendars/CalendarModal.tsx +++ b/src/features/Calendars/CalendarModal.tsx @@ -30,7 +30,7 @@ function CalendarPopover({ }) { const dispatch = useAppDispatch(); const userId = - useAppSelector((state) => state.user.userData.openpaasId) ?? ""; + useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; const [name, setName] = useState(calendar?.name ?? ""); const [description, setDescription] = useState(calendar?.description ?? ""); const [color, setColor] = useState(calendar?.color ?? ""); diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 5bc56ab..0c51855 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -74,7 +74,7 @@ export default function EventDisplayModal({ ); const userPersonnalCalendars: Calendars[] = calendars.filter( - (c) => c.id?.split("/")[0] === user.userData.openpaasId + (c) => c.id?.split("/")[0] === user.userData?.openpaasId ); // Form state @@ -97,7 +97,7 @@ export default function EventDisplayModal({ const [timezone, setTimezone] = useState(event?.timezone ?? "UTC"); const [newCalId, setNewCalId] = useState(event?.calId); const [calendarid, setCalendarid] = useState( - calId.split("/")[0] === user.userData.openpaasId + calId.split("/")[0] === user.userData?.openpaasId ? userPersonnalCalendars.findIndex((cal) => cal.id === calId) : calendars.findIndex((cal) => cal.id === calId) ); @@ -198,7 +198,7 @@ export default function EventDisplayModal({ }; const calList = - calId.split("/")[0] === user.userData.openpaasId + calId.split("/")[0] === user.userData?.openpaasId ? Object.keys(userPersonnalCalendars).map((calendar, index) => ( diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index 92aa3ae..d55cb9c 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -46,7 +46,8 @@ function EventPopover({ const dispatch = useAppDispatch(); const organizer = useAppSelector((state) => state.user.organiserData); - const userId = useAppSelector((state) => state.user.userData.openpaasId); + const userId = + useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; const selectPersonnalCalendars = createSelector( (state) => state.calendars, (calendars) => diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index c92ec07..e0eb0fc 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -234,8 +234,17 @@ export function calendarEventToJCal( ]); }); + const timezoneData = TIMEZONES.zones[event.timezone]; + if (!timezoneData) { + const vtimezone = new ICAL.Timezone({ + component: TIMEZONES.zones["Etc/UTC"].ics, + tzid: "Etc/UTC", + }); + return ["vcalendar", [], [vevent, vtimezone.component.jCal]]; + } + const vtimezone = new ICAL.Timezone({ - component: TIMEZONES.zones[event.timezone].ics, + component: timezoneData.ics, tzid: event.timezone, }); return ["vcalendar", [], [vevent, vtimezone.component.jCal]]; diff --git a/src/features/User/HandleLogin.tsx b/src/features/User/HandleLogin.tsx index e591078..7e964ab 100644 --- a/src/features/User/HandleLogin.tsx +++ b/src/features/User/HandleLogin.tsx @@ -2,7 +2,6 @@ import React, { useEffect } from "react"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { Auth } from "./oidcAuth"; import { Loading } from "../../components/Loading/Loading"; -import { Error } from "../../components/Error/Error"; import { push } from "redux-first-history"; import { redirectTo } from "../../utils/apiUtils"; import { getOpenPaasUserDataAsync, setTokens, setUserData } from "./userSlice"; @@ -27,7 +26,7 @@ export function HandleLogin() { dispatch(setUserData(savedUser)); dispatch(getOpenPaasUserDataAsync()); dispatch(getCalendarsListAsync()); - dispatch(push("/")); + dispatch(push("/calendar")); return; }