diff --git a/apps/web/src/atoms/selected-display-items.ts b/apps/web/src/atoms/selected-display-items.ts new file mode 100644 index 00000000..52adffc7 --- /dev/null +++ b/apps/web/src/atoms/selected-display-items.ts @@ -0,0 +1,41 @@ +import { atom } from "jotai"; + +export const selectedDisplayItemIdsAtom = atom([]); + +export const isDisplayItemSelected = (displayItemId: string) => + atom((get) => { + const items = get(selectedDisplayItemIdsAtom); + + return items.includes(displayItemId); + }); + +export const isEventSelected = (eventId: string) => + atom((get) => { + const items = get(selectedDisplayItemIdsAtom); + + return items.includes(`event_${eventId}`); + }); + +export const selectedEventIdsAtom = atom( + (get) => { + return get(selectedDisplayItemIdsAtom) + .filter((id) => id.startsWith("event_")) + .map((id) => id.slice(6)); + }, + (get, set, update: string[] | ((prev: string[]) => string[])) => { + const prev = get(selectedDisplayItemIdsAtom) + .filter((id) => id.startsWith("event_")) + .map((id) => id.slice(6)); + + const eventIds = typeof update === "function" ? update(prev) : update; + + const otherItems = get(selectedDisplayItemIdsAtom).filter( + (id) => !id.startsWith("event_"), + ); + + set(selectedDisplayItemIdsAtom, [ + ...otherItems, + ...eventIds.map((id) => `event_${id}`), + ]); + }, +); diff --git a/apps/web/src/atoms/selected-events.ts b/apps/web/src/atoms/selected-events.ts deleted file mode 100644 index 233f30ab..00000000 --- a/apps/web/src/atoms/selected-events.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { atom } from "jotai"; - -export const selectedEventIdsAtom = atom([]); - -export const isEventSelected = (eventId: string) => - atom((get) => { - const events = get(selectedEventIdsAtom); - - return events.some((selectedEventId) => { - return selectedEventId === eventId; - }); - }); diff --git a/apps/web/src/atoms/window-stack.ts b/apps/web/src/atoms/window-stack.ts index c437be01..1aaa9d98 100644 --- a/apps/web/src/atoms/window-stack.ts +++ b/apps/web/src/atoms/window-stack.ts @@ -1,7 +1,7 @@ import { atom } from "jotai"; import { StackWindowEntry } from "@/components/command-bar/stacked-window"; -import { selectedEventIdsAtom } from "./selected-events"; +import { selectedEventIdsAtom } from "./selected-display-items"; function createWindowId() { return `window-${crypto.randomUUID()}`; diff --git a/apps/web/src/atoms/window-state.ts b/apps/web/src/atoms/window-state.ts index bee21d2f..b0c22d0d 100644 --- a/apps/web/src/atoms/window-state.ts +++ b/apps/web/src/atoms/window-state.ts @@ -1,6 +1,6 @@ import { atom } from "jotai"; -import { selectedEventIdsAtom } from "@/atoms/selected-events"; +import { selectedEventIdsAtom } from "@/atoms/selected-display-items"; export const windowStateAtom = atom<"default" | "expanded">((get) => { const events = get(selectedEventIdsAtom); diff --git a/apps/web/src/components/calendar-view.tsx b/apps/web/src/components/calendar-view.tsx index 6e9b8249..16cf6e18 100644 --- a/apps/web/src/components/calendar-view.tsx +++ b/apps/web/src/components/calendar-view.tsx @@ -22,11 +22,12 @@ import { CalendarHeader } from "@/components/calendar/header/calendar-header"; import { MonthView } from "@/components/calendar/month-view/month-view"; import { WeekView } from "@/components/calendar/week-view/week-view"; import { db, mapEventQueryInput } from "@/lib/db"; +import { DisplayItem, isEvent } from "@/lib/display-item"; import { cn } from "@/lib/utils"; import { applyOptimisticActions } from "./calendar/hooks/apply-optimistic-actions"; import { optimisticActionsByEventIdAtom } from "./calendar/hooks/optimistic-actions"; import { useEventsForDisplay } from "./calendar/hooks/use-events"; -import { filterPastEvents } from "./calendar/utils/event"; +import { filterPastItems } from "./calendar/utils/event"; interface CalendarContentProps { scrollContainerRef: React.RefObject; @@ -54,22 +55,26 @@ function CalendarContent({ scrollContainerRef }: CalendarContentProps) { ); }, [data?.events, data?.recurringMasterEvents]); - const events = React.useMemo(() => { - const events = applyOptimisticActions({ + const displayItems = React.useMemo(() => { + const eventItems = applyOptimisticActions({ items: data?.events ?? [], timeZone: defaultTimeZone, optimisticActions, }); - const pastFiltered = showPastEvents - ? events - : filterPastEvents(events, defaultTimeZone); + const pastFiltered: DisplayItem[] = showPastEvents + ? eventItems + : filterPastItems(eventItems, defaultTimeZone); + + return pastFiltered.filter((item) => { + if (!isEvent(item)) { + return true; + } - return pastFiltered.filter((eventItem) => { const preference = getCalendarPreference( calendarPreferences, - eventItem.event.calendar.provider.accountId, - eventItem.event.calendar.id, + item.event.calendar.provider.accountId, + item.event.calendar.id, ); return !(preference?.hidden === true); @@ -83,24 +88,26 @@ function CalendarContent({ scrollContainerRef }: CalendarContentProps) { ]); if (view === "month") { - return ; + return ; } if (view === "week") { - return ; + return ( + + ); } if (view === "day") { return ( ); } - return ; + return ; } interface CalendarViewProps { diff --git a/apps/web/src/components/calendar/agenda-view/agenda-view-day.tsx b/apps/web/src/components/calendar/agenda-view/agenda-view-day.tsx index 58ec2abe..c071326c 100644 --- a/apps/web/src/components/calendar/agenda-view/agenda-view-day.tsx +++ b/apps/web/src/components/calendar/agenda-view/agenda-view-day.tsx @@ -8,9 +8,9 @@ import { Temporal } from "temporal-polyfill"; import { isToday } from "@repo/temporal"; import { calendarSettingsAtom } from "@/atoms/calendar-settings"; -import { eventOverlapsDay } from "@/components/calendar/utils/positioning"; -import { EventCollectionItem } from "../hooks/event-collection"; -import { AgendaViewEvent } from "./agenda-view-event"; +import { displayItemOverlapsDay } from "@/components/calendar/utils/positioning"; +import { DisplayItem, isInlineItem } from "@/lib/display-item"; +import { AgendaViewItem } from "./agenda-view-event"; interface AgendaViewDayHeaderProps { day: Temporal.PlainDate; @@ -51,15 +51,17 @@ function AgendaViewDayContainer({ children }: AgendaViewDayContainerProps) { interface AgendaViewDayProps { day: Temporal.PlainDate; - items: EventCollectionItem[]; + items: DisplayItem[]; } export function AgendaViewDay({ day, items }: AgendaViewDayProps) { - const events = React.useMemo(() => { - return items.filter((item) => eventOverlapsDay(item, day)); + const dayItems = React.useMemo(() => { + return items.filter( + (item) => isInlineItem(item) && displayItemOverlapsDay(item, day), + ); }, [day, items]); - if (events.length === 0) { + if (dayItems.length === 0) { return null; } @@ -67,8 +69,8 @@ export function AgendaViewDay({ day, items }: AgendaViewDayProps) { - {events.map((event) => ( - + {dayItems.map((item) => ( + ))} diff --git a/apps/web/src/components/calendar/agenda-view/agenda-view-event.tsx b/apps/web/src/components/calendar/agenda-view/agenda-view-event.tsx index 32d121a7..60f6259b 100644 --- a/apps/web/src/components/calendar/agenda-view/agenda-view-event.tsx +++ b/apps/web/src/components/calendar/agenda-view/agenda-view-event.tsx @@ -2,28 +2,47 @@ import * as React from "react"; -import { EventItem } from "@/components/calendar/event/event-item"; -import type { EventCollectionItem } from "@/components/calendar/hooks/event-collection"; +import { DisplayItemComponent } from "@/components/calendar/display-item/display-item"; import { useSelectAction } from "@/components/calendar/hooks/use-optimistic-mutations"; +import { + DisplayItem, + InlineDisplayItem, + isEvent, + isInlineItem, +} from "@/lib/display-item"; -interface AgendaViewEventProps { - item: EventCollectionItem; +interface AgendaViewItemProps { + item: DisplayItem; } -export function AgendaViewEvent({ item }: AgendaViewEventProps) { +export function AgendaViewItem({ item }: AgendaViewItemProps) { + if (!isInlineItem(item)) { + return null; + } + + return ; +} + +interface AgendaViewInlineItemProps { + item: InlineDisplayItem; +} + +function AgendaViewInlineItem({ item }: AgendaViewInlineItemProps) { const selectAction = useSelectAction(); const onClick = React.useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - selectAction(item.event); + if (isEvent(item)) { + selectAction(item.event); + } }, - [selectAction, item.event], + [selectAction, item], ); return ( - -

No events found

+

No items found

- There are no events scheduled for this time period. + There are no items scheduled for this time period.

); @@ -25,7 +25,7 @@ function AgendaViewEmpty() { interface AgendaViewProps { currentDate: Temporal.PlainDate; - items: EventCollectionItem[]; + items: DisplayItem[]; } export function AgendaView({ currentDate, items }: AgendaViewProps) { diff --git a/apps/web/src/components/calendar/day-view/day-view.tsx b/apps/web/src/components/calendar/day-view/day-view.tsx index 2597315b..e943d72b 100644 --- a/apps/web/src/components/calendar/day-view/day-view.tsx +++ b/apps/web/src/components/calendar/day-view/day-view.tsx @@ -11,15 +11,14 @@ import { isToday, toDate } from "@repo/temporal"; import { calendarSettingsAtom } from "@/atoms/calendar-settings"; import { timeZonesAtom } from "@/atoms/timezones"; import { currentDateAtom } from "@/atoms/view-preferences"; -import { DragAwareWrapper } from "@/components/calendar/event/drag-aware-wrapper"; +import { DisplayItemComponent } from "@/components/calendar/display-item/display-item"; +import { DisplayItemContainer } from "@/components/calendar/event/display-item-container"; import { DragPreview } from "@/components/calendar/event/drag-preview"; import { DraggableEvent } from "@/components/calendar/event/draggable-event"; -import { EventItem } from "@/components/calendar/event/event-item"; import { useEdgeAutoScroll } from "@/components/calendar/hooks/drag-and-drop/use-auto-scroll"; import { useDoubleClickToCreate } from "@/components/calendar/hooks/drag-and-drop/use-double-click-to-create"; import { useDragToCreate } from "@/components/calendar/hooks/drag-and-drop/use-drag-to-create"; -import type { EventCollectionItem } from "@/components/calendar/hooks/event-collection"; -import { useWeekEventCollection } from "@/components/calendar/hooks/use-event-collection"; +import { useWeekDisplayCollection } from "@/components/calendar/hooks/use-event-collection"; import { useGridLayout } from "@/components/calendar/hooks/use-grid-layout"; import { useSelectAction } from "@/components/calendar/hooks/use-optimistic-mutations"; import { HOURS } from "@/components/calendar/timeline/constants"; @@ -29,57 +28,72 @@ import { } from "@/components/calendar/timeline/time-indicator"; import { Timeline } from "@/components/calendar/timeline/timeline"; import { TimelineHeader } from "@/components/calendar/timeline/timeline-header"; +import type { PositionedDisplayItem } from "@/components/calendar/utils/positioning"; import { useScrollToCurrentTime } from "@/components/calendar/week-view/use-scroll-to-current-time"; +import { + isEvent, + type DisplayItem, + type InlineDisplayItem, +} from "@/lib/display-item"; import { cn } from "@/lib/utils"; interface DayViewProps { currentDate: Temporal.PlainDate; - events: EventCollectionItem[]; + items: DisplayItem[]; scrollContainerRef: React.RefObject; } interface PositionedEventProps { - positionedEvent: { - item: EventCollectionItem; - top: number; - height: number; - left: number; - width: number; - zIndex: number; - }; + positionedItem: PositionedDisplayItem; containerRef: React.RefObject; } function PositionedEvent({ - positionedEvent, + positionedItem, containerRef, }: PositionedEventProps) { + const style = { + top: `${positionedItem.top}px`, + height: `${positionedItem.height}px`, + left: `${positionedItem.left * 100}%`, + width: `${positionedItem.width * 100}%`, + }; + + if (!isEvent(positionedItem.item)) { + return ( + e.stopPropagation()} + > + + + ); + } + return ( - e.stopPropagation()} > - + ); } -export function DayView({ events, scrollContainerRef }: DayViewProps) { +export function DayView({ items, scrollContainerRef }: DayViewProps) { const currentDate = useAtomValue(currentDateAtom); const timeZones = useAtomValue(timeZonesAtom); const containerRef = React.useRef(null); @@ -94,7 +108,7 @@ export function DayView({ events, scrollContainerRef }: DayViewProps) { useEdgeAutoScroll(scrollContainerRef, { headerRef }); - const eventCollection = useWeekEventCollection(events, [currentDate]); + const displayCollection = useWeekDisplayCollection(items, [currentDate]); const gridTemplateColumns = useGridLayout([currentDate], { includeTimeColumn: true, @@ -117,9 +131,9 @@ export function DayView({ events, scrollContainerRef }: DayViewProps) {
- {eventCollection.allDayEvents.map((item) => ( - ( + @@ -133,10 +147,10 @@ export function DayView({ events, scrollContainerRef }: DayViewProps) { >
- {eventCollection.positionedEvents[0]?.map((positionedEvent) => ( + {displayCollection.positionedItems[0]?.map((positionedItem) => ( ))} @@ -219,27 +233,28 @@ function DayViewHeaderDay({ day }: DayViewHeaderProps) { ); } -interface DayViewPositionedEventProps { - item: EventCollectionItem; +interface DayViewPositionedItemProps { + item: InlineDisplayItem; currentDate: Temporal.PlainDate; } -function DayViewPositionedEvent({ +function DayViewPositionedItem({ item, currentDate, -}: DayViewPositionedEventProps) { +}: DayViewPositionedItemProps) { const selectAction = useSelectAction(); const onClick = React.useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - selectAction(item.event); + if (isEvent(item)) { + selectAction(item.event); + } }, - [selectAction, item.event], + [selectAction, item], ); const { isFirstDay, isLastDay } = React.useMemo(() => { - // For single-day events, ensure they are properly marked as first and last day const isFirstDay = Temporal.PlainDate.compare(item.start, currentDate) >= 0; const isLastDay = Temporal.PlainDate.compare(item.end, currentDate) <= 0; @@ -248,7 +263,7 @@ function DayViewPositionedEvent({ return (
- void; + className?: string; + children: React.ReactNode; + onMouseDown?: (e: React.MouseEvent) => void; + onTouchStart?: (e: React.TouchEvent) => void; + "data-selected"?: boolean; +} + +function DisplayItemWrapper({ + color, + isFirstDay = true, + isLastDay = true, + onClick, + className, + children, + onMouseDown, + onTouchStart, + "data-selected": dataSelected, +}: DisplayItemWrapperProps) { + return ( +
+ {children} +
+ ); +} + +interface DisplayItemProps { + item: InlineDisplayItem; + view: "month" | "week" | "day" | "agenda"; + onClick?: (e: React.MouseEvent) => void; + showTime?: boolean; + isFirstDay?: boolean; + isLastDay?: boolean; + children?: React.ReactNode; + className?: string; + onMouseDown?: (e: React.MouseEvent) => void; + onTouchStart?: (e: React.TouchEvent) => void; +} + +export function DisplayItemComponent({ + item, + view, + onClick, + showTime, + isFirstDay = true, + isLastDay = true, + children, + className, + onMouseDown, + onTouchStart, +}: DisplayItemProps) { + const isSelectedAtom = React.useMemo( + () => isDisplayItemSelected(item.id), + [item.id], + ); + const isSelected = useAtomValue(isSelectedAtom); + + const duration = React.useMemo(() => { + return item.start.until(item.end).total({ unit: "minute" }); + }, [item.start, item.end]); + + const { defaultTimeZone, locale, use12Hour } = + useAtomValue(calendarSettingsAtom); + + const itemTime = React.useMemo(() => { + if (isAllDay(item)) { + return "All day"; + } + return formatTime({ + value: item.start, + use12Hour, + locale, + timeZone: defaultTimeZone, + }); + }, [item, use12Hour, locale, defaultTimeZone]); + + const { title, color } = getDisplayItemDetails(item); + + if (view === "month") { + return ( + +
+
+ {children} + + {!isFirstDay ?
: null} + + {title}{" "} + {!isAllDay(item) && isFirstDay && ( + + {itemTime} + + )} + +
+ + ); + } + + if (view === "week" || view === "day") { + return ( + + {children} +
+
+
+ + {title} +
+ {showTime && duration > 30 ? ( +
+ {itemTime} +
+ ) : null} +
+ + ); + } + + // Agenda view + return ( + + ); +} + +function DisplayItemTypeIndicator({ item }: { item: InlineDisplayItem }) { + if (isTask(item)) { + return ( + + Task + + ); + } + return null; +} + +function AgendaItemDetails({ item }: { item: InlineDisplayItem }) { + if (isEvent(item)) { + const event = item.event; + return ( + <> + {event.location ? ( + <> + ยท + {event.location} + + ) : null} + + ); + } + return null; +} + +function getDisplayItemDetails(item: DisplayItem): { + title: string; + color: string; +} { + if (isEvent(item)) { + const event = item.event; + const title = + event.title && event.title.length ? event.title : "(untitled)"; + const color = + event.color ?? + `var(${calendarColorVariable(event.calendar.provider.accountId, event.calendar.id)}, var(--color-muted-foreground))`; + return { title, color }; + } + + if (isTask(item)) { + const title = item.value.title || "(untitled task)"; + const color = "var(--color-muted-foreground)"; + return { title, color }; + } + + return { title: "(unknown)", color: "var(--color-muted-foreground)" }; +} diff --git a/apps/web/src/components/calendar/event/drag-aware-wrapper.tsx b/apps/web/src/components/calendar/event/display-item-container.tsx similarity index 53% rename from apps/web/src/components/calendar/event/drag-aware-wrapper.tsx rename to apps/web/src/components/calendar/event/display-item-container.tsx index 0a28bad1..fa143af0 100644 --- a/apps/web/src/components/calendar/event/drag-aware-wrapper.tsx +++ b/apps/web/src/components/calendar/event/display-item-container.tsx @@ -4,22 +4,23 @@ import * as React from "react"; import { useAtomValue } from "jotai"; import { draggingAtom } from "@/atoms/drag-resize-state"; +import { DisplayItem } from "@/lib/display-item"; import { cn } from "@/lib/utils"; -interface DragAwareWrapperProps extends React.ComponentProps<"div"> { - eventId: string; +interface DisplayItemContainerProps extends React.ComponentProps<"div"> { + item: DisplayItem; } -// TODO: replace with a portal -export function DragAwareWrapper({ +export function DisplayItemContainer({ className, - eventId, + item, children, style, ...props -}: DragAwareWrapperProps) { - const draggedEventId = useAtomValue(draggingAtom); - const isDragging = draggedEventId === eventId; +}: DisplayItemContainerProps) { + const draggedItemId = useAtomValue(draggingAtom); + + const isDragging = item.id ? draggedItemId === item.id : false; return (
{ - // Prevent possible text/image dragging flash on some browsers e.preventDefault(); addDraggedEventId(item.event.id); @@ -157,7 +156,6 @@ export function DraggableEvent({ } if (view === "day") { - // Can't move all day events in the day view if (event.start instanceof Temporal.PlainDate) { return; } @@ -221,8 +219,6 @@ export function DraggableEvent({ const onDragEnd = (_e: PointerEvent, info: PanInfo) => { removeDraggedEventId(item.event.id); - // Do not reset transform immediately to avoid flashback to original - // position. We'll reset when the event data updates optimistically. let columnOffset = 0; @@ -244,7 +240,6 @@ export function DraggableEvent({ } } - // Calculate vertical movement relative to the container so that auto-scroll is taken into account. let deltaY = info.offset.y; if (containerRef.current && dragStartRelative.current !== null) { @@ -258,9 +253,6 @@ export function DraggableEvent({ moveEvent(deltaY, columnOffset); }; - // When the event time updates (optimistic or confirmed), reset the local - // transform so the item renders at its new computed position without a - // visual flash. Use layout effect to apply before paint to avoid flicker. React.useLayoutEffect(() => { top.set(0); left.set(0); @@ -308,7 +300,6 @@ export function DraggableEvent({ } const rect = containerRef.current.getBoundingClientRect(); - // Guard against onPan firing before onPanStart by lazily initializing if (!resizeInitializedRef.current) { setIsResizing(true); startHeight.current = heightRef.current ?? 0; @@ -328,7 +319,6 @@ export function DraggableEvent({ } const rect = containerRef.current.getBoundingClientRect(); - // Guard against onPan firing before onPanStart by lazily initializing if (!resizeInitializedRef.current) { setIsResizing(true); startHeight.current = heightRef.current ?? 0; @@ -390,7 +380,6 @@ export function DraggableEvent({ const onResizeTopEnd = (_: PointerEvent, info: PanInfo) => { setIsResizing(false); resetCursor(); - // Keep the visual offset until optimistic update lands to avoid flashback if (!containerRef.current) { return; } @@ -410,7 +399,6 @@ export function DraggableEvent({ setIsResizing(false); resetCursor(); - // Keep the visual state until optimistic update applies if (!containerRef.current) { return; } @@ -436,7 +424,7 @@ export function DraggableEvent({ [item.event, selectAction], ); - if (item.event.allDay || view === "month") { + if (isAllDay(item) || view === "month") { return ( void; showTime?: boolean; - currentTime?: Temporal.ZonedDateTime; // For updating time during drag isFirstDay?: boolean; isLastDay?: boolean; children?: React.ReactNode; @@ -85,7 +83,6 @@ export function EventItem({ view, onClick, showTime, - currentTime, isFirstDay = true, isLastDay = true, children, @@ -93,10 +90,6 @@ export function EventItem({ onMouseDown, onTouchStart, }: EventItemProps) { - // Use the provided currentTime (for dragging) or the event's actual time - const displayStart = currentTime ?? item.start; - const displayEnd = currentTime ?? item.end; - const isSelectedAtom = React.useMemo( () => isEventSelected(item.event.id), [item.event.id], @@ -104,8 +97,8 @@ export function EventItem({ const isSelected = useAtomValue(isSelectedAtom); const duration = React.useMemo(() => { - return displayStart.until(displayEnd).total({ unit: "minute" }); - }, [displayStart, displayEnd]); + return item.start.until(item.end).total({ unit: "minute" }); + }, [item.start, item.end]); const { defaultTimeZone, locale, use12Hour } = useAtomValue(calendarSettingsAtom); @@ -114,8 +107,8 @@ export function EventItem({ return "All day"; } - return `${formatTime({ value: displayStart, use12Hour, locale, timeZone: defaultTimeZone })}`; - }, [displayStart, item.event.allDay, use12Hour, locale, defaultTimeZone]); + return `${formatTime({ value: item.start, use12Hour, locale, timeZone: defaultTimeZone })}`; + }, [item.start, item.event.allDay, use12Hour, locale, defaultTimeZone]); const displayTitle = item.event.title && item.event.title.length diff --git a/apps/web/src/components/calendar/hooks/apply-optimistic-actions.ts b/apps/web/src/components/calendar/hooks/apply-optimistic-actions.ts index 5454a863..97db1855 100644 --- a/apps/web/src/components/calendar/hooks/apply-optimistic-actions.ts +++ b/apps/web/src/components/calendar/hooks/apply-optimistic-actions.ts @@ -1,11 +1,11 @@ import { isBefore } from "@repo/temporal"; +import { EventDisplayItem, createEventDisplayItem } from "@/lib/display-item"; import { insertIntoSorted } from "@/lib/sorted-actions"; -import { EventCollectionItem, convertEventToItem } from "./event-collection"; import { OptimisticAction } from "./optimistic-actions"; interface ApplyOptimisticActionsOptions { - items: EventCollectionItem[]; + items: EventDisplayItem[]; timeZone: string; optimisticActions: Record; } @@ -21,7 +21,7 @@ export function applyOptimisticActions({ for (const action of Object.values(optimisticActions)) { if (action.type === "update") { - const item = convertEventToItem(action.event, timeZone); + const item = createEventDisplayItem(action.event, timeZone); optimisticItems = insertIntoSorted(optimisticItems, item, (a) => isBefore(a.start, action.event.start, { @@ -33,14 +33,15 @@ export function applyOptimisticActions({ (event) => event.event.id !== action.eventId, ); } else if (action.type === "create") { - const item = convertEventToItem(action.event, timeZone); + const item = createEventDisplayItem(action.event, timeZone); + optimisticItems = insertIntoSorted(optimisticItems, item, (a) => isBefore(a.start, action.event.start, { timeZone, }), ); } else if (action.type === "draft") { - const item = convertEventToItem(action.event, timeZone); + const item = createEventDisplayItem(action.event, timeZone); optimisticItems = insertIntoSorted(optimisticItems, item, (a) => isBefore(a.start, action.event.start, { diff --git a/apps/web/src/components/calendar/hooks/event-collection.ts b/apps/web/src/components/calendar/hooks/event-collection.ts deleted file mode 100644 index ce647bc3..00000000 --- a/apps/web/src/components/calendar/hooks/event-collection.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Temporal } from "temporal-polyfill"; - -import { toZonedDateTime } from "@repo/temporal"; - -import type { CalendarEvent } from "@/lib/interfaces"; - -export type EventCollectionItem = { - event: CalendarEvent; - start: Temporal.ZonedDateTime; - end: Temporal.ZonedDateTime; -}; - -function createEventCollectionItem( - event: CalendarEvent, - timeZone: string, -): EventCollectionItem { - const start = toZonedDateTime(event.start, { timeZone }); - let endExclusive = toZonedDateTime(event.end, { timeZone }); - - if (event.allDay) { - const startDate = start.toPlainDate(); - const endDate = endExclusive.toPlainDate(); - - if (Temporal.PlainDate.compare(startDate, endDate) === 0) { - endExclusive = start.add({ days: 1 }); - } - } - - const end = - Temporal.ZonedDateTime.compare(endExclusive, start) > 0 - ? endExclusive.subtract({ seconds: 1 }) - : start; - - return { - event, - start, - end, - }; -} - -export function mapEventsToItems( - events: CalendarEvent[], - timeZone: string, -): EventCollectionItem[] { - return events.map((event) => createEventCollectionItem(event, timeZone)); -} - -export function convertEventToItem( - event: CalendarEvent, - timeZone: string, -): EventCollectionItem { - return createEventCollectionItem(event, timeZone); -} diff --git a/apps/web/src/components/calendar/hooks/use-event-collection.ts b/apps/web/src/components/calendar/hooks/use-event-collection.ts index e2b9a6af..07ee4cd2 100644 --- a/apps/web/src/components/calendar/hooks/use-event-collection.ts +++ b/apps/web/src/components/calendar/hooks/use-event-collection.ts @@ -7,18 +7,28 @@ import { isAfter, isBefore, isWeekend } from "@repo/temporal"; import { cellHeightAtom } from "@/atoms/cell-height"; import { viewPreferencesAtom } from "@/atoms/view-preferences"; import { - calculateWeekViewEventPositions, - getAllDayEventCollectionsForDays, - getEventCollectionsForDay, - type PositionedEvent, + calculateWeekViewDisplayItemPositions, + displayItemOverlapsDay, + filterInlineItems, + getAllDayItemCollectionsForDays, + getDisplayItemCollectionsForDay, + type PositionedDisplayItem, } from "@/components/calendar/utils/positioning"; -import { EventCollectionItem } from "./event-collection"; - -function preFilterEventsByDateRange( - items: EventCollectionItem[], +import { + BackgroundDisplayItem, + DisplayItem, + InlineDisplayItem, + SideDisplayItem, + isBackgroundItem, + isInlineItem, + isSideItem, +} from "@/lib/display-item"; + +function preFilterItemsByDateRange( + items: T[], startDate: Temporal.PlainDate, endDate: Temporal.PlainDate, -) { +): T[] { return items.filter(({ start, end }) => { return ( Temporal.PlainDate.compare(start.toPlainDate(), endDate) <= 0 && @@ -28,16 +38,14 @@ function preFilterEventsByDateRange( } function isWithinWeekdayRange( - item: EventCollectionItem, + item: DisplayItem, rangeStart: Temporal.PlainDate, rangeEnd: Temporal.PlainDate, ) { - const eventStart = item.start.toPlainDate(); - const eventEnd = item.end.toPlainDate(); - const clampedStart = isBefore(eventStart, rangeStart) - ? rangeStart - : eventStart; - const clampedEnd = isAfter(eventEnd, rangeEnd) ? rangeEnd : eventEnd; + const itemStart = item.start.toPlainDate(); + const itemEnd = item.end.toPlainDate(); + const clampedStart = isBefore(itemStart, rangeStart) ? rangeStart : itemStart; + const clampedEnd = isAfter(itemEnd, rangeEnd) ? rangeEnd : itemEnd; if (clampedStart.until(clampedEnd, { largestUnit: "days" }).days >= 2) { return true; @@ -46,89 +54,142 @@ function isWithinWeekdayRange( return !isWeekend(clampedStart) || !isWeekend(clampedEnd); } -export interface EventCollectionByDay { - dayEvents: EventCollectionItem[]; - spanningEvents: EventCollectionItem[]; - allDayEvents: EventCollectionItem[]; - allEvents: EventCollectionItem[]; +export interface DisplayItemCollectionByDay { + dayItems: InlineDisplayItem[]; + spanningItems: InlineDisplayItem[]; + allDayItems: InlineDisplayItem[]; + allItems: InlineDisplayItem[]; + backgroundItems: BackgroundDisplayItem[]; + sideItems: SideDisplayItem[]; } -export interface MonthEventCollection { - eventsByDay: Map; +export interface MonthDisplayCollection { + itemsByDay: Map; } -export interface WeekEventCollection { - allDayEvents: EventCollectionItem[]; - positionedEvents: PositionedEvent[][]; +export interface WeekDisplayCollection { + allDayItems: InlineDisplayItem[]; + positionedItems: PositionedDisplayItem[][]; + backgroundItems: BackgroundDisplayItem[]; + sideItems: SideDisplayItem[]; } -export function useMonthEventCollection( - items: EventCollectionItem[], +export function useMonthDisplayCollection( + items: DisplayItem[], days: Temporal.PlainDate[], -): MonthEventCollection { +): MonthDisplayCollection { return useMemo(() => { if (items.length === 0 || days.length === 0) { - return { eventsByDay: new Map() }; + return { itemsByDay: new Map() }; } - const events = preFilterEventsByDateRange(items, days.at(0)!, days.at(-1)!); - const eventsByDay = new Map(); + const filtered = preFilterItemsByDateRange( + items, + days.at(0)!, + days.at(-1)!, + ); + const inlineItems = filtered.filter(isInlineItem); + const backgroundItems = filtered.filter(isBackgroundItem); + const sideItems = filtered.filter(isSideItem); + + const itemsByDay = new Map(); for (const day of days) { - eventsByDay.set(day.toString(), getEventCollectionsForDay(events, day)); + const inlineCollection = getDisplayItemCollectionsForDay( + inlineItems, + day, + ); + const dayBackgroundItems = backgroundItems.filter((item) => + displayItemOverlapsDay(item, day), + ); + const daySideItems = sideItems.filter((item) => + displayItemOverlapsDay(item, day), + ); + + itemsByDay.set(day.toString(), { + ...inlineCollection, + backgroundItems: dayBackgroundItems, + sideItems: daySideItems, + }); } - return { eventsByDay }; + return { itemsByDay }; }, [items, days]); } -export function useWeekEventCollection( - items: EventCollectionItem[], +export function useWeekDisplayCollection( + items: DisplayItem[], days: Temporal.PlainDate[], -): WeekEventCollection { +): WeekDisplayCollection { const cellHeight = useAtomValue(cellHeightAtom); return useMemo(() => { - if (items.length === 0 || days.length === 0) { - return { allDayEvents: [], positionedEvents: [] }; + if (days.length === 0) { + return { + allDayItems: [], + positionedItems: [], + backgroundItems: [], + sideItems: [], + }; } - const events = preFilterEventsByDateRange(items, days.at(0)!, days.at(-1)!); + if (items.length === 0) { + return { + allDayItems: [], + positionedItems: days.map(() => []), + backgroundItems: [], + sideItems: [], + }; + } - if (events.length === 0) { - return { allDayEvents: [], positionedEvents: days.map(() => []) }; + const filtered = preFilterItemsByDateRange( + items, + days.at(0)!, + days.at(-1)!, + ); + const inlineItems = filterInlineItems(filtered); + const backgroundItems = filtered.filter(isBackgroundItem); + const sideItems = filtered.filter(isSideItem); + + if (inlineItems.length === 0) { + return { + allDayItems: [], + positionedItems: days.map(() => []), + backgroundItems, + sideItems, + }; } - const allDayEvents = getAllDayEventCollectionsForDays(events, days); + const allDayItems = getAllDayItemCollectionsForDays(inlineItems, days); - const positionedEvents = calculateWeekViewEventPositions({ - events, + const positionedItems = calculateWeekViewDisplayItemPositions({ + items: inlineItems, days, cellHeight, }); - return { allDayEvents, positionedEvents }; + return { allDayItems, positionedItems, backgroundItems, sideItems }; }, [items, days, cellHeight]); } -export function useWeekRowEvents( - collection: MonthEventCollection, +export function useWeekRowItems( + collection: MonthDisplayCollection, weekStart: Temporal.PlainDate, weekEnd: Temporal.PlainDate, -): EventCollectionItem[] { +): InlineDisplayItem[] { const { showWeekends } = useAtomValue(viewPreferencesAtom); return useMemo(() => { - const uniqueEvents = new Map(); + const uniqueItems = new Map(); let day = weekStart; while (Temporal.PlainDate.compare(day, weekEnd) <= 0) { - const dayEvents = collection.eventsByDay.get(day.toString()); + const dayItems = collection.itemsByDay.get(day.toString()); - if (dayEvents) { - for (const item of dayEvents.allEvents) { - uniqueEvents.set(item.event.id, item); + if (dayItems) { + for (const item of dayItems.allItems) { + uniqueItems.set(item.id, item); } } @@ -136,11 +197,11 @@ export function useWeekRowEvents( } if (showWeekends) { - return [...uniqueEvents.values()]; + return [...uniqueItems.values()]; } - return [...uniqueEvents.values()].filter((item) => + return [...uniqueItems.values()].filter((item) => isWithinWeekdayRange(item, weekStart, weekEnd), ); - }, [collection.eventsByDay, showWeekends, weekStart, weekEnd]); + }, [collection.itemsByDay, showWeekends, weekStart, weekEnd]); } diff --git a/apps/web/src/components/calendar/hooks/use-events.ts b/apps/web/src/components/calendar/hooks/use-events.ts index a06cabcf..0f8f4401 100644 --- a/apps/web/src/components/calendar/hooks/use-events.ts +++ b/apps/web/src/components/calendar/hooks/use-events.ts @@ -6,9 +6,9 @@ import { endOfMonth, startOfMonth } from "@repo/temporal"; import { calendarSettingsAtom } from "@/atoms/calendar-settings"; import { currentDateAtom } from "@/atoms/view-preferences"; +import { createEventDisplayItem } from "@/lib/display-item"; import { RouterOutputs } from "@/lib/trpc"; import { useTRPC } from "@/lib/trpc/client"; -import { mapEventsToItems } from "./event-collection"; const TIME_RANGE_DAYS_PAST = 30; const TIME_RANGE_DAYS_FUTURE = 30; @@ -65,7 +65,9 @@ export function useEventsForDisplay() { } return { - events: mapEventsToItems(data.events, defaultTimeZone), + events: data.events.map((event) => + createEventDisplayItem(event, defaultTimeZone), + ), recurringMasterEvents: data.recurringMasterEvents, }; }, diff --git a/apps/web/src/components/calendar/hooks/use-multi-day-overflow.ts b/apps/web/src/components/calendar/hooks/use-multi-day-overflow.ts index 7382535b..ca63e8ec 100644 --- a/apps/web/src/components/calendar/hooks/use-multi-day-overflow.ts +++ b/apps/web/src/components/calendar/hooks/use-multi-day-overflow.ts @@ -1,27 +1,27 @@ import * as React from "react"; -import { EventCollectionItem } from "@/components/calendar/hooks/event-collection"; import { useContainerSize } from "@/hooks/use-container-size"; +import { InlineDisplayItem } from "@/lib/display-item"; import { EventGap, EventHeight } from "../constants"; import { - organizeEventsWithOverflow, - type EventCapacityInfo, + organizeItemsWithOverflow, + type DisplayItemCapacityInfo, } from "../utils/multi-day-layout"; interface UseMultiDayOverflowOptions { - events: EventCollectionItem[]; + items: InlineDisplayItem[]; timeZone: string; containerRef: React.RefObject; minVisibleLanes?: number; } export interface UseMultiDayOverflowResult { - capacityInfo: EventCapacityInfo; - overflowEvents: EventCollectionItem[]; + capacityInfo: DisplayItemCapacityInfo; + overflowItems: InlineDisplayItem[]; } export function useMultiDayOverflow({ - events, + items, timeZone, containerRef, minVisibleLanes, @@ -29,19 +29,19 @@ export function useMultiDayOverflow({ const { height } = useContainerSize(containerRef); return React.useMemo(() => { - const capacityInfo = organizeEventsWithOverflow({ - events, + const capacityInfo = organizeItemsWithOverflow({ + items, availableHeight: minVisibleLanes ? Math.max(minVisibleLanes * (EventHeight + EventGap), height) : height, timeZone, - eventHeight: EventHeight, - eventGap: EventGap, + itemHeight: EventHeight, + itemGap: EventGap, }); return { capacityInfo, - overflowEvents: capacityInfo.overflowLanes.flat(), + overflowItems: capacityInfo.overflowLanes.flat(), }; - }, [events, minVisibleLanes, height, timeZone]); + }, [items, minVisibleLanes, height, timeZone]); } diff --git a/apps/web/src/components/calendar/hooks/use-optimistic-mutations.ts b/apps/web/src/components/calendar/hooks/use-optimistic-mutations.ts index e9fd7a11..a59cb4c5 100644 --- a/apps/web/src/components/calendar/hooks/use-optimistic-mutations.ts +++ b/apps/web/src/components/calendar/hooks/use-optimistic-mutations.ts @@ -4,7 +4,7 @@ import * as React from "react"; import { useQuery } from "@tanstack/react-query"; import { useAtom, useSetAtom } from "jotai"; -import { selectedEventIdsAtom } from "@/atoms/selected-events"; +import { selectedEventIdsAtom } from "@/atoms/selected-display-items"; import { useSidebarWithSide } from "@/components/ui/sidebar"; import type { CalendarEvent, DraftEvent } from "@/lib/interfaces"; import { useTRPC } from "@/lib/trpc/client"; diff --git a/apps/web/src/components/calendar/month-view/month-view-day.tsx b/apps/web/src/components/calendar/month-view/month-view-day.tsx index d88e5a5c..f291981f 100644 --- a/apps/web/src/components/calendar/month-view/month-view-day.tsx +++ b/apps/web/src/components/calendar/month-view/month-view-day.tsx @@ -12,7 +12,7 @@ import { viewPreferencesAtom } from "@/atoms/view-preferences"; import { useDoubleClickToCreate } from "@/components/calendar/hooks/drag-and-drop/use-double-click-to-create"; import type { UseMultiDayOverflowResult } from "@/components/calendar/hooks/use-multi-day-overflow"; import { OverflowIndicator } from "@/components/calendar/overflow/overflow-indicator"; -import { eventsStartingOn } from "@/components/calendar/utils/event"; +import { itemsStartingOn } from "@/components/calendar/utils/event"; import { cn } from "@/lib/utils"; interface MonthViewDayProps { @@ -111,10 +111,9 @@ function MonthViewCell({ [viewPreferences.showWeekends, day], ); - // Determine if this day is in the last visible column const isLastVisibleColumn = viewPreferences.showWeekends - ? dayIndex === 6 // Saturday is last when weekends shown - : dayIndex === 5; // Friday is last when weekends hidden + ? dayIndex === 6 + : dayIndex === 5; return (
eventsStartingOn(overflow.overflowEvents, day), - [overflow.overflowEvents, day], + const dayOverflowItems = React.useMemo( + () => itemsStartingOn(overflow.overflowItems, day), + [overflow.overflowItems, day], ); - if (dayOverflowEvents.length === 0) { + if (dayOverflowItems.length === 0) { return null; } return (
- +
); } diff --git a/apps/web/src/components/calendar/month-view/month-view-positioned-event.tsx b/apps/web/src/components/calendar/month-view/month-view-positioned-event.tsx index 0affd27e..71be6649 100644 --- a/apps/web/src/components/calendar/month-view/month-view-positioned-event.tsx +++ b/apps/web/src/components/calendar/month-view/month-view-positioned-event.tsx @@ -5,14 +5,15 @@ import { Temporal } from "temporal-polyfill"; import { isAfter, isBefore, isSameDay } from "@repo/temporal"; +import { DisplayItemComponent } from "@/components/calendar/display-item/display-item"; import { DraggableEvent } from "@/components/calendar/event/draggable-event"; import { getGridPosition } from "@/components/calendar/utils/multi-day-layout"; -import { DragAwareWrapper } from "../event/drag-aware-wrapper"; -import { EventCollectionItem } from "../hooks/event-collection"; +import { InlineDisplayItem, isEvent } from "@/lib/display-item"; +import { DisplayItemContainer } from "../event/display-item-container"; interface PositionedEventProps { y: number; - item: EventCollectionItem; + item: InlineDisplayItem; weekStart: Temporal.PlainDate; weekEnd: Temporal.PlainDate; containerRef: React.RefObject; @@ -29,40 +30,6 @@ export function PositionedEvent({ rows, columns, }: PositionedEventProps) { - return ( - - - - ); -} - -interface PositionedContainerProps { - children: React.ReactNode; - item: EventCollectionItem; - y: number; - weekStart: Temporal.PlainDate; - weekEnd: Temporal.PlainDate; -} - -function PositionedContainer({ - children, - item, - y, - weekStart, - weekEnd, -}: PositionedContainerProps) { const style = React.useMemo(() => { const { colStart, span } = getGridPosition(item, weekStart, weekEnd); @@ -72,59 +39,52 @@ function PositionedContainer({ }; }, [item, weekStart, weekEnd, y]); - return ( - - {children} - - ); -} - -interface PositionedContentProps { - item: EventCollectionItem; - weekStart: Temporal.PlainDate; - weekEnd: Temporal.PlainDate; - containerRef: React.RefObject; - rows: number; - columns: number; -} - -function PositionedContent({ - item, - weekStart, - weekEnd, - containerRef, - rows, - columns, -}: PositionedContentProps) { const { isFirstDay, isLastDay } = React.useMemo(() => { - // Calculate actual first/last day based on event dates - const eventStart = item.start.toPlainDate(); - const eventEnd = item.end.toPlainDate(); + const itemStart = item.start.toPlainDate(); + const itemEnd = item.end.toPlainDate(); - // For single-day events, ensure they are properly marked as first and last day const isFirstDay = - isAfter(eventStart, weekStart) || isSameDay(eventStart, weekStart); - const isLastDay = - isBefore(eventEnd, weekEnd) || isSameDay(eventEnd, weekEnd); + isAfter(itemStart, weekStart) || isSameDay(itemStart, weekStart); + const isLastDay = isBefore(itemEnd, weekEnd) || isSameDay(itemEnd, weekEnd); return { isFirstDay, isLastDay }; }, [item.start, item.end, weekStart, weekEnd]); + if (!isEvent(item)) { + return ( + + + + ); + } + return ( - + className="pointer-events-auto my-px min-w-0" + style={style} + > + + ); } diff --git a/apps/web/src/components/calendar/month-view/month-view-week.tsx b/apps/web/src/components/calendar/month-view/month-view-week.tsx index 70546bfd..3598088e 100644 --- a/apps/web/src/components/calendar/month-view/month-view-week.tsx +++ b/apps/web/src/components/calendar/month-view/month-view-week.tsx @@ -10,8 +10,8 @@ import { CalendarSettings } from "@/atoms/calendar-settings"; import { viewPreferencesAtom } from "@/atoms/view-preferences"; import { useMultiDayOverflow } from "@/components/calendar/hooks/use-multi-day-overflow"; import { - useWeekRowEvents, - type MonthEventCollection, + useWeekRowItems, + type MonthDisplayCollection, } from "../hooks/use-event-collection"; import { MemoizedMonthViewDay } from "./month-view-day"; import { MemoizedPositionedEvent } from "./month-view-positioned-event"; @@ -20,7 +20,7 @@ interface MonthViewWeekItemProps { week: Temporal.PlainDate[]; weekIndex: number; rows: number; - eventCollection: MonthEventCollection; + displayCollection: MonthDisplayCollection; settings: CalendarSettings; containerRef: React.RefObject; @@ -31,7 +31,7 @@ export function MonthViewWeek({ week, weekIndex, rows, - eventCollection, + displayCollection, settings, containerRef, currentDate, @@ -46,12 +46,11 @@ export function MonthViewWeek({ ); }, [viewPreferences.showWeekends, week]); - const weekEvents = useWeekRowEvents(eventCollection, weekStart, weekEnd); + const weekItems = useWeekRowItems(displayCollection, weekStart, weekEnd); const overflowRef = React.useRef(null); - // Use overflow hook to manage event display const overflow = useMultiDayOverflow({ - events: weekEvents, + items: weekItems, timeZone: settings.defaultTimeZone, containerRef: overflowRef, }); @@ -78,7 +77,7 @@ export function MonthViewWeek({ lane.map((item) => { return ( { @@ -50,7 +50,7 @@ export function MonthView({ currentDate, events }: MonthViewProps) { const containerRef = React.useRef(null); const gridTemplateColumns = useGridLayout(getWeekDays(currentDate)); - const eventCollection = useMonthEventCollection(events, days); + const displayCollection = useMonthDisplayCollection(items, days); const rows = weeks.length; @@ -78,7 +78,7 @@ export function MonthView({ currentDate, events }: MonthViewProps) { week={week} weekIndex={weekIndex} rows={rows} - eventCollection={eventCollection} + displayCollection={displayCollection} settings={settings} containerRef={containerRef} currentDate={currentDate} diff --git a/apps/web/src/components/calendar/overflow/overflow-indicator.tsx b/apps/web/src/components/calendar/overflow/overflow-indicator.tsx index 8388b40e..1f7a900f 100644 --- a/apps/web/src/components/calendar/overflow/overflow-indicator.tsx +++ b/apps/web/src/components/calendar/overflow/overflow-indicator.tsx @@ -9,19 +9,18 @@ import { Temporal } from "temporal-polyfill"; import { isSameDay, toDate } from "@repo/temporal"; import { calendarSettingsAtom } from "@/atoms/calendar-settings"; -import { EventItem } from "@/components/calendar/event/event-item"; +import { DisplayItemComponent } from "@/components/calendar/display-item/display-item"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import type { CalendarEvent } from "@/lib/interfaces"; +import { InlineDisplayItem, isEvent } from "@/lib/display-item"; import { cn } from "@/lib/utils"; -import { EventCollectionItem } from "../hooks/event-collection"; import { useSelectAction } from "../hooks/use-optimistic-mutations"; interface OverflowIndicatorProps { - items: EventCollectionItem[]; + items: InlineDisplayItem[]; date: Temporal.PlainDate; gridColumn?: string; @@ -39,9 +38,11 @@ export function OverflowIndicator({ const timeZone = useAtomValue(calendarSettingsAtom).defaultTimeZone; const selectAction = useSelectAction(); - const onEventClick = React.useCallback( - (event: CalendarEvent) => { - selectAction(event); + const onItemClick = React.useCallback( + (item: InlineDisplayItem) => { + if (isEvent(item)) { + selectAction(item.event); + } setOpen(false); }, [selectAction], @@ -92,34 +93,28 @@ export function OverflowIndicator({
-
- {items.length === 0 ? ( -
No events
- ) : ( -
- {items.map((item) => ( - - ))} -
- )} +
+ {items.map((item) => ( + + ))}
); } -interface OverflowEventsProps { - item: EventCollectionItem; +interface OverflowItemProps { + item: InlineDisplayItem; date: Temporal.PlainDate; - onEventClick: (event: CalendarEvent) => void; + onItemClick: (item: InlineDisplayItem) => void; } -function OverflowEvent({ item, date, onEventClick }: OverflowEventsProps) { +function OverflowItem({ item, date, onItemClick }: OverflowItemProps) { const { defaultTimeZone } = useAtomValue(calendarSettingsAtom); const isFirstDay = isSameDay(date, item.start, { timeZone: defaultTimeZone }); @@ -127,11 +122,11 @@ function OverflowEvent({ item, date, onEventClick }: OverflowEventsProps) { return (
onEventClick(item.event)} + onClick={() => onItemClick(item)} > - ( + items: T[], timeZone: string, -) { +): T[] { const now = Temporal.Now.zonedDateTimeISO(timeZone); - - return events.filter((event) => isAfter(event.end, now)); + return items.filter((item) => isAfter(item.end, now)); } -export function eventsStartingOn( - events: EventCollectionItem[], +export function itemsStartingOn( + items: T[], day: Temporal.PlainDate, -) { - return events.filter((event) => isSameDay(event.start.toPlainDate(), day)); +): T[] { + return items.filter((item) => isSameDay(item.start.toPlainDate(), day)); } diff --git a/apps/web/src/components/calendar/utils/lane-packer.ts b/apps/web/src/components/calendar/utils/lane-packer.ts index 5549d62c..cd7db26d 100644 --- a/apps/web/src/components/calendar/utils/lane-packer.ts +++ b/apps/web/src/components/calendar/utils/lane-packer.ts @@ -1,13 +1,13 @@ -import { EventCollectionItem } from "@/components/calendar/hooks/event-collection"; import { MinHeap } from "@/lib/data-structures/min-heap"; +import { InlineDisplayItem } from "@/lib/display-item"; interface LaneEntry { laneIndex: number; endEpoch: number; } -export interface CachedEvent { - item: EventCollectionItem; +export interface CachedDisplayItem { + item: InlineDisplayItem; startEpoch: number; endEpoch: number; durationDays: number; @@ -17,37 +17,36 @@ export interface CachedEvent { * Stateful lane packer using greedy interval partitioning. * * Current usage: Create fresh instance per render (stateless behavior). - * Future infinite scroll: Reuse instance, call insertEvents() incrementally. + * Future infinite scroll: Reuse instance, call insertItems() incrementally. * - * @complexity O(n log k) where n = events, k = concurrent lanes + * @complexity O(n log k) where n = items, k = concurrent lanes */ export class LanePacker { - private lanes: EventCollectionItem[][] = []; + private lanes: InlineDisplayItem[][] = []; private heap = new MinHeap((a, b) => a.endEpoch !== b.endEpoch ? a.endEpoch - b.endEpoch : a.laneIndex - b.laneIndex, ); - constructor(events?: CachedEvent[]) { - if (events) { - this.insertEvents(events); + constructor(items?: CachedDisplayItem[]) { + if (items) { + this.insertItems(items); } } /** - * Insert a batch of events (must be sorted by startEpoch, then -durationDays). + * Insert a batch of items (must be sorted by startEpoch, then -durationDays). * * Expects inclusive intervals: startEpoch and endEpoch represent the first and - * last day of the event (not closed-open). Lane reuse uses strict `>` comparison, - * so an event starting the day after another ends will share a lane. + * last day of the item (not closed-open). Lane reuse uses strict `>` comparison, + * so an item starting the day after another ends will share a lane. */ - insertEvents(events: CachedEvent[]) { - for (const { item, startEpoch, endEpoch } of events) { + insertItems(items: CachedDisplayItem[]) { + for (const { item, startEpoch, endEpoch } of items) { const top = this.heap.peek(); if (top && startEpoch > top.endEpoch) { - // Reuse existing lane - O(log k) this.heap.pop(); this.lanes[top.laneIndex]!.push(item); this.heap.push({ laneIndex: top.laneIndex, endEpoch }); @@ -55,7 +54,6 @@ export class LanePacker { continue; } - // Create new lane - O(log k) const laneIndex = this.lanes.length; this.lanes.push([item]); @@ -64,22 +62,22 @@ export class LanePacker { } /** - * Evict events that end before threshold (for infinite scroll). + * Evict items that end before threshold (for infinite scroll). * Call when window scrolls forward to free memory. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars evictBefore(epochThreshold: number) { - // Filter events from lanes and rebuild heap + // Filter items from lanes and rebuild heap // (Implementation deferred until infinite scroll is added) } /** - * Evict events that start after threshold (for infinite scroll). + * Evict items that start after threshold (for infinite scroll). * Call when window scrolls forward to free memory. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars evictAfter(epochThreshold: number) { - // Filter events from lanes and rebuild heap + // Filter items from lanes and rebuild heap // (Implementation deferred until infinite scroll is added) } @@ -99,6 +97,6 @@ export class LanePacker { } } -export function lanePacker(events?: CachedEvent[]) { - return new LanePacker(events); +export function lanePacker(items?: CachedDisplayItem[]) { + return new LanePacker(items); } diff --git a/apps/web/src/components/calendar/utils/multi-day-layout.ts b/apps/web/src/components/calendar/utils/multi-day-layout.ts index 1c2c9672..ec4cd2e8 100644 --- a/apps/web/src/components/calendar/utils/multi-day-layout.ts +++ b/apps/web/src/components/calendar/utils/multi-day-layout.ts @@ -1,17 +1,17 @@ /** - * MULTI-DAY EVENT LAYOUT UTILITIES + * MULTI-DAY DISPLAY ITEM LAYOUT UTILITIES * * ## Interval Convention - * Events use inclusive intervals [startDay, endDay]: - * - 1-day event on Monday: start=Mon, end=Mon + * Items use inclusive intervals [startDay, endDay]: + * - 1-day item on Monday: start=Mon, end=Mon * - 3-day Mon-Wed: start=Mon, end=Wed * - * Note: EventCollectionItem.end is made inclusive upstream by subtracting + * Note: DisplayItem.end is made inclusive upstream by subtracting * 1 second from the exclusive end. This module then works entirely with * inclusive day ranges. * * ## Algorithm: Greedy Interval Partitioning - * Events are assigned to horizontal "lanes" to avoid overlap. + * Items are assigned to horizontal "lanes" to avoid overlap. * A min-heap tracks the soonest-ending lane, achieving O(log k) * per insertion where k = number of concurrent lanes. * @@ -19,23 +19,26 @@ * The LanePacker class supports incremental insertions. * When infinite scroll is implemented: * 1. Maintain a single LanePacker instance per scroll container - * 2. Call insertEvents() with newly-visible events on scroll - * 3. Call evictBefore() to free memory as events scroll out + * 2. Call insertItems() with newly-visible items on scroll + * 3. Call evictBefore() to free memory as items scroll out */ import { Temporal } from "temporal-polyfill"; import { isAfter, isBefore, startOfDay } from "@repo/temporal"; -import { EventCollectionItem } from "@/components/calendar/hooks/event-collection"; -import { CachedEvent, lanePacker } from "./lane-packer"; +import { InlineDisplayItem } from "@/lib/display-item"; +import { CachedDisplayItem, lanePacker } from "./lane-packer"; // ============================================================================ // CACHING & SORTING HELPERS // ============================================================================ -/** Convert events to cached format with precomputed epoch values */ -function cacheEventMetadata(events: EventCollectionItem[], timeZone: string) { - return events.map((item) => { +/** Convert items to cached format with precomputed epoch values */ +function cacheItemMetadata( + items: InlineDisplayItem[], + timeZone: string, +): CachedDisplayItem[] { + return items.map((item) => { const start = item.start.toPlainDate(); const end = item.end.toPlainDate(); @@ -44,7 +47,7 @@ function cacheEventMetadata(events: EventCollectionItem[], timeZone: string) { const endEpoch = startOfDay(end, { timeZone }).toInstant() .epochMilliseconds; - // +1 so a 1-day event has durationDays === 1 + // +1 so a 1-day item has durationDays === 1 const durationDays = start.until(end).total({ unit: "days" }) + 1; return { item, startEpoch, endEpoch, durationDays }; @@ -52,7 +55,7 @@ function cacheEventMetadata(events: EventCollectionItem[], timeZone: string) { } /** Sort: earliest start first, longer duration first on ties, then by id for stability */ -function sortByStartThenDuration(cached: CachedEvent[]) { +function sortByStartThenDuration(cached: CachedDisplayItem[]) { return cached.slice().sort((a, b) => { if (a.startEpoch !== b.startEpoch) { return a.startEpoch - b.startEpoch; @@ -62,7 +65,7 @@ function sortByStartThenDuration(cached: CachedEvent[]) { return b.durationDays - a.durationDays; } - return a.item.event.id.localeCompare(b.item.event.id); + return a.item.id.localeCompare(b.item.id); }); } @@ -71,17 +74,17 @@ function sortByStartThenDuration(cached: CachedEvent[]) { // ============================================================================ /** - * Place multi-day events into lanes to avoid overlaps. - * Returns an array of lanes, where each lane contains non-overlapping events. + * Place multi-day items into lanes to avoid overlaps. + * Returns an array of lanes, where each lane contains non-overlapping items. * * @complexity O(n log n) for sorting + O(n log k) for placement = O(n log n) */ -function placeIntoLanes(events: EventCollectionItem[], timeZone: string) { - if (events.length === 0) { +function placeIntoLanes(items: InlineDisplayItem[], timeZone: string) { + if (items.length === 0) { return []; } - const cached = cacheEventMetadata(events, timeZone); + const cached = cacheItemMetadata(items, timeZone); const sorted = sortByStartThenDuration(cached); return lanePacker(sorted).getLanes(); @@ -96,51 +99,50 @@ interface GridPosition { span: number; } -export interface EventCapacityInfo { +export interface DisplayItemCapacityInfo { totalLanes: number; - visibleLanes: EventCollectionItem[][]; - overflowLanes: EventCollectionItem[][]; + visibleLanes: InlineDisplayItem[][]; + overflowLanes: InlineDisplayItem[][]; } /** - * Calculate the maximum number of event lanes that can fit in the available space + * Calculate the maximum number of item lanes that can fit in the available space */ -function calculateEventCapacity( +function calculateItemCapacity( availableHeight: number, - eventHeight: number = 24, - eventGap: number = 4, + itemHeight: number = 24, + itemGap: number = 4, minVisibleLanes: number = 2, ) { if (availableHeight <= 0) { return minVisibleLanes; } - const eventSpacePerLane = eventHeight + eventGap; - const calculatedLanes = Math.floor(availableHeight / eventSpacePerLane); + const itemSpacePerLane = itemHeight + itemGap; + const calculatedLanes = Math.floor(availableHeight / itemSpacePerLane); - // Always show at least minVisibleLanes, even if it overflows return Math.max(calculatedLanes, minVisibleLanes); } -interface OrganizeEventsWithOverflowOptions { - events: EventCollectionItem[]; +interface OrganizeItemsWithOverflowOptions { + items: InlineDisplayItem[]; availableHeight: number; timeZone: string; - eventHeight?: number; - eventGap?: number; + itemHeight?: number; + itemGap?: number; } /** - * Organize events into visible and overflow lanes based on available space + * Organize items into visible and overflow lanes based on available space */ -export function organizeEventsWithOverflow({ - events, +export function organizeItemsWithOverflow({ + items, availableHeight, timeZone, - eventHeight = 24, - eventGap = 4, -}: OrganizeEventsWithOverflowOptions): EventCapacityInfo { - if (events.length === 0) { + itemHeight = 24, + itemGap = 4, +}: OrganizeItemsWithOverflowOptions): DisplayItemCapacityInfo { + if (items.length === 0) { return { totalLanes: 0, visibleLanes: [], @@ -148,22 +150,17 @@ export function organizeEventsWithOverflow({ }; } - // Calculate all lanes - const allLanes = placeIntoLanes(events, timeZone); + const allLanes = placeIntoLanes(items, timeZone); const totalLanes = allLanes.length; - // Step 1: How many event lanes *could* fit given the available space? - const maxVisibleLanes = calculateEventCapacity( + const maxVisibleLanes = calculateItemCapacity( availableHeight, - eventHeight, - eventGap, + itemHeight, + itemGap, ); - // Step 2: Slice lanes based on the initial capacity. const overflowLanes = allLanes.slice(maxVisibleLanes); - // Step 3: If there is any overflow we need to reserve one lane for the - // "+X more" button. We do this by reducing the visible lane count by one. if (overflowLanes.length === 0) { return { totalLanes, visibleLanes: allLanes, overflowLanes: [] }; } @@ -176,24 +173,20 @@ export function organizeEventsWithOverflow({ } /** - * Calculate the grid position for a multi-day event within a week row + * Calculate the grid position for a multi-day item within a week row */ export function getGridPosition( - item: EventCollectionItem, + item: InlineDisplayItem, weekStart: Temporal.PlainDate, weekEnd: Temporal.PlainDate, ): GridPosition { const startDate = item.start.toPlainDate(); const endDate = item.end.toPlainDate(); - // Clamp the event to the week's visible range const clampedStart = isBefore(startDate, weekStart) ? weekStart : startDate; const clampedEnd = isAfter(endDate, weekEnd) ? weekEnd : endDate; - // Calculate column start (0-based index) const colStart = weekStart.until(clampedStart).total({ unit: "days" }); - - // Calculate span (number of days the event covers in this week) const span = clampedStart.until(clampedEnd).total({ unit: "days" }) + 1; return { colStart, span }; diff --git a/apps/web/src/components/calendar/utils/positioning.ts b/apps/web/src/components/calendar/utils/positioning.ts index 0f0b573b..a92f788d 100644 --- a/apps/web/src/components/calendar/utils/positioning.ts +++ b/apps/web/src/components/calendar/utils/positioning.ts @@ -8,12 +8,17 @@ import { startOfDay, } from "@repo/temporal"; -import { EventCollectionItem } from "../hooks/event-collection"; +import { + DisplayItem, + InlineDisplayItem, + isAllDay, + isInlineItem, +} from "@/lib/display-item"; const PROXIMITY_THRESHOLD = 40; -export function eventOverlapsDay( - item: EventCollectionItem, +export function displayItemOverlapsDay( + item: DisplayItem, day: Temporal.PlainDate, ) { const start = item.start.toPlainDate(); @@ -26,75 +31,74 @@ export function eventOverlapsDay( ); } -export function isAllDayOrMultiDay(item: EventCollectionItem) { - return item.event.allDay || isMultiDayEvent(item); +export function isAllDayOrMultiDay(item: InlineDisplayItem) { + return isAllDay(item) || isMultiDayItem(item); } -function isMultiDayEvent(item: EventCollectionItem) { - return item.event.allDay || !isSameDay(item.start, item.end); +function isMultiDayItem(item: InlineDisplayItem) { + return isAllDay(item) || !isSameDay(item.start, item.end); } /** - * Get event collections for multiple days (pass single day as [day] for single-day use) + * Get display item collections for a day */ -export function getEventCollectionsForDay( - events: EventCollectionItem[], +export function getDisplayItemCollectionsForDay( + items: InlineDisplayItem[], day: Temporal.PlainDate, ) { - const dayEvents: EventCollectionItem[] = []; - const spanningEvents: EventCollectionItem[] = []; - const allEvents: EventCollectionItem[] = []; + const dayItems: InlineDisplayItem[] = []; + const spanningItems: InlineDisplayItem[] = []; + const allItems: InlineDisplayItem[] = []; - for (const event of events) { - if (!eventOverlapsDay(event, day)) { + for (const item of items) { + if (!displayItemOverlapsDay(item, day)) { continue; } - allEvents.push(event); + allItems.push(item); - const start = event.start.toPlainDate(); + const start = item.start.toPlainDate(); if (isSameDay(day, start)) { - dayEvents.push(event); - } else if (isMultiDayEvent(event)) { - spanningEvents.push(event); + dayItems.push(item); + } else if (isMultiDayItem(item)) { + spanningItems.push(item); } } return { - dayEvents, - spanningEvents, - allDayEvents: [...spanningEvents, ...dayEvents], - allEvents, + dayItems, + spanningItems, + allDayItems: [...spanningItems, ...dayItems], + allItems, }; } function isOverlappingWithRange( - event: EventCollectionItem, + item: InlineDisplayItem, days: Temporal.PlainDate[], ) { - return days.some((day) => eventOverlapsDay(event, day)); + return days.some((day) => displayItemOverlapsDay(item, day)); } + /** - * Get aggregated all-day events for multiple days + * Get aggregated all-day items for multiple days */ -export function getAllDayEventCollectionsForDays( - events: EventCollectionItem[], +export function getAllDayItemCollectionsForDays( + items: InlineDisplayItem[], days: Temporal.PlainDate[], ) { if (days.length === 0) { return []; } - const allDayEvents = events.filter( - (event) => isAllDayOrMultiDay(event) && isOverlappingWithRange(event, days), + return items.filter( + (item) => isAllDayOrMultiDay(item) && isOverlappingWithRange(item, days), ); - - return allDayEvents; } -export interface PositionedEvent { - item: EventCollectionItem; +export interface PositionedDisplayItem { + item: InlineDisplayItem; top: number; height: number; left: number; @@ -102,20 +106,20 @@ export interface PositionedEvent { zIndex: number; } -function getTimedEventsForDay( - events: EventCollectionItem[], +function getTimedItemsForDay( + items: InlineDisplayItem[], day: Temporal.PlainDate, -): EventCollectionItem[] { - return events.filter((event) => { - if (isAllDayOrMultiDay(event)) { +): InlineDisplayItem[] { + return items.filter((item) => { + if (isAllDayOrMultiDay(item)) { return false; } - return eventOverlapsDay(event, day); + return displayItemOverlapsDay(item, day); }); } -function clampToStartOfDay(item: EventCollectionItem, day: Temporal.PlainDate) { +function clampToStartOfDay(item: InlineDisplayItem, day: Temporal.PlainDate) { if (isSameDay(day, item.start, { timeZone: item.start.timeZoneId })) { return item.start; } @@ -123,7 +127,7 @@ function clampToStartOfDay(item: EventCollectionItem, day: Temporal.PlainDate) { return startOfDay(day, { timeZone: item.start.timeZoneId }); } -function clampToEndOfDay(item: EventCollectionItem, day: Temporal.PlainDate) { +function clampToEndOfDay(item: InlineDisplayItem, day: Temporal.PlainDate) { if (isSameDay(day, item.end, { timeZone: item.end.timeZoneId })) { return item.end; } @@ -131,7 +135,7 @@ function clampToEndOfDay(item: EventCollectionItem, day: Temporal.PlainDate) { return endOfDay(day, { timeZone: item.end.timeZoneId }); } -function calculateEventDimensions( +function calculateItemDimensions( start: Temporal.ZonedDateTime, end: Temporal.ZonedDateTime, startHour: number, @@ -147,13 +151,13 @@ function calculateEventDimensions( } interface ProximityGroup { - events: EventCollectionItem[]; + items: InlineDisplayItem[]; startMinutes: number; endMinutes: number; } -interface GroupEventsByProximityOptions { - sortedEvents: EventCollectionItem[]; +interface GroupItemsByProximityOptions { + sortedItems: InlineDisplayItem[]; day: Temporal.PlainDate; cellHeight: number; } @@ -173,7 +177,7 @@ function isWithinProximity({ }: IsWithinProximityOptions) { const thresholdMinutes = (PROXIMITY_THRESHOLD / cellHeight) * 60; - const start = clampToStartOfDay(lastGroup.events.at(-1)!, day); + const start = clampToStartOfDay(lastGroup.items.at(-1)!, day); const startsWithinProximity = startMinutes - (start.hour * 60 + start.minute) <= thresholdMinutes; @@ -182,20 +186,20 @@ function isWithinProximity({ return startsWithinProximity && startsBeforeGroupEnds; } -function groupEventsByProximity({ - sortedEvents, +function groupItemsByProximity({ + sortedItems, day, cellHeight, -}: GroupEventsByProximityOptions) { - if (sortedEvents.length === 0) { +}: GroupItemsByProximityOptions) { + if (sortedItems.length === 0) { return []; } const groups: ProximityGroup[] = []; - for (const event of sortedEvents) { - const start = clampToStartOfDay(event, day); - const end = clampToEndOfDay(event, day); + for (const item of sortedItems) { + const start = clampToStartOfDay(item, day); + const end = clampToEndOfDay(item, day); const startMinutes = start.hour * 60 + start.minute; const endMinutes = end.hour * 60 + end.minute; @@ -210,14 +214,14 @@ function groupEventsByProximity({ day, }) ) { - lastGroup.events.push(event); + lastGroup.items.push(item); lastGroup.endMinutes = Math.max(lastGroup.endMinutes, endMinutes); continue; } groups.push({ - events: [event], + items: [item], startMinutes, endMinutes, }); @@ -259,7 +263,7 @@ function calculatePosition({ columns, cellHeight, }: CalculatePositionOptions) { - const { top, height } = calculateEventDimensions(start, end, 0, cellHeight); + const { top, height } = calculateItemDimensions(start, end, 0, cellHeight); const offsetPercentage = Math.min(overlapDepth * 0.1, 0.5); const availableWidth = 1 - offsetPercentage; @@ -272,25 +276,25 @@ function calculatePosition({ return { top, height, left, width, zIndex }; } -function positionEventsForDay( - events: EventCollectionItem[], +function positionItemsForDay( + items: InlineDisplayItem[], day: Temporal.PlainDate, cellHeight: number, ) { - const timedEvents = getTimedEventsForDay(events, day); - const sortedEvents = sortEventsForCollisionDetection(timedEvents); + const timedItems = getTimedItemsForDay(items, day); + const sortedItems = sortItemsForCollisionDetection(timedItems); - if (sortedEvents.length === 0) { + if (sortedItems.length === 0) { return []; } - const groups = groupEventsByProximity({ - sortedEvents, + const groups = groupItemsByProximity({ + sortedItems, day, cellHeight, }); - const positioned: PositionedEvent[] = []; + const positioned: PositionedDisplayItem[] = []; const activeGroupEnds: number[] = []; for (const group of groups) { @@ -298,7 +302,7 @@ function positionEventsForDay( activeGroupEnds.shift(); } - for (const [index, item] of group.events.entries()) { + for (const [index, item] of group.items.entries()) { const start = clampToStartOfDay(item, day); const end = clampToEndOfDay(item, day); @@ -307,7 +311,7 @@ function positionEventsForDay( end, index, overlapDepth: activeGroupEnds.length, - columns: group.events.length, + columns: group.items.length, cellHeight, }); @@ -322,22 +326,22 @@ function positionEventsForDay( return positioned; } -interface CalculateWeekViewEventPositionsOptions { - events: EventCollectionItem[]; +interface CalculateWeekViewPositionsOptions { + items: InlineDisplayItem[]; days: Temporal.PlainDate[]; cellHeight: number; } -export function calculateWeekViewEventPositions({ - events, +export function calculateWeekViewDisplayItemPositions({ + items, days, cellHeight, -}: CalculateWeekViewEventPositionsOptions) { - return days.map((day) => positionEventsForDay(events, day, cellHeight)); +}: CalculateWeekViewPositionsOptions) { + return days.map((day) => positionItemsForDay(items, day, cellHeight)); } -function sortEventsForCollisionDetection(events: EventCollectionItem[]) { - return [...events].sort((a, b) => { +function sortItemsForCollisionDetection(items: InlineDisplayItem[]) { + return [...items].sort((a, b) => { if (isBefore(a.start, b.start)) { return -1; } @@ -352,3 +356,10 @@ function sortEventsForCollisionDetection(events: EventCollectionItem[]) { return bDuration - aDuration; }); } + +/** + * Filter DisplayItems to only inline items + */ +export function filterInlineItems(items: DisplayItem[]): InlineDisplayItem[] { + return items.filter(isInlineItem); +} diff --git a/apps/web/src/components/calendar/week-view/week-view-all-day-event.tsx b/apps/web/src/components/calendar/week-view/week-view-all-day-event.tsx index 8ec854bb..257aa14f 100644 --- a/apps/web/src/components/calendar/week-view/week-view-all-day-event.tsx +++ b/apps/web/src/components/calendar/week-view/week-view-all-day-event.tsx @@ -3,14 +3,15 @@ import * as React from "react"; import { Temporal } from "temporal-polyfill"; -import { DragAwareWrapper } from "@/components/calendar/event/drag-aware-wrapper"; +import { DisplayItemComponent } from "@/components/calendar/display-item/display-item"; +import { DisplayItemContainer } from "@/components/calendar/event/display-item-container"; import { DraggableEvent } from "@/components/calendar/event/draggable-event"; import { getGridPosition } from "@/components/calendar/utils/multi-day-layout"; -import { EventCollectionItem } from "../hooks/event-collection"; +import { InlineDisplayItem, isEvent } from "@/lib/display-item"; interface WeekViewAllDayEventProps { y: number; - item: EventCollectionItem; + item: InlineDisplayItem; weekStart: Temporal.PlainDate; weekEnd: Temporal.PlainDate; containerRef: React.RefObject; @@ -28,23 +29,41 @@ export function WeekViewAllDayEvent({ const { colStart, span } = getGridPosition(item, weekStart, weekEnd); const { isFirstDay, isLastDay } = React.useMemo(() => { - // For single-day events, ensure they are properly marked as first and last day const isFirstDay = Temporal.PlainDate.compare(item.start, weekStart) >= 0; const isLastDay = Temporal.PlainDate.compare(item.end, weekEnd) <= 0; return { isFirstDay, isLastDay }; }, [item.start, item.end, weekStart, weekEnd]); + const style = { + gridColumn: `${colStart + 2} / span ${span}`, + gridRow: y + 1, + }; + + if (!isEvent(item)) { + return ( + + + + ); + } + return ( - - + ); } diff --git a/apps/web/src/components/calendar/week-view/week-view-all-day-section.tsx b/apps/web/src/components/calendar/week-view/week-view-all-day-section.tsx index 9185b660..b2c40ef8 100644 --- a/apps/web/src/components/calendar/week-view/week-view-all-day-section.tsx +++ b/apps/web/src/components/calendar/week-view/week-view-all-day-section.tsx @@ -7,28 +7,28 @@ import { isToday, isWeekend } from "@repo/temporal"; import { calendarSettingsAtom } from "@/atoms/calendar-settings"; import { viewPreferencesAtom } from "@/atoms/view-preferences"; import { useDoubleClickToCreate } from "@/components/calendar/hooks/drag-and-drop/use-double-click-to-create"; -import { WeekEventCollection } from "@/components/calendar/hooks/use-event-collection"; +import { WeekDisplayCollection } from "@/components/calendar/hooks/use-event-collection"; import { useMultiDayOverflow, type UseMultiDayOverflowResult, } from "@/components/calendar/hooks/use-multi-day-overflow"; import { useUnselectAllAction } from "@/components/calendar/hooks/use-optimistic-mutations"; import { OverflowIndicator } from "@/components/calendar/overflow/overflow-indicator"; -import { eventsStartingOn } from "@/components/calendar/utils/event"; +import { itemsStartingOn } from "@/components/calendar/utils/event"; import { WeekViewAllDayEvent } from "@/components/calendar/week-view/week-view-all-day-event"; import { cn } from "@/lib/utils"; interface WeekViewAllDaySectionProps { allDays: Temporal.PlainDate[]; visibleDays: Temporal.PlainDate[]; - eventCollection: WeekEventCollection; + displayCollection: WeekDisplayCollection; containerRef: React.RefObject; } export function WeekViewAllDaySection({ allDays, visibleDays, - eventCollection, + displayCollection, containerRef, }: WeekViewAllDaySectionProps) { const settings = useAtomValue(calendarSettingsAtom); @@ -36,7 +36,7 @@ export function WeekViewAllDaySection({ const overflowRef = React.useRef(null); const overflow = useMultiDayOverflow({ - events: eventCollection.allDayEvents, + items: displayCollection.allDayItems, timeZone: settings.defaultTimeZone, containerRef: overflowRef, minVisibleLanes: 10, @@ -66,11 +66,11 @@ export function WeekViewAllDaySection({
{overflow.capacityInfo.visibleLanes.map((lane, y) => - lane.map((evt) => ( + lane.map((item) => ( { const isDayVisible = viewPreferences.showWeekends || !isWeekend; const visibleDayIndex = visibleDays.findIndex( @@ -111,15 +111,14 @@ function WeekViewAllDayColumn({ const isLastVisibleDay = isDayVisible && visibleDayIndex === visibleDays.length - 1; - // Filter overflow events to only show those that start on this day - const dayOverflowEvents = eventsStartingOn(overflow.overflowEvents, day); + const dayOverflowItems = itemsStartingOn(overflow.overflowItems, day); - return { isDayVisible, isLastVisibleDay, dayOverflowEvents }; + return { isDayVisible, isLastVisibleDay, dayOverflowItems }; }, [ day, visibleDays, isWeekend, - overflow.overflowEvents, + overflow.overflowItems, viewPreferences.showWeekends, ]); @@ -155,11 +154,11 @@ function WeekViewAllDayColumn({ ref={overflowRef} /> - {/* Show overflow indicator for this day if there are overflow events that start on this day */} - {dayOverflowEvents.length > 0 ? ( + {/* Show overflow indicator for this day if there are overflow items that start on this day */} + {dayOverflowItems.length > 0 ? (
diff --git a/apps/web/src/components/calendar/week-view/week-view-column.tsx b/apps/web/src/components/calendar/week-view/week-view-column.tsx index 7bc1308a..ab050933 100644 --- a/apps/web/src/components/calendar/week-view/week-view-column.tsx +++ b/apps/web/src/components/calendar/week-view/week-view-column.tsx @@ -10,24 +10,24 @@ import { viewPreferencesAtom } from "@/atoms/view-preferences"; import { DragPreview } from "@/components/calendar/event/drag-preview"; import { useDoubleClickToCreate } from "@/components/calendar/hooks/drag-and-drop/use-double-click-to-create"; import { useDragToCreate } from "@/components/calendar/hooks/drag-and-drop/use-drag-to-create"; -import { WeekEventCollection } from "@/components/calendar/hooks/use-event-collection"; +import { WeekDisplayCollection } from "@/components/calendar/hooks/use-event-collection"; import { HOURS } from "@/components/calendar/timeline/constants"; import { TimeIndicator } from "@/components/calendar/timeline/time-indicator"; -import type { PositionedEvent } from "@/components/calendar/utils/positioning"; +import type { PositionedDisplayItem } from "@/components/calendar/utils/positioning"; import { cn } from "@/lib/utils"; import { WeekViewEvent } from "./week-view-event"; interface WeekViewDayColumnsProps { date: Temporal.PlainDate; visibleDays: Temporal.PlainDate[]; - eventCollection: WeekEventCollection; + displayCollection: WeekDisplayCollection; containerRef: React.RefObject; } export function WeekViewDayColumn({ date, visibleDays, - eventCollection, + displayCollection, containerRef, }: WeekViewDayColumnsProps) { const viewPreferences = useAtomValue(viewPreferencesAtom); @@ -46,8 +46,8 @@ export function WeekViewDayColumn({ return { isDayVisible, isLastVisibleDay, visibleDayIndex, weekend }; }, [viewPreferences.showWeekends, visibleDays, date]); - const positionedEvents = - eventCollection.positionedEvents[visibleDayIndex] ?? []; + const positionedItems = + displayCollection.positionedItems[visibleDayIndex] ?? []; return (
- {positionedEvents.map((positionedEvent: PositionedEvent) => ( + {positionedItems.map((positionedItem: PositionedDisplayItem) => ( diff --git a/apps/web/src/components/calendar/week-view/week-view-event.tsx b/apps/web/src/components/calendar/week-view/week-view-event.tsx index afb9cb6a..369c8ab7 100644 --- a/apps/web/src/components/calendar/week-view/week-view-event.tsx +++ b/apps/web/src/components/calendar/week-view/week-view-event.tsx @@ -1,44 +1,59 @@ import * as React from "react"; -import { DragAwareWrapper } from "@/components/calendar/event/drag-aware-wrapper"; +import { DisplayItemComponent } from "@/components/calendar/display-item/display-item"; +import { DisplayItemContainer } from "@/components/calendar/event/display-item-container"; import { DraggableEvent } from "@/components/calendar/event/draggable-event"; -import type { PositionedEvent } from "@/components/calendar/utils/positioning"; +import type { PositionedDisplayItem } from "@/components/calendar/utils/positioning"; +import { isEvent } from "@/lib/display-item"; interface WeekViewEventProps { - positionedEvent: PositionedEvent; + positionedItem: PositionedDisplayItem; containerRef: React.RefObject; columns: number; } export function WeekViewEvent({ - positionedEvent, + positionedItem, containerRef, columns, }: WeekViewEventProps) { const style = React.useMemo(() => { return { - top: `${positionedEvent.top}px`, - height: `${positionedEvent.height}px`, - left: `${positionedEvent.left * 100}%`, - width: `${positionedEvent.width * 100}%`, + top: `${positionedItem.top}px`, + height: `${positionedItem.height}px`, + left: `${positionedItem.left * 100}%`, + width: `${positionedItem.width * 100}%`, }; - }, [positionedEvent]); + }, [positionedItem]); + + if (!isEvent(positionedItem.item)) { + return ( + + + + ); + } return ( - - + ); } diff --git a/apps/web/src/components/calendar/week-view/week-view.tsx b/apps/web/src/components/calendar/week-view/week-view.tsx index 040c85f9..0fd2483a 100644 --- a/apps/web/src/components/calendar/week-view/week-view.tsx +++ b/apps/web/src/components/calendar/week-view/week-view.tsx @@ -10,12 +10,12 @@ import { calendarSettingsAtom } from "@/atoms/calendar-settings"; import { timeZonesAtom } from "@/atoms/timezones"; import { currentDateAtom, viewPreferencesAtom } from "@/atoms/view-preferences"; import { useEdgeAutoScroll } from "@/components/calendar/hooks/drag-and-drop/use-auto-scroll"; -import type { EventCollectionItem } from "@/components/calendar/hooks/event-collection"; -import { useWeekEventCollection } from "@/components/calendar/hooks/use-event-collection"; +import { useWeekDisplayCollection } from "@/components/calendar/hooks/use-event-collection"; import { useGridLayout } from "@/components/calendar/hooks/use-grid-layout"; import { TimeIndicatorBackground } from "@/components/calendar/timeline/time-indicator"; import { Timeline } from "@/components/calendar/timeline/timeline"; import { getWeek } from "@/components/calendar/utils/date-time"; +import type { DisplayItem } from "@/lib/display-item"; import { useUnselectAllAction } from "../hooks/use-optimistic-mutations"; import { useScrollToCurrentTime } from "./use-scroll-to-current-time"; import { WeekViewAllDaySection } from "./week-view-all-day-section"; @@ -23,12 +23,12 @@ import { WeekViewDayColumn } from "./week-view-column"; import { WeekViewHeader } from "./week-view-header"; interface WeekViewProps extends React.ComponentProps<"div"> { - events: EventCollectionItem[]; + items: DisplayItem[]; scrollContainerRef: React.RefObject; } export function WeekView({ - events, + items, scrollContainerRef, ...props }: WeekViewProps) { @@ -52,7 +52,7 @@ export function WeekView({ }; }, [currentDate, settings.weekStartsOn, viewPreferences.showWeekends]); - const eventCollection = useWeekEventCollection(events, visibleDays); + const displayCollection = useWeekDisplayCollection(items, visibleDays); const containerRef = React.useRef(null); const headerRef = React.useRef(null); @@ -71,7 +71,7 @@ export function WeekView({
@@ -87,7 +87,7 @@ export function WeekView({ key={date.toString()} date={date} visibleDays={visibleDays} - eventCollection={eventCollection} + displayCollection={displayCollection} containerRef={containerRef} /> ))} diff --git a/apps/web/src/components/command-bar/command-window.tsx b/apps/web/src/components/command-bar/command-window.tsx index 7d7dfc80..ee1aece5 100644 --- a/apps/web/src/components/command-bar/command-window.tsx +++ b/apps/web/src/components/command-bar/command-window.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { useAtomValue } from "jotai"; import { AnimatePresence, motion, type Variants } from "motion/react"; -import { selectedEventIdsAtom } from "@/atoms/selected-events"; +import { selectedEventIdsAtom } from "@/atoms/selected-display-items"; import { cn } from "@/lib/utils"; import { StackedWindow } from "./stacked-window"; import { useWindowStack } from "./use-window-stack"; diff --git a/apps/web/src/components/command-bar/use-window-stack.ts b/apps/web/src/components/command-bar/use-window-stack.ts index c7fc7851..bd0083ab 100644 --- a/apps/web/src/components/command-bar/use-window-stack.ts +++ b/apps/web/src/components/command-bar/use-window-stack.ts @@ -1,7 +1,7 @@ import * as React from "react"; import { useAtomValue } from "jotai"; -import { selectedEventIdsAtom } from "@/atoms/selected-events"; +import { selectedEventIdsAtom } from "@/atoms/selected-display-items"; import { windowStackAtom } from "@/atoms/window-stack"; import { StackWindowEntry } from "./stacked-window"; diff --git a/apps/web/src/components/command-bar/windows/bulk-action-window.tsx b/apps/web/src/components/command-bar/windows/bulk-action-window.tsx index 28f1ee18..4426a6d7 100644 --- a/apps/web/src/components/command-bar/windows/bulk-action-window.tsx +++ b/apps/web/src/components/command-bar/windows/bulk-action-window.tsx @@ -1,7 +1,7 @@ import { ChevronUpIcon, TrashIcon } from "@heroicons/react/16/solid"; import { useAtomValue } from "jotai"; -import { selectedEventIdsAtom } from "@/atoms/selected-events"; +import { selectedEventIdsAtom } from "@/atoms/selected-display-items"; import { Key } from "@/components/ui/keyboard-shortcut"; import { ActionButton, ActionShortcut } from "../actions"; import { Window } from "../window"; diff --git a/apps/web/src/components/event-form/utils/use-event-form.ts b/apps/web/src/components/event-form/utils/use-event-form.ts index 51be73ce..87bdb362 100644 --- a/apps/web/src/components/event-form/utils/use-event-form.ts +++ b/apps/web/src/components/event-form/utils/use-event-form.ts @@ -4,7 +4,7 @@ import * as React from "react"; import { useAtom, useAtomValue } from "jotai"; import { calendarSettingsAtom } from "@/atoms/calendar-settings"; -import { selectedEventIdsAtom } from "@/atoms/selected-events"; +import { selectedEventIdsAtom } from "@/atoms/selected-display-items"; import { EventFormStateContext } from "@/components/calendar/flows/event-form/event-form-state-provider"; import { useFormAction, diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index e1b52add..af47d435 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as React from "react"; import { useRender } from "@base-ui-components/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; diff --git a/apps/web/src/lib/display-item.ts b/apps/web/src/lib/display-item.ts new file mode 100644 index 00000000..32f59781 --- /dev/null +++ b/apps/web/src/lib/display-item.ts @@ -0,0 +1,182 @@ +import { Temporal } from "temporal-polyfill"; + +import { isAfter, isSameDay, toZonedDateTime } from "@repo/temporal"; + +import type { CalendarEvent } from "@/lib/interfaces"; + +function getInclusiveEnd(event: CalendarEvent, timeZone: string) { + const start = toZonedDateTime(event.start, { timeZone }); + const endExclusive = toZonedDateTime(event.end, { timeZone }); + + if (!event.allDay) { + if (isAfter(endExclusive, start)) { + return { start, end: endExclusive.subtract({ seconds: 1 }) }; + } + + return { start, end: start }; + } + + const end = isSameDay(start, endExclusive) + ? start.add({ days: 1 }) + : endExclusive; + + return { start, end: end.subtract({ seconds: 1 }) }; +} + +export type DisplayItem = + | EventDisplayItem + | TaskDisplayItem + | LocationDisplayItem + | JourneyDisplayItem; + +export type InlineDisplayItem = EventDisplayItem | TaskDisplayItem; +export type BackgroundDisplayItem = LocationDisplayItem; +export type SideDisplayItem = JourneyDisplayItem; + +export interface EventDisplayItem { + id: string; + type: "event"; + display: "inline"; + event: CalendarEvent; + start: Temporal.ZonedDateTime; + end: Temporal.ZonedDateTime; +} + +export interface TaskDisplayItem { + id: string; + type: "task"; + display: "inline"; + value: { + title: string; + allDay: boolean; + }; + start: Temporal.ZonedDateTime; + end: Temporal.ZonedDateTime; +} + +export interface LocationDisplayItem { + id: string; + type: "location"; + display: "background"; + value: object; + start: Temporal.ZonedDateTime; + end: Temporal.ZonedDateTime; +} + +export interface JourneyDisplayItem { + id: string; + type: "journey"; + display: "side"; + value: object; + start: Temporal.ZonedDateTime; + end: Temporal.ZonedDateTime; +} + +export function isEvent(item: DisplayItem): item is EventDisplayItem { + return item.type === "event"; +} + +export function isTask(item: DisplayItem): item is TaskDisplayItem { + return item.type === "task"; +} + +export function isLocation(item: DisplayItem): item is LocationDisplayItem { + return item.type === "location"; +} + +export function isJourney(item: DisplayItem): item is JourneyDisplayItem { + return item.type === "journey"; +} + +export function isInlineItem( + item: DisplayItem, +): item is EventDisplayItem | TaskDisplayItem { + return item.display === "inline"; +} + +export function isBackgroundItem( + item: DisplayItem, +): item is LocationDisplayItem { + return item.display === "background"; +} + +export function isSideItem(item: DisplayItem): item is JourneyDisplayItem { + return item.display === "side"; +} + +export function isAllDay(item: DisplayItem): boolean { + if (isEvent(item)) { + return item.event.allDay ?? false; + } + + if (isTask(item)) { + return item.value.allDay; + } + + return false; +} + +export function createEventDisplayItem( + event: CalendarEvent, + timeZone: string, +): EventDisplayItem { + const { start, end } = getInclusiveEnd(event, timeZone); + + return { + id: `event_${event.id}`, + type: "event", + display: "inline", + event, + start, + end, + }; +} + +export function createTaskDisplayItem( + taskId: string, + title: string, + allDay: boolean, + start: Temporal.ZonedDateTime, + end: Temporal.ZonedDateTime, +): TaskDisplayItem { + return { + id: `task_${taskId}`, + type: "task", + display: "inline", + value: { title, allDay }, + start, + end, + }; +} + +export function createLocationDisplayItem( + locationId: string, + value: object, + start: Temporal.ZonedDateTime, + end: Temporal.ZonedDateTime, +): LocationDisplayItem { + return { + id: `location_${locationId}`, + type: "location", + display: "background", + value, + start, + end, + }; +} + +export function createJourneyDisplayItem( + journeyId: string, + value: object, + start: Temporal.ZonedDateTime, + end: Temporal.ZonedDateTime, +): JourneyDisplayItem { + return { + id: `journey_${journeyId}`, + type: "journey", + display: "side", + value, + start, + end, + }; +} diff --git a/apps/web/src/lib/hotkeys/event-hotkeys.tsx b/apps/web/src/lib/hotkeys/event-hotkeys.tsx index c1418c5b..a0f75ea7 100644 --- a/apps/web/src/lib/hotkeys/event-hotkeys.tsx +++ b/apps/web/src/lib/hotkeys/event-hotkeys.tsx @@ -6,7 +6,7 @@ import { useHotkeys } from "react-hotkeys-hook"; import { Temporal } from "temporal-polyfill"; import { calendarSettingsAtom } from "@/atoms/calendar-settings"; -import { selectedEventIdsAtom } from "@/atoms/selected-events"; +import { selectedEventIdsAtom } from "@/atoms/selected-display-items"; import { useDeleteAction } from "@/components/calendar/flows/delete-event/use-delete-action"; import { useCreateDraftAction, diff --git a/apps/web/src/lib/mock-display-items.ts b/apps/web/src/lib/mock-display-items.ts new file mode 100644 index 00000000..37f859f1 --- /dev/null +++ b/apps/web/src/lib/mock-display-items.ts @@ -0,0 +1,215 @@ +import { Temporal } from "temporal-polyfill"; + +import { + DisplayItem, + JourneyDisplayItem, + LocationDisplayItem, + TaskDisplayItem, + createJourneyDisplayItem, + createLocationDisplayItem, + createTaskDisplayItem, +} from "@/lib/display-item"; + +interface GenerateMockTasksOptions { + startDate: Temporal.PlainDate; + timeZone: string; + count?: number; +} + +export function generateMockTasks({ + startDate, + timeZone, + count = 3, +}: GenerateMockTasksOptions): TaskDisplayItem[] { + const tasks: TaskDisplayItem[] = []; + + const taskTemplates = [ + { + title: "Review PR #123", + allDay: false, + offsetHours: 10, + durationHours: 1, + }, + { + title: "Team standup", + allDay: false, + offsetHours: 9, + durationHours: 0.5, + }, + { + title: "Deploy to staging", + allDay: true, + offsetHours: 0, + durationHours: 24, + }, + { + title: "Write documentation", + allDay: false, + offsetHours: 14, + durationHours: 2, + }, + { title: "Code review", allDay: false, offsetHours: 15, durationHours: 1 }, + ]; + + for (let i = 0; i < count; i++) { + const template = taskTemplates[i % taskTemplates.length]!; + const dayOffset = Math.floor(i / taskTemplates.length); + const taskDate = startDate.add({ days: dayOffset }); + + const start = template.allDay + ? Temporal.ZonedDateTime.from({ + year: taskDate.year, + month: taskDate.month, + day: taskDate.day, + hour: 0, + minute: 0, + timeZone, + }) + : Temporal.ZonedDateTime.from({ + year: taskDate.year, + month: taskDate.month, + day: taskDate.day, + hour: template.offsetHours, + minute: 0, + timeZone, + }); + + const end = start.add({ hours: template.durationHours }); + + tasks.push( + createTaskDisplayItem( + `mock-task-${i}`, + template.title, + template.allDay, + start, + end, + ), + ); + } + + return tasks; +} + +interface GenerateMockLocationsOptions { + startDate: Temporal.PlainDate; + timeZone: string; + count?: number; +} + +export function generateMockLocations({ + startDate, + timeZone, + count = 2, +}: GenerateMockLocationsOptions): LocationDisplayItem[] { + const locations: LocationDisplayItem[] = []; + + const locationTemplates = [ + { name: "Office", offsetHours: 9, durationHours: 8 }, + { name: "Coffee shop", offsetHours: 14, durationHours: 2 }, + { name: "Home", offsetHours: 17, durationHours: 5 }, + ]; + + for (let i = 0; i < count; i++) { + const template = locationTemplates[i % locationTemplates.length]!; + const dayOffset = Math.floor(i / locationTemplates.length); + const locationDate = startDate.add({ days: dayOffset }); + + const start = Temporal.ZonedDateTime.from({ + year: locationDate.year, + month: locationDate.month, + day: locationDate.day, + hour: template.offsetHours, + minute: 0, + timeZone, + }); + + const end = start.add({ hours: template.durationHours }); + + locations.push( + createLocationDisplayItem( + `mock-location-${i}`, + { name: template.name }, + start, + end, + ), + ); + } + + return locations; +} + +interface GenerateMockJourneysOptions { + startDate: Temporal.PlainDate; + timeZone: string; + count?: number; +} + +export function generateMockJourneys({ + startDate, + timeZone, + count = 1, +}: GenerateMockJourneysOptions): JourneyDisplayItem[] { + const journeys: JourneyDisplayItem[] = []; + + const journeyTemplates = [ + { from: "Home", to: "Office", offsetHours: 8, durationMinutes: 45 }, + { from: "Office", to: "Coffee shop", offsetHours: 13, durationMinutes: 15 }, + { from: "Office", to: "Home", offsetHours: 17, durationMinutes: 45 }, + ]; + + for (let i = 0; i < count; i++) { + const template = journeyTemplates[i % journeyTemplates.length]!; + const dayOffset = Math.floor(i / journeyTemplates.length); + const journeyDate = startDate.add({ days: dayOffset }); + + const start = Temporal.ZonedDateTime.from({ + year: journeyDate.year, + month: journeyDate.month, + day: journeyDate.day, + hour: template.offsetHours, + minute: 0, + timeZone, + }); + + const end = start.add({ minutes: template.durationMinutes }); + + journeys.push( + createJourneyDisplayItem( + `mock-journey-${i}`, + { from: template.from, to: template.to }, + start, + end, + ), + ); + } + + return journeys; +} + +interface GenerateMockDisplayItemsOptions { + startDate: Temporal.PlainDate; + timeZone: string; + taskCount?: number; + locationCount?: number; + journeyCount?: number; +} + +export function generateMockDisplayItems({ + startDate, + timeZone, + taskCount = 3, + locationCount = 0, + journeyCount = 0, +}: GenerateMockDisplayItemsOptions): DisplayItem[] { + const items: DisplayItem[] = []; + + items.push(...generateMockTasks({ startDate, timeZone, count: taskCount })); + items.push( + ...generateMockLocations({ startDate, timeZone, count: locationCount }), + ); + items.push( + ...generateMockJourneys({ startDate, timeZone, count: journeyCount }), + ); + + return items; +}