diff --git a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx new file mode 100644 index 0000000000..85d9c603d7 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx @@ -0,0 +1,47 @@ +import styled from '@emotion/styled'; +import { startOfMonth } from 'date-fns'; + +import { CalendarMonthCard } from '@/activities/calendar/components/CalendarMonthCard'; +import { sortCalendarEventsDesc } from '@/activities/calendar/utils/sortCalendarEvents'; +import { mockedCalendarEvents } from '~/testing/mock-data/calendar'; +import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy'; +import { sortDesc } from '~/utils/sort'; + +const StyledContainer = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(8)}; + padding: ${({ theme }) => theme.spacing(6)}; + width: 100%; +`; + +export const Calendar = () => { + const sortedCalendarEvents = [...mockedCalendarEvents].sort( + sortCalendarEventsDesc, + ); + const calendarEventsByMonthTime = groupArrayItemsBy( + sortedCalendarEvents, + ({ startsAt }) => startOfMonth(startsAt).getTime(), + ); + const sortedMonthTimes = Object.keys(calendarEventsByMonthTime) + .map(Number) + .sort(sortDesc); + + return ( + + {sortedMonthTimes.map((monthTime) => { + const monthCalendarEvents = calendarEventsByMonthTime[monthTime]; + + return ( + !!monthCalendarEvents?.length && ( + + ) + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx new file mode 100644 index 0000000000..8dd80ef74b --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx @@ -0,0 +1,79 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { endOfDay, format, isPast } from 'date-fns'; + +import { CalendarEventRow } from '@/activities/calendar/components/CalendarEventRow'; +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { CardContent } from '@/ui/layout/card/components/CardContent'; + +type CalendarDayCardContentProps = { + calendarEvents: CalendarEvent[]; + divider?: boolean; +}; + +const StyledCardContent = styled(CardContent)<{ active: boolean }>` + align-items: flex-start; + border-color: ${({ theme }) => theme.border.color.light}; + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(3)}; + padding: ${({ theme }) => theme.spacing(2, 3)}; + + ${({ active }) => + !active && + css` + background-color: transparent; + `} +`; + +const StyledDayContainer = styled.div` + text-align: center; + width: ${({ theme }) => theme.spacing(6)}; +`; + +const StyledWeekDay = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.xxs}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; +`; + +const StyledDateDay = styled.div` + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + +const StyledEvents = styled.div` + align-items: stretch; + display: flex; + flex: 1 0 auto; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(3)}; +`; + +const StyledEventRow = styled(CalendarEventRow)` + flex: 1 0 auto; +`; + +export const CalendarDayCardContent = ({ + calendarEvents, + divider, +}: CalendarDayCardContentProps) => { + const endOfDayDate = endOfDay(calendarEvents[0].startsAt); + const isPastDay = isPast(endOfDayDate); + + return ( + + + {format(endOfDayDate, 'EE')} + {format(endOfDayDate, 'dd')} + + + {calendarEvents.map((calendarEvent) => ( + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx new file mode 100644 index 0000000000..1e61d9be6a --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx @@ -0,0 +1,131 @@ +import { css, useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { endOfDay, format, isPast } from 'date-fns'; + +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { IconArrowRight, IconLock } from '@/ui/display/icon'; +import { Card } from '@/ui/layout/card/components/Card'; +import { CardContent } from '@/ui/layout/card/components/CardContent'; + +type CalendarEventRowProps = { + calendarEvent: CalendarEvent; + className?: string; +}; + +const StyledContainer = styled.div` + align-items: center; + display: inline-flex; + gap: ${({ theme }) => theme.spacing(3)}; + height: ${({ theme }) => theme.spacing(6)}; +`; + +const StyledAttendanceIndicator = styled.div<{ active?: boolean }>` + background-color: ${({ theme }) => theme.tag.background.gray}; + height: 100%; + width: ${({ theme }) => theme.spacing(1)}; + + ${({ active, theme }) => + active && + css` + background-color: ${theme.tag.background.red}; + `} +`; + +const StyledLabels = styled.div` + align-items: center; + display: flex; + color: ${({ theme }) => theme.font.color.tertiary}; + gap: ${({ theme }) => theme.spacing(2)}; + flex: 1 0 auto; +`; + +const StyledTime = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + width: ${({ theme }) => theme.spacing(26)}; +`; + +const StyledTitle = styled.div<{ active: boolean; canceled: boolean }>` + flex: 1 0 auto; + + ${({ theme, active }) => + active && + css` + color: ${theme.font.color.primary}; + font-weight: ${theme.font.weight.medium}; + `} + + ${({ canceled }) => + canceled && + css` + text-decoration: line-through; + `} +`; + +const StyledVisibilityCard = styled(Card)<{ active: boolean }>` + color: ${({ active, theme }) => + active ? theme.font.color.primary : theme.font.color.light}; + border-color: ${({ theme }) => theme.border.color.light}; + flex: 1 0 auto; +`; + +const StyledVisibilityCardContent = styled(CardContent)` + align-items: center; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + padding: ${({ theme }) => theme.spacing(0, 1)}; + height: ${({ theme }) => theme.spacing(6)}; + background-color: ${({ theme }) => theme.background.transparent.lighter}; +`; + +export const CalendarEventRow = ({ + calendarEvent, + className, +}: CalendarEventRowProps) => { + const theme = useTheme(); + + const hasEventEnded = calendarEvent.endsAt + ? isPast(calendarEvent.endsAt) + : calendarEvent.isFullDay && isPast(endOfDay(calendarEvent.startsAt)); + + return ( + + + + + {calendarEvent.isFullDay ? ( + 'All Day' + ) : ( + <> + {format(calendarEvent.startsAt, 'HH:mm')} + {!!calendarEvent.endsAt && ( + <> + + {format(calendarEvent.endsAt, 'HH:mm')} + + )} + + )} + + {calendarEvent.visibility === 'METADATA' ? ( + + + + Not shared + + + ) : ( + + {calendarEvent.title} + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx new file mode 100644 index 0000000000..45ccf37c83 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx @@ -0,0 +1,41 @@ +import { startOfDay } from 'date-fns'; + +import { CalendarDayCardContent } from '@/activities/calendar/components/CalendarDayCardContent'; +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { Card } from '@/ui/layout/card/components/Card'; +import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy'; +import { sortDesc } from '~/utils/sort'; + +type CalendarMonthCardProps = { + calendarEvents: CalendarEvent[]; +}; + +export const CalendarMonthCard = ({ + calendarEvents, +}: CalendarMonthCardProps) => { + const calendarEventsByDayTime = groupArrayItemsBy( + calendarEvents, + ({ startsAt }) => startOfDay(startsAt).getTime(), + ); + const sortedDayTimes = Object.keys(calendarEventsByDayTime) + .map(Number) + .sort(sortDesc); + + return ( + + {sortedDayTimes.map((dayTime, index) => { + const dayCalendarEvents = calendarEventsByDayTime[dayTime]; + + return ( + !!dayCalendarEvents?.length && ( + + ) + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/__stories__/Calendar.stories.tsx b/packages/twenty-front/src/modules/activities/calendar/components/__stories__/Calendar.stories.tsx new file mode 100644 index 0000000000..26c9083c2f --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/__stories__/Calendar.stories.tsx @@ -0,0 +1,18 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { Calendar } from '@/activities/calendar/components/Calendar'; +import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; + +const meta: Meta = { + title: 'Modules/Activities/Calendar/Calendar', + component: Calendar, + decorators: [ComponentDecorator], + parameters: { + container: { width: 728 }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/twenty-front/src/modules/activities/calendar/types/CalendarEvent.ts b/packages/twenty-front/src/modules/activities/calendar/types/CalendarEvent.ts new file mode 100644 index 0000000000..db48befed0 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/types/CalendarEvent.ts @@ -0,0 +1,10 @@ +// TODO: use backend CalendarEvent type when ready +export type CalendarEvent = { + endsAt?: Date; + id: string; + isFullDay: boolean; + startsAt: Date; + isCanceled?: boolean; + title?: string; + visibility: 'METADATA' | 'SHARE_EVERYTHING'; +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/sortCalendarEvents.test.ts b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/sortCalendarEvents.test.ts new file mode 100644 index 0000000000..c56b434c27 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/sortCalendarEvents.test.ts @@ -0,0 +1,285 @@ +import { addHours } from 'date-fns'; + +import { + sortCalendarEventsAsc, + sortCalendarEventsDesc, +} from '../sortCalendarEvents'; + +const someDate = new Date(2000, 1, 1); +const someDatePlusOneHour = addHours(someDate, 1); +const someDatePlusTwoHours = addHours(someDate, 2); +const someDatePlusThreeHours = addHours(someDate, 3); + +describe('sortCalendarEventsAsc', () => { + it('sorts non-intersecting events by ascending order', () => { + // Given + const eventA = { + startsAt: someDate, + endsAt: someDatePlusOneHour, + }; + const eventB = { + startsAt: someDatePlusTwoHours, + endsAt: someDatePlusThreeHours, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(-1); + expect(invertedArgsResult).toBe(1); + }); + + it('sorts intersecting events by start date ascending order', () => { + // Given + const eventA = { + startsAt: someDate, + endsAt: someDatePlusTwoHours, + }; + const eventB = { + startsAt: someDatePlusOneHour, + endsAt: someDatePlusThreeHours, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(-1); + expect(invertedArgsResult).toBe(1); + }); + + it('sorts events with same start date by end date ascending order', () => { + // Given + const eventA = { + startsAt: someDate, + endsAt: someDatePlusTwoHours, + }; + const eventB = { + startsAt: someDate, + endsAt: someDatePlusThreeHours, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(-1); + expect(invertedArgsResult).toBe(1); + }); + + it('sorts events with same end date by start date ascending order', () => { + // Given + const eventA = { + startsAt: someDate, + endsAt: someDatePlusThreeHours, + }; + const eventB = { + startsAt: someDatePlusOneHour, + endsAt: someDatePlusThreeHours, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(-1); + expect(invertedArgsResult).toBe(1); + }); + + it('sorts events without end date by start date ascending order', () => { + // Given + const eventA = { + startsAt: someDate, + }; + const eventB = { + startsAt: someDatePlusOneHour, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(-1); + expect(invertedArgsResult).toBe(1); + }); + + it('returns 0 for events with same start date and no end date', () => { + // Given + const eventA = { + startsAt: someDate, + }; + const eventB = { + startsAt: someDate, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(0); + expect(invertedArgsResult).toBe(0); + }); + + it('returns 0 for events with same start date if one of them has no end date', () => { + // Given + const eventA = { + startsAt: someDate, + endsAt: someDatePlusOneHour, + }; + const eventB = { + startsAt: someDate, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(0); + expect(invertedArgsResult).toBe(0); + }); +}); + +describe('sortCalendarEventsDesc', () => { + it('sorts non-intersecting events by descending order', () => { + // Given + const eventA = { + startsAt: someDate, + endsAt: someDatePlusOneHour, + }; + const eventB = { + startsAt: someDatePlusTwoHours, + endsAt: someDatePlusThreeHours, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result).toBe(1); + expect(invertedArgsResult).toBe(-1); + }); + + it('sorts intersecting events by start date descending order', () => { + // Given + const eventA = { + startsAt: someDate, + endsAt: someDatePlusTwoHours, + }; + const eventB = { + startsAt: someDatePlusOneHour, + endsAt: someDatePlusThreeHours, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result).toBe(1); + expect(invertedArgsResult).toBe(-1); + }); + + it('sorts events with same start date by end date descending order', () => { + // Given + const eventA = { + startsAt: someDate, + endsAt: someDatePlusTwoHours, + }; + const eventB = { + startsAt: someDate, + endsAt: someDatePlusThreeHours, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result).toBe(1); + expect(invertedArgsResult).toBe(-1); + }); + + it('sorts events with same end date by start date descending order', () => { + // Given + const eventA = { + startsAt: someDate, + endsAt: someDatePlusThreeHours, + }; + const eventB = { + startsAt: someDatePlusOneHour, + endsAt: someDatePlusThreeHours, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result).toBe(1); + expect(invertedArgsResult).toBe(-1); + }); + + it('sorts events without end date by start date descending order', () => { + // Given + const eventA = { + startsAt: someDate, + }; + const eventB = { + startsAt: someDatePlusOneHour, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result).toBe(1); + expect(invertedArgsResult).toBe(-1); + }); + + it('returns 0 for events with same start date and no end date', () => { + // Given + const eventA = { + startsAt: someDate, + }; + const eventB = { + startsAt: someDate, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result === 0).toBe(true); + expect(invertedArgsResult === 0).toBe(true); + }); + + it('returns 0 for events with same start date if one of them has no end date', () => { + // Given + const eventA = { + startsAt: someDate, + endsAt: someDatePlusOneHour, + }; + const eventB = { + startsAt: someDate, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result === 0).toBe(true); + expect(invertedArgsResult === 0).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/sortCalendarEvents.ts b/packages/twenty-front/src/modules/activities/calendar/utils/sortCalendarEvents.ts new file mode 100644 index 0000000000..6150037e8d --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/sortCalendarEvents.ts @@ -0,0 +1,26 @@ +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { sortAsc } from '~/utils/sort'; + +export const sortCalendarEventsAsc = ( + calendarEventA: Pick, + calendarEventB: Pick, +) => { + const startsAtSort = sortAsc( + calendarEventA.startsAt.getTime(), + calendarEventB.startsAt.getTime(), + ); + + if (startsAtSort === 0 && calendarEventA.endsAt && calendarEventB.endsAt) { + return sortAsc( + calendarEventA.endsAt.getTime(), + calendarEventB.endsAt.getTime(), + ); + } + + return startsAtSort; +}; + +export const sortCalendarEventsDesc = ( + calendarEventA: Pick, + calendarEventB: Pick, +) => -sortCalendarEventsAsc(calendarEventA, calendarEventB); diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx index 678a3b2757..2dc6846bac 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; +import { Calendar } from '@/activities/calendar/components/Calendar'; import { EmailThreads } from '@/activities/emails/components/EmailThreads'; import { Attachments } from '@/activities/files/components/Attachments'; import { Notes } from '@/activities/notes/components/Notes'; @@ -136,6 +137,7 @@ export const ShowPageRightContainer = ({ )} {activeTabId === 'emails' && } + {activeTabId === 'calendar' && } ); }; diff --git a/packages/twenty-front/src/testing/mock-data/calendar.ts b/packages/twenty-front/src/testing/mock-data/calendar.ts new file mode 100644 index 0000000000..7435ef582a --- /dev/null +++ b/packages/twenty-front/src/testing/mock-data/calendar.ts @@ -0,0 +1,52 @@ +import { addDays, subMonths } from 'date-fns'; + +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; + +export const mockedCalendarEvents: CalendarEvent[] = [ + { + id: '9a6b35f1-6078-415b-9540-f62671bb81d0', + endsAt: addDays(new Date().setHours(11, 30), 1), + isFullDay: false, + startsAt: addDays(new Date().setHours(10, 0), 1), + visibility: 'METADATA', + }, + { + id: '19b32878-a950-4968-9e3b-ce5da514ea41', + endsAt: new Date(new Date().setHours(18, 40)), + isCanceled: true, + isFullDay: false, + startsAt: new Date(new Date().setHours(18, 0)), + title: 'Bug solving', + visibility: 'SHARE_EVERYTHING', + }, + { + id: '6ad1cbcb-2ac4-409e-aff0-48165556fc0c', + endsAt: new Date(new Date().setHours(16, 30)), + isFullDay: false, + startsAt: new Date(new Date().setHours(15, 15)), + title: 'Onboarding Follow-Up Call', + visibility: 'SHARE_EVERYTHING', + }, + { + id: '52cc83e3-f3dc-4c25-8a7d-5ff857612142', + endsAt: new Date(new Date().setHours(10, 30)), + isFullDay: false, + startsAt: new Date(new Date().setHours(10, 0)), + title: 'Onboarding Call', + visibility: 'SHARE_EVERYTHING', + }, + { + id: '5a792d11-259a-4099-af51-59eb85e15d83', + isFullDay: true, + startsAt: subMonths(new Date().setHours(8, 0), 1), + visibility: 'METADATA', + }, + { + id: '89e2a1c7-3d3f-4e79-a492-aa5de3785fc5', + endsAt: subMonths(new Date().setHours(14, 30), 3), + isFullDay: false, + startsAt: subMonths(new Date().setHours(14, 0), 3), + title: 'Alan x Garry', + visibility: 'SHARE_EVERYTHING', + }, +]; diff --git a/packages/twenty-front/src/utils/array/groupArrayItemsBy.ts b/packages/twenty-front/src/utils/array/groupArrayItemsBy.ts index 37b1c9b704..1aa3fac710 100644 --- a/packages/twenty-front/src/utils/array/groupArrayItemsBy.ts +++ b/packages/twenty-front/src/utils/array/groupArrayItemsBy.ts @@ -17,10 +17,7 @@ * vegetable: [{ id: '2', type: 'vegetable' }], * } */ -export const groupArrayItemsBy = < - ArrayItem extends Record, - Key extends string, ->( +export const groupArrayItemsBy = ( array: ArrayItem[], computeGroupKey: (item: ArrayItem) => Key, ) =>