From 2405f05826104c8a5cad1a09f23598b2fe0de377 Mon Sep 17 00:00:00 2001 From: Tiberiu Sabau Date: Tue, 3 Mar 2026 22:37:12 +0100 Subject: [PATCH 1/4] feat(web): add repeat icon to recurring events --- .../Event/Grid/GridEvent/GridEvent.tsx | 7 +++++ .../components/Grid/AllDayRow/AllDayEvent.tsx | 7 +++++ .../SomedayEventRectangle.tsx | 28 +++++++++---------- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx index eb82e7c88..667d107e5 100644 --- a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx +++ b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx @@ -23,6 +23,7 @@ import { FlexDirections, FlexWrap, } from "@web/components/Flex/styled"; +import { RepeatIcon } from "@web/components/Icons/Repeat"; import { Text } from "@web/components/Text"; import { selectIsEventPending } from "@web/ducks/events/selectors/pending.selectors"; import { useAppSelector } from "@web/store/store.hooks"; @@ -140,6 +141,12 @@ const _GridEvent = ( flexWrap={FlexWrap.WRAP} > + {isRecurring && ( + + )} {event.title} {!event.isAllDay && ( diff --git a/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx b/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx index f15c0c7b0..334af8a04 100644 --- a/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx +++ b/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx @@ -9,6 +9,7 @@ import { type Schema_GridEvent } from "@web/common/types/web.event.types"; import { getEventPosition } from "@web/common/utils/position/position.util"; import { Flex } from "@web/components/Flex"; import { AlignItems, FlexDirections } from "@web/components/Flex/styled"; +import { RepeatIcon } from "@web/components/Icons/Repeat"; import { SpaceCharacter } from "@web/components/SpaceCharacter"; import { Text } from "@web/components/Text"; import { selectIsEventPending } from "@web/ducks/events/selectors/pending.selectors"; @@ -96,6 +97,12 @@ const AllDayEvent = ({ direction={FlexDirections.COLUMN} > + {isRecurring && ( + + )} {event.title} diff --git a/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.tsx b/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.tsx index 15fd46b16..1e4ff54fd 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.tsx +++ b/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.tsx @@ -5,10 +5,11 @@ import { FlexDirections, JustifyContent, } from "@web/components/Flex/styled"; +import { RepeatIcon } from "@web/components/Icons/Repeat"; import { Text } from "@web/components/Text"; import { type Props_DraftForm } from "@web/views/Calendar/components/Draft/context/DraftContext"; import { type Actions_Sidebar } from "@web/views/Calendar/components/Draft/sidebar/hooks/useSidebarActions"; -import { StyledMigrateArrow, StyledRecurrenceText } from "./styled"; +import { StyledMigrateArrow } from "./styled"; interface Props { category: Categories_Event; @@ -26,6 +27,7 @@ export const SomedayEventRectangle = ({ const target = category === Categories_Event.SOMEDAY_WEEK ? "week" : "month"; const canMigrate = !event.recurrence?.rule || event.recurrence?.rule.length === 0; + const isRecurring = !canMigrate; return (
@@ -34,9 +36,17 @@ export const SomedayEventRectangle = ({ direction={FlexDirections.ROW} justifyContent={JustifyContent.SPACE_BETWEEN} > - {event.title} + + {isRecurring && ( + + )} + {event.title} + - {canMigrate ? ( + {canMigrate && ( { @@ -59,18 +69,6 @@ export const SomedayEventRectangle = ({ {">"} - ) : ( - - { - e.stopPropagation(); - alert("Can't migrate recurring events"); - }} - title="Can't migrate recurring events" - > - ☝️ - - )}
From 2bbf3c9358031bf7cdc13332b78fad198420dde5 Mon Sep 17 00:00:00 2001 From: Tiberiu Sabau Date: Tue, 3 Mar 2026 23:10:19 +0100 Subject: [PATCH 2/4] fix(web) add unit tests --- .../Event/Grid/GridEvent/GridEvent.test.tsx | 151 +++++++++++++++++- .../Event/Grid/GridEvent/GridEvent.tsx | 7 +- .../components/Grid/AllDayRow/AllDayEvent.tsx | 7 +- .../SomedayEventRectangle.tsx | 10 +- .../SomedayEventContainer/styled.ts | 13 -- 5 files changed, 169 insertions(+), 19 deletions(-) diff --git a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.test.tsx b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.test.tsx index 3a6f1eea4..2b40682e1 100644 --- a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.test.tsx +++ b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.test.tsx @@ -1,5 +1,9 @@ import { screen } from "@testing-library/react"; -import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; +import { + createMockBaseEvent, + createMockInstance, + createMockStandaloneEvent, +} from "@core/util/test/ccal.event.factory"; import { render } from "@web/__tests__/__mocks__/mock.render"; import { createInitialState } from "@web/__tests__/utils/state/store.test.util"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; @@ -234,4 +238,149 @@ describe("GridEvent", () => { // but here we're testing that the selector only checks event1's pending state expect(renderSpy).toHaveBeenCalledTimes(2); // Rerender was called, but memo should prevent actual re-render }); + + describe("repeat icon for recurring events", () => { + it("should render repeat icon for base recurring event with rule", () => { + const baseEvent = createMockBaseEvent({ + _id: "base-event-1", + title: "Weekly Meeting", + recurrence: { rule: ["RRULE:FREQ=WEEKLY"] }, + }); + + const event = createMockGridEvent(baseEvent); + + const initialState = createInitialState({ + events: { + pendingEvents: { + eventIds: [], + }, + }, + }); + + render( + , + { state: initialState }, + ); + + const icon = screen.getByTestId("repeat-icon"); + expect(icon).toBeInTheDocument(); + expect(screen.getByText("Weekly Meeting")).toBeInTheDocument(); + }); + + it("should render repeat icon for recurring instance with eventId", () => { + const baseEventId = "base-123"; + const gBaseId = "gbase-123"; + const instance = createMockInstance(baseEventId, gBaseId, { + _id: "instance-1", + title: "Weekly Meeting Instance", + }); + + const event = createMockGridEvent(instance); + + const initialState = createInitialState({ + events: { + pendingEvents: { + eventIds: [], + }, + }, + }); + + render( + , + { state: initialState }, + ); + + const icon = screen.getByTestId("repeat-icon"); + expect(icon).toBeInTheDocument(); + }); + + it("should not render repeat icon for non-recurring event", () => { + const event = createMockGridEvent({ + _id: "standalone-1", + title: "One-time Meeting", + recurrence: undefined, + }); + + const initialState = createInitialState({ + events: { + pendingEvents: { + eventIds: [], + }, + }, + }); + + render( + , + { state: initialState }, + ); + + const icon = screen.queryByTestId("repeat-icon"); + expect(icon).not.toBeInTheDocument(); + expect(screen.getByText("One-time Meeting")).toBeInTheDocument(); + }); + + it("should not render repeat icon when recurrence is disabled (rule is null)", () => { + const event = createMockGridEvent({ + _id: "disabled-recurring-1", + title: "Disabled Recurring Meeting", + recurrence: { rule: null }, + }); + + const initialState = createInitialState({ + events: { + pendingEvents: { + eventIds: [], + }, + }, + }); + + render( + , + { state: initialState }, + ); + + const icon = screen.queryByTestId("repeat-icon"); + expect(icon).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx index 667d107e5..598ccfa78 100644 --- a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx +++ b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx @@ -75,7 +75,11 @@ const _GridEvent = ( const isPending = useAppSelector((state) => event._id ? selectIsEventPending(state, event._id) : false, ); - const isRecurring = event.recurrence && event.recurrence?.eventId !== null; + const rule = event.recurrence?.rule; + const recurrenceEventId = event.recurrence?.eventId; + const isRecurring = + (Array.isArray(rule) && rule.length > 0) || + typeof recurrenceEventId === "string"; const position = getEventPosition( event, @@ -143,6 +147,7 @@ const _GridEvent = ( {isRecurring && ( diff --git a/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx b/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx index 334af8a04..ca72e32a3 100644 --- a/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx +++ b/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx @@ -54,7 +54,11 @@ const AllDayEvent = ({ const isPending = useAppSelector((state) => selectIsEventPending(state, event._id!), ); - const isRecurring = event.recurrence && event.recurrence?.eventId !== null; + const rule = event.recurrence?.rule; + const recurrenceEventId = event.recurrence?.eventId; + const isRecurring = + (Array.isArray(rule) && rule.length > 0) || + typeof recurrenceEventId === "string"; const styledEventProps = { [DATA_EVENT_ELEMENT_ID]: event._id, @@ -99,6 +103,7 @@ const AllDayEvent = ({ {isRecurring && ( diff --git a/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.tsx b/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.tsx index 1e4ff54fd..2df3244b6 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.tsx +++ b/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.tsx @@ -25,9 +25,12 @@ export const SomedayEventRectangle = ({ onMigrate, }: Props) => { const target = category === Categories_Event.SOMEDAY_WEEK ? "week" : "month"; - const canMigrate = - !event.recurrence?.rule || event.recurrence?.rule.length === 0; - const isRecurring = !canMigrate; + const rule = event.recurrence?.rule; + const recurrenceEventId = event.recurrence?.eventId; + const isRecurring = + (Array.isArray(rule) && rule.length > 0) || + typeof recurrenceEventId === "string"; + const canMigrate = !isRecurring; return (
@@ -39,6 +42,7 @@ export const SomedayEventRectangle = ({ {isRecurring && ( diff --git a/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/styled.ts b/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/styled.ts index a363f106f..5fe82f277 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/styled.ts +++ b/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/styled.ts @@ -18,16 +18,3 @@ export const StyledMigrateArrow = styled.span` export const StyledMigrateArrowInForm = styled(StyledMigrateArrow)` font-size: 27px; `; -export const StyledRecurrenceText = styled.span` - border: 1px solid ${({ theme }) => theme.color.border.primary}; - border-radius: 2px; - font-size: 10px; - opacity: 0; - transition: opacity 0.2s; - width: 43px; - - &:hover { - opacity: 1; - transition: border ease-in 0.2s; - } -`; From 741f07fb2fa776af9040fb4d379a02d2acc3e06f Mon Sep 17 00:00:00 2001 From: Tiberiu Sabau Date: Thu, 5 Mar 2026 11:55:20 +0100 Subject: [PATCH 3/4] fix: re-add warning for recurring events --- .../Event/Grid/GridEvent/GridEvent.tsx | 1 + .../components/Grid/AllDayRow/AllDayEvent.tsx | 1 + .../SomedayEventRectangle.tsx | 23 ++++++++++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx index 598ccfa78..e3aabc8b4 100644 --- a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx +++ b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx @@ -147,6 +147,7 @@ const _GridEvent = ( {isRecurring && (
From 62cddb28c99b10e9c11995c05722111d5b16096b Mon Sep 17 00:00:00 2001 From: Tiberiu Sabau Date: Thu, 5 Mar 2026 12:40:12 +0100 Subject: [PATCH 4/4] fix: simplify logic and improve test coverage --- .../Event/Grid/GridEvent/GridEvent.test.tsx | 8 +- .../Event/Grid/GridEvent/GridEvent.tsx | 6 +- .../Grid/AllDayRow/AllDayEvent.test.tsx | 193 ++++++++++++++++++ .../components/Grid/AllDayRow/AllDayEvent.tsx | 6 +- .../SomedayEventRectangle.test.tsx | 170 +++++++++++++++ .../SomedayEventRectangle.tsx | 20 +- .../SomedayEventContainer/styled.ts | 14 ++ 7 files changed, 390 insertions(+), 27 deletions(-) create mode 100644 packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.test.tsx create mode 100644 packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.test.tsx diff --git a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.test.tsx b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.test.tsx index 2b40682e1..187cee94f 100644 --- a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.test.tsx +++ b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.test.tsx @@ -272,7 +272,7 @@ describe("GridEvent", () => { { state: initialState }, ); - const icon = screen.getByTestId("repeat-icon"); + const icon = screen.getByLabelText("Recurring event"); expect(icon).toBeInTheDocument(); expect(screen.getByText("Weekly Meeting")).toBeInTheDocument(); }); @@ -310,7 +310,7 @@ describe("GridEvent", () => { { state: initialState }, ); - const icon = screen.getByTestId("repeat-icon"); + const icon = screen.getByLabelText("Recurring event"); expect(icon).toBeInTheDocument(); }); @@ -344,7 +344,7 @@ describe("GridEvent", () => { { state: initialState }, ); - const icon = screen.queryByTestId("repeat-icon"); + const icon = screen.queryByLabelText("Recurring event"); expect(icon).not.toBeInTheDocument(); expect(screen.getByText("One-time Meeting")).toBeInTheDocument(); }); @@ -379,7 +379,7 @@ describe("GridEvent", () => { { state: initialState }, ); - const icon = screen.queryByTestId("repeat-icon"); + const icon = screen.queryByLabelText("Recurring event"); expect(icon).not.toBeInTheDocument(); }); }); diff --git a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx index e3aabc8b4..0d98f791e 100644 --- a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx +++ b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx @@ -78,8 +78,7 @@ const _GridEvent = ( const rule = event.recurrence?.rule; const recurrenceEventId = event.recurrence?.eventId; const isRecurring = - (Array.isArray(rule) && rule.length > 0) || - typeof recurrenceEventId === "string"; + Array.isArray(rule) || typeof recurrenceEventId === "string"; const position = getEventPosition( event, @@ -147,8 +146,7 @@ const _GridEvent = ( {isRecurring && (