From d161fb49ee8479ee08b825f4fb955d9f674b5c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Thu, 26 Sep 2024 14:48:52 +0100 Subject: [PATCH] chore: implement event grouping in the event timeline (#8254) https://linear.app/unleash/issue/2-2663/implement-event-grouping-when-multiple-events-happen-in-a-short-period This PR introduces a grouping logic for timeline events, enhancing the way events are displayed when they occur close to each other. We also updated and refactored components to support handling groups of events rather than individual events. Also includes some minor code cleanups and optimizations as part of general refactoring efforts (scouting). ![image](https://github.com/user-attachments/assets/eed74ddd-017c-430d-b919-3cb7e257052d) --------- Co-authored-by: David Leek --- .../component/common/Markdown/Markdown.tsx | 7 +- .../events/EventTimeline/EventTimeline.tsx | 59 +++++++-- .../EventTimelineEventTooltip.tsx | 50 -------- .../EventTimelineEventCircle.tsx} | 68 ++++------ .../EventTimelineEventGroup.tsx | 52 ++++++++ .../EventTimelineEventTooltip.tsx | 116 ++++++++++++++++++ frontend/src/utils/formatDate.ts | 11 ++ 7 files changed, 259 insertions(+), 104 deletions(-) delete mode 100644 frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx rename frontend/src/component/events/EventTimeline/{EventTimelineEvent/EventTimelineEvent.tsx => EventTimelineEventGroup/EventTimelineEventCircle.tsx} (58%) create mode 100644 frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventGroup.tsx create mode 100644 frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx diff --git a/frontend/src/component/common/Markdown/Markdown.tsx b/frontend/src/component/common/Markdown/Markdown.tsx index d4077c8552a1..fa8f1b02dd3c 100644 --- a/frontend/src/component/common/Markdown/Markdown.tsx +++ b/frontend/src/component/common/Markdown/Markdown.tsx @@ -19,6 +19,9 @@ const LinkRenderer = ({ ); }; -export const Markdown = (props: ComponentProps) => ( - +export const Markdown = ({ + components, + ...props +}: ComponentProps) => ( + ); diff --git a/frontend/src/component/events/EventTimeline/EventTimeline.tsx b/frontend/src/component/events/EventTimeline/EventTimeline.tsx index 956f588bd073..faf43cdfd81c 100644 --- a/frontend/src/component/events/EventTimeline/EventTimeline.tsx +++ b/frontend/src/component/events/EventTimeline/EventTimeline.tsx @@ -2,15 +2,19 @@ import { styled } from '@mui/material'; import type { EventSchema, EventSchemaType } from 'openapi'; import { startOfDay, sub } from 'date-fns'; import { useEventSearch } from 'hooks/api/getters/useEventSearch/useEventSearch'; -import { EventTimelineEvent } from './EventTimelineEvent/EventTimelineEvent'; +import { EventTimelineEventGroup } from './EventTimelineEventGroup/EventTimelineEventGroup'; import { EventTimelineHeader } from './EventTimelineHeader/EventTimelineHeader'; import { useEventTimeline } from './useEventTimeline'; +import { useMemo } from 'react'; export type EnrichedEvent = EventSchema & { label: string; summary: string; + timestamp: number; }; +export type TimelineEventGroup = EnrichedEvent[]; + const StyledRow = styled('div')({ display: 'flex', flexDirection: 'row', @@ -88,6 +92,8 @@ export const EventTimeline = () => { const endDate = new Date(); const startDate = sub(endDate, timeSpan.value); + const endTime = endDate.getTime(); + const startTime = startDate.getTime(); const { events: baseEvents } = useEventSearch( { @@ -98,19 +104,52 @@ export const EventTimeline = () => { { refreshInterval: 10 * 1000 }, ); - const events = baseEvents as EnrichedEvent[]; + const events = useMemo(() => { + return baseEvents.map((event) => ({ + ...event, + timestamp: new Date(event.createdAt).getTime(), + })); + }, [baseEvents]) as EnrichedEvent[]; const filteredEvents = events.filter( (event) => - new Date(event.createdAt).getTime() >= startDate.getTime() && - new Date(event.createdAt).getTime() <= endDate.getTime() && - RELEVANT_EVENT_TYPES.includes(event.type) && + event.timestamp >= startTime && + event.timestamp <= endTime && (!event.environment || !environment || event.environment === environment.name), ); - const sortedEvents = [...filteredEvents].reverse(); + const sortedEvents = filteredEvents.reverse(); + + const timespanInMs = endTime - startTime; + const groupingThresholdInMs = useMemo( + () => timespanInMs * 0.02, + [timespanInMs], + ); + + const groups = useMemo( + () => + sortedEvents.reduce((groups: TimelineEventGroup[], event) => { + if (groups.length === 0) { + groups.push([event]); + } else { + const lastGroup = groups[groups.length - 1]; + const lastEventInGroup = lastGroup[lastGroup.length - 1]; + + if ( + event.timestamp - lastEventInGroup.timestamp <= + groupingThresholdInMs + ) { + lastGroup.push(event); + } else { + groups.push([event]); + } + } + return groups; + }, []), + [sortedEvents, groupingThresholdInMs], + ); return ( <> @@ -126,10 +165,10 @@ export const EventTimeline = () => { - {sortedEvents.map((event) => ( - ( + diff --git a/frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx b/frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx deleted file mode 100644 index db09f744d866..000000000000 --- a/frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { styled } from '@mui/material'; -import { Markdown } from 'component/common/Markdown/Markdown'; -import { useLocationSettings } from 'hooks/useLocationSettings'; -import { formatDateYMDHMS } from 'utils/formatDate'; -import type { EnrichedEvent } from '../../EventTimeline'; - -const StyledTooltipHeader = styled('div')(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: theme.spacing(1), - gap: theme.spacing(2), - flexWrap: 'wrap', -})); - -const StyledTooltipTitle = styled('div')(({ theme }) => ({ - fontWeight: theme.fontWeight.bold, - fontSize: theme.fontSizes.smallBody, - wordBreak: 'break-word', - flex: 1, -})); - -const StyledDateTime = styled('div')(({ theme }) => ({ - color: theme.palette.text.secondary, - whiteSpace: 'nowrap', -})); - -interface IEventTimelineEventTooltipProps { - event: EnrichedEvent; -} - -export const EventTimelineEventTooltip = ({ - event, -}: IEventTimelineEventTooltipProps) => { - const { locationSettings } = useLocationSettings(); - const eventDateTime = formatDateYMDHMS( - event.createdAt, - locationSettings?.locale, - ); - - return ( - <> - - {event.label} - {eventDateTime} - - {event.summary} - - ); -}; diff --git a/frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEvent.tsx b/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventCircle.tsx similarity index 58% rename from frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEvent.tsx rename to frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventCircle.tsx index 15f72edc3ba5..375340c697eb 100644 --- a/frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEvent.tsx +++ b/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventCircle.tsx @@ -1,34 +1,22 @@ import type { EventSchemaType } from 'openapi'; -import { styled } from '@mui/material'; -import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip'; -import { EventTimelineEventTooltip } from './EventTimelineEventTooltip/EventTimelineEventTooltip'; import ToggleOnIcon from '@mui/icons-material/ToggleOn'; import ToggleOffIcon from '@mui/icons-material/ToggleOff'; import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined'; import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined'; import SegmentsIcon from '@mui/icons-material/DonutLargeOutlined'; import QuestionMarkIcon from '@mui/icons-material/QuestionMark'; -import type { EnrichedEvent } from '../EventTimeline'; +import { styled } from '@mui/material'; +import type { TimelineEventGroup } from '../EventTimeline'; +import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; +import type { HTMLAttributes } from 'react'; type DefaultEventVariant = 'secondary'; type CustomEventVariant = 'success' | 'neutral'; type EventVariant = DefaultEventVariant | CustomEventVariant; -const StyledEvent = styled('div', { - shouldForwardProp: (prop) => prop !== 'position', -})<{ position: string }>(({ position }) => ({ - position: 'absolute', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - left: position, - transform: 'translateX(-50%)', - zIndex: 1, -})); - const StyledEventCircle = styled('div', { shouldForwardProp: (prop) => prop !== 'variant', -})<{ variant: EventVariant }>(({ theme, variant }) => ({ +})<{ variant?: EventVariant }>(({ theme, variant = 'secondary' }) => ({ height: theme.spacing(3), width: theme.spacing(3), borderRadius: '50%', @@ -76,35 +64,31 @@ const customEventVariants: Partial< 'feature-archived': 'neutral', }; -interface IEventTimelineEventProps { - event: EnrichedEvent; - startDate: Date; - endDate: Date; +interface IEventTimelineEventCircleProps + extends HTMLAttributes { + group: TimelineEventGroup; } -export const EventTimelineEvent = ({ - event, - startDate, - endDate, -}: IEventTimelineEventProps) => { - const timelineDuration = endDate.getTime() - startDate.getTime(); - const eventTime = new Date(event.createdAt).getTime(); - - const position = `${((eventTime - startDate.getTime()) / timelineDuration) * 100}%`; +export const EventTimelineEventCircle = ({ + group, + ...props +}: IEventTimelineEventCircleProps) => { + if (group.length === 1) { + const event = group[0]; - const variant = customEventVariants[event.type] || 'secondary'; + return ( + + {getEventIcon(event.type)} + + ); + } return ( - - } - maxWidth={320} - arrow - > - - {getEventIcon(event.type)} - - - + + + ); }; diff --git a/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventGroup.tsx b/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventGroup.tsx new file mode 100644 index 000000000000..a9ecfba70941 --- /dev/null +++ b/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventGroup.tsx @@ -0,0 +1,52 @@ +import { Badge, styled } from '@mui/material'; +import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip'; +import { EventTimelineEventTooltip } from './EventTimelineEventTooltip/EventTimelineEventTooltip'; +import type { TimelineEventGroup } from '../EventTimeline'; +import { EventTimelineEventCircle } from './EventTimelineEventCircle'; + +const StyledEvent = styled('div', { + shouldForwardProp: (prop) => prop !== 'position', +})<{ position: string }>(({ position }) => ({ + position: 'absolute', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + left: position, + transform: 'translateX(-50%)', + zIndex: 1, +})); + +interface IEventTimelineEventProps { + group: TimelineEventGroup; + startDate: Date; + endDate: Date; +} + +export const EventTimelineEventGroup = ({ + group, + startDate, + endDate, +}: IEventTimelineEventProps) => { + const timelineDuration = endDate.getTime() - startDate.getTime(); + const eventTime = new Date(group[0].createdAt).getTime(); + + const position = `${((eventTime - startDate.getTime()) / timelineDuration) * 100}%`; + + return ( + + } + maxWidth={320} + arrow + > + + + + + + ); +}; diff --git a/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx b/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx new file mode 100644 index 000000000000..2c8281eaac95 --- /dev/null +++ b/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx @@ -0,0 +1,116 @@ +import { styled } from '@mui/material'; +import { Markdown } from 'component/common/Markdown/Markdown'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import { + formatDateHMS, + formatDateYMDHMS, + formatDateYMD, +} from 'utils/formatDate'; +import type { TimelineEventGroup } from '../../EventTimeline'; +import { EventTimelineEventCircle } from '../EventTimelineEventCircle'; + +const StyledTooltipHeader = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: theme.spacing(1), + gap: theme.spacing(2), + flexWrap: 'wrap', +})); + +const StyledTooltipTitle = styled('div')(({ theme }) => ({ + fontWeight: theme.fontWeight.bold, + fontSize: theme.fontSizes.smallBody, + wordBreak: 'break-word', + flex: 1, +})); + +const StyledDateTime = styled('div')(({ theme }) => ({ + color: theme.palette.text.secondary, + whiteSpace: 'nowrap', +})); + +const StyledDate = styled('div')(({ theme }) => ({ + color: theme.palette.text.secondary, + whiteSpace: 'nowrap', +})); + +const StyledTooltipItem = styled('div')(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + marginBottom: theme.spacing(1), +})); + +const StyledEventTimelineEventCircle = styled(EventTimelineEventCircle)( + ({ theme }) => ({ + marginTop: theme.spacing(0.5), + height: theme.spacing(2.5), + width: theme.spacing(2.5), + transition: 'none', + '& > svg': { + height: theme.spacing(2), + }, + '&:hover': { + transform: 'none', + }, + }), +); + +interface IEventTimelineEventTooltipProps { + group: TimelineEventGroup; +} + +export const EventTimelineEventTooltip = ({ + group, +}: IEventTimelineEventTooltipProps) => { + const { locationSettings } = useLocationSettings(); + + if (group.length === 1) { + const event = group[0]; + const eventDateTime = formatDateYMDHMS( + event.createdAt, + locationSettings?.locale, + ); + + return ( + <> + + {event.label} + {eventDateTime} + + {event.summary} + + ); + } + + const firstEvent = group[0]; + const eventDate = formatDateYMD( + firstEvent.createdAt, + locationSettings?.locale, + ); + + return ( + <> + + + {group.length} events occurred + + {eventDate} + + {group.map((event) => ( + + +
+ + {formatDateHMS( + event.createdAt, + locationSettings?.locale, + )} + + {event.summary} +
+
+ ))} + + ); +}; diff --git a/frontend/src/utils/formatDate.ts b/frontend/src/utils/formatDate.ts index 86729a5d12d9..fe07de16437b 100644 --- a/frontend/src/utils/formatDate.ts +++ b/frontend/src/utils/formatDate.ts @@ -49,3 +49,14 @@ export const formatDateHM = ( minute: '2-digit', }); }; + +export const formatDateHMS = ( + date: number | string | Date, + locale: string, +): string => { + return new Date(date).toLocaleString(locale, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +};