diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 0fd4a733c..81b623585 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,7 +1,7 @@ { "name": "@alphaday/frontend", "private": true, - "version": "4.0.2", + "version": "4.0.3", "type": "module", "scripts": { "prepare": "export VITE_COMMIT=$(git rev-parse --short HEAD)", diff --git a/packages/frontend/src/components/views-tab/ViewsTab.tsx b/packages/frontend/src/components/views-tab/ViewsTab.tsx index b82cc923e..f75448890 100644 --- a/packages/frontend/src/components/views-tab/ViewsTab.tsx +++ b/packages/frontend/src/components/views-tab/ViewsTab.tsx @@ -5,8 +5,10 @@ import { twMerge, ViewTabButton, ShareViewDialog, + IconButton, } from "@alphaday/ui-kit"; import { useWindowSize } from "src/api/hooks"; +import useHeaderScroll from "src/api/hooks/useHeaderScroll"; import { ETutorialTipId, TCachedView, @@ -64,6 +66,9 @@ const ViewsTab: FC = memo(function ViewsTab({ const [showShareViewDialog, setShowShareViewDialog] = useState(false); + const { setHeaderRef, handleClickScroll, hideLeftPan, hideRightPan } = + useHeaderScroll(); + const isSelectedViewModified = selectedView && isViewModified(selectedView); const tabsCount = subscribedViews.length + (extraOptions?.length || 0); const { width } = useWindowSize(); @@ -132,69 +137,89 @@ const ViewsTab: FC = memo(function ViewsTab({ mobileOpen && "border-b-2 border-b-background" )} > -
- {subscribedViews.length !== 0 && isWalletBoardAllowed && ( - - currentTutorialTipId === - ETutorialTipId.WalletView && - ref && - setTutFocusElemRef(ref) - } - > - + {!hideLeftPan && ( + handleClickScroll()} + className="shrink-0 ml-1 h-8 !px-1 !rounded !bg-backgroundVariant100 hover:!bg-backgroundVariant200 active:!bg-backgroundVariant100" + /> + )} +
+ {subscribedViews.length !== 0 && isWalletBoardAllowed && ( + + currentTutorialTipId === + ETutorialTipId.WalletView && + ref && + setTutFocusElemRef(ref) } - walletViewName={walletView?.data.name} - options={[ - { - handler: - walletView && - walletViewState === - EWalletViewState.Ready - ? () => handleShareView(walletView) + > + + handleShareView( + walletView + ) + : undefined, + icon: "share", + title: "Share board", + key: "share-board", + }, + { + handler: walletView + ? () => + onRemoveView({ + id: walletView.data.id, + isReadOnly: + walletView.isReadOnly, + hash: walletView.data + .hash, + slug: walletView.data + .slug, + }) : undefined, - icon: "share", - title: "Share board", - key: "share-board", - }, - { - handler: walletView - ? () => - onRemoveView({ - id: walletView.data.id, - isReadOnly: - walletView.isReadOnly, - hash: walletView.data.hash, - slug: walletView.data.slug, - }) - : undefined, - icon: "trash", - title: "Remove board", - key: "remove-board", - }, - ]} - /> - - )} - {filteredSubscribedViews.map((view, index) => { - /** - * note: only including actions allowed for custom views for now - */ - const viewMenuOptions: TViewTabMenuOption[] | undefined = - view.data.is_system_view + icon: "trash", + title: "Remove board", + key: "remove-board", + }, + ]} + /> + + )} + {filteredSubscribedViews.map((view, index) => { + /** + * note: only including actions allowed for custom views for now + */ + const viewMenuOptions: + | TViewTabMenuOption[] + | undefined = view.data.is_system_view ? undefined : [ { @@ -220,70 +245,88 @@ const ViewsTab: FC = memo(function ViewsTab({ key: "remove-board", }, ]; - return ( - - index === - Math.floor(subscribedViews.length / 2) && // set the tut focus to the view in the middle - currentTutorialTipId === - ETutorialTipId.SwitchView && - ref && - setTutFocusElemRef(ref) - } - style={tabButtonWrapperStyle} - > - handleSelectTab(view)} - title={`Open ${view.data.name}`} - selected={ - selectedView?.data.hash === view.data.hash + return ( + + index === + Math.floor( + subscribedViews.length / 2 + ) && // set the tut focus to the view in the middle + currentTutorialTipId === + ETutorialTipId.SwitchView && + ref && + setTutFocusElemRef(ref) } - modified={isViewModified(view)} - options={viewMenuOptions} + style={tabButtonWrapperStyle} > - {view.data.name} - - - ); - })} - {extraOptions && - !mobileOpen && - extraOptions.map((option) => ( - - { - if (isSelectedViewModified) { - toast( - "Connect and verify your wallet to edit boards and enjoy more customizations", - { - type: EToastRole.Error, - } - ); - } - } - : option.handler - } - selected={false} - title="Save current board" - // disabled={option.disabled} + handleSelectTab(view)} + title={`Open ${view.data.name}`} + selected={ + selectedView?.data.hash === + view.data.hash + } + modified={isViewModified(view)} + options={viewMenuOptions} + > + + {view.data.name} + + + + ); + })} + {extraOptions && + !mobileOpen && + extraOptions.map((option) => ( + - {option.title} - - - ))} + { + if (isSelectedViewModified) { + toast( + "Connect and verify your wallet to edit boards and enjoy more customizations", + { + type: EToastRole.Error, + } + ); + } + } + : option.handler + } + selected={false} + title="Save current board" + // disabled={option.disabled} + > + {option.title} + + + ))} +
+ {!hideRightPan && ( + handleClickScroll(true)} + className="shrink-0 mr-1 h-8 !px-1 !rounded !bg-backgroundVariant100 hover:!bg-backgroundVariant200 active:!bg-backgroundVariant100" + /> + )}
> = ({ const pollingInterval = (moduleData.widget.refresh_interval || POLLING_INTERVAL) * 1000; + const tagsParam = tags ? filteringListToStr(tags) : undefined; + const queryOpts = { skip: !selectedDate, pollingInterval }; + + // Compute month boundaries using native Date (immutable — each is a new object) + const monthStartDate = new Date( + selectedDate.getFullYear(), + selectedDate.getMonth(), + 1 + ); + const formatDate = (d: Date) => d.toISOString().slice(0, 10); + const addMonths = (d: Date, n: number) => + new Date(d.getFullYear(), d.getMonth() + n, 1); + + const prevStart = formatDate(addMonths(monthStartDate, -1)); + const currStart = formatDate(monthStartDate); + const nextStart = formatDate(addMonths(monthStartDate, 1)); + const nextEnd = formatDate(addMonths(monthStartDate, 2)); + + const { + data: prevMonthData, + isLoading: isLoadingPrev, + isFetching: isFetchingPrev, + } = useGetEventsQuery( + { + period_after: prevStart, + period_before: currStart, + limit: QUERY_EVENTS_HARD_LIMIT, + tags: tagsParam, + }, + queryOpts + ); + const { - data: eventsData, - isLoading: isLoadingEvents, - isFetching: isFetchingEvents, + data: currMonthData, + isLoading: isLoadingCurr, + isFetching: isFetchingCurr, } = useGetEventsQuery( { - period_after: moment(selectedDate) - .startOf("month") - .subtract(1, "month") - .format("YYYY-MM-DD"), - period_before: moment(selectedDate) - .startOf("month") - .add(1, "month") - .format("YYYY-MM-DD"), + period_after: currStart, + period_before: nextStart, limit: QUERY_EVENTS_HARD_LIMIT, - tags: tags ? filteringListToStr(tags) : undefined, + tags: tagsParam, }, - { skip: !selectedDate, pollingInterval } + queryOpts ); + const { + data: nextMonthData, + isLoading: isLoadingNext, + isFetching: isFetchingNext, + } = useGetEventsQuery( + { + period_after: nextStart, + period_before: nextEnd, + limit: QUERY_EVENTS_HARD_LIMIT, + tags: tagsParam, + }, + queryOpts + ); + + const mergedEvents = useMemo(() => { + const prev = prevMonthData?.results ?? []; + const curr = currMonthData?.results ?? []; + const next = nextMonthData?.results ?? []; + const seen = new Set(); + return [...prev, ...curr, ...next].filter((e) => { + if (seen.has(e.id)) return false; + seen.add(e.id); + return true; + }); + }, [ + prevMonthData?.results, + currMonthData?.results, + nextMonthData?.results, + ]); + + const isLoadingEvents = isLoadingPrev || isLoadingCurr || isLoadingNext; + const isFetchingEvents = isFetchingPrev || isFetchingCurr || isFetchingNext; + const closestEvent: TEvent | undefined = useMemo( - () => getClosestEvent(eventsData?.results, selectedDate), - [eventsData?.results, selectedDate] + () => getClosestEvent(mergedEvents, selectedDate), + [mergedEvents, selectedDate] ); const { @@ -305,7 +364,7 @@ const CalendarContainer: FC> = ({ return ( { }; const MAX_DOTS_PER_DAY = 30; +const DOT_CLASS = + "fc-daygrid-event fc-daygrid-block-event fc-h-event fc-event fc-event-start fc-event-end fc-event-today"; -// Track which events have been added to each day to avoid race conditions -const dayEventCounts = new Map>(); - -const eventRender = (info: EventMountArg, widgetHash: string) => { - const { event, backgroundColor } = info; +/** + * Build all event dots in a single batch pass instead of per-event callbacks. + * Uses a pre-built day cell map to avoid repeated DOM queries, + * native Date arithmetic instead of moment, and DocumentFragment + * for a single DOM write per day cell. + */ +const renderEventDots = (events: TEvent[], widgetHash: string): void => { const cal = document.querySelector(`#cal-${widgetHash}`); - const { start } = event; - const end = moment(event.end || start) - .add(1, "days") - .format(); - - const now = moment(start); - if (cal) { - while (now.isBefore(end, "day")) { - const dateStr = now.format("YYYY-MM-DD"); - const daygrid = cal.querySelector( - `.fc-daygrid-day[data-date="${dateStr}"] .fc-daygrid-day-frame .fc-daygrid-day-events` - ); - - if (daygrid) { - // Initialize tracking for this day if needed - if (!dayEventCounts.has(dateStr)) { - dayEventCounts.set(dateStr, new Set()); - } - - const eventsForDay = dayEventCounts.get(dateStr); - if (eventsForDay) { - // Check if we haven't already added this event and haven't exceeded limit - if ( - !eventsForDay.has(event.id) && - eventsForDay.size < MAX_DOTS_PER_DAY - ) { - const prevDot = daygrid.querySelector( - `[data-id="${event.id}"]` - ); - if (!prevDot) { - const dot = document.createElement("span"); - dot.style.backgroundColor = backgroundColor; - dot.className = - "fc-daygrid-event fc-daygrid-block-event fc-h-event fc-event fc-event-start fc-event-end fc-event-today"; - dot.setAttribute("data-id", event.id); - daygrid.append(dot); - - // Track that we added this event - eventsForDay.add(event.id); - } + if (!cal) return; + + // Clear any previously rendered dots and "+X more" links before re-rendering + cal.querySelectorAll( + `.${DOT_CLASS.split(" ")[0]}, .fc-daygrid-more-link` + ).forEach((el) => el.remove()); + + // Build day cell lookup map once (max 42 cells in a month grid) + const dayCellMap = new Map(); + cal.querySelectorAll(".fc-daygrid-day").forEach((cell) => { + const date = cell.getAttribute("data-date"); + const container = cell.querySelector( + ".fc-daygrid-day-frame .fc-daygrid-day-events" + ); + if (date && container) dayCellMap.set(date, container); + }); + + // Group dots by day + const dotsByDay = new Map< + string, + { id: string; color: string | undefined }[] + >(); + + events + .filter((event) => event.start) + .forEach((event) => { + const startTime = new Date(event.start); + const endTime = + new Date(event.end || event.start).getTime() + 86_400_000; + const current = new Date(startTime); + + while (current.getTime() < endTime) { + const dateStr = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, "0")}-${String(current.getDate()).padStart(2, "0")}`; + if (dayCellMap.has(dateStr)) { + let dayDots = dotsByDay.get(dateStr); + if (!dayDots) { + dayDots = []; + dotsByDay.set(dateStr, dayDots); } + dayDots.push({ + id: event.id, + color: event.backgroundColor, + }); } + current.setDate(current.getDate() + 1); + } + }); + + // Render dots using DocumentFragment (single DOM write per day) + Array.from(dotsByDay.entries()).forEach(([dateStr, dots]) => { + const container = dayCellMap.get(dateStr); + if (!container) return; + + const fragment = document.createDocumentFragment(); + const seen = new Set(); + let overflow = 0; + + dots.forEach((dot) => { + if (seen.has(dot.id)) return; + seen.add(dot.id); + if (seen.size <= MAX_DOTS_PER_DAY) { + const span = document.createElement("span"); + if (dot.color) span.style.backgroundColor = dot.color; + span.className = DOT_CLASS; + fragment.appendChild(span); + } else { + overflow += 1; } - now.add(1, "days"); + }); + + if (overflow > 0) { + const moreEl = document.createElement("span"); + moreEl.className = "fc-daygrid-more-link fc-more-link text-[11px]"; + moreEl.textContent = `+${overflow} more`; + fragment.appendChild(moreEl); } - } + + container.appendChild(fragment); + }); }; interface ICalendarMonth extends ICalendarBaseProps { @@ -140,7 +177,7 @@ export const CalendarMonth: FC = ({ events, onDatesSet, showFullSize, - catFilters, + catFilters: _catFilters, selectedDate, widgetHash, handleHeaderTooltips, @@ -149,9 +186,9 @@ export const CalendarMonth: FC = ({ isAlphaModalOpen, handleIsAlphaModalOpen, }) => { - const [key, setKey] = useState(0); // to force calendar to rerender - const calendarRef = useRef(null); + const eventsRef = useRef(events); + eventsRef.current = events; const handleSize = (event: ViewContentArg): void => { const contentAPi = event.view.calendar; @@ -296,6 +333,10 @@ export const CalendarMonth: FC = ({ const handleNewMonthView = useCallback( (info: DatesSetArg) => { handleHeaderTooltips(info, widgetHash, showFullSize); + // Re-render dots after FC has swapped grid cells for the new month + if (eventsRef.current?.length) { + renderEventDots(eventsRef.current, widgetHash); + } if (onDatesSet != null) { onDatesSet(info.view.currentStart.toString()); } @@ -360,22 +401,31 @@ export const CalendarMonth: FC = ({ n2?.setAttribute("data-tip", "Next Week"); n2?.setAttribute("data-place", "right"); } + + // Render dots on initial mount when events are already available + if (eventsRef.current?.length) { + renderEventDots(eventsRef.current, widgetHash); + } } }, [widgetHash] ); + // Re-render dots when event data changes (e.g., new data from queries). + // viewDidMount and datesSet handle lifecycle-driven rendering; + // this effect handles data-driven updates when the grid is already stable. useEffect(() => { - // Clear the day event counts when calendar re-renders - dayEventCounts.clear(); - setKey((prev) => prev + 1); - }, [events, catFilters]); + if (!events?.length) return undefined; + const rafId = requestAnimationFrame(() => { + renderEventDots(events, widgetHash); + }); + return () => cancelAnimationFrame(rafId); + }, [events, widgetHash]); return ( = ({ }, }} navLinkDayClick={() => {}} // this controls the date number click - eventDisplay="block" + eventDisplay="none" events={events} - eventDidMount={(...args) => eventRender(...args, widgetHash)} ref={calendarRef} windowResize={handleSize} contentHeight="auto"