From 7e7dc9f19714709258c7eef31405da8e50045c6d Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 12 Jun 2026 16:20:31 -0500 Subject: [PATCH 1/5] feat(web): support cross-zone week event dnd --- .../interaction/model/AllDayDragVisual.ts | 10 + .../interaction/model/TimedDragVisual.ts | 9 + .../events/selectors/someday.selectors.ts | 5 + .../hooks/actions/useDraftActions.test.ts | 63 ++- .../Draft/hooks/actions/useDraftActions.ts | 76 ++++ .../Week/hooks/shortcuts/useWeekShortcuts.ts | 24 +- .../WeekInteractionCoordinator.tsx | 54 +++ .../WeekInteractionAdapter.allDayDrag.test.ts | 102 ++++- .../WeekInteractionAdapter.timedDrag.test.ts | 102 ++++- .../adapter/WeekInteractionAdapter.ts | 363 +++++++++++++++++- .../adapter/WeekInteractionAdapter.types.ts | 14 + .../adapter/commit/allDayVisualToGridEvent.ts | 22 +- .../commit/timedDragVisualToGridEvent.ts | 17 + 13 files changed, 848 insertions(+), 13 deletions(-) diff --git a/packages/web/src/common/calendar-grid/interaction/model/AllDayDragVisual.ts b/packages/web/src/common/calendar-grid/interaction/model/AllDayDragVisual.ts index 8c67423d8..91db507b9 100644 --- a/packages/web/src/common/calendar-grid/interaction/model/AllDayDragVisual.ts +++ b/packages/web/src/common/calendar-grid/interaction/model/AllDayDragVisual.ts @@ -1,10 +1,20 @@ import { type VisualPoint, type VisualRect } from "./TimedDragVisual"; export interface AllDayDragVisual { + crossSurfaceDrop?: { + dayIndex: number; + startMinutes: number; + type: "timed"; + } | null; dayIndex: number; eventId: string; initialDayIndex: number; pointerStart: VisualPoint; + sidebarDrop?: { + category: string; + index: number; + type: "sidebar"; + } | null; sourceRect: VisualRect; transform: VisualPoint; type: "allDayDrag"; diff --git a/packages/web/src/common/calendar-grid/interaction/model/TimedDragVisual.ts b/packages/web/src/common/calendar-grid/interaction/model/TimedDragVisual.ts index e42830a6e..e40c12845 100644 --- a/packages/web/src/common/calendar-grid/interaction/model/TimedDragVisual.ts +++ b/packages/web/src/common/calendar-grid/interaction/model/TimedDragVisual.ts @@ -11,6 +11,10 @@ export interface VisualRect { } export interface TimedDragVisual { + crossSurfaceDrop?: { + dayIndex: number; + type: "allDay"; + } | null; dayIndex: number; durationMinutes: number; endMinutes: number; @@ -19,6 +23,11 @@ export interface TimedDragVisual { initialEndMinutes: number; initialStartMinutes: number; pointerStart: VisualPoint; + sidebarDrop?: { + category: string; + index: number; + type: "sidebar"; + } | null; sourceRect: VisualRect; startMinutes: number; transform: VisualPoint; diff --git a/packages/web/src/ducks/events/selectors/someday.selectors.ts b/packages/web/src/ducks/events/selectors/someday.selectors.ts index 2cf631606..a6a3dd542 100644 --- a/packages/web/src/ducks/events/selectors/someday.selectors.ts +++ b/packages/web/src/ducks/events/selectors/someday.selectors.ts @@ -61,3 +61,8 @@ export const selectSomedayWeekCount = createSelector( selectCategorizedEvents, (somedayEvents) => somedayEvents.columns[COLUMN_WEEK].eventIds.length, ); + +export const selectSomedayMonthCount = createSelector( + selectCategorizedEvents, + (somedayEvents) => somedayEvents.columns[COLUMN_MONTH].eventIds.length, +); diff --git a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.test.ts b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.test.ts index bc1d0104a..33e7ea7b7 100644 --- a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.test.ts +++ b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.test.ts @@ -11,6 +11,7 @@ import { import { type Schema_GridEvent } from "@web/common/types/web.event.types"; import { type Activity_DraftEvent } from "@web/ducks/events/slices/draft.slice.types"; import { createEventSlice } from "@web/ducks/events/slices/event.slice"; +import { getWeekEventsSlice } from "@web/ducks/events/slices/week.slice"; import { type Setters_Draft, type State_Draft_Local, @@ -120,7 +121,8 @@ const setDraftActivity = ( const renderDraftActions = (draftOverrides: Partial) => { const setDraft = mock(); - const { wrapper } = createStoreWrapper(currentState); + const { store, wrapper } = createStoreWrapper(currentState); + const dispatch = spyOn(store, "dispatch"); const { result } = renderHook( () => useDraftActions( @@ -135,8 +137,9 @@ const renderDraftActions = (draftOverrides: Partial) => { ); setDraft.mockClear(); + dispatch.mockClear(); - return { result, setDraft }; + return { dispatch, result, setDraft, store }; }; const expectDraftRange = ( @@ -307,6 +310,62 @@ describe("useDraftActions", () => { expect(setDraft).not.toHaveBeenCalled(); }); + it("converts a timed draft to all-day by keyboard", () => { + setDraftActivity("keyboardEdit", Categories_Event.TIMED); + const { result, setDraft } = renderDraftActions({ + _id: "event-1", + isAllDay: false, + startDate: "2024-01-16T10:00:00.000Z", + endDate: "2024-01-16T11:00:00.000Z", + }); + + expect(result.current.convertDraftSurfaceByKeyboard()).toBe(true); + + expect(setDraft).toHaveBeenCalledWith( + expect.objectContaining({ + endDate: "2024-01-17", + isAllDay: true, + startDate: "2024-01-16", + }), + ); + }); + + it("moves a draft to Someday Month by keyboard", () => { + setDraftActivity("keyboardEdit", Categories_Event.TIMED); + const { dispatch, result } = renderDraftActions({ + _id: "event-1", + isAllDay: false, + startDate: "2024-01-16T10:00:00.000Z", + endDate: "2024-01-16T11:00:00.000Z", + }); + let didMove = false; + + act(() => { + didMove = result.current.moveDraftToSidebarByKeyboard( + Categories_Event.SOMEDAY_MONTH, + ); + }); + + expect(didMove).toBe(true); + + const convertAction = dispatch.mock.calls + .map(([action]) => action) + .find((action) => action.type === getWeekEventsSlice.actionNames.convert); + + expect(convertAction).toEqual( + expect.objectContaining({ + payload: { + event: expect.objectContaining({ + _id: "event-1", + isAllDay: false, + isSomeday: true, + order: 0, + }), + }, + }), + ); + }); + it("moves a shortcut-created all-day draft horizontally and ignores vertical arrows", () => { setDraftActivity("createShortcut", Categories_Event.ALLDAY); const { result, setDraft } = renderDraftActions({ diff --git a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts index 151b8ba16..e1eacdef3 100644 --- a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts +++ b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts @@ -2,6 +2,7 @@ import { ObjectId } from "bson"; import { useCallback } from "react"; import { Priorities, + SOMEDAY_MONTH_LIMIT_MSG, SOMEDAY_WEEK_LIMIT_MSG, } from "@core/constants/core.constants"; import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; @@ -32,7 +33,9 @@ import { selectDraftStatus, } from "@web/ducks/events/selectors/draft.selectors"; import { + selectIsAtMonthlyLimit, selectIsAtWeeklyLimit, + selectSomedayMonthCount, selectSomedayWeekCount, } from "@web/ducks/events/selectors/someday.selectors"; import { selectPaginatedEventsBySectionType } from "@web/ducks/events/selectors/util.selectors"; @@ -83,7 +86,9 @@ export const useDraftActions = ( weekProps: WeekProps, ) => { const dispatch = useAppDispatch(); + const isAtMonthlyLimit = useAppSelector(selectIsAtMonthlyLimit); const isAtWeeklyLimit = useAppSelector(selectIsAtWeeklyLimit); + const somedayMonthCount = useAppSelector(selectSomedayMonthCount); const somedayWeekCount = useAppSelector(selectSomedayWeekCount); const reduxDraft = useAppSelector(selectDraft); const pendingEventIds = useAppSelector( @@ -449,6 +454,75 @@ export const useDraftActions = ( [activity, draft, isInsideVisibleWeek, isTimedDraftInsideOneDay, setDraft], ); + const convertDraftSurfaceByKeyboard = useCallback(() => { + if (!canRepositionDraftByKeyboard(activity) || !draft) return false; + + const start = dayjs(draft.startDate).startOf("day"); + + if (draft.isAllDay) { + setDraft({ + ...draft, + endDate: start.add(10, "hour").format(), + isAllDay: false, + startDate: start.add(9, "hour").format(), + }); + return true; + } + + setDraft({ + ...draft, + endDate: start.add(1, "day").format(YEAR_MONTH_DAY_FORMAT), + isAllDay: true, + startDate: start.format(YEAR_MONTH_DAY_FORMAT), + }); + return true; + }, [activity, draft, setDraft]); + + const moveDraftToSidebarByKeyboard = useCallback( + ( + category: Categories_Event.SOMEDAY_WEEK | Categories_Event.SOMEDAY_MONTH, + ) => { + if (!canRepositionDraftByKeyboard(activity) || !draft?._id) return false; + + const isWeek = category === Categories_Event.SOMEDAY_WEEK; + + if (isWeek && isAtWeeklyLimit) { + alert(SOMEDAY_WEEK_LIMIT_MSG); + return true; + } + + if (!isWeek && isAtMonthlyLimit) { + alert(SOMEDAY_MONTH_LIMIT_MSG); + return true; + } + + const event: Payload_ConvertEvent["event"] = { + ...draft, + _id: draft._id, + isAllDay: false, + isSomeday: true, + order: isWeek ? somedayWeekCount : somedayMonthCount, + priority: draft.priority ?? Priorities.UNASSIGNED, + user: draft.user ?? "", + }; + + dispatch(getWeekEventsSlice.actions.convert({ event })); + discard(); + + return true; + }, + [ + activity, + discard, + dispatch, + draft, + isAtMonthlyLimit, + isAtWeeklyLimit, + somedayMonthCount, + somedayWeekCount, + ], + ); + const drag = useCallback( (e: Omit) => { const updateTimesDuringDrag = ( @@ -749,7 +823,9 @@ export const useDraftActions = ( duplicateEvent, discard, drag, + convertDraftSurfaceByKeyboard, openForm, + moveDraftToSidebarByKeyboard, repositionDraftByKeyboard, reset, resize, diff --git a/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcuts.ts b/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcuts.ts index 86b4f6ecc..dcb421a6b 100644 --- a/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcuts.ts +++ b/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcuts.ts @@ -54,7 +54,11 @@ export const useWeekShortcuts = ({ const dispatch = useAppDispatch(); const context = useSidebarContext(true); const { - actions: { repositionDraftByKeyboard }, + actions: { + convertDraftSurfaceByKeyboard, + moveDraftToSidebarByKeyboard, + repositionDraftByKeyboard, + }, } = useDraftContext(); const isSidebarOpen = useAppSelector(selectIsSidebarOpen); @@ -115,8 +119,12 @@ export const useWeekShortcuts = ({ ); const createAllDayDraftEvent = useCallback(() => { + if (convertDraftSurfaceByKeyboard()) { + return; + } + void createAlldayDraft(startOfView, endOfView, "createShortcut", dispatch); - }, [dispatch, startOfView, endOfView]); + }, [convertDraftSurfaceByKeyboard, dispatch, startOfView, endOfView]); const createTimedDraftEvent = useCallback(() => { void createTimedDraft( @@ -128,12 +136,20 @@ export const useWeekShortcuts = ({ }, [isCurrentWeek, startOfView, dispatch]); const createSomedayMonthDraft = useCallback(() => { + if (moveDraftToSidebarByKeyboard(Categories_Event.SOMEDAY_MONTH)) { + return; + } + _createSomedayDraft(Categories_Event.SOMEDAY_MONTH); - }, [_createSomedayDraft]); + }, [_createSomedayDraft, moveDraftToSidebarByKeyboard]); const createSomedayWeekDraft = useCallback(() => { + if (moveDraftToSidebarByKeyboard(Categories_Event.SOMEDAY_WEEK)) { + return; + } + _createSomedayDraft(Categories_Event.SOMEDAY_WEEK); - }, [_createSomedayDraft]); + }, [_createSomedayDraft, moveDraftToSidebarByKeyboard]); const focusFirstCalendarEvent = useCallback(() => { const target = getFirstVisibleCalendarEventTarget(); diff --git a/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx b/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx index 3dc4f67e6..823f2f200 100644 --- a/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx +++ b/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx @@ -5,13 +5,27 @@ import { useMemo, useRef, } from "react"; +import { + Priorities, + SOMEDAY_MONTH_LIMIT_MSG, + SOMEDAY_WEEK_LIMIT_MSG, +} from "@core/constants/core.constants"; +import { Categories_Event } from "@core/types/event.types"; import { CalendarInteractionPointerCaptureBoundary } from "@web/common/calendar-interaction/react/CalendarInteractionPointerCaptureBoundary"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; +import { type Payload_ConvertEvent } from "@web/ducks/events/event.types"; import { selectAllDayEvents, selectGridEvents, } from "@web/ducks/events/selectors/event.selectors"; +import { + selectCategorizedEvents, + selectIsAtMonthlyLimit, + selectIsAtWeeklyLimit, + selectSomedayWeekCount, +} from "@web/ducks/events/selectors/someday.selectors"; import { draftSlice } from "@web/ducks/events/slices/draft.slice"; +import { getWeekEventsSlice } from "@web/ducks/events/slices/week.slice"; import { useAppDispatch, useAppSelector } from "@web/store/store.hooks"; import { useDraftContext } from "@web/views/Week/components/Draft/context/useDraftContext"; import { type WeekProps } from "@web/views/Week/hooks/useWeek"; @@ -20,6 +34,7 @@ import { createWeekInteractionAdapter, type WeekAllDayDragCommitResult, type WeekAllDayResizeCommitResult, + type WeekCalendarToSidebarCommitResult, type WeekInteractionRuntime, type WeekTimedDragCommitResult, type WeekTimedResizeCommitResult, @@ -41,6 +56,10 @@ export const WeekInteractionCoordinator: FC = ({ const pendingEventIds = useAppSelector( (state) => state.events.pendingEvents.eventIds, ); + const categorizedSomedayEvents = useAppSelector(selectCategorizedEvents); + const isAtMonthlyLimit = useAppSelector(selectIsAtMonthlyLimit); + const isAtWeeklyLimit = useAppSelector(selectIsAtWeeklyLimit); + const somedayWeekCount = useAppSelector(selectSomedayWeekCount); const { actions, confirmation, setters, state } = useDraftContext(); const layoutSourcesRef = useRef(getLayoutSources); const timedEventsById = useMemo(() => { @@ -116,6 +135,40 @@ export const WeekInteractionCoordinator: FC = ({ void confirmation.onSubmit(result.event); }; + const commitCalendarToSidebar = ( + result: WeekCalendarToSidebarCommitResult, + ) => { + const isWeekDrop = result.category === Categories_Event.SOMEDAY_WEEK; + + if (isWeekDrop && isAtWeeklyLimit) { + alert(SOMEDAY_WEEK_LIMIT_MSG); + return; + } + + if (!isWeekDrop && isAtMonthlyLimit) { + alert(SOMEDAY_MONTH_LIMIT_MSG); + return; + } + + const order = isWeekDrop + ? Math.max(result.index, somedayWeekCount) + : Math.max( + result.index, + categorizedSomedayEvents.columns.month.eventIds.length, + ); + const event: Payload_ConvertEvent["event"] = { + ...result.event, + _id: result.eventId, + isAllDay: false, + isSomeday: true, + order, + priority: result.event.priority ?? Priorities.UNASSIGNED, + user: result.event.user ?? "", + }; + + dispatch(getWeekEventsSlice.actions.convert({ event })); + }; + runtimeRef.current = { getAllDayEventById: (eventId) => allDayEventsById.get(eventId) ?? null, getTimedEventById: (eventId) => timedEventsById.get(eventId) ?? null, @@ -125,6 +178,7 @@ export const WeekInteractionCoordinator: FC = ({ onClickTimedEvent: openTimedEvent, onCommitAllDayDrag: commitSavedMutation, onCommitAllDayResize: commitSavedMutation, + onCommitCalendarToSidebar: commitCalendarToSidebar, onCommitTimedDrag: commitSavedMutation, onCommitTimedResize: commitSavedMutation, onMotionActivation: (target) => { diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts index d025183e1..4bd032628 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts @@ -1,8 +1,12 @@ +import { Categories_Event } from "@core/types/event.types"; import { ID_ALLDAY_COLUMNS, + ID_GRID_COLUMNS_TIMED, ID_GRID_MAIN, } from "@web/common/constants/web.constants"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; +import { somedayDropTargetRegistry } from "@web/components/PlannerSidebar/SomedayEventSections/interaction/registry/somedayDropTargetRegistry"; +import { somedayEventRegistry } from "@web/components/PlannerSidebar/SomedayEventSections/interaction/registry/somedayEventRegistry"; import { createWeekInteractionAdapter } from "@web/views/Week/interaction/adapter/WeekInteractionAdapter"; import { weekEventRegistry } from "@web/views/Week/interaction/registry/weekEventRegistry"; import { resetWeekInteractionEdgeNavigationState } from "@web/views/Week/interaction/state/weekInteractionEdgeNavigationState"; @@ -126,7 +130,10 @@ const createHarness = ({ const child = document.createElement("span"); const mainGrid = document.createElement("div"); const allDayColumns = document.createElement("div"); + const timedColumns = document.createElement("div"); + const sidebarMonth = document.createElement("div"); const onClickAllDayEvent = mock(); + const onCommitCalendarToSidebar = mock(); const onCommitAllDayDrag = mock(); const onMotionActivation = mock(); const onRequestWeekNavigation = mock(); @@ -134,9 +141,11 @@ const createHarness = ({ source.style.visibility = "visible"; mainGrid.id = ID_GRID_MAIN; allDayColumns.id = ID_ALLDAY_COLUMNS; + timedColumns.id = ID_GRID_COLUMNS_TIMED; source.append(child); allDayColumns.append(source); - document.body.append(mainGrid, allDayColumns); + mainGrid.append(timedColumns); + document.body.append(mainGrid, allDayColumns, sidebarMonth); Object.defineProperty(mainGrid, "clientHeight", { value: 1300 }); Object.defineProperty(mainGrid, "scrollHeight", { value: 2600 }); mainGrid.scrollTop = 0; @@ -153,13 +162,29 @@ const createHarness = ({ top: 20, width: 700, }); + setRect(timedColumns, { + height: 2400, + left: 100, + top: 100, + width: 700, + }); setRect(source, sourceRect); + setRect(sidebarMonth, { + height: 400, + left: 900, + top: 100, + width: 300, + }); weekEventRegistry.register({ element: source, eventId: event._id!, eventType: "all-day", }); + somedayDropTargetRegistry.register({ + category: Categories_Event.SOMEDAY_MONTH, + element: sidebarMonth, + }); const adapter = createWeekInteractionAdapter({ engineOptions: { @@ -187,6 +212,7 @@ const createHarness = ({ getTimedEventById: () => null, isEventPending: () => isPending, onClickAllDayEvent, + onCommitCalendarToSidebar, onClickTimedEvent: () => undefined, onCommitAllDayDrag, onCommitTimedDrag: () => undefined, @@ -213,6 +239,7 @@ const createHarness = ({ event, flushFrame, onClickAllDayEvent, + onCommitCalendarToSidebar, onCommitAllDayDrag, onMotionActivation, onRequestWeekNavigation, @@ -223,6 +250,8 @@ const createHarness = ({ afterEach(() => { document.body.innerHTML = ""; + somedayDropTargetRegistry.clear(); + somedayEventRegistry.clear(); weekEventRegistry.clear(); resetWeekInteractionEdgeNavigationState(); }); @@ -385,6 +414,77 @@ describe("WeekInteractionAdapter all-day drag", () => { ).toBeNull(); }); + it("converts an all-day event to timed when dragged into the timed grid", () => { + const { adapter, child, event, flushFrame, onCommitAllDayDrag } = + createHarness(); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 30 }), + ); + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 430, y: 300 }), + ); + + flushFrame(); + + const overlay = document.body.querySelector( + "[data-calendar-interaction-overlay]", + ) as HTMLElement | null; + + expect(overlay?.style.transition).toContain("transform"); + expect(overlay?.style.height).toBe("100px"); + + adapter.handlePointerUp( + makePointerEvent("pointerup", { target: child, x: 430, y: 300 }), + ); + + expect(onCommitAllDayDrag).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + _id: event._id, + endDate: expect.stringContaining("03:00"), + isAllDay: false, + startDate: expect.stringContaining("02:00"), + }), + eventId: event._id, + hasMoved: true, + type: "allDayDragEnd", + }), + ); + }); + + it("commits an all-day event to the Someday Month sidebar drop zone", () => { + const { + adapter, + child, + event, + flushFrame, + onCommitAllDayDrag, + onCommitCalendarToSidebar, + } = createHarness(); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 30 }), + ); + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 950, y: 150 }), + ); + flushFrame(); + adapter.handlePointerUp( + makePointerEvent("pointerup", { target: child, x: 950, y: 150 }), + ); + + expect(onCommitAllDayDrag).not.toHaveBeenCalled(); + expect(onCommitCalendarToSidebar).toHaveBeenCalledWith({ + category: Categories_Event.SOMEDAY_MONTH, + event, + eventId: event._id, + hadFormOpenBeforeInteraction: false, + index: 0, + type: "calendarToSidebar", + }); + }); + it("requests one all-day edge navigation after the edge dwell", () => { const { adapter, child, flushFrame, onRequestWeekNavigation } = createHarness(); diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts index 9712066d1..06a952fa9 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts @@ -1,8 +1,12 @@ +import { Categories_Event } from "@core/types/event.types"; import { + ID_ALLDAY_COLUMNS, ID_GRID_COLUMNS_TIMED, ID_GRID_MAIN, } from "@web/common/constants/web.constants"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; +import { somedayDropTargetRegistry } from "@web/components/PlannerSidebar/SomedayEventSections/interaction/registry/somedayDropTargetRegistry"; +import { somedayEventRegistry } from "@web/components/PlannerSidebar/SomedayEventSections/interaction/registry/somedayEventRegistry"; import { createWeekInteractionAdapter } from "@web/views/Week/interaction/adapter/WeekInteractionAdapter"; import { weekEventRegistry } from "@web/views/Week/interaction/registry/weekEventRegistry"; import { @@ -130,18 +134,22 @@ const createHarness = ({ const source = document.createElement("div"); const child = document.createElement("span"); const mainGrid = document.createElement("div"); + const allDayColumns = document.createElement("div"); const columns = document.createElement("div"); + const sidebarWeek = document.createElement("div"); const onClickTimedEvent = mock(); + const onCommitCalendarToSidebar = mock(); const onCommitTimedDrag = mock(); const onMotionActivation = mock(); const onRequestWeekNavigation = mock(); source.style.visibility = "visible"; mainGrid.id = ID_GRID_MAIN; + allDayColumns.id = ID_ALLDAY_COLUMNS; columns.id = ID_GRID_COLUMNS_TIMED; source.append(child); mainGrid.append(columns, source); - document.body.append(mainGrid); + document.body.append(allDayColumns, mainGrid, sidebarWeek); Object.defineProperty(mainGrid, "clientHeight", { value: 1300 }); Object.defineProperty(mainGrid, "scrollHeight", { value: 2600 }); mainGrid.scrollTop = mainGridScrollTop; @@ -158,13 +166,29 @@ const createHarness = ({ top: 100, width: 700, }); + setRect(allDayColumns, { + height: 40, + left: 100, + top: 20, + width: 700, + }); setRect(source, sourceRect); + setRect(sidebarWeek, { + height: 400, + left: 900, + top: 100, + width: 300, + }); const unregister = weekEventRegistry.register({ element: source, eventId: event._id!, eventType: "timed", }); + const unregisterSidebarWeek = somedayDropTargetRegistry.register({ + category: Categories_Event.SOMEDAY_WEEK, + element: sidebarWeek, + }); const adapter = createWeekInteractionAdapter({ engineOptions: { @@ -191,6 +215,7 @@ const createHarness = ({ getTimedEventById: (eventId) => (eventId === event._id ? event : null), isEventPending: () => isPending, onClickTimedEvent, + onCommitCalendarToSidebar, onCommitTimedDrag, onMotionActivation, onRequestWeekNavigation, @@ -229,17 +254,21 @@ const createHarness = ({ frameCallbacks, mainGrid, onClickTimedEvent, + onCommitCalendarToSidebar, onCommitTimedDrag, onMotionActivation, onRequestWeekNavigation, source, timerCallbacks, unregister, + unregisterSidebarWeek, }; }; afterEach(() => { document.body.innerHTML = ""; + somedayDropTargetRegistry.clear(); + somedayEventRegistry.clear(); weekEventRegistry.clear(); resetWeekInteractionEdgeNavigationState(); }); @@ -418,6 +447,77 @@ describe("WeekInteractionAdapter timed drag", () => { ).toBeNull(); }); + it("converts a timed event to all-day when dragged into the all-day row", () => { + const { adapter, child, event, flushFrame, onCommitTimedDrag } = + createHarness(); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 1020 }), + ); + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 430, y: 30 }), + ); + + flushFrame(); + + const overlay = document.body.querySelector( + "[data-calendar-interaction-overlay]", + ) as HTMLElement | null; + + expect(overlay?.style.transition).toContain("transform"); + expect(overlay?.style.height).toBe("20px"); + + adapter.handlePointerUp( + makePointerEvent("pointerup", { target: child, x: 430, y: 30 }), + ); + + expect(onCommitTimedDrag).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + _id: event._id, + endDate: "2026-05-21", + isAllDay: true, + startDate: "2026-05-20", + }), + eventId: event._id, + hasMoved: true, + type: "timedDragEnd", + }), + ); + }); + + it("commits a timed event to the Someday Week sidebar drop zone", () => { + const { + adapter, + child, + event, + flushFrame, + onCommitCalendarToSidebar, + onCommitTimedDrag, + } = createHarness(); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 1020 }), + ); + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 950, y: 150 }), + ); + flushFrame(); + adapter.handlePointerUp( + makePointerEvent("pointerup", { target: child, x: 950, y: 150 }), + ); + + expect(onCommitTimedDrag).not.toHaveBeenCalled(); + expect(onCommitCalendarToSidebar).toHaveBeenCalledWith({ + category: Categories_Event.SOMEDAY_WEEK, + event, + eventId: event._id, + hadFormOpenBeforeInteraction: false, + index: 0, + type: "calendarToSidebar", + }); + }); + it("adds a temporary time label while dragging a saved timed event that did not render one", () => { const { adapter, child, flushFrame } = createHarness(); diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts index 001d22f21..9cf1f4327 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts @@ -15,6 +15,15 @@ import { createCalendarInteractionEngine, } from "@web/common/calendar-interaction/CalendarInteractionEngine"; import { isEligibleCalendarInteractionPointerDown } from "@web/common/calendar-interaction/calendarInteractionPointer"; +import { somedayDropTargetRegistry } from "@web/components/PlannerSidebar/SomedayEventSections/interaction/registry/somedayDropTargetRegistry"; +import { + type SomedayInteractionCategory, + somedayEventRegistry, +} from "@web/components/PlannerSidebar/SomedayEventSections/interaction/registry/somedayEventRegistry"; +import { + EVENT_ALLDAY_HEIGHT, + TIMED_EVENT_COLUMN_INSET, +} from "../../layout.constants"; import { type WeekInteractionEventType, weekEventRegistry, @@ -27,6 +36,7 @@ import { setWeekInteractionMotionActive } from "../state/weekInteractionMotionSt import { buildAllDayWeekLayoutCache, buildTimedWeekLayoutCache, + getNearestDayColumn, type WeekLayoutCache, type WeekLayoutCacheSources, } from "./geometry/weekLayoutCache"; @@ -70,6 +80,7 @@ import { createWeekEdgeNavigationController } from "./weekEdgeNavigation"; export type { WeekAllDayDragCommitResult, WeekAllDayResizeCommitResult, + WeekCalendarToSidebarCommitResult, WeekInteractionAdapter, WeekInteractionRuntime, WeekTimedDragCommitResult, @@ -89,6 +100,19 @@ const activeEdgeNavigationIndicatorState = { isTimerActive: false, progress: 0, } as const; +const CROSS_SURFACE_SNAP_TRANSITION = + "height 160ms cubic-bezier(0.16, 1, 0.3, 1), width 160ms cubic-bezier(0.16, 1, 0.3, 1), transform 120ms cubic-bezier(0.16, 1, 0.3, 1)"; +const DEFAULT_CONVERTED_TIMED_DURATION_MINUTES = 60; + +interface WeekSidebarDrop { + category: SomedayInteractionCategory; + index: number; + type: "sidebar"; +} + +type WeekSidebarDroppableVisual = WeekEdgeNavigableVisual & { + sidebarDrop?: WeekSidebarDrop | null; +}; export const createWeekInteractionAdapter = ({ engineOptions, @@ -97,8 +121,10 @@ export const createWeekInteractionAdapter = ({ }: WeekInteractionAdapterOptions = {}): WeekInteractionAdapter => { const edgeNavigation = createWeekEdgeNavigationController(); let isLayoutRebuildPending = false; + let allDayLayout: WeekLayoutCache | null = null; let layout: WeekLayoutCache | null = null; let scrollTop: number | null = null; + let timedLayout: WeekLayoutCache | null = null; const engine: CalendarInteractionEngine< WeekInteractionTarget, @@ -208,6 +234,11 @@ export const createWeekInteractionAdapter = ({ return isOwnedPointer; } + if (result.result.type === "calendarToSidebar") { + currentRuntime.onCommitCalendarToSidebar?.(result.result); + return isOwnedPointer; + } + currentRuntime.onCommitTimedResize?.(result.result); return isOwnedPointer; @@ -238,8 +269,21 @@ export const createWeekInteractionAdapter = ({ }, commit: ({ target, visual }) => { let result: WeekInteractionCommitResult; - - if (visual.type === "allDayDrag" && target.type === "allDayDrag") { + const sidebarDrop = getVisualSidebarDrop(visual); + + if (sidebarDrop && isDragTarget(target)) { + result = { + category: sidebarDrop.category, + event: target.event, + eventId: target.event._id!, + hadFormOpenBeforeInteraction: target.hadFormOpenBeforeInteraction, + index: sidebarDrop.index, + type: "calendarToSidebar", + }; + } else if ( + visual.type === "allDayDrag" && + target.type === "allDayDrag" + ) { result = commitAllDayDragInteraction(target, visual); } else if ( visual.type === "allDayResize" && @@ -275,6 +319,11 @@ export const createWeekInteractionAdapter = ({ const sourceRect = readElementRect(sourceElement); setLayout(layout); + if (isDragTarget(target)) { + setCrossSurfaceLayouts(getLayoutSources()); + } else { + clearCrossSurfaceLayouts(); + } if (isDragTarget(target)) { setWeekInteractionEdgeNavigationState( activeEdgeNavigationIndicatorState, @@ -350,10 +399,64 @@ export const createWeekInteractionAdapter = ({ pointer, timestamp, ); + const sidebarDrop = resolveSidebarDrop(pointer); + + if (sidebarDrop) { + const nextVisual = { + ...nextEdgeNavigation.visual, + crossSurfaceDrop: null, + sidebarDrop, + transform: { + x: pointer.x - nextEdgeNavigation.visual.pointerStart.x, + y: pointer.y - nextEdgeNavigation.visual.pointerStart.y, + }, + }; + + return { + overlay: { + transform: nextVisual.transform, + }, + shouldContinue: nextEdgeNavigation.isDwellActive, + visual: nextVisual, + }; + } + + const timedDrop = resolveTimedCrossSurfaceDrop(pointer); + + if (timedDrop) { + const overlayRect = getTimedCrossSurfaceOverlayRect( + timedDrop, + nextEdgeNavigation.visual, + ); + const nextVisual = { + ...nextEdgeNavigation.visual, + crossSurfaceDrop: timedDrop, + transform: { + x: overlayRect.left - nextEdgeNavigation.visual.sourceRect.left, + y: overlayRect.top - nextEdgeNavigation.visual.sourceRect.top, + }, + }; + + return { + overlay: { + height: overlayRect.height, + mutate: applyCrossSurfaceSnapTransition, + transform: nextVisual.transform, + width: overlayRect.width, + }, + shouldContinue: nextEdgeNavigation.isDwellActive, + visual: nextVisual, + }; + } + const nextVisual = updateAllDayDragInteractionVisual({ layout, pointer, - visual: nextEdgeNavigation.visual, + visual: { + ...nextEdgeNavigation.visual, + crossSurfaceDrop: null, + sidebarDrop: null, + }, }); return { @@ -415,12 +518,68 @@ export const createWeekInteractionAdapter = ({ pointer, timestamp, ); + const sidebarDrop = resolveSidebarDrop(pointer); + + if (sidebarDrop) { + const nextVisual = { + ...nextEdgeNavigation.visual, + crossSurfaceDrop: null, + sidebarDrop, + transform: { + x: pointer.x - nextEdgeNavigation.visual.pointerStart.x, + y: pointer.y - nextEdgeNavigation.visual.pointerStart.y, + }, + }; + + return { + overlay: { + transform: nextVisual.transform, + }, + shouldContinue: + smartScroll.isScrolling || nextEdgeNavigation.isDwellActive, + visual: nextVisual, + }; + } + + const allDayDrop = resolveAllDayCrossSurfaceDrop(pointer); + + if (allDayDrop) { + const overlayRect = getAllDayCrossSurfaceOverlayRect( + allDayDrop, + nextEdgeNavigation.visual, + ); + const nextVisual = { + ...nextEdgeNavigation.visual, + crossSurfaceDrop: allDayDrop, + transform: { + x: overlayRect.left - nextEdgeNavigation.visual.sourceRect.left, + y: overlayRect.top - nextEdgeNavigation.visual.sourceRect.top, + }, + }; + + return { + overlay: { + height: overlayRect.height, + mutate: applyCrossSurfaceSnapTransition, + transform: nextVisual.transform, + width: overlayRect.width, + }, + shouldContinue: + smartScroll.isScrolling || nextEdgeNavigation.isDwellActive, + visual: nextVisual, + }; + } + const next = updateTimedDragInteractionVisual({ layout, pointer, scrollDeltaPx: smartScroll.scrollDeltaPx, target, - visual: nextEdgeNavigation.visual, + visual: { + ...nextEdgeNavigation.visual, + crossSurfaceDrop: null, + sidebarDrop: null, + }, }); return { @@ -678,6 +837,9 @@ export const createWeekInteractionAdapter = ({ } setLayout(nextLayout); + if (isDragTarget(target)) { + setCrossSurfaceLayouts(getLayoutSources()); + } isLayoutRebuildPending = false; } @@ -688,6 +850,7 @@ export const createWeekInteractionAdapter = ({ function clearInteractionState() { layout = null; scrollTop = null; + clearCrossSurfaceLayouts(); resetEdgeNavigation(); isLayoutRebuildPending = false; } @@ -697,6 +860,155 @@ export const createWeekInteractionAdapter = ({ scrollTop = nextLayout.smartScroll?.initialScrollTop ?? null; } + function setCrossSurfaceLayouts(sources: WeekLayoutCacheSources) { + allDayLayout = buildAllDayWeekLayoutCache(sources); + timedLayout = buildTimedWeekLayoutCache(sources); + } + + function clearCrossSurfaceLayouts() { + allDayLayout = null; + timedLayout = null; + } + + function resolveAllDayCrossSurfaceDrop(pointer: VisualPoint) { + const crossLayout = allDayLayout; + + if (!crossLayout || !isPointInLayout(pointer, crossLayout)) { + return null; + } + + const column = getNearestDayColumn(crossLayout.dayColumns, pointer.x); + + if (!column || !isPointInsideColumns(pointer, crossLayout.dayColumns)) { + return null; + } + + return { + dayIndex: column.index, + type: "allDay" as const, + }; + } + + function resolveTimedCrossSurfaceDrop(pointer: VisualPoint) { + const crossLayout = timedLayout; + + if (!crossLayout || !isPointInLayout(pointer, crossLayout)) { + return null; + } + + const column = getNearestDayColumn(crossLayout.dayColumns, pointer.x); + + if (!column || !isPointInsideColumns(pointer, crossLayout.dayColumns)) { + return null; + } + + const gridY = + pointer.y - + crossLayout.edgeNavigation.top + + (crossLayout.smartScroll?.element.scrollTop ?? 0); + const startMinutes = Math.max( + 0, + Math.floor( + gridY / crossLayout.pixelsPerMinute / crossLayout.snapMinutes, + ) * crossLayout.snapMinutes, + ); + + return { + dayIndex: column.index, + startMinutes, + type: "timed" as const, + }; + } + + function resolveSidebarDrop(pointer: VisualPoint): WeekSidebarDrop | null { + for (const target of somedayDropTargetRegistry.getTargets()) { + const rect = target.element.getBoundingClientRect(); + + if (!isPointInRect(pointer, rect)) { + continue; + } + + const events = somedayEventRegistry.getEvents(target.category); + const insertionIndex = events.findIndex((event) => { + const eventRect = event.element.getBoundingClientRect(); + + return pointer.y < eventRect.top + eventRect.height / 2; + }); + + return { + category: target.category, + index: insertionIndex === -1 ? events.length : insertionIndex, + type: "sidebar", + }; + } + + return null; + } + + function getAllDayCrossSurfaceOverlayRect( + drop: { dayIndex: number }, + visual: WeekEdgeNavigableVisual, + ) { + const crossLayout = allDayLayout; + const column = crossLayout?.dayColumns.find( + (day) => day.index === drop.dayIndex, + ); + + if (!crossLayout || !column) { + return { + height: visual.sourceRect.height, + left: visual.sourceRect.left, + top: visual.sourceRect.top, + width: visual.sourceRect.width, + }; + } + + return { + height: EVENT_ALLDAY_HEIGHT, + left: column.left, + top: crossLayout.edgeNavigation.top, + width: visual.sourceRect.width, + }; + } + + function getTimedCrossSurfaceOverlayRect( + drop: { dayIndex: number; startMinutes: number }, + visual: WeekEdgeNavigableVisual, + ) { + const crossLayout = timedLayout; + const column = crossLayout?.dayColumns.find( + (day) => day.index === drop.dayIndex, + ); + + if (!crossLayout || !column) { + return { + height: visual.sourceRect.height, + left: visual.sourceRect.left, + top: visual.sourceRect.top, + width: visual.sourceRect.width, + }; + } + + const scrollTop = crossLayout.smartScroll?.element.scrollTop ?? 0; + + return { + height: + DEFAULT_CONVERTED_TIMED_DURATION_MINUTES * crossLayout.pixelsPerMinute, + left: column.left + TIMED_EVENT_COLUMN_INSET, + top: + crossLayout.edgeNavigation.top + + drop.startMinutes * crossLayout.pixelsPerMinute - + scrollTop, + width: Math.max(0, column.width - TIMED_EVENT_COLUMN_INSET * 2), + }; + } + + function applyCrossSurfaceSnapTransition(node: HTMLElement) { + node.style.transition = isReducedMotionPreferred() + ? "none" + : CROSS_SURFACE_SNAP_TRANSITION; + } + return { cancel, connectCancellationEvents, @@ -764,3 +1076,46 @@ const readElementRect = (element: HTMLElement): VisualRect => { width: rect.width, }; }; + +const isPointInLayout = (point: VisualPoint, layout: WeekLayoutCache) => + point.x >= layout.edgeNavigation.left && + point.x <= layout.edgeNavigation.right && + point.y > layout.edgeNavigation.top && + point.y < layout.edgeNavigation.bottom; + +const isPointInsideColumns = ( + point: VisualPoint, + columns: WeekLayoutCache["dayColumns"], +) => { + const firstColumn = columns[0]; + const lastColumn = columns[columns.length - 1]; + + if (!firstColumn || !lastColumn) { + return false; + } + + return ( + point.x >= firstColumn.left && point.x <= lastColumn.left + lastColumn.width + ); +}; + +const isPointInRect = ( + point: VisualPoint, + rect: Pick, +) => + point.x >= rect.left && + point.x <= rect.right && + point.y >= rect.top && + point.y <= rect.bottom; + +const getVisualSidebarDrop = ( + visual: WeekInteractionVisual, +): WeekSidebarDrop | null => + "sidebarDrop" in visual + ? ((visual as WeekSidebarDroppableVisual).sidebarDrop ?? null) + : null; + +const isReducedMotionPreferred = () => + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts index ef19746f4..8ad6f0407 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts @@ -13,6 +13,7 @@ import { type CalendarInteractionEngineSchedulerOptions, } from "@web/common/calendar-interaction/CalendarInteractionEngine"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; +import { type SomedayInteractionCategory } from "@web/components/PlannerSidebar/SomedayEventSections/interaction/registry/somedayEventRegistry"; import { type WeekInteractionRegisteredTarget } from "../registry/weekEventRegistry"; import { type WeekLayoutCacheSources } from "./geometry/weekLayoutCache"; @@ -36,6 +37,9 @@ export interface WeekInteractionRuntime { onClickTimedEvent: (event: Schema_GridEvent) => void; onCommitAllDayDrag?: (result: WeekAllDayDragCommitResult) => void; onCommitAllDayResize?: (result: WeekAllDayResizeCommitResult) => void; + onCommitCalendarToSidebar?: ( + result: WeekCalendarToSidebarCommitResult, + ) => void; onCommitTimedDrag: (result: WeekTimedDragCommitResult) => void; onCommitTimedResize?: (result: WeekTimedResizeCommitResult) => void; onMotionActivation?: (target: WeekInteractionTarget) => void; @@ -88,6 +92,15 @@ export interface WeekTimedDragTarget { type: "timedDrag"; } +export interface WeekCalendarToSidebarCommitResult { + category: SomedayInteractionCategory; + event: Schema_GridEvent; + eventId: string; + hadFormOpenBeforeInteraction: boolean; + index: number; + type: "calendarToSidebar"; +} + export interface WeekTimedResizeCommitResult { event: Schema_GridEvent; eventId: string; @@ -119,6 +132,7 @@ export type WeekInteractionVisual = export type WeekInteractionCommitResult = | WeekAllDayDragCommitResult | WeekAllDayResizeCommitResult + | WeekCalendarToSidebarCommitResult | WeekTimedDragCommitResult | WeekTimedResizeCommitResult; diff --git a/packages/web/src/views/Week/interaction/adapter/commit/allDayVisualToGridEvent.ts b/packages/web/src/views/Week/interaction/adapter/commit/allDayVisualToGridEvent.ts index a089cc8bc..61993ec08 100644 --- a/packages/web/src/views/Week/interaction/adapter/commit/allDayVisualToGridEvent.ts +++ b/packages/web/src/views/Week/interaction/adapter/commit/allDayVisualToGridEvent.ts @@ -5,12 +5,32 @@ import { type AllDayResizeVisual } from "@web/common/calendar-grid/interaction/m import { type Schema_GridEvent } from "@web/common/types/web.event.types"; export const hasAllDayDragVisualMoved = (visual: AllDayDragVisual) => - visual.dayIndex !== visual.initialDayIndex || visual.weekOffsetDays !== 0; + visual.crossSurfaceDrop?.type === "timed" || + visual.dayIndex !== visual.initialDayIndex || + visual.weekOffsetDays !== 0; export const allDayDragVisualToGridEvent = ( event: Schema_GridEvent, visual: AllDayDragVisual, ): Schema_GridEvent => { + if (visual.crossSurfaceDrop?.type === "timed") { + const dayDelta = + visual.crossSurfaceDrop.dayIndex - + visual.initialDayIndex + + visual.weekOffsetDays; + const startDate = dayjs(event.startDate) + .add(dayDelta, "day") + .startOf("day") + .add(visual.crossSurfaceDrop.startMinutes, "minutes"); + + return { + ...event, + endDate: startDate.add(60, "minutes").format(), + isAllDay: false, + startDate: startDate.format(), + }; + } + const dayDelta = visual.dayIndex - visual.initialDayIndex + visual.weekOffsetDays; diff --git a/packages/web/src/views/Week/interaction/adapter/commit/timedDragVisualToGridEvent.ts b/packages/web/src/views/Week/interaction/adapter/commit/timedDragVisualToGridEvent.ts index 796705067..bbd97774c 100644 --- a/packages/web/src/views/Week/interaction/adapter/commit/timedDragVisualToGridEvent.ts +++ b/packages/web/src/views/Week/interaction/adapter/commit/timedDragVisualToGridEvent.ts @@ -1,9 +1,11 @@ +import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; import dayjs from "@core/util/date/dayjs"; import { type TimedDragVisual } from "@web/common/calendar-grid/interaction/model/TimedDragVisual"; import { type TimedResizeVisual } from "@web/common/calendar-grid/interaction/model/TimedResizeVisual"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; export const hasTimedDragVisualMoved = (visual: TimedDragVisual) => + visual.crossSurfaceDrop?.type === "allDay" || visual.dayIndex !== visual.initialDayIndex || visual.weekOffsetDays !== 0 || visual.startMinutes !== visual.initialStartMinutes || @@ -13,6 +15,21 @@ export const timedDragVisualToGridEvent = ( event: Schema_GridEvent, visual: TimedDragVisual, ): Schema_GridEvent => { + if (visual.crossSurfaceDrop?.type === "allDay") { + const dayDelta = + visual.crossSurfaceDrop.dayIndex - + visual.initialDayIndex + + visual.weekOffsetDays; + const movedDay = dayjs(event.startDate).add(dayDelta, "day").startOf("day"); + + return { + ...event, + endDate: movedDay.add(1, "day").format(YEAR_MONTH_DAY_FORMAT), + isAllDay: true, + startDate: movedDay.format(YEAR_MONTH_DAY_FORMAT), + }; + } + const dayDelta = visual.dayIndex - visual.initialDayIndex + visual.weekOffsetDays; const movedDay = dayjs(event.startDate).add(dayDelta, "day").startOf("day"); From cfb0b5fe8fdea4c7611b9c79e4a92fa3163bedb1 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 12 Jun 2026 20:19:36 -0500 Subject: [PATCH 2/5] fix(web): harden cross-zone week drag interactions - Stop grid smart scroll once a timed drag preview snaps to the all-day row or a sidebar drop zone - Suppress edge week-navigation dwell while hovering sidebar drop zones so the week no longer changes under a sidebar drop - Restore the drag overlay's size when returning to the home surface and keep transform un-eased outside the snapped cross-surface mode - Smart scroll the grid while an all-day drag hovers the timed grid so off-screen times are reachable - Clamp all-day to timed conversions so the event ends within the dropped day - Fix crash on Someday Month drops (wrong column key) and route all calendar-to-someday conversions through a shared helper that sets category date ranges, rewrites recurrence frequency, and defaults priority/user; discard the draft after pointer conversions Co-Authored-By: Claude Fable 5 --- .../utils/event/someday.event.util.test.ts | 102 ++++++++++- .../common/utils/event/someday.event.util.ts | 55 ++++++ .../hooks/actions/useDraftActions.test.ts | 4 + .../Draft/hooks/actions/useDraftActions.ts | 45 ++--- .../WeekInteractionCoordinator.tsx | 25 ++- .../WeekInteractionAdapter.allDayDrag.test.ts | 54 +++++- .../WeekInteractionAdapter.timedDrag.test.ts | 93 +++++++++- .../adapter/WeekInteractionAdapter.ts | 160 ++++++++++++------ 8 files changed, 436 insertions(+), 102 deletions(-) diff --git a/packages/web/src/common/utils/event/someday.event.util.test.ts b/packages/web/src/common/utils/event/someday.event.util.test.ts index 60ee507b0..06042c4d9 100644 --- a/packages/web/src/common/utils/event/someday.event.util.test.ts +++ b/packages/web/src/common/utils/event/someday.event.util.test.ts @@ -6,7 +6,7 @@ import { RRULE, RRULE_COUNT_WEEKS, } from "@core/constants/core.constants"; -import { type Schema_Event } from "@core/types/event.types"; +import { Categories_Event, type Schema_Event } from "@core/types/event.types"; import dayjs from "@core/util/date/dayjs"; import { createMockBaseEvent, @@ -18,6 +18,7 @@ import { type Schema_SomedayEventsColumn, } from "@web/common/types/web.event.types"; import { + assembleSomedayConversionEvent, categorizeSomedayEvents, setSomedayEventsOrder, } from "@web/common/utils/event/someday.event.util"; @@ -520,3 +521,102 @@ describe("computeRelativeEventDateRange", () => { }); }); }); + +describe("assembleSomedayConversionEvent", () => { + const viewStart = dayjs("2024-03-17"); + const viewEnd = dayjs("2024-03-23"); + + const gridEvent: Schema_Event = { + _id: "grid-event-1", + title: "Grid event", + endDate: "2024-03-19T11:00:00.000Z", + isAllDay: false, + isSomeday: false, + origin: Origin.COMPASS, + priority: Priorities.WORK, + startDate: "2024-03-19T10:00:00.000Z", + user: "user-1", + }; + + it("uses the view week's date range for a Week conversion", () => { + const result = assembleSomedayConversionEvent(gridEvent, { + category: Categories_Event.SOMEDAY_WEEK, + order: 2, + viewEnd, + viewStart, + }); + + expect(result).toEqual( + expect.objectContaining({ + endDate: "2024-03-23", + isAllDay: false, + isSomeday: true, + order: 2, + priority: Priorities.WORK, + startDate: "2024-03-17", + title: "Grid event", + }), + ); + }); + + it("uses the view month's date range for a Month conversion", () => { + const result = assembleSomedayConversionEvent(gridEvent, { + category: Categories_Event.SOMEDAY_MONTH, + order: 0, + viewEnd, + viewStart, + }); + + expect(result).toEqual( + expect.objectContaining({ + endDate: "2024-03-31", + isSomeday: true, + startDate: "2024-03-01", + }), + ); + }); + + it("rewrites the recurrence frequency to match the destination column", () => { + const recurringEvent: Schema_Event = { + ...gridEvent, + recurrence: { rule: ["RRULE:FREQ=DAILY;COUNT=10;INTERVAL=1"] }, + }; + + const weekResult = assembleSomedayConversionEvent(recurringEvent, { + category: Categories_Event.SOMEDAY_WEEK, + order: 0, + viewEnd, + viewStart, + }); + const monthResult = assembleSomedayConversionEvent(recurringEvent, { + category: Categories_Event.SOMEDAY_MONTH, + order: 0, + viewEnd, + viewStart, + }); + + expect(weekResult.recurrence?.rule).toEqual([ + "RRULE:FREQ=WEEKLY;COUNT=10;INTERVAL=1", + ]); + expect(monthResult.recurrence?.rule).toEqual([ + "RRULE:FREQ=MONTHLY;COUNT=10;INTERVAL=1", + ]); + }); + + it("defaults missing priority and user fields", () => { + const sparseEvent: Schema_Event = { + ...gridEvent, + priority: undefined, + user: undefined, + }; + const result = assembleSomedayConversionEvent(sparseEvent, { + category: Categories_Event.SOMEDAY_WEEK, + order: 0, + viewEnd, + viewStart, + }); + + expect(result.priority).toBe(Priorities.UNASSIGNED); + expect(result.user).toBe(""); + }); +}); diff --git a/packages/web/src/common/utils/event/someday.event.util.ts b/packages/web/src/common/utils/event/someday.event.util.ts index 0b411180c..cee63bdd4 100644 --- a/packages/web/src/common/utils/event/someday.event.util.ts +++ b/packages/web/src/common/utils/event/someday.event.util.ts @@ -1,3 +1,4 @@ +import { Priorities } from "@core/constants/core.constants"; import { Categories_Event, type Schema_Event } from "@core/types/event.types"; import dayjs, { type Dayjs } from "@core/util/date/dayjs"; import { @@ -9,6 +10,7 @@ import { type Schema_SomedayEvent, type Schema_SomedayEventsColumn, } from "@web/common/types/web.event.types"; +import { getDatesByCategory } from "@web/common/utils/datetime/web.date.util"; import { validateSomedayEvents } from "@web/common/validators/someday.event.validator"; const uniqBy = (array: T[], iteratee: (item: T) => K): T[] => { @@ -142,3 +144,56 @@ export const isSomedayEventActionMenuOpen = () => { const actionMenu = document.getElementById(ID_SOMEDAY_EVENT_ACTION_MENU); return !!actionMenu; }; + +/** + * Builds the payload for converting a calendar (grid) event into a someday + * sidebar event. Someday events are categorized into the Week/Month columns + * by their dates, so the original grid dates must be replaced with the + * category's date range. + */ +export const assembleSomedayConversionEvent = ( + event: T, + { + category, + order, + viewEnd, + viewStart, + }: { + category: Categories_Event.SOMEDAY_MONTH | Categories_Event.SOMEDAY_WEEK; + order: number; + viewEnd: Dayjs; + viewStart: Dayjs; + }, +) => { + const { startDate, endDate } = getDatesByCategory( + category, + viewStart, + viewEnd, + ); + const frequency = + category === Categories_Event.SOMEDAY_WEEK ? "WEEKLY" : "MONTHLY"; + const recurrence = event.recurrence?.rule + ? { + ...event.recurrence, + rule: event.recurrence.rule.map((rule) => { + const isRRule = rule.startsWith("RRULE:"); + + if (!isRRule) return rule; + + return rule.replace(/FREQ=\w+;/, `FREQ=${frequency};`); + }), + } + : event.recurrence; + + return { + ...event, + endDate, + isAllDay: false, + isSomeday: true, + order, + priority: event.priority ?? Priorities.UNASSIGNED, + recurrence, + startDate, + user: event.user ?? "", + }; +}; diff --git a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.test.ts b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.test.ts index 33e7ea7b7..fa5fea989 100644 --- a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.test.ts +++ b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.test.ts @@ -357,9 +357,13 @@ describe("useDraftActions", () => { payload: { event: expect.objectContaining({ _id: "event-1", + // Someday events are bucketed into the Week/Month columns by + // date, so a Month move must take the view month's date range. + endDate: "2024-01-31", isAllDay: false, isSomeday: true, order: 0, + startDate: "2024-01-01", }), }, }), diff --git a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts index e1eacdef3..050f8795b 100644 --- a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts +++ b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts @@ -24,6 +24,7 @@ import { type Schema_WebEvent, } from "@web/common/types/web.event.types"; import { assembleDefaultEvent } from "@web/common/utils/event/event.util"; +import { assembleSomedayConversionEvent } from "@web/common/utils/event/someday.event.util"; import { type Payload_ConvertEvent, type Payload_EditEvent, @@ -226,36 +227,20 @@ export const useDraftActions = ( } const event: Payload_ConvertEvent["event"] = { - ...draft, + ...assembleSomedayConversionEvent(draft!, { + category: Categories_Event.SOMEDAY_WEEK, + order: somedayWeekCount, + viewEnd: dayjs(end), + viewStart: dayjs(start), + }), _id: draft!._id!, - user: draft?.user ?? "", - isAllDay: false, - isSomeday: true, - startDate: start, - endDate: end, - origin: draft?.origin, - priority: draft?.priority ?? Priorities.UNASSIGNED, - order: somedayWeekCount, }; - if (isRecurrence()) { - event.recurrence = { - ...event.recurrence, - rule: event.recurrence?.rule?.map((rule) => { - const isRRule = rule.startsWith("RRULE:"); - - if (!isRRule) return rule; - - return rule.replace(/FREQ=\w+;/, "FREQ=WEEKLY;"); - }) as string[], - }; - } - dispatch(getWeekEventsSlice.actions.convert({ event })); discard(); }, - [discard, dispatch, draft, isAtWeeklyLimit, somedayWeekCount, isRecurrence], + [discard, dispatch, draft, isAtWeeklyLimit, somedayWeekCount], ); const openForm = useCallback(() => { @@ -497,13 +482,13 @@ export const useDraftActions = ( } const event: Payload_ConvertEvent["event"] = { - ...draft, + ...assembleSomedayConversionEvent(draft, { + category, + order: isWeek ? somedayWeekCount : somedayMonthCount, + viewEnd: weekProps.component.endOfView, + viewStart: weekProps.component.startOfView, + }), _id: draft._id, - isAllDay: false, - isSomeday: true, - order: isWeek ? somedayWeekCount : somedayMonthCount, - priority: draft.priority ?? Priorities.UNASSIGNED, - user: draft.user ?? "", }; dispatch(getWeekEventsSlice.actions.convert({ event })); @@ -520,6 +505,8 @@ export const useDraftActions = ( isAtWeeklyLimit, somedayMonthCount, somedayWeekCount, + weekProps.component.endOfView, + weekProps.component.startOfView, ], ); diff --git a/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx b/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx index 823f2f200..7b6a3b57b 100644 --- a/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx +++ b/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx @@ -6,22 +6,22 @@ import { useRef, } from "react"; import { - Priorities, SOMEDAY_MONTH_LIMIT_MSG, SOMEDAY_WEEK_LIMIT_MSG, } from "@core/constants/core.constants"; import { Categories_Event } from "@core/types/event.types"; import { CalendarInteractionPointerCaptureBoundary } from "@web/common/calendar-interaction/react/CalendarInteractionPointerCaptureBoundary"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; +import { assembleSomedayConversionEvent } from "@web/common/utils/event/someday.event.util"; import { type Payload_ConvertEvent } from "@web/ducks/events/event.types"; import { selectAllDayEvents, selectGridEvents, } from "@web/ducks/events/selectors/event.selectors"; import { - selectCategorizedEvents, selectIsAtMonthlyLimit, selectIsAtWeeklyLimit, + selectSomedayMonthCount, selectSomedayWeekCount, } from "@web/ducks/events/selectors/someday.selectors"; import { draftSlice } from "@web/ducks/events/slices/draft.slice"; @@ -56,9 +56,9 @@ export const WeekInteractionCoordinator: FC = ({ const pendingEventIds = useAppSelector( (state) => state.events.pendingEvents.eventIds, ); - const categorizedSomedayEvents = useAppSelector(selectCategorizedEvents); const isAtMonthlyLimit = useAppSelector(selectIsAtMonthlyLimit); const isAtWeeklyLimit = useAppSelector(selectIsAtWeeklyLimit); + const somedayMonthCount = useAppSelector(selectSomedayMonthCount); const somedayWeekCount = useAppSelector(selectSomedayWeekCount); const { actions, confirmation, setters, state } = useDraftContext(); const layoutSourcesRef = useRef(getLayoutSources); @@ -150,23 +150,18 @@ export const WeekInteractionCoordinator: FC = ({ return; } - const order = isWeekDrop - ? Math.max(result.index, somedayWeekCount) - : Math.max( - result.index, - categorizedSomedayEvents.columns.month.eventIds.length, - ); const event: Payload_ConvertEvent["event"] = { - ...result.event, + ...assembleSomedayConversionEvent(result.event, { + category: result.category, + order: isWeekDrop ? somedayWeekCount : somedayMonthCount, + viewEnd: weekProps.component.endOfView, + viewStart: weekProps.component.startOfView, + }), _id: result.eventId, - isAllDay: false, - isSomeday: true, - order, - priority: result.event.priority ?? Priorities.UNASSIGNED, - user: result.event.user ?? "", }; dispatch(getWeekEventsSlice.actions.convert({ event })); + actions.discard(); }; runtimeRef.current = { diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts index 4bd032628..1dad710a9 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts @@ -390,7 +390,7 @@ describe("WeekInteractionAdapter all-day drag", () => { ) as HTMLElement | null; expect(overlay).toBeTruthy(); - expect(overlay?.style.transition).toBe("none"); + expect(overlay?.style.transition).not.toContain("transform"); expect(overlay?.style.transform).toBe("translate3d(100px, 0px, 0)"); adapter.handlePointerUp( @@ -453,6 +453,58 @@ describe("WeekInteractionAdapter all-day drag", () => { ); }); + it("smart scrolls the grid while hovering the timed grid near its bottom edge", () => { + const { adapter, child, flushFrame } = createHarness(); + const mainGrid = document.getElementById(ID_GRID_MAIN) as HTMLElement; + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 30 }), + ); + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 430, y: 1320 }), + ); + + flushFrame(); + expect(mainGrid.scrollTop).toBe(10); + + flushFrame(); + expect(mainGrid.scrollTop).toBe(20); + }); + + it("clamps an all-day to timed conversion so it ends within the dropped day", () => { + const { adapter, child, event, flushFrame, onCommitAllDayDrag } = + createHarness(); + const mainGrid = document.getElementById(ID_GRID_MAIN) as HTMLElement; + + mainGrid.scrollTop = 1300; + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 30 }), + ); + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 430, y: 1390 }), + ); + + flushFrame(); + + adapter.handlePointerUp( + makePointerEvent("pointerup", { target: child, x: 430, y: 1390 }), + ); + + expect(onCommitAllDayDrag).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + _id: event._id, + endDate: expect.stringContaining("00:00"), + isAllDay: false, + startDate: expect.stringContaining("23:00"), + }), + hasMoved: true, + type: "allDayDragEnd", + }), + ); + }); + it("commits an all-day event to the Someday Month sidebar drop zone", () => { const { adapter, diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts index 06a952fa9..3dfc6263c 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts @@ -422,7 +422,7 @@ describe("WeekInteractionAdapter timed drag", () => { ) as HTMLElement | null; expect(overlay).toBeTruthy(); - expect(overlay?.style.transition).toBe("none"); + expect(overlay?.style.transition).not.toContain("transform"); expect(overlay?.style.transform).toBe("translate3d(0px, 100px, 0)"); adapter.handlePointerUp( @@ -486,6 +486,97 @@ describe("WeekInteractionAdapter timed drag", () => { ); }); + it("stops smart scrolling once the pointer is over the all-day row", () => { + const { adapter, child, flushFrame, frameCallbacks, mainGrid } = + createHarness({ mainGridScrollTop: 600 }); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 1020 }), + ); + + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 430, y: 120 }), + ); + flushFrame(); + + expect(mainGrid.scrollTop).toBe(590); + + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 430, y: 30 }), + ); + flushFrame(); + + expect(mainGrid.scrollTop).toBe(590); + expect(frameCallbacks.size).toBe(0); + }); + + it("restores the timed overlay shape after leaving the all-day row", () => { + const { adapter, child, flushFrame } = createHarness(); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 1020 }), + ); + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 430, y: 30 }), + ); + flushFrame(); + + const overlay = document.body.querySelector( + "[data-calendar-interaction-overlay]", + ) as HTMLElement | null; + + expect(overlay?.style.height).toBe("20px"); + + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 430, y: 1120 }), + ); + flushFrame(); + + expect(overlay?.style.height).toBe("100px"); + expect(overlay?.style.width).toBe("90px"); + expect(overlay?.style.transition).not.toContain("transform"); + }); + + it("does not scroll or dwell-navigate while hovering a sidebar drop zone", () => { + const { + adapter, + child, + flushFrame, + mainGrid, + onCommitCalendarToSidebar, + onRequestWeekNavigation, + } = createHarness({ mainGridScrollTop: 600 }); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 1020 }), + ); + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 950, y: 120 }), + ); + flushFrame(16); + + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 950, y: 121 }), + ); + flushFrame(800); + + expect(mainGrid.scrollTop).toBe(600); + expect(onRequestWeekNavigation).not.toHaveBeenCalled(); + expect(getWeekInteractionEdgeNavigationState().currentEdge).toBeNull(); + + adapter.handlePointerUp( + makePointerEvent("pointerup", { target: child, x: 950, y: 121 }), + ); + + expect(onRequestWeekNavigation).not.toHaveBeenCalled(); + expect(onCommitCalendarToSidebar).toHaveBeenCalledWith( + expect.objectContaining({ + category: Categories_Event.SOMEDAY_WEEK, + type: "calendarToSidebar", + }), + ); + }); + it("commits a timed event to the Someday Week sidebar drop zone", () => { const { adapter, diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts index 9cf1f4327..0b0afbb95 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts @@ -102,7 +102,12 @@ const activeEdgeNavigationIndicatorState = { } as const; const CROSS_SURFACE_SNAP_TRANSITION = "height 160ms cubic-bezier(0.16, 1, 0.3, 1), width 160ms cubic-bezier(0.16, 1, 0.3, 1), transform 120ms cubic-bezier(0.16, 1, 0.3, 1)"; +// Shape-only: transform must follow the pointer without easing outside the +// snapped cross-surface mode, while still animating the size restore. +const SHAPE_SNAP_TRANSITION = + "height 160ms cubic-bezier(0.16, 1, 0.3, 1), width 160ms cubic-bezier(0.16, 1, 0.3, 1)"; const DEFAULT_CONVERTED_TIMED_DURATION_MINUTES = 60; +const MINUTES_PER_DAY = 24 * 60; interface WeekSidebarDrop { category: SomedayInteractionCategory; @@ -122,6 +127,7 @@ export const createWeekInteractionAdapter = ({ const edgeNavigation = createWeekEdgeNavigationController(); let isLayoutRebuildPending = false; let allDayLayout: WeekLayoutCache | null = null; + let crossSurfaceScrollTop: number | null = null; let layout: WeekLayoutCache | null = null; let scrollTop: number | null = null; let timedLayout: WeekLayoutCache | null = null; @@ -394,36 +400,21 @@ export const createWeekInteractionAdapter = ({ } if (visual.type === "allDayDrag") { - const nextEdgeNavigation = updateEdgeNavigation( - visual, - pointer, - timestamp, - ); const sidebarDrop = resolveSidebarDrop(pointer); if (sidebarDrop) { - const nextVisual = { - ...nextEdgeNavigation.visual, - crossSurfaceDrop: null, - sidebarDrop, - transform: { - x: pointer.x - nextEdgeNavigation.visual.pointerStart.x, - y: pointer.y - nextEdgeNavigation.visual.pointerStart.y, - }, - }; - - return { - overlay: { - transform: nextVisual.transform, - }, - shouldContinue: nextEdgeNavigation.isDwellActive, - visual: nextVisual, - }; + return updateSidebarDropVisual(visual, pointer, sidebarDrop); } + const nextEdgeNavigation = updateEdgeNavigation( + visual, + pointer, + timestamp, + ); const timedDrop = resolveTimedCrossSurfaceDrop(pointer); if (timedDrop) { + const crossSurfaceScroll = applyCrossSurfaceSmartScroll(pointer); const overlayRect = getTimedCrossSurfaceOverlayRect( timedDrop, nextEdgeNavigation.visual, @@ -444,7 +435,9 @@ export const createWeekInteractionAdapter = ({ transform: nextVisual.transform, width: overlayRect.width, }, - shouldContinue: nextEdgeNavigation.isDwellActive, + shouldContinue: + crossSurfaceScroll.isScrolling || + nextEdgeNavigation.isDwellActive, visual: nextVisual, }; } @@ -461,7 +454,10 @@ export const createWeekInteractionAdapter = ({ return { overlay: { + height: nextVisual.sourceRect.height, + mutate: applyShapeSnapTransition, transform: nextVisual.transform, + width: nextVisual.sourceRect.width, }, shouldContinue: nextEdgeNavigation.isDwellActive, visual: nextVisual, @@ -512,35 +508,17 @@ export const createWeekInteractionAdapter = ({ throw new Error("Mismatched Week interaction target"); } - const smartScroll = applySmartScroll(pointer); - const nextEdgeNavigation = updateEdgeNavigation( - visual, - pointer, - timestamp, - ); const sidebarDrop = resolveSidebarDrop(pointer); if (sidebarDrop) { - const nextVisual = { - ...nextEdgeNavigation.visual, - crossSurfaceDrop: null, - sidebarDrop, - transform: { - x: pointer.x - nextEdgeNavigation.visual.pointerStart.x, - y: pointer.y - nextEdgeNavigation.visual.pointerStart.y, - }, - }; - - return { - overlay: { - transform: nextVisual.transform, - }, - shouldContinue: - smartScroll.isScrolling || nextEdgeNavigation.isDwellActive, - visual: nextVisual, - }; + return updateSidebarDropVisual(visual, pointer, sidebarDrop); } + const nextEdgeNavigation = updateEdgeNavigation( + visual, + pointer, + timestamp, + ); const allDayDrop = resolveAllDayCrossSurfaceDrop(pointer); if (allDayDrop) { @@ -564,12 +542,12 @@ export const createWeekInteractionAdapter = ({ transform: nextVisual.transform, width: overlayRect.width, }, - shouldContinue: - smartScroll.isScrolling || nextEdgeNavigation.isDwellActive, + shouldContinue: nextEdgeNavigation.isDwellActive, visual: nextVisual, }; } + const smartScroll = applySmartScroll(pointer); const next = updateTimedDragInteractionVisual({ layout, pointer, @@ -584,8 +562,13 @@ export const createWeekInteractionAdapter = ({ return { overlay: { - mutate: (node) => updateCalendarOverlayTimeLabel(node, next.event), + height: next.visual.sourceRect.height, + mutate: (node) => { + applyShapeSnapTransition(node); + updateCalendarOverlayTimeLabel(node, next.event); + }, transform: next.visual.transform, + width: next.visual.sourceRect.width, }, shouldContinue: smartScroll.isScrolling || nextEdgeNavigation.isDwellActive, @@ -863,13 +846,68 @@ export const createWeekInteractionAdapter = ({ function setCrossSurfaceLayouts(sources: WeekLayoutCacheSources) { allDayLayout = buildAllDayWeekLayoutCache(sources); timedLayout = buildTimedWeekLayoutCache(sources); + crossSurfaceScrollTop = timedLayout?.smartScroll?.initialScrollTop ?? null; } function clearCrossSurfaceLayouts() { allDayLayout = null; + crossSurfaceScrollTop = null; timedLayout = null; } + // Mirrors applySmartScroll for an all-day drag hovering the timed grid, + // where the active layout cache has no smart-scroll element of its own. + function applyCrossSurfaceSmartScroll(pointer: VisualPoint) { + if (!timedLayout?.smartScroll || crossSurfaceScrollTop === null) { + return { isScrolling: false }; + } + + const frame = getSmartScrollFrame({ + cache: timedLayout.smartScroll, + pointerY: pointer.y, + scrollTop: crossSurfaceScrollTop, + }); + + if (frame.scrollTop !== crossSurfaceScrollTop) { + timedLayout.smartScroll.element.scrollTop = frame.scrollTop; + crossSurfaceScrollTop = frame.scrollTop; + } + + return { isScrolling: frame.velocityPx !== 0 }; + } + + function updateSidebarDropVisual( + visual: TVisual, + pointer: VisualPoint, + sidebarDrop: WeekSidebarDrop, + ) { + // Parking over a sidebar drop zone must not dwell-navigate weeks or + // smart-scroll the grid; the pointer is outside both surfaces. + resetEdgeNavigation(); + setWeekInteractionEdgeNavigationState(activeEdgeNavigationIndicatorState); + + const nextVisual = { + ...visual, + crossSurfaceDrop: null, + sidebarDrop, + transform: { + x: pointer.x - visual.pointerStart.x, + y: pointer.y - visual.pointerStart.y, + }, + }; + + return { + overlay: { + height: visual.sourceRect.height, + mutate: applyShapeSnapTransition, + transform: nextVisual.transform, + width: visual.sourceRect.width, + }, + shouldContinue: false, + visual: nextVisual, + }; + } + function resolveAllDayCrossSurfaceDrop(pointer: VisualPoint) { const crossLayout = allDayLayout; @@ -906,11 +944,17 @@ export const createWeekInteractionAdapter = ({ pointer.y - crossLayout.edgeNavigation.top + (crossLayout.smartScroll?.element.scrollTop ?? 0); - const startMinutes = Math.max( - 0, - Math.floor( - gridY / crossLayout.pixelsPerMinute / crossLayout.snapMinutes, - ) * crossLayout.snapMinutes, + // Clamp so the converted event still ends within the dropped day. + const latestStartMinutes = + MINUTES_PER_DAY - DEFAULT_CONVERTED_TIMED_DURATION_MINUTES; + const startMinutes = Math.min( + latestStartMinutes, + Math.max( + 0, + Math.floor( + gridY / crossLayout.pixelsPerMinute / crossLayout.snapMinutes, + ) * crossLayout.snapMinutes, + ), ); return { @@ -1009,6 +1053,12 @@ export const createWeekInteractionAdapter = ({ : CROSS_SURFACE_SNAP_TRANSITION; } + function applyShapeSnapTransition(node: HTMLElement) { + node.style.transition = isReducedMotionPreferred() + ? "none" + : SHAPE_SNAP_TRANSITION; + } + return { cancel, connectCancellationEvents, From d288c160dc173734f1761b7955b84daa516cafb8 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 13 Jun 2026 18:15:02 -0500 Subject: [PATCH 3/5] feat(web): show someday drop-zone styles when dragging a grid event Dragging a timed or all-day grid event over the Someday sidebar now lights up the Week/Month drop zones (dashed active border, red when the column is full), matching the feedback shown when reordering someday items. Previously the zones stayed inert because their styling keys off sidebar `isDragging` (= isDNDing && sidebar draft), which the separate WeekInteractionAdapter drag path never sets. - Add a dedicated `isCalendarDragActive` sidebar state + a `setCalendarSidebarDropPreview` action, reusing `blockedSomedayDropColumn` for the full-column state. This avoids faking a sidebar draft (which would render a ghost someday list item via isDraftingNew). - SomedayEventsContainer honors the new flag alongside isDragging for the active style, expanded drop height, and hidden add button. - WeekInteractionAdapter emits a deduped onPreviewCalendarToSidebar on each move (the category under the pointer, or null), and clears it on commit and cancel via clearInteractionState. - WeekInteractionCoordinator maps the previewed category to its column and capacity-blocked flag (reusing the limit selectors it already reads) and drives the sidebar action through the shared SidebarDraftProvider context. Styling activates only while the dragged event is over the sidebar and clears on return to the grid, on a normal grid drop, and on Escape/cancel. Co-Authored-By: Claude Fable 5 --- .../SomedayEventsContainer.test.tsx | 84 ++++++++++++++----- .../SomedayEventsContainer.tsx | 22 +++-- .../draft/hooks/useSidebarActions.test.ts | 1 + .../draft/hooks/useSidebarActions.ts | 19 +++++ .../draft/hooks/useSidebarState.ts | 6 ++ .../WeekInteractionCoordinator.tsx | 19 +++++ .../WeekInteractionAdapter.allDayDrag.test.ts | 27 ++++++ .../WeekInteractionAdapter.timedDrag.test.ts | 68 +++++++++++++++ .../adapter/WeekInteractionAdapter.ts | 20 +++++ .../adapter/WeekInteractionAdapter.types.ts | 3 + 10 files changed, 241 insertions(+), 28 deletions(-) diff --git a/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.test.tsx b/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.test.tsx index cf4a53268..1c00cab4c 100644 --- a/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.test.tsx +++ b/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.test.tsx @@ -9,11 +9,23 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; const mockCreateSomedayDraft = mock(); -mock.module("@web/components/DND/DropZone", () => ({ - DropZone: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})); +const defaultSidebarState = () => ({ + blockedSomedayDropColumn: null as string | null, + draft: null, + isCalendarDragActive: false, + isDragging: false, + isDraftingNew: false, + isSomedayFormOpen: false, + somedayEvents: { + columns: { + weekEvents: { eventIds: [] }, + monthEvents: { eventIds: [] }, + }, + events: {}, + }, +}); + +let sidebarState = defaultSidebarState(); mock.module( "@web/components/PlannerSidebar/draft/context/useSidebarContext", @@ -22,20 +34,7 @@ mock.module( actions: { createSomedayDraft: mockCreateSomedayDraft, }, - state: { - blockedSomedayDropColumn: null, - draft: null, - isDragging: false, - isDraftingNew: false, - isSomedayFormOpen: false, - somedayEvents: { - columns: { - weekEvents: { eventIds: [] }, - monthEvents: { eventIds: [] }, - }, - events: {}, - }, - }, + state: sidebarState, }), }), ); @@ -74,6 +73,7 @@ const renderSomedayEventsContainer = ( describe("SomedayEventsContainer", () => { beforeEach(() => { mockCreateSomedayDraft.mockClear(); + sidebarState = defaultSidebarState(); }); it("keeps the visible add label in the week button's accessible name", () => { @@ -101,4 +101,50 @@ describe("SomedayEventsContainer", () => { screen.getByRole("button", { name: "Add item to month" }), ).toBeTruthy(); }); + + it("hides the add button while a calendar event is dragged over the sidebar", () => { + sidebarState = { ...defaultSidebarState(), isCalendarDragActive: true }; + + renderSomedayEventsContainer({ + category: Categories_Event.SOMEDAY_WEEK, + events: [], + isDraftingNew: false, + }); + + expect( + screen.queryByRole("button", { name: "Add item to week" }), + ).toBeNull(); + }); + + it("marks the drop zone invalid when its column is the blocked target", () => { + sidebarState = { + ...defaultSidebarState(), + blockedSomedayDropColumn: "weekEvents", + isCalendarDragActive: true, + }; + + const { container } = renderSomedayEventsContainer({ + category: Categories_Event.SOMEDAY_WEEK, + events: [], + isDraftingNew: false, + }); + + expect(container.querySelector('[aria-invalid="true"]')).not.toBeNull(); + }); + + it("does not mark the drop zone invalid when another column is blocked", () => { + sidebarState = { + ...defaultSidebarState(), + blockedSomedayDropColumn: "monthEvents", + isCalendarDragActive: true, + }; + + const { container } = renderSomedayEventsContainer({ + category: Categories_Event.SOMEDAY_WEEK, + events: [], + isDraftingNew: false, + }); + + expect(container.querySelector('[aria-invalid="true"]')).toBeNull(); + }); }); diff --git a/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.tsx b/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.tsx index 9c1062167..4908ba9c9 100644 --- a/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.tsx +++ b/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.tsx @@ -74,24 +74,28 @@ export const SomedayEventsContainer: FC = ({ const isDraftingThisCategory = state.isDraftingNew && category === draftCategory; const isBlockedDropTarget = state.blockedSomedayDropColumn === colName; + // A drag is active for styling purposes whether it originates from the + // sidebar (`isDragging`) or from a calendar event dragged over the sidebar. + const isInteractionActive = state.isDragging || state.isCalendarDragActive; const addTargetLabel = getAddTargetLabel(category); const addLabel = `Add item to ${addTargetLabel}`; const addShortcut = category === Categories_Event.SOMEDAY_MONTH ? "Shift+M" : "Shift+W"; - const activeDropZoneStyle: React.CSSProperties | undefined = state.isDragging - ? { - boxSizing: "border-box", - height: getActiveDropZoneHeight(events.length, category), - } - : undefined; + const activeDropZoneStyle: React.CSSProperties | undefined = + isInteractionActive + ? { + boxSizing: "border-box", + height: getActiveDropZoneHeight(events.length, category), + } + : undefined; return (
= ({ ))}
- {!isDraftingNew && !state.isDragging && ( + {!isDraftingNew && !isInteractionActive && (
({ blockedSomedayDropColumn: null, draft: somedayEvent, + isCalendarDragActive: false, isDrafting: true, isDraftingExisting: true, isDraftingNew: false, diff --git a/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.ts b/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.ts index 43d880bff..75a4b2916 100644 --- a/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.ts +++ b/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.ts @@ -235,6 +235,7 @@ export const useSidebarActions = ( const { setBlockedSomedayDropColumn, setDraft, + setIsCalendarDragActive, setIsDrafting, setIsSomedayFormOpen, setSomedayEvents, @@ -377,6 +378,23 @@ export const useSidebarActions = ( ); }; + // Drives the Someday drop-zone styling while a calendar (grid) event is + // dragged over the sidebar. Unlike sidebar-originated drags, this path does + // not own a sidebar draft, so it toggles a dedicated flag instead of + // `isDragging`. Passing `null` clears the styling (pointer left / drag end). + const setCalendarSidebarDropPreview = ( + preview: { column: string; isBlocked: boolean } | null, + ) => { + if (!preview) { + setIsCalendarDragActive(false); + setBlockedSomedayDropColumn(null); + return; + } + + setIsCalendarDragActive(true); + setBlockedSomedayDropColumn(preview.isBlocked ? preview.column : null); + }; + const previewBlockedSomedaySidebarDrop = ( result: SomedaySidebarCommitResult, ) => { @@ -852,6 +870,7 @@ export const useSidebarActions = ( previewBlockedSomedaySidebarDrop, previewSomedaySidebarDrop, reset, + setCalendarSidebarDropPreview, setDraft, startSomedayInteraction, }; diff --git a/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarState.ts b/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarState.ts index 55cc21241..92232f755 100644 --- a/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarState.ts +++ b/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarState.ts @@ -28,6 +28,10 @@ export const useSidebarState = () => { string | null >(null); const [isSomedayFormOpen, setIsSomedayFormOpen] = useState(false); + // True only while a calendar (grid) event is being dragged over the sidebar. + // The grid drag runs through WeekInteractionAdapter, not the sidebar draft + // path, so it can't flip `isDragging`; this lets the drop zones light up. + const [isCalendarDragActive, setIsCalendarDragActive] = useState(false); const isDragging = isDNDing && draft !== null; @@ -46,6 +50,7 @@ export const useSidebarState = () => { somedayMonthIds, somedayWeekIds, blockedSomedayDropColumn, + isCalendarDragActive, isDrafting, isDraftingNew, isDraftingExisting, @@ -56,6 +61,7 @@ export const useSidebarState = () => { const setters = { setDraft, setBlockedSomedayDropColumn, + setIsCalendarDragActive, setIsDrafting, setIsDraftingExisting, setIsSomedayFormOpen, diff --git a/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx b/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx index 7b6a3b57b..f561040b2 100644 --- a/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx +++ b/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx @@ -11,8 +11,10 @@ import { } from "@core/constants/core.constants"; import { Categories_Event } from "@core/types/event.types"; import { CalendarInteractionPointerCaptureBoundary } from "@web/common/calendar-interaction/react/CalendarInteractionPointerCaptureBoundary"; +import { COLUMN_MONTH, COLUMN_WEEK } from "@web/common/constants/web.constants"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; import { assembleSomedayConversionEvent } from "@web/common/utils/event/someday.event.util"; +import { useSidebarContext } from "@web/components/PlannerSidebar/draft/context/useSidebarContext"; import { type Payload_ConvertEvent } from "@web/ducks/events/event.types"; import { selectAllDayEvents, @@ -60,6 +62,7 @@ export const WeekInteractionCoordinator: FC = ({ const isAtWeeklyLimit = useAppSelector(selectIsAtWeeklyLimit); const somedayMonthCount = useAppSelector(selectSomedayMonthCount); const somedayWeekCount = useAppSelector(selectSomedayWeekCount); + const sidebarContext = useSidebarContext(true); const { actions, confirmation, setters, state } = useDraftContext(); const layoutSourcesRef = useRef(getLayoutSources); const timedEventsById = useMemo(() => { @@ -164,6 +167,21 @@ export const WeekInteractionCoordinator: FC = ({ actions.discard(); }; + const previewCalendarToSidebar: WeekInteractionRuntime["onPreviewCalendarToSidebar"] = + (preview) => { + if (!preview) { + sidebarContext?.actions.setCalendarSidebarDropPreview(null); + return; + } + + const isWeek = preview.category === Categories_Event.SOMEDAY_WEEK; + + sidebarContext?.actions.setCalendarSidebarDropPreview({ + column: isWeek ? COLUMN_WEEK : COLUMN_MONTH, + isBlocked: isWeek ? isAtWeeklyLimit : isAtMonthlyLimit, + }); + }; + runtimeRef.current = { getAllDayEventById: (eventId) => allDayEventsById.get(eventId) ?? null, getTimedEventById: (eventId) => timedEventsById.get(eventId) ?? null, @@ -181,6 +199,7 @@ export const WeekInteractionCoordinator: FC = ({ actions.closeForm(); } }, + onPreviewCalendarToSidebar: previewCalendarToSidebar, onRequestWeekNavigation: (direction) => { if (direction === "prev") { weekProps.util.decrementWeek("drag-to-edge"); diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts index 1dad710a9..bf1a8aa9c 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts @@ -136,6 +136,7 @@ const createHarness = ({ const onCommitCalendarToSidebar = mock(); const onCommitAllDayDrag = mock(); const onMotionActivation = mock(); + const onPreviewCalendarToSidebar = mock(); const onRequestWeekNavigation = mock(); source.style.visibility = "visible"; @@ -217,6 +218,7 @@ const createHarness = ({ onCommitAllDayDrag, onCommitTimedDrag: () => undefined, onMotionActivation, + onPreviewCalendarToSidebar, onRequestWeekNavigation, }), }); @@ -242,6 +244,7 @@ const createHarness = ({ onCommitCalendarToSidebar, onCommitAllDayDrag, onMotionActivation, + onPreviewCalendarToSidebar, onRequestWeekNavigation, source, timerCallbacks, @@ -505,6 +508,30 @@ describe("WeekInteractionAdapter all-day drag", () => { ); }); + it("previews the Someday Month sidebar drop zone while an all-day drag hovers it", () => { + const { adapter, child, flushFrame, onPreviewCalendarToSidebar } = + createHarness(); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 30 }), + ); + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 950, y: 150 }), + ); + flushFrame(); + + expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith({ + category: Categories_Event.SOMEDAY_MONTH, + }); + + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 360, y: 30 }), + ); + flushFrame(); + + expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith(null); + }); + it("commits an all-day event to the Someday Month sidebar drop zone", () => { const { adapter, diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts index 3dfc6263c..5c8ddfd07 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts @@ -141,6 +141,7 @@ const createHarness = ({ const onCommitCalendarToSidebar = mock(); const onCommitTimedDrag = mock(); const onMotionActivation = mock(); + const onPreviewCalendarToSidebar = mock(); const onRequestWeekNavigation = mock(); source.style.visibility = "visible"; @@ -218,6 +219,7 @@ const createHarness = ({ onCommitCalendarToSidebar, onCommitTimedDrag, onMotionActivation, + onPreviewCalendarToSidebar, onRequestWeekNavigation, }), }); @@ -257,6 +259,7 @@ const createHarness = ({ onCommitCalendarToSidebar, onCommitTimedDrag, onMotionActivation, + onPreviewCalendarToSidebar, onRequestWeekNavigation, source, timerCallbacks, @@ -577,6 +580,71 @@ describe("WeekInteractionAdapter timed drag", () => { ); }); + it("previews the Someday sidebar drop zone as the pointer enters, leaves, and commits", () => { + const { adapter, child, flushFrame, onPreviewCalendarToSidebar } = + createHarness(); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 1020 }), + ); + + // Enter the sidebar zone -> reports the Week category once. + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 950, y: 150 }), + ); + flushFrame(); + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 950, y: 170 }), + ); + flushFrame(); + + expect(onPreviewCalendarToSidebar).toHaveBeenCalledTimes(1); + expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith({ + category: Categories_Event.SOMEDAY_WEEK, + }); + + // Move back over the grid -> clears the preview. + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 320, y: 800 }), + ); + flushFrame(); + + expect(onPreviewCalendarToSidebar).toHaveBeenCalledTimes(2); + expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith(null); + + // Re-enter then drop -> preview set again, then cleared on commit. + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 950, y: 150 }), + ); + flushFrame(); + adapter.handlePointerUp( + makePointerEvent("pointerup", { target: child, x: 950, y: 150 }), + ); + + expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith(null); + }); + + it("clears the sidebar preview when the drag is cancelled", () => { + const { adapter, child, flushFrame, onPreviewCalendarToSidebar } = + createHarness(); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 1020 }), + ); + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 950, y: 150 }), + ); + flushFrame(); + + expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith({ + category: Categories_Event.SOMEDAY_WEEK, + }); + + adapter.cancel(); + + expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith(null); + }); + it("commits a timed event to the Someday Week sidebar drop zone", () => { const { adapter, diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts index 0b0afbb95..c8b0a86b5 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts @@ -128,6 +128,7 @@ export const createWeekInteractionAdapter = ({ let isLayoutRebuildPending = false; let allDayLayout: WeekLayoutCache | null = null; let crossSurfaceScrollTop: number | null = null; + let lastReportedSidebarCategory: SomedayInteractionCategory | null = null; let layout: WeekLayoutCache | null = null; let scrollTop: number | null = null; let timedLayout: WeekLayoutCache | null = null; @@ -402,6 +403,8 @@ export const createWeekInteractionAdapter = ({ if (visual.type === "allDayDrag") { const sidebarDrop = resolveSidebarDrop(pointer); + reportSidebarPreview(sidebarDrop?.category ?? null); + if (sidebarDrop) { return updateSidebarDropVisual(visual, pointer, sidebarDrop); } @@ -510,6 +513,8 @@ export const createWeekInteractionAdapter = ({ const sidebarDrop = resolveSidebarDrop(pointer); + reportSidebarPreview(sidebarDrop?.category ?? null); + if (sidebarDrop) { return updateSidebarDropVisual(visual, pointer, sidebarDrop); } @@ -834,6 +839,9 @@ export const createWeekInteractionAdapter = ({ layout = null; scrollTop = null; clearCrossSurfaceLayouts(); + // Runs on both commit and cancel, so it clears the sidebar drop-zone + // styling for Escape, pointercancel, regular grid drops, and sidebar drops. + reportSidebarPreview(null); resetEdgeNavigation(); isLayoutRebuildPending = false; } @@ -964,6 +972,18 @@ export const createWeekInteractionAdapter = ({ }; } + // Notifies React (sidebar state) which Someday column the drag is over, so + // the drop zones can light up. Deduped to one call per category change to + // avoid a setState every animation frame. + function reportSidebarPreview(category: SomedayInteractionCategory | null) { + if (category === lastReportedSidebarCategory) { + return; + } + + lastReportedSidebarCategory = category; + runtime().onPreviewCalendarToSidebar?.(category ? { category } : null); + } + function resolveSidebarDrop(pointer: VisualPoint): WeekSidebarDrop | null { for (const target of somedayDropTargetRegistry.getTargets()) { const rect = target.element.getBoundingClientRect(); diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts index 8ad6f0407..bfa008d54 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts @@ -43,6 +43,9 @@ export interface WeekInteractionRuntime { onCommitTimedDrag: (result: WeekTimedDragCommitResult) => void; onCommitTimedResize?: (result: WeekTimedResizeCommitResult) => void; onMotionActivation?: (target: WeekInteractionTarget) => void; + onPreviewCalendarToSidebar?: ( + preview: { category: SomedayInteractionCategory } | null, + ) => void; onRequestWeekNavigation?: (direction: "next" | "prev") => void; } From a60aeccf2ba0cd88ee16d3f0e40e0d24d2e83c4b Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 13 Jun 2026 18:34:21 -0500 Subject: [PATCH 4/5] feat(web): live reorder + nearest-list snap for grid->sidebar drag Two follow-up fixes for dragging a grid event into the Someday sidebar: 1. Live reordering. While hovering a grid event over a Someday list, the existing rows now shift with the standard FLIP animation as the pointer moves, instead of only rearranging after the drop. The calendar-drag preview now inserts a someday-shaped placeholder at the hovered index (via setSomedayEvents), mirroring the native sidebar reorder path; the placeholder is excluded from the index math to avoid jitter, and the snapshot is restored on leave/cancel. 2. Nearest-list snap. Dragging into the empty space between (or around) the two lists no longer jumps the event back to the grid. resolveSidebarDrop now treats the whole sidebar column as sidebar territory and snaps to the vertically nearest zone, so moving down from the Week list into the gap lands on the Month list. The preview channel (onPreviewCalendarToSidebar) now carries the hovered index and the dragged event, deduped on category+index so the placeholder follows the pointer without a setState every frame. Co-Authored-By: Claude Fable 5 --- .../draft/hooks/useSidebarActions.test.ts | 92 +++++++++++++ .../draft/hooks/useSidebarActions.ts | 95 ++++++++++++- .../WeekInteractionCoordinator.tsx | 11 ++ .../WeekInteractionAdapter.allDayDrag.test.ts | 4 +- .../WeekInteractionAdapter.timedDrag.test.ts | 78 ++++++++++- .../adapter/WeekInteractionAdapter.ts | 128 +++++++++++++----- .../adapter/WeekInteractionAdapter.types.ts | 6 +- 7 files changed, 367 insertions(+), 47 deletions(-) diff --git a/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.test.ts b/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.test.ts index 43f350513..76f5cefb1 100644 --- a/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.test.ts +++ b/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.test.ts @@ -66,6 +66,7 @@ const createSetters = (): Setters_Sidebar => ({ setBlockedSomedayDropColumn: mock(), setDraft: mock(), + setIsCalendarDragActive: mock(), setIsDrafting: mock(), setIsDraftingExisting: mock(), setIsSomedayFormOpen: mock(), @@ -140,4 +141,95 @@ describe("useSidebarActions", () => { }); expect(draftStartAction).toBeUndefined(); }); + + const renderActions = (setters: Setters_Sidebar) => { + const { wrapper } = createStoreWrapper(currentState); + + return renderHook( + () => + useSidebarActions( + { + onGoToDate: mock(), + viewEnd: dayjs("2024-01-21"), + viewStart: dayjs("2024-01-15"), + }, + createState(), + setters, + ), + { wrapper }, + ); + }; + + const placeholderEvent: Schema_Event = { + ...somedayEvent, + _id: "grid-event-1", + title: "Grid event", + }; + + it("inserts a placeholder row while a calendar event is previewed over the sidebar", () => { + const setters = createSetters(); + const { result } = renderActions(setters); + + result.current.setCalendarSidebarDropPreview({ + column: COLUMN_WEEK, + event: placeholderEvent, + index: 0, + isBlocked: false, + }); + + expect(setters.setIsCalendarDragActive).toHaveBeenCalledWith(true); + expect(setters.setBlockedSomedayDropColumn).toHaveBeenLastCalledWith(null); + + const previewState = ( + setters.setSomedayEvents as ReturnType + ).mock.calls.at(-1)?.[0] as State_Sidebar["somedayEvents"]; + + expect(previewState.columns[COLUMN_WEEK].eventIds).toEqual([ + "grid-event-1", + somedayEvent._id!, + ]); + expect(previewState.events["grid-event-1"]).toBeTruthy(); + }); + + it("marks the column blocked without inserting a placeholder when full", () => { + const setters = createSetters(); + const { result } = renderActions(setters); + + result.current.setCalendarSidebarDropPreview({ + column: COLUMN_WEEK, + event: placeholderEvent, + index: 0, + isBlocked: true, + }); + + expect(setters.setIsCalendarDragActive).toHaveBeenCalledWith(true); + expect(setters.setBlockedSomedayDropColumn).toHaveBeenLastCalledWith( + COLUMN_WEEK, + ); + expect(setters.setSomedayEvents).not.toHaveBeenCalled(); + }); + + it("restores the list and clears the flags when the preview ends", () => { + const setters = createSetters(); + const { result } = renderActions(setters); + + result.current.setCalendarSidebarDropPreview({ + column: COLUMN_WEEK, + event: placeholderEvent, + index: 0, + isBlocked: false, + }); + result.current.setCalendarSidebarDropPreview(null); + + const restoredState = ( + setters.setSomedayEvents as ReturnType + ).mock.calls.at(-1)?.[0] as State_Sidebar["somedayEvents"]; + + // Restores the original snapshot (no placeholder). + expect(restoredState.columns[COLUMN_WEEK].eventIds).toEqual([ + somedayEvent._id!, + ]); + expect(setters.setIsCalendarDragActive).toHaveBeenLastCalledWith(false); + expect(setters.setBlockedSomedayDropColumn).toHaveBeenLastCalledWith(null); + }); }); diff --git a/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.ts b/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.ts index 75a4b2916..4d851976a 100644 --- a/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.ts +++ b/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.ts @@ -179,6 +179,44 @@ const getSomedayEventsAfterSidebarDrop = ({ }; }; +// Inserts a not-yet-someday calendar event into a column at `index` as a +// preview placeholder. Unlike a sidebar reorder there is no source column to +// remove from; the event is added to the events map and the target column. +const getSomedayEventsAfterCalendarDrop = ({ + baseEvents, + column, + event, + index, +}: { + baseEvents: State_Sidebar["somedayEvents"]; + column: string; + event: Schema_Event; + index: number; +}) => { + const targetColumn = baseEvents.columns[column as keyof SomedayEventsColumns]; + const eventIds = Array.from(targetColumn.eventIds).filter( + (id) => id !== event._id, + ); + const clampedIndex = Math.min(Math.max(index, 0), eventIds.length); + + eventIds.splice(clampedIndex, 0, event._id!); + + return { + ...baseEvents, + columns: { + ...baseEvents.columns, + [targetColumn.id]: { + ...targetColumn, + eventIds, + }, + }, + events: { + ...baseEvents.events, + [event._id!]: event, + }, + }; +}; + const applySomedayColumnOrder = ({ eventIds, events, @@ -378,21 +416,66 @@ export const useSidebarActions = ( ); }; - // Drives the Someday drop-zone styling while a calendar (grid) event is - // dragged over the sidebar. Unlike sidebar-originated drags, this path does - // not own a sidebar draft, so it toggles a dedicated flag instead of - // `isDragging`. Passing `null` clears the styling (pointer left / drag end). + // Drives the Someday list while a calendar (grid) event is dragged over the + // sidebar. Unlike sidebar-originated drags, this path does not own a sidebar + // draft, so it toggles a dedicated flag instead of `isDragging` and inserts a + // placeholder row at the hovered index so existing rows animate to make room. + // Passing `null` restores the list and clears the styling (pointer left the + // sidebar / drag ended). const setCalendarSidebarDropPreview = ( - preview: { column: string; isBlocked: boolean } | null, + preview: { + column: string; + event: Schema_Event; + index: number; + isBlocked: boolean; + } | null, ) => { if (!preview) { + const snapshot = interactionSnapshotRef.current; + + if (snapshot) { + setSomedayEvents(snapshot); + } + + interactionSnapshotRef.current = null; + interactionPreviewKeyRef.current = null; setIsCalendarDragActive(false); setBlockedSomedayDropColumn(null); return; } setIsCalendarDragActive(true); - setBlockedSomedayDropColumn(preview.isBlocked ? preview.column : null); + + const snapshot = getInteractionSnapshot(); + + if (preview.isBlocked) { + // No room in the target column: show the blocked state and keep the list + // unchanged (no placeholder gap). + if (interactionPreviewKeyRef.current !== null) { + setSomedayEvents(snapshot); + } + + interactionPreviewKeyRef.current = null; + setBlockedSomedayDropColumn(preview.column); + return; + } + + const previewKey = `${preview.event._id}:${preview.column}:${preview.index}`; + + if (previewKey === interactionPreviewKeyRef.current) { + return; + } + + interactionPreviewKeyRef.current = previewKey; + setBlockedSomedayDropColumn(null); + setSomedayEvents( + getSomedayEventsAfterCalendarDrop({ + baseEvents: snapshot, + column: preview.column, + event: preview.event, + index: preview.index, + }), + ); }; const previewBlockedSomedaySidebarDrop = ( diff --git a/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx b/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx index f561040b2..645abad40 100644 --- a/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx +++ b/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx @@ -175,9 +175,20 @@ export const WeekInteractionCoordinator: FC = ({ } const isWeek = preview.category === Categories_Event.SOMEDAY_WEEK; + // Build the someday-shaped placeholder the list renders while hovering, + // so existing rows animate to make room. It mirrors what the drop will + // commit (assembleSomedayConversionEvent is also used at commit time). + const placeholder = assembleSomedayConversionEvent(preview.event, { + category: preview.category, + order: preview.index, + viewEnd: weekProps.component.endOfView, + viewStart: weekProps.component.startOfView, + }); sidebarContext?.actions.setCalendarSidebarDropPreview({ column: isWeek ? COLUMN_WEEK : COLUMN_MONTH, + event: { ...placeholder, _id: preview.event._id! }, + index: preview.index, isBlocked: isWeek ? isAtWeeklyLimit : isAtMonthlyLimit, }); }; diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts index bf1a8aa9c..5fa918864 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts @@ -509,7 +509,7 @@ describe("WeekInteractionAdapter all-day drag", () => { }); it("previews the Someday Month sidebar drop zone while an all-day drag hovers it", () => { - const { adapter, child, flushFrame, onPreviewCalendarToSidebar } = + const { adapter, child, event, flushFrame, onPreviewCalendarToSidebar } = createHarness(); adapter.handlePointerDown( @@ -522,6 +522,8 @@ describe("WeekInteractionAdapter all-day drag", () => { expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith({ category: Categories_Event.SOMEDAY_MONTH, + event: expect.objectContaining({ _id: event._id }), + index: 0, }); adapter.handlePointerMove( diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts index 5c8ddfd07..13fc48336 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts @@ -581,7 +581,7 @@ describe("WeekInteractionAdapter timed drag", () => { }); it("previews the Someday sidebar drop zone as the pointer enters, leaves, and commits", () => { - const { adapter, child, flushFrame, onPreviewCalendarToSidebar } = + const { adapter, child, event, flushFrame, onPreviewCalendarToSidebar } = createHarness(); adapter.handlePointerDown( @@ -601,6 +601,8 @@ describe("WeekInteractionAdapter timed drag", () => { expect(onPreviewCalendarToSidebar).toHaveBeenCalledTimes(1); expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith({ category: Categories_Event.SOMEDAY_WEEK, + event: expect.objectContaining({ _id: event._id }), + index: 0, }); // Move back over the grid -> clears the preview. @@ -625,7 +627,7 @@ describe("WeekInteractionAdapter timed drag", () => { }); it("clears the sidebar preview when the drag is cancelled", () => { - const { adapter, child, flushFrame, onPreviewCalendarToSidebar } = + const { adapter, child, event, flushFrame, onPreviewCalendarToSidebar } = createHarness(); adapter.handlePointerDown( @@ -638,6 +640,8 @@ describe("WeekInteractionAdapter timed drag", () => { expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith({ category: Categories_Event.SOMEDAY_WEEK, + event: expect.objectContaining({ _id: event._id }), + index: 0, }); adapter.cancel(); @@ -677,6 +681,76 @@ describe("WeekInteractionAdapter timed drag", () => { }); }); + it("snaps to the sidebar list instead of the grid in empty space below the list", () => { + const { + adapter, + child, + event, + flushFrame, + onCommitCalendarToSidebar, + onCommitTimedDrag, + } = createHarness(); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 1020 }), + ); + // x:950 is within the sidebar column (900-1200) but y:620 is below the + // Week list rect (top 100, bottom 500) — far from the grid. + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 950, y: 620 }), + ); + flushFrame(); + adapter.handlePointerUp( + makePointerEvent("pointerup", { target: child, x: 950, y: 620 }), + ); + + expect(onCommitTimedDrag).not.toHaveBeenCalled(); + expect(onCommitCalendarToSidebar).toHaveBeenCalledWith( + expect.objectContaining({ + category: Categories_Event.SOMEDAY_WEEK, + eventId: event._id, + type: "calendarToSidebar", + }), + ); + }); + + it("snaps to the vertically nearest list when between two sidebar lists", () => { + const { adapter, child, flushFrame, onPreviewCalendarToSidebar } = + createHarness(); + // Register a second (Month) drop zone stacked below the Week list, leaving + // a gap between them: Week is y[100,500], Month is y[700,1100]. + const sidebarMonth = document.createElement("div"); + + document.body.append(sidebarMonth); + setRect(sidebarMonth, { height: 400, left: 900, top: 700, width: 300 }); + somedayDropTargetRegistry.register({ + category: Categories_Event.SOMEDAY_MONTH, + element: sidebarMonth, + }); + + adapter.handlePointerDown( + makePointerEvent("pointerdown", { target: child, x: 320, y: 1020 }), + ); + + // Just below the Week list (gap is 500-700) -> nearer Week. + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 950, y: 540 }), + ); + flushFrame(); + expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith( + expect.objectContaining({ category: Categories_Event.SOMEDAY_WEEK }), + ); + + // Just above the Month list -> nearer Month. + adapter.handlePointerMove( + makePointerEvent("pointermove", { target: child, x: 950, y: 660 }), + ); + flushFrame(); + expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith( + expect.objectContaining({ category: Categories_Event.SOMEDAY_MONTH }), + ); + }); + it("adds a temporary time label while dragging a saved timed event that did not render one", () => { const { adapter, child, flushFrame } = createHarness(); diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts index c8b0a86b5..9338ab42e 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts @@ -15,6 +15,7 @@ import { createCalendarInteractionEngine, } from "@web/common/calendar-interaction/CalendarInteractionEngine"; import { isEligibleCalendarInteractionPointerDown } from "@web/common/calendar-interaction/calendarInteractionPointer"; +import { type Schema_GridEvent } from "@web/common/types/web.event.types"; import { somedayDropTargetRegistry } from "@web/components/PlannerSidebar/SomedayEventSections/interaction/registry/somedayDropTargetRegistry"; import { type SomedayInteractionCategory, @@ -128,7 +129,7 @@ export const createWeekInteractionAdapter = ({ let isLayoutRebuildPending = false; let allDayLayout: WeekLayoutCache | null = null; let crossSurfaceScrollTop: number | null = null; - let lastReportedSidebarCategory: SomedayInteractionCategory | null = null; + let lastReportedSidebarKey: string | null = null; let layout: WeekLayoutCache | null = null; let scrollTop: number | null = null; let timedLayout: WeekLayoutCache | null = null; @@ -400,10 +401,15 @@ export const createWeekInteractionAdapter = ({ }; } + const draggedEventId = isDragTarget(target) + ? (target.event._id ?? null) + : null; + const draggedEvent = isDragTarget(target) ? target.event : null; + if (visual.type === "allDayDrag") { - const sidebarDrop = resolveSidebarDrop(pointer); + const sidebarDrop = resolveSidebarDrop(pointer, draggedEventId); - reportSidebarPreview(sidebarDrop?.category ?? null); + reportSidebarPreview(sidebarDrop, draggedEvent); if (sidebarDrop) { return updateSidebarDropVisual(visual, pointer, sidebarDrop); @@ -511,9 +517,9 @@ export const createWeekInteractionAdapter = ({ throw new Error("Mismatched Week interaction target"); } - const sidebarDrop = resolveSidebarDrop(pointer); + const sidebarDrop = resolveSidebarDrop(pointer, draggedEventId); - reportSidebarPreview(sidebarDrop?.category ?? null); + reportSidebarPreview(sidebarDrop, draggedEvent); if (sidebarDrop) { return updateSidebarDropVisual(visual, pointer, sidebarDrop); @@ -841,7 +847,7 @@ export const createWeekInteractionAdapter = ({ clearCrossSurfaceLayouts(); // Runs on both commit and cancel, so it clears the sidebar drop-zone // styling for Escape, pointercancel, regular grid drops, and sidebar drops. - reportSidebarPreview(null); + reportSidebarPreview(null, null); resetEdgeNavigation(); isLayoutRebuildPending = false; } @@ -972,41 +978,98 @@ export const createWeekInteractionAdapter = ({ }; } - // Notifies React (sidebar state) which Someday column the drag is over, so - // the drop zones can light up. Deduped to one call per category change to - // avoid a setState every animation frame. - function reportSidebarPreview(category: SomedayInteractionCategory | null) { - if (category === lastReportedSidebarCategory) { + // Notifies React (sidebar state) where in the Someday lists the drag would + // land, so the drop zones can light up and the list can open a live gap. + // Deduped to one call per category+index change to avoid a setState every + // animation frame. + function reportSidebarPreview( + preview: { category: SomedayInteractionCategory; index: number } | null, + event: Schema_GridEvent | null, + ) { + const key = preview ? `${preview.category}:${preview.index}` : null; + + if (key === lastReportedSidebarKey) { return; } - lastReportedSidebarCategory = category; - runtime().onPreviewCalendarToSidebar?.(category ? { category } : null); + lastReportedSidebarKey = key; + runtime().onPreviewCalendarToSidebar?.( + preview && event + ? { category: preview.category, event, index: preview.index } + : null, + ); } - function resolveSidebarDrop(pointer: VisualPoint): WeekSidebarDrop | null { - for (const target of somedayDropTargetRegistry.getTargets()) { + function resolveSidebarDrop( + pointer: VisualPoint, + draggedEventId: string | null, + ): WeekSidebarDrop | null { + const targets = somedayDropTargetRegistry.getTargets(); + + if (targets.length === 0) { + return null; + } + + // The sidebar is its own column, horizontally disjoint from the grid, so + // "pointer is within the sidebar column" cleanly separates the two. Inside + // that column we snap to the vertically nearest zone (Week/Month) rather + // than requiring the pointer to be exactly inside a zone — otherwise the + // gap between the lists would fall through to the grid. + let sidebarLeft = Number.POSITIVE_INFINITY; + let sidebarRight = Number.NEGATIVE_INFINITY; + let insideTarget: SomedayInteractionCategory | null = null; + let nearestTarget: SomedayInteractionCategory | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + + for (const target of targets) { const rect = target.element.getBoundingClientRect(); - if (!isPointInRect(pointer, rect)) { - continue; + sidebarLeft = Math.min(sidebarLeft, rect.left); + sidebarRight = Math.max(sidebarRight, rect.right); + + const verticalDistance = + pointer.y < rect.top + ? rect.top - pointer.y + : pointer.y > rect.bottom + ? pointer.y - rect.bottom + : 0; + + if (verticalDistance === 0) { + insideTarget = target.category; } - const events = somedayEventRegistry.getEvents(target.category); - const insertionIndex = events.findIndex((event) => { - const eventRect = event.element.getBoundingClientRect(); + if (verticalDistance < nearestDistance) { + nearestDistance = verticalDistance; + nearestTarget = target.category; + } + } - return pointer.y < eventRect.top + eventRect.height / 2; - }); + if (pointer.x < sidebarLeft || pointer.x > sidebarRight) { + return null; + } - return { - category: target.category, - index: insertionIndex === -1 ? events.length : insertionIndex, - type: "sidebar", - }; + const category = insideTarget ?? nearestTarget; + + if (!category) { + return null; } - return null; + // Exclude the dragged event's own preview placeholder from the index math + // so the insertion point doesn't oscillate as the placeholder shifts. + const events = somedayEventRegistry + .getEvents(category) + .filter((event) => event.eventId !== draggedEventId); + const insertionIndex = events.findIndex((event) => { + const eventRect = event.element.getBoundingClientRect(); + + return pointer.y < eventRect.top + eventRect.height / 2; + }); + + return { + category, + index: insertionIndex === -1 ? events.length : insertionIndex, + type: "sidebar", + }; } function getAllDayCrossSurfaceOverlayRect( @@ -1169,15 +1232,6 @@ const isPointInsideColumns = ( ); }; -const isPointInRect = ( - point: VisualPoint, - rect: Pick, -) => - point.x >= rect.left && - point.x <= rect.right && - point.y >= rect.top && - point.y <= rect.bottom; - const getVisualSidebarDrop = ( visual: WeekInteractionVisual, ): WeekSidebarDrop | null => diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts index bfa008d54..9a1f5c68d 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts @@ -44,7 +44,11 @@ export interface WeekInteractionRuntime { onCommitTimedResize?: (result: WeekTimedResizeCommitResult) => void; onMotionActivation?: (target: WeekInteractionTarget) => void; onPreviewCalendarToSidebar?: ( - preview: { category: SomedayInteractionCategory } | null, + preview: { + category: SomedayInteractionCategory; + event: Schema_GridEvent; + index: number; + } | null, ) => void; onRequestWeekNavigation?: (direction: "next" | "prev") => void; } From 0c382c2ccc35d0c6cbbf6c826ce7757bd88bda83 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Wed, 17 Jun 2026 20:11:48 -0500 Subject: [PATCH 5/5] refactor(web): simplify grid->sidebar drag per PR review Addresses review feedback that the cross-zone drag was too complex and introduced too much parallel state/logic. Consolidates onto existing primitives instead of a second preview pipeline. - Reuse the native someday reorder pipeline. A grid event dragged over the sidebar is now injected into the someday snapshot (startCalendarSidebarDrag) so the existing previewSomedaySidebarDrop / getSomedayEventsAfterSidebarDrop / isDragging machinery drives the live reorder, blocked state, and drop-zone styling. Removes the parallel setCalendarSidebarDropPreview and getSomedayEventsAfterCalendarDrop. - One drag state. Deletes isCalendarDragActive; the single isDragging flag now covers grid drags too. SomedayEventsContainer keys only on isDragging. - Conversion via a mapper. Replaces the generic assembleSomedayConversionEvent (with inline RRULE regex) with MapEvent.toSomeday (concrete signature) plus a Zod-validated rewriteRecurrenceFreq helper in core. The existing "Move to Sidebar" action shares the same mapper. - Slims the week adapter: onPreviewCalendarToSidebar payload narrows to {category,index}; adds an onCancelInteraction teardown hook. - Removes the keyboard conversion feature (Shift+A/W/M draft conversion) to be reintroduced in a separate PR. The floating overlay is shrunk to a someday-row chip over the sidebar so it no longer covers the list (the visual bug that made the reorder look broken). Verified visually with a new Playwright repro (e2e/someday/drag-grid-event-to-sidebar.spec.ts): rows open a live gap, the overlay is a small chip, and a drop between lists converts to the nearest list. Co-Authored-By: Claude Fable 5 --- .../drag-grid-event-to-sidebar.spec.ts | 168 ++++++++++++++++++ packages/core/src/mappers/map.event.test.ts | 72 +++++++- packages/core/src/mappers/map.event.ts | 27 +++ .../core/src/mappers/map.recurrence.test.ts | 61 +++++++ packages/core/src/mappers/map.recurrence.ts | 44 +++++ .../utils/event/someday.event.util.test.ts | 102 +---------- .../common/utils/event/someday.event.util.ts | 55 ------ .../SomedayEventsContainer.test.tsx | 9 +- .../SomedayEventsContainer.tsx | 22 +-- .../draft/hooks/useSidebarActions.test.ts | 87 ++++----- .../draft/hooks/useSidebarActions.ts | 144 +++++---------- .../draft/hooks/useSidebarState.ts | 6 - .../hooks/actions/useDraftActions.test.ts | 61 ------- .../Draft/hooks/actions/useDraftActions.ts | 91 +--------- .../Week/hooks/shortcuts/useWeekShortcuts.ts | 24 +-- .../WeekInteractionCoordinator.tsx | 103 +++++++---- .../WeekInteractionAdapter.allDayDrag.test.ts | 3 +- .../WeekInteractionAdapter.timedDrag.test.ts | 6 +- .../adapter/WeekInteractionAdapter.ts | 79 ++++++-- .../adapter/WeekInteractionAdapter.types.ts | 7 +- 20 files changed, 618 insertions(+), 553 deletions(-) create mode 100644 e2e/someday/drag-grid-event-to-sidebar.spec.ts create mode 100644 packages/core/src/mappers/map.recurrence.test.ts create mode 100644 packages/core/src/mappers/map.recurrence.ts diff --git a/e2e/someday/drag-grid-event-to-sidebar.spec.ts b/e2e/someday/drag-grid-event-to-sidebar.spec.ts new file mode 100644 index 000000000..e8d30ca6a --- /dev/null +++ b/e2e/someday/drag-grid-event-to-sidebar.spec.ts @@ -0,0 +1,168 @@ +import { expect, type Page, test } from "@playwright/test"; +import { + createEventTitle, + ensureSidebarOpen, + expectSomedayEventVisible, + fillTitleAndSaveEventForm, + openSomedayEventFormWithMouse, + prepareCalendarPage, +} from "../utils/event-test-utils"; + +test.skip( + ({ isMobile }) => isMobile, + "Mouse flows are desktop-only in week view.", +); + +const sidebarButton = (page: Page, name: string) => + page.locator("#sidebar").getByRole("button", { name }); + +const centerOf = async ( + page: Page, + name: string, + scope: "sidebar" | "grid", +) => { + const locator = + scope === "sidebar" + ? sidebarButton(page, name) + : page.locator("#mainGrid").getByRole("button", { name }); + const box = await locator.boundingBox(); + + if (!box) { + throw new Error(`Expected ${scope} element "${name}" to have a box.`); + } + + return { x: box.x + box.width / 2, y: box.y + box.height / 2, box }; +}; + +const createSomeday = async ( + page: Page, + section: "week" | "month", + prefix: string, +) => { + const title = createEventTitle(prefix); + await openSomedayEventFormWithMouse(page, section); + await fillTitleAndSaveEventForm(page, title); + await expectSomedayEventVisible(page, title); + return title; +}; + +// Creates a TALL timed event via vertical drag-select and waits for its +// optimistic/pending state to settle, so it can be reliably grabbed for a drag +// (a short, freshly-created event lands on the resize handle / refuses to drag). +const createTimed = async (page: Page, prefix: string) => { + const title = createEventTitle(prefix); + const grid = await page.locator("#mainGrid").boundingBox(); + + if (!grid) throw new Error("Expected the week grid to be visible."); + + const gx = grid.x + grid.width * 0.4; + const gy = grid.y + grid.height * 0.25; + + await page.mouse.move(gx, gy); + await page.mouse.down(); + await page.mouse.move(gx, gy + 160, { steps: 12 }); + await page.mouse.up(); + await fillTitleAndSaveEventForm(page, title); + + await expect( + page.locator("#mainGrid").getByRole("button", { name: title }), + ).toBeVisible({ timeout: 8000 }); + await page.waitForTimeout(2500); + + return title; +}; + +test("drags a timed grid event into the Someday week list and reorders rows live", async ({ + page, +}) => { + await prepareCalendarPage(page); + await ensureSidebarOpen(page); + + const weekA = await createSomeday(page, "week", "Week A"); + const weekB = await createSomeday(page, "week", "Week B"); + const timed = await createTimed(page, "Timed T"); + + const start = await centerOf(page, timed, "grid"); + const a = await centerOf(page, weekA, "sidebar"); + const b = await centerOf(page, weekB, "sidebar"); + + // Aim for the seam between A and B in the week list. + const target = { x: a.x, y: (a.y + b.y) / 2 }; + + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(target.x, target.y, { steps: 16 }); + await page.waitForTimeout(250); + + await page.screenshot({ + path: "test-results/repro/week-list-mid-drag.png", + }); + + // The dragged event should appear in the sidebar between A and B (the existing + // rows opened a gap). The dragged row drops its `button` role, so match text. + const placeholder = page + .locator("#sidebar") + .getByText(timed, { exact: false }); + await expect(placeholder).toBeVisible(); + + const placeholderBox = await placeholder.boundingBox(); + const aBox = await sidebarButton(page, weekA).boundingBox(); + const bBox = await sidebarButton(page, weekB).boundingBox(); + + await page.mouse.up(); + + expect(placeholderBox).not.toBeNull(); + expect(aBox).not.toBeNull(); + expect(bBox).not.toBeNull(); + + if (placeholderBox && aBox && bBox) { + const placeholderMid = placeholderBox.y + placeholderBox.height / 2; + const aMid = aBox.y + aBox.height / 2; + const bMid = bBox.y + bBox.height / 2; + + expect(aMid).toBeLessThan(placeholderMid); + expect(placeholderMid).toBeLessThan(bMid); + } +}); + +test("snaps to the nearest Someday list when dropped between the lists", async ({ + page, +}) => { + await prepareCalendarPage(page); + await ensureSidebarOpen(page); + + const weekA = await createSomeday(page, "week", "Week A"); + const monthM = await createSomeday(page, "month", "Month M"); + const timed = await createTimed(page, "Timed T"); + + const start = await centerOf(page, timed, "grid"); + const a = await centerOf(page, weekA, "sidebar"); + const m = await centerOf(page, monthM, "sidebar"); + + // A point in the empty space between the week list (above) and the month + // list (below), biased toward the month list so "nearest" should be Month. + const gapTarget = { x: a.x, y: m.box.y - 8 }; + + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(gapTarget.x, gapTarget.y, { steps: 16 }); + await page.waitForTimeout(250); + + await page.screenshot({ + path: "test-results/repro/between-lists-mid-drag.png", + }); + + await page.mouse.up(); + await page.waitForTimeout(400); + + await page.screenshot({ + path: "test-results/repro/between-lists-after-drop.png", + }); + + // It should have converted to a Someday event (nearest = Month), not jumped + // back onto the grid. + await expect( + page.locator("#mainGrid").getByRole("button", { name: timed }), + ).toHaveCount(0, { timeout: 8000 }); + await expect(sidebarButton(page, timed)).toBeVisible({ timeout: 8000 }); +}); diff --git a/packages/core/src/mappers/map.event.test.ts b/packages/core/src/mappers/map.event.test.ts index e44ebdc52..73c306f5d 100644 --- a/packages/core/src/mappers/map.event.test.ts +++ b/packages/core/src/mappers/map.event.test.ts @@ -1,6 +1,7 @@ import { ObjectId } from "bson"; +import { Origin, Priorities } from "@core/constants/core.constants"; import { MapEvent } from "@core/mappers/map.event"; -import { type Schema_Event } from "@core/types/event.types"; +import { Categories_Event, type Schema_Event } from "@core/types/event.types"; import { createMockBaseEvent, createMockInstance, @@ -25,3 +26,72 @@ describe("MapEvent.removeProviderData", () => { expect((result as Schema_Event).recurrence?.eventId).toBeUndefined(); }); }); + +describe("MapEvent.toSomeday", () => { + const baseEvent: Schema_Event = { + _id: "event-1", + title: "Grid event", + startDate: "2024-03-19T10:00:00.000Z", + endDate: "2024-03-19T11:00:00.000Z", + isAllDay: false, + isSomeday: false, + origin: Origin.COMPASS, + priority: Priorities.WORK, + user: "user-1", + }; + + it("maps a calendar event into a someday payload with the given dates", () => { + const result = MapEvent.toSomeday(baseEvent, { + category: Categories_Event.SOMEDAY_WEEK, + endDate: "2024-03-23", + order: 2, + startDate: "2024-03-17", + }); + + expect(result).toEqual( + expect.objectContaining({ + _id: "event-1", + endDate: "2024-03-23", + isAllDay: false, + isSomeday: true, + order: 2, + priority: Priorities.WORK, + startDate: "2024-03-17", + title: "Grid event", + }), + ); + }); + + it("rewrites the recurrence FREQ for the destination list", () => { + const result = MapEvent.toSomeday( + { ...baseEvent, recurrence: { rule: ["RRULE:FREQ=DAILY;COUNT=5"] } }, + { + category: Categories_Event.SOMEDAY_MONTH, + endDate: "2024-03-31", + order: 0, + startDate: "2024-03-01", + }, + ); + + expect(result.recurrence?.rule).toEqual(["RRULE:FREQ=MONTHLY;COUNT=5"]); + }); + + it("defaults missing priority and user", () => { + const result = MapEvent.toSomeday( + { + ...baseEvent, + priority: undefined, + user: undefined as unknown as string, + }, + { + category: Categories_Event.SOMEDAY_WEEK, + endDate: "2024-03-23", + order: 0, + startDate: "2024-03-17", + }, + ); + + expect(result.priority).toBe(Priorities.UNASSIGNED); + expect(result.user).toBe(""); + }); +}); diff --git a/packages/core/src/mappers/map.event.ts b/packages/core/src/mappers/map.event.ts index 96720f61c..f8730e0ef 100644 --- a/packages/core/src/mappers/map.event.ts +++ b/packages/core/src/mappers/map.event.ts @@ -3,8 +3,10 @@ import mergeWith from "lodash.mergewith"; import { Origin, Priorities } from "@core/constants/core.constants"; import { BaseError } from "@core/errors/errors.base"; +import { rewriteRecurrenceFreq } from "@core/mappers/map.recurrence"; import { CalendarProvider, + type Categories_Event, type Event_Core, type Schema_Event, type Schema_Event_Recur_Base, @@ -78,6 +80,31 @@ export namespace MapEvent { return coreEvent; }; + export const toSomeday = ( + event: Schema_Event, + { + category, + endDate, + order, + startDate, + }: { + category: Categories_Event.SOMEDAY_WEEK | Categories_Event.SOMEDAY_MONTH; + endDate: string; + order: number; + startDate: string; + }, + ): Schema_Event => ({ + ...event, + endDate, + isAllDay: false, + isSomeday: true, + order, + priority: event.priority ?? Priorities.UNASSIGNED, + recurrence: rewriteRecurrenceFreq(event.recurrence, category), + startDate, + user: event.user ?? "", + }); + export const toGcal = ( event: Schema_Event, { status = "confirmed" }: Pick = {}, diff --git a/packages/core/src/mappers/map.recurrence.test.ts b/packages/core/src/mappers/map.recurrence.test.ts new file mode 100644 index 000000000..d1973a62e --- /dev/null +++ b/packages/core/src/mappers/map.recurrence.test.ts @@ -0,0 +1,61 @@ +import { rewriteRecurrenceFreq } from "@core/mappers/map.recurrence"; +import { Categories_Event } from "@core/types/event.types"; + +describe("rewriteRecurrenceFreq", () => { + it("rewrites FREQ to WEEKLY for the week list", () => { + const result = rewriteRecurrenceFreq( + { rule: ["RRULE:FREQ=DAILY;COUNT=10;INTERVAL=1"] }, + Categories_Event.SOMEDAY_WEEK, + ); + + expect(result?.rule).toEqual(["RRULE:FREQ=WEEKLY;COUNT=10;INTERVAL=1"]); + }); + + it("rewrites FREQ to MONTHLY for the month list", () => { + const result = rewriteRecurrenceFreq( + { rule: ["RRULE:FREQ=WEEKLY;COUNT=10"] }, + Categories_Event.SOMEDAY_MONTH, + ); + + expect(result?.rule).toEqual(["RRULE:FREQ=MONTHLY;COUNT=10"]); + }); + + it("rewrites FREQ with no trailing tokens", () => { + const result = rewriteRecurrenceFreq( + { rule: ["RRULE:FREQ=DAILY"] }, + Categories_Event.SOMEDAY_WEEK, + ); + + expect(result?.rule).toEqual(["RRULE:FREQ=WEEKLY"]); + }); + + it("leaves non-RRULE lines untouched", () => { + const result = rewriteRecurrenceFreq( + { rule: ["RRULE:FREQ=DAILY", "EXDATE:20240101T000000Z"] }, + Categories_Event.SOMEDAY_WEEK, + ); + + expect(result?.rule).toEqual([ + "RRULE:FREQ=WEEKLY", + "EXDATE:20240101T000000Z", + ]); + }); + + it("preserves the recurrence eventId", () => { + const result = rewriteRecurrenceFreq( + { rule: ["RRULE:FREQ=DAILY"], eventId: "abc" }, + Categories_Event.SOMEDAY_WEEK, + ); + + expect(result?.eventId).toBe("abc"); + }); + + it("returns the recurrence unchanged when there is no rule", () => { + expect( + rewriteRecurrenceFreq(undefined, Categories_Event.SOMEDAY_WEEK), + ).toBeUndefined(); + expect( + rewriteRecurrenceFreq({ rule: null }, Categories_Event.SOMEDAY_WEEK), + ).toEqual({ rule: null }); + }); +}); diff --git a/packages/core/src/mappers/map.recurrence.ts b/packages/core/src/mappers/map.recurrence.ts new file mode 100644 index 000000000..ea18c67ae --- /dev/null +++ b/packages/core/src/mappers/map.recurrence.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { Categories_Event } from "@core/types/event.types"; + +export interface RecurrenceWithRule { + rule?: string[] | null; + eventId?: string; +} + +const SomedayCategory = z.enum([ + Categories_Event.SOMEDAY_WEEK, + Categories_Event.SOMEDAY_MONTH, +]); + +const SomedayFreq: Record< + z.infer, + "WEEKLY" | "MONTHLY" +> = { + [Categories_Event.SOMEDAY_WEEK]: "WEEKLY", + [Categories_Event.SOMEDAY_MONTH]: "MONTHLY", +}; + +const RecurrenceRules = z.array(z.string()); + +/** + * Rewrites the FREQ of each RRULE line so a converted Someday event recurs at + * its destination list's cadence (weekly for the week list, monthly for the + * month list). Non-RRULE lines (EXDATE, etc.) pass through untouched. Returns + * the recurrence unchanged when there is no rule. + */ +export const rewriteRecurrenceFreq = ( + recurrence: RecurrenceWithRule | undefined, + category: z.infer, +): RecurrenceWithRule | undefined => { + if (!recurrence?.rule) { + return recurrence; + } + + const freq = SomedayFreq[SomedayCategory.parse(category)]; + const rule = RecurrenceRules.parse(recurrence.rule).map((line) => + line.startsWith("RRULE:") ? line.replace(/FREQ=\w+/, `FREQ=${freq}`) : line, + ); + + return { ...recurrence, rule }; +}; diff --git a/packages/web/src/common/utils/event/someday.event.util.test.ts b/packages/web/src/common/utils/event/someday.event.util.test.ts index 06042c4d9..60ee507b0 100644 --- a/packages/web/src/common/utils/event/someday.event.util.test.ts +++ b/packages/web/src/common/utils/event/someday.event.util.test.ts @@ -6,7 +6,7 @@ import { RRULE, RRULE_COUNT_WEEKS, } from "@core/constants/core.constants"; -import { Categories_Event, type Schema_Event } from "@core/types/event.types"; +import { type Schema_Event } from "@core/types/event.types"; import dayjs from "@core/util/date/dayjs"; import { createMockBaseEvent, @@ -18,7 +18,6 @@ import { type Schema_SomedayEventsColumn, } from "@web/common/types/web.event.types"; import { - assembleSomedayConversionEvent, categorizeSomedayEvents, setSomedayEventsOrder, } from "@web/common/utils/event/someday.event.util"; @@ -521,102 +520,3 @@ describe("computeRelativeEventDateRange", () => { }); }); }); - -describe("assembleSomedayConversionEvent", () => { - const viewStart = dayjs("2024-03-17"); - const viewEnd = dayjs("2024-03-23"); - - const gridEvent: Schema_Event = { - _id: "grid-event-1", - title: "Grid event", - endDate: "2024-03-19T11:00:00.000Z", - isAllDay: false, - isSomeday: false, - origin: Origin.COMPASS, - priority: Priorities.WORK, - startDate: "2024-03-19T10:00:00.000Z", - user: "user-1", - }; - - it("uses the view week's date range for a Week conversion", () => { - const result = assembleSomedayConversionEvent(gridEvent, { - category: Categories_Event.SOMEDAY_WEEK, - order: 2, - viewEnd, - viewStart, - }); - - expect(result).toEqual( - expect.objectContaining({ - endDate: "2024-03-23", - isAllDay: false, - isSomeday: true, - order: 2, - priority: Priorities.WORK, - startDate: "2024-03-17", - title: "Grid event", - }), - ); - }); - - it("uses the view month's date range for a Month conversion", () => { - const result = assembleSomedayConversionEvent(gridEvent, { - category: Categories_Event.SOMEDAY_MONTH, - order: 0, - viewEnd, - viewStart, - }); - - expect(result).toEqual( - expect.objectContaining({ - endDate: "2024-03-31", - isSomeday: true, - startDate: "2024-03-01", - }), - ); - }); - - it("rewrites the recurrence frequency to match the destination column", () => { - const recurringEvent: Schema_Event = { - ...gridEvent, - recurrence: { rule: ["RRULE:FREQ=DAILY;COUNT=10;INTERVAL=1"] }, - }; - - const weekResult = assembleSomedayConversionEvent(recurringEvent, { - category: Categories_Event.SOMEDAY_WEEK, - order: 0, - viewEnd, - viewStart, - }); - const monthResult = assembleSomedayConversionEvent(recurringEvent, { - category: Categories_Event.SOMEDAY_MONTH, - order: 0, - viewEnd, - viewStart, - }); - - expect(weekResult.recurrence?.rule).toEqual([ - "RRULE:FREQ=WEEKLY;COUNT=10;INTERVAL=1", - ]); - expect(monthResult.recurrence?.rule).toEqual([ - "RRULE:FREQ=MONTHLY;COUNT=10;INTERVAL=1", - ]); - }); - - it("defaults missing priority and user fields", () => { - const sparseEvent: Schema_Event = { - ...gridEvent, - priority: undefined, - user: undefined, - }; - const result = assembleSomedayConversionEvent(sparseEvent, { - category: Categories_Event.SOMEDAY_WEEK, - order: 0, - viewEnd, - viewStart, - }); - - expect(result.priority).toBe(Priorities.UNASSIGNED); - expect(result.user).toBe(""); - }); -}); diff --git a/packages/web/src/common/utils/event/someday.event.util.ts b/packages/web/src/common/utils/event/someday.event.util.ts index cee63bdd4..0b411180c 100644 --- a/packages/web/src/common/utils/event/someday.event.util.ts +++ b/packages/web/src/common/utils/event/someday.event.util.ts @@ -1,4 +1,3 @@ -import { Priorities } from "@core/constants/core.constants"; import { Categories_Event, type Schema_Event } from "@core/types/event.types"; import dayjs, { type Dayjs } from "@core/util/date/dayjs"; import { @@ -10,7 +9,6 @@ import { type Schema_SomedayEvent, type Schema_SomedayEventsColumn, } from "@web/common/types/web.event.types"; -import { getDatesByCategory } from "@web/common/utils/datetime/web.date.util"; import { validateSomedayEvents } from "@web/common/validators/someday.event.validator"; const uniqBy = (array: T[], iteratee: (item: T) => K): T[] => { @@ -144,56 +142,3 @@ export const isSomedayEventActionMenuOpen = () => { const actionMenu = document.getElementById(ID_SOMEDAY_EVENT_ACTION_MENU); return !!actionMenu; }; - -/** - * Builds the payload for converting a calendar (grid) event into a someday - * sidebar event. Someday events are categorized into the Week/Month columns - * by their dates, so the original grid dates must be replaced with the - * category's date range. - */ -export const assembleSomedayConversionEvent = ( - event: T, - { - category, - order, - viewEnd, - viewStart, - }: { - category: Categories_Event.SOMEDAY_MONTH | Categories_Event.SOMEDAY_WEEK; - order: number; - viewEnd: Dayjs; - viewStart: Dayjs; - }, -) => { - const { startDate, endDate } = getDatesByCategory( - category, - viewStart, - viewEnd, - ); - const frequency = - category === Categories_Event.SOMEDAY_WEEK ? "WEEKLY" : "MONTHLY"; - const recurrence = event.recurrence?.rule - ? { - ...event.recurrence, - rule: event.recurrence.rule.map((rule) => { - const isRRule = rule.startsWith("RRULE:"); - - if (!isRRule) return rule; - - return rule.replace(/FREQ=\w+;/, `FREQ=${frequency};`); - }), - } - : event.recurrence; - - return { - ...event, - endDate, - isAllDay: false, - isSomeday: true, - order, - priority: event.priority ?? Priorities.UNASSIGNED, - recurrence, - startDate, - user: event.user ?? "", - }; -}; diff --git a/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.test.tsx b/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.test.tsx index 1c00cab4c..f44d76007 100644 --- a/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.test.tsx +++ b/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.test.tsx @@ -12,7 +12,6 @@ const mockCreateSomedayDraft = mock(); const defaultSidebarState = () => ({ blockedSomedayDropColumn: null as string | null, draft: null, - isCalendarDragActive: false, isDragging: false, isDraftingNew: false, isSomedayFormOpen: false, @@ -102,8 +101,8 @@ describe("SomedayEventsContainer", () => { ).toBeTruthy(); }); - it("hides the add button while a calendar event is dragged over the sidebar", () => { - sidebarState = { ...defaultSidebarState(), isCalendarDragActive: true }; + it("hides the add button while a drag is active over the sidebar", () => { + sidebarState = { ...defaultSidebarState(), isDragging: true }; renderSomedayEventsContainer({ category: Categories_Event.SOMEDAY_WEEK, @@ -120,7 +119,7 @@ describe("SomedayEventsContainer", () => { sidebarState = { ...defaultSidebarState(), blockedSomedayDropColumn: "weekEvents", - isCalendarDragActive: true, + isDragging: true, }; const { container } = renderSomedayEventsContainer({ @@ -136,7 +135,7 @@ describe("SomedayEventsContainer", () => { sidebarState = { ...defaultSidebarState(), blockedSomedayDropColumn: "monthEvents", - isCalendarDragActive: true, + isDragging: true, }; const { container } = renderSomedayEventsContainer({ diff --git a/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.tsx b/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.tsx index 4908ba9c9..9c1062167 100644 --- a/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.tsx +++ b/packages/web/src/components/PlannerSidebar/SomedayEventSections/SomedayEvents/SomedayEventsContainer/SomedayEventsContainer.tsx @@ -74,28 +74,24 @@ export const SomedayEventsContainer: FC = ({ const isDraftingThisCategory = state.isDraftingNew && category === draftCategory; const isBlockedDropTarget = state.blockedSomedayDropColumn === colName; - // A drag is active for styling purposes whether it originates from the - // sidebar (`isDragging`) or from a calendar event dragged over the sidebar. - const isInteractionActive = state.isDragging || state.isCalendarDragActive; const addTargetLabel = getAddTargetLabel(category); const addLabel = `Add item to ${addTargetLabel}`; const addShortcut = category === Categories_Event.SOMEDAY_MONTH ? "Shift+M" : "Shift+W"; - const activeDropZoneStyle: React.CSSProperties | undefined = - isInteractionActive - ? { - boxSizing: "border-box", - height: getActiveDropZoneHeight(events.length, category), - } - : undefined; + const activeDropZoneStyle: React.CSSProperties | undefined = state.isDragging + ? { + boxSizing: "border-box", + height: getActiveDropZoneHeight(events.length, category), + } + : undefined; return (
= ({ ))}
- {!isDraftingNew && !isInteractionActive && ( + {!isDraftingNew && !state.isDragging && (
({ blockedSomedayDropColumn: null, draft: somedayEvent, - isCalendarDragActive: false, isDrafting: true, isDraftingExisting: true, isDraftingNew: false, @@ -66,7 +65,6 @@ const createSetters = (): Setters_Sidebar => ({ setBlockedSomedayDropColumn: mock(), setDraft: mock(), - setIsCalendarDragActive: mock(), setIsDrafting: mock(), setIsDraftingExisting: mock(), setIsSomedayFormOpen: mock(), @@ -166,70 +164,59 @@ describe("useSidebarActions", () => { title: "Grid event", }; - it("inserts a placeholder row while a calendar event is previewed over the sidebar", () => { + it("starts a calendar sidebar drag by injecting the event and flagging the drag", () => { const setters = createSetters(); - const { result } = renderActions(setters); - - result.current.setCalendarSidebarDropPreview({ - column: COLUMN_WEEK, - event: placeholderEvent, - index: 0, - isBlocked: false, - }); - - expect(setters.setIsCalendarDragActive).toHaveBeenCalledWith(true); - expect(setters.setBlockedSomedayDropColumn).toHaveBeenLastCalledWith(null); - - const previewState = ( - setters.setSomedayEvents as ReturnType - ).mock.calls.at(-1)?.[0] as State_Sidebar["somedayEvents"]; + const { store, wrapper } = createStoreWrapper(currentState); + const dispatchSpy = spyOn(store, "dispatch"); + const { result } = renderHook( + () => + useSidebarActions( + { + onGoToDate: mock(), + viewEnd: dayjs("2024-01-21"), + viewStart: dayjs("2024-01-15"), + }, + createState(), + setters, + ), + { wrapper }, + ); - expect(previewState.columns[COLUMN_WEEK].eventIds).toEqual([ - "grid-event-1", - somedayEvent._id!, - ]); - expect(previewState.events["grid-event-1"]).toBeTruthy(); - }); + // The Week column starts with one event, so the synthetic source index is 1. + const source = result.current.startCalendarSidebarDrag(placeholderEvent); - it("marks the column blocked without inserting a placeholder when full", () => { - const setters = createSetters(); - const { result } = renderActions(setters); + expect(source).toEqual({ droppableId: COLUMN_WEEK, index: 1 }); + expect(setters.setDraft).toHaveBeenCalledWith(placeholderEvent); + expect(setters.setIsDraftingExisting).toHaveBeenCalledWith(true); - result.current.setCalendarSidebarDropPreview({ - column: COLUMN_WEEK, - event: placeholderEvent, - index: 0, - isBlocked: true, - }); + const startedDnd = dispatchSpy.mock.calls + .map(([action]) => action) + .some((action) => action.type === draftSlice.actions.startDnd.type); - expect(setters.setIsCalendarDragActive).toHaveBeenCalledWith(true); - expect(setters.setBlockedSomedayDropColumn).toHaveBeenLastCalledWith( - COLUMN_WEEK, - ); - expect(setters.setSomedayEvents).not.toHaveBeenCalled(); + expect(startedDnd).toBe(true); }); - it("restores the list and clears the flags when the preview ends", () => { + it("reorders the injected event through the native sidebar preview", () => { const setters = createSetters(); const { result } = renderActions(setters); - result.current.setCalendarSidebarDropPreview({ - column: COLUMN_WEEK, - event: placeholderEvent, - index: 0, - isBlocked: false, + result.current.startCalendarSidebarDrag(placeholderEvent); + result.current.previewSomedaySidebarDrop({ + destination: { droppableId: COLUMN_WEEK, index: 0 }, + eventId: "grid-event-1", + source: { droppableId: COLUMN_WEEK, index: 1 }, + type: "sidebarDrop", }); - result.current.setCalendarSidebarDropPreview(null); - const restoredState = ( + const previewState = ( setters.setSomedayEvents as ReturnType ).mock.calls.at(-1)?.[0] as State_Sidebar["somedayEvents"]; - // Restores the original snapshot (no placeholder). - expect(restoredState.columns[COLUMN_WEEK].eventIds).toEqual([ + // The injected event moves to the hovered index, ahead of the existing row. + expect(previewState.columns[COLUMN_WEEK].eventIds).toEqual([ + "grid-event-1", somedayEvent._id!, ]); - expect(setters.setIsCalendarDragActive).toHaveBeenLastCalledWith(false); - expect(setters.setBlockedSomedayDropColumn).toHaveBeenLastCalledWith(null); + expect(previewState.events["grid-event-1"]).toBeTruthy(); }); }); diff --git a/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.ts b/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.ts index 4d851976a..cca3c068b 100644 --- a/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.ts +++ b/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarActions.ts @@ -179,44 +179,6 @@ const getSomedayEventsAfterSidebarDrop = ({ }; }; -// Inserts a not-yet-someday calendar event into a column at `index` as a -// preview placeholder. Unlike a sidebar reorder there is no source column to -// remove from; the event is added to the events map and the target column. -const getSomedayEventsAfterCalendarDrop = ({ - baseEvents, - column, - event, - index, -}: { - baseEvents: State_Sidebar["somedayEvents"]; - column: string; - event: Schema_Event; - index: number; -}) => { - const targetColumn = baseEvents.columns[column as keyof SomedayEventsColumns]; - const eventIds = Array.from(targetColumn.eventIds).filter( - (id) => id !== event._id, - ); - const clampedIndex = Math.min(Math.max(index, 0), eventIds.length); - - eventIds.splice(clampedIndex, 0, event._id!); - - return { - ...baseEvents, - columns: { - ...baseEvents.columns, - [targetColumn.id]: { - ...targetColumn, - eventIds, - }, - }, - events: { - ...baseEvents.events, - [event._id!]: event, - }, - }; -}; - const applySomedayColumnOrder = ({ eventIds, events, @@ -273,8 +235,8 @@ export const useSidebarActions = ( const { setBlockedSomedayDropColumn, setDraft, - setIsCalendarDragActive, setIsDrafting, + setIsDraftingExisting, setIsSomedayFormOpen, setSomedayEvents, } = setters; @@ -416,68 +378,6 @@ export const useSidebarActions = ( ); }; - // Drives the Someday list while a calendar (grid) event is dragged over the - // sidebar. Unlike sidebar-originated drags, this path does not own a sidebar - // draft, so it toggles a dedicated flag instead of `isDragging` and inserts a - // placeholder row at the hovered index so existing rows animate to make room. - // Passing `null` restores the list and clears the styling (pointer left the - // sidebar / drag ended). - const setCalendarSidebarDropPreview = ( - preview: { - column: string; - event: Schema_Event; - index: number; - isBlocked: boolean; - } | null, - ) => { - if (!preview) { - const snapshot = interactionSnapshotRef.current; - - if (snapshot) { - setSomedayEvents(snapshot); - } - - interactionSnapshotRef.current = null; - interactionPreviewKeyRef.current = null; - setIsCalendarDragActive(false); - setBlockedSomedayDropColumn(null); - return; - } - - setIsCalendarDragActive(true); - - const snapshot = getInteractionSnapshot(); - - if (preview.isBlocked) { - // No room in the target column: show the blocked state and keep the list - // unchanged (no placeholder gap). - if (interactionPreviewKeyRef.current !== null) { - setSomedayEvents(snapshot); - } - - interactionPreviewKeyRef.current = null; - setBlockedSomedayDropColumn(preview.column); - return; - } - - const previewKey = `${preview.event._id}:${preview.column}:${preview.index}`; - - if (previewKey === interactionPreviewKeyRef.current) { - return; - } - - interactionPreviewKeyRef.current = previewKey; - setBlockedSomedayDropColumn(null); - setSomedayEvents( - getSomedayEventsAfterCalendarDrop({ - baseEvents: snapshot, - column: preview.column, - event: preview.event, - index: preview.index, - }), - ); - }; - const previewBlockedSomedaySidebarDrop = ( result: SomedaySidebarCommitResult, ) => { @@ -514,6 +414,46 @@ export const useSidebarActions = ( setIsDrafting(true); }; + // Grid-event analog of `startSomedayInteraction`: injects a calendar event + // into the snapshot as a Week-list row so the native sidebar reorder pipeline + // (`previewSomedaySidebarDrop` → `getSomedayEventsAfterSidebarDrop`) treats it + // like any someday row. `setDraft` + `startDnd` flip the single `isDragging` + // flag; `setIsDraftingExisting(true)` keeps `isDraftingNew` false so no + // phantom draft row renders. Returns the synthetic source for preview results. + const startCalendarSidebarDrag = ( + event: Schema_Event, + ): SomedayDragLocation | null => { + if (!event._id) return null; + + const weekColumn = state.somedayEvents.columns[COLUMN_WEEK]; + const source: SomedayDragLocation = { + droppableId: COLUMN_WEEK, + index: weekColumn.eventIds.length, + }; + + interactionSnapshotRef.current = { + ...state.somedayEvents, + columns: { + ...state.somedayEvents.columns, + [COLUMN_WEEK]: { + ...weekColumn, + eventIds: [...weekColumn.eventIds, event._id], + }, + }, + events: { ...state.somedayEvents.events, [event._id]: event }, + }; + interactionPreviewKeyRef.current = null; + + dispatch(draftSlice.actions.startDnd(undefined)); + setBlockedSomedayDropColumn(null); + setDraft(event); + setIsDrafting(true); + setIsDraftingExisting(true); + setIsSomedayFormOpen(false); + + return source; + }; + const cancelSomedayInteraction = () => { clearSomedayInteractionPreview({ shouldRestore: true }); discardSomedayInteraction(); @@ -953,8 +893,8 @@ export const useSidebarActions = ( previewBlockedSomedaySidebarDrop, previewSomedaySidebarDrop, reset, - setCalendarSidebarDropPreview, setDraft, + startCalendarSidebarDrag, startSomedayInteraction, }; }; diff --git a/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarState.ts b/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarState.ts index 92232f755..55cc21241 100644 --- a/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarState.ts +++ b/packages/web/src/components/PlannerSidebar/draft/hooks/useSidebarState.ts @@ -28,10 +28,6 @@ export const useSidebarState = () => { string | null >(null); const [isSomedayFormOpen, setIsSomedayFormOpen] = useState(false); - // True only while a calendar (grid) event is being dragged over the sidebar. - // The grid drag runs through WeekInteractionAdapter, not the sidebar draft - // path, so it can't flip `isDragging`; this lets the drop zones light up. - const [isCalendarDragActive, setIsCalendarDragActive] = useState(false); const isDragging = isDNDing && draft !== null; @@ -50,7 +46,6 @@ export const useSidebarState = () => { somedayMonthIds, somedayWeekIds, blockedSomedayDropColumn, - isCalendarDragActive, isDrafting, isDraftingNew, isDraftingExisting, @@ -61,7 +56,6 @@ export const useSidebarState = () => { const setters = { setDraft, setBlockedSomedayDropColumn, - setIsCalendarDragActive, setIsDrafting, setIsDraftingExisting, setIsSomedayFormOpen, diff --git a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.test.ts b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.test.ts index fa5fea989..59766f466 100644 --- a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.test.ts +++ b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.test.ts @@ -11,7 +11,6 @@ import { import { type Schema_GridEvent } from "@web/common/types/web.event.types"; import { type Activity_DraftEvent } from "@web/ducks/events/slices/draft.slice.types"; import { createEventSlice } from "@web/ducks/events/slices/event.slice"; -import { getWeekEventsSlice } from "@web/ducks/events/slices/week.slice"; import { type Setters_Draft, type State_Draft_Local, @@ -310,66 +309,6 @@ describe("useDraftActions", () => { expect(setDraft).not.toHaveBeenCalled(); }); - it("converts a timed draft to all-day by keyboard", () => { - setDraftActivity("keyboardEdit", Categories_Event.TIMED); - const { result, setDraft } = renderDraftActions({ - _id: "event-1", - isAllDay: false, - startDate: "2024-01-16T10:00:00.000Z", - endDate: "2024-01-16T11:00:00.000Z", - }); - - expect(result.current.convertDraftSurfaceByKeyboard()).toBe(true); - - expect(setDraft).toHaveBeenCalledWith( - expect.objectContaining({ - endDate: "2024-01-17", - isAllDay: true, - startDate: "2024-01-16", - }), - ); - }); - - it("moves a draft to Someday Month by keyboard", () => { - setDraftActivity("keyboardEdit", Categories_Event.TIMED); - const { dispatch, result } = renderDraftActions({ - _id: "event-1", - isAllDay: false, - startDate: "2024-01-16T10:00:00.000Z", - endDate: "2024-01-16T11:00:00.000Z", - }); - let didMove = false; - - act(() => { - didMove = result.current.moveDraftToSidebarByKeyboard( - Categories_Event.SOMEDAY_MONTH, - ); - }); - - expect(didMove).toBe(true); - - const convertAction = dispatch.mock.calls - .map(([action]) => action) - .find((action) => action.type === getWeekEventsSlice.actionNames.convert); - - expect(convertAction).toEqual( - expect.objectContaining({ - payload: { - event: expect.objectContaining({ - _id: "event-1", - // Someday events are bucketed into the Week/Month columns by - // date, so a Month move must take the view month's date range. - endDate: "2024-01-31", - isAllDay: false, - isSomeday: true, - order: 0, - startDate: "2024-01-01", - }), - }, - }), - ); - }); - it("moves a shortcut-created all-day draft horizontally and ignores vertical arrows", () => { setDraftActivity("createShortcut", Categories_Event.ALLDAY); const { result, setDraft } = renderDraftActions({ diff --git a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts index 050f8795b..010d977d6 100644 --- a/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts +++ b/packages/web/src/views/Week/components/Draft/hooks/actions/useDraftActions.ts @@ -2,7 +2,6 @@ import { ObjectId } from "bson"; import { useCallback } from "react"; import { Priorities, - SOMEDAY_MONTH_LIMIT_MSG, SOMEDAY_WEEK_LIMIT_MSG, } from "@core/constants/core.constants"; import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; @@ -23,8 +22,8 @@ import { type Schema_GridEvent, type Schema_WebEvent, } from "@web/common/types/web.event.types"; +import { getDatesByCategory } from "@web/common/utils/datetime/web.date.util"; import { assembleDefaultEvent } from "@web/common/utils/event/event.util"; -import { assembleSomedayConversionEvent } from "@web/common/utils/event/someday.event.util"; import { type Payload_ConvertEvent, type Payload_EditEvent, @@ -34,9 +33,7 @@ import { selectDraftStatus, } from "@web/ducks/events/selectors/draft.selectors"; import { - selectIsAtMonthlyLimit, selectIsAtWeeklyLimit, - selectSomedayMonthCount, selectSomedayWeekCount, } from "@web/ducks/events/selectors/someday.selectors"; import { selectPaginatedEventsBySectionType } from "@web/ducks/events/selectors/util.selectors"; @@ -87,9 +84,7 @@ export const useDraftActions = ( weekProps: WeekProps, ) => { const dispatch = useAppDispatch(); - const isAtMonthlyLimit = useAppSelector(selectIsAtMonthlyLimit); const isAtWeeklyLimit = useAppSelector(selectIsAtWeeklyLimit); - const somedayMonthCount = useAppSelector(selectSomedayMonthCount); const somedayWeekCount = useAppSelector(selectSomedayWeekCount); const reduxDraft = useAppSelector(selectDraft); const pendingEventIds = useAppSelector( @@ -226,12 +221,17 @@ export const useDraftActions = ( return; } + const { startDate, endDate } = getDatesByCategory( + Categories_Event.SOMEDAY_WEEK, + dayjs(start), + dayjs(end), + ); const event: Payload_ConvertEvent["event"] = { - ...assembleSomedayConversionEvent(draft!, { + ...MapEvent.toSomeday(draft!, { category: Categories_Event.SOMEDAY_WEEK, + endDate, order: somedayWeekCount, - viewEnd: dayjs(end), - viewStart: dayjs(start), + startDate, }), _id: draft!._id!, }; @@ -439,77 +439,6 @@ export const useDraftActions = ( [activity, draft, isInsideVisibleWeek, isTimedDraftInsideOneDay, setDraft], ); - const convertDraftSurfaceByKeyboard = useCallback(() => { - if (!canRepositionDraftByKeyboard(activity) || !draft) return false; - - const start = dayjs(draft.startDate).startOf("day"); - - if (draft.isAllDay) { - setDraft({ - ...draft, - endDate: start.add(10, "hour").format(), - isAllDay: false, - startDate: start.add(9, "hour").format(), - }); - return true; - } - - setDraft({ - ...draft, - endDate: start.add(1, "day").format(YEAR_MONTH_DAY_FORMAT), - isAllDay: true, - startDate: start.format(YEAR_MONTH_DAY_FORMAT), - }); - return true; - }, [activity, draft, setDraft]); - - const moveDraftToSidebarByKeyboard = useCallback( - ( - category: Categories_Event.SOMEDAY_WEEK | Categories_Event.SOMEDAY_MONTH, - ) => { - if (!canRepositionDraftByKeyboard(activity) || !draft?._id) return false; - - const isWeek = category === Categories_Event.SOMEDAY_WEEK; - - if (isWeek && isAtWeeklyLimit) { - alert(SOMEDAY_WEEK_LIMIT_MSG); - return true; - } - - if (!isWeek && isAtMonthlyLimit) { - alert(SOMEDAY_MONTH_LIMIT_MSG); - return true; - } - - const event: Payload_ConvertEvent["event"] = { - ...assembleSomedayConversionEvent(draft, { - category, - order: isWeek ? somedayWeekCount : somedayMonthCount, - viewEnd: weekProps.component.endOfView, - viewStart: weekProps.component.startOfView, - }), - _id: draft._id, - }; - - dispatch(getWeekEventsSlice.actions.convert({ event })); - discard(); - - return true; - }, - [ - activity, - discard, - dispatch, - draft, - isAtMonthlyLimit, - isAtWeeklyLimit, - somedayMonthCount, - somedayWeekCount, - weekProps.component.endOfView, - weekProps.component.startOfView, - ], - ); - const drag = useCallback( (e: Omit) => { const updateTimesDuringDrag = ( @@ -810,9 +739,7 @@ export const useDraftActions = ( duplicateEvent, discard, drag, - convertDraftSurfaceByKeyboard, openForm, - moveDraftToSidebarByKeyboard, repositionDraftByKeyboard, reset, resize, diff --git a/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcuts.ts b/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcuts.ts index dcb421a6b..86b4f6ecc 100644 --- a/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcuts.ts +++ b/packages/web/src/views/Week/hooks/shortcuts/useWeekShortcuts.ts @@ -54,11 +54,7 @@ export const useWeekShortcuts = ({ const dispatch = useAppDispatch(); const context = useSidebarContext(true); const { - actions: { - convertDraftSurfaceByKeyboard, - moveDraftToSidebarByKeyboard, - repositionDraftByKeyboard, - }, + actions: { repositionDraftByKeyboard }, } = useDraftContext(); const isSidebarOpen = useAppSelector(selectIsSidebarOpen); @@ -119,12 +115,8 @@ export const useWeekShortcuts = ({ ); const createAllDayDraftEvent = useCallback(() => { - if (convertDraftSurfaceByKeyboard()) { - return; - } - void createAlldayDraft(startOfView, endOfView, "createShortcut", dispatch); - }, [convertDraftSurfaceByKeyboard, dispatch, startOfView, endOfView]); + }, [dispatch, startOfView, endOfView]); const createTimedDraftEvent = useCallback(() => { void createTimedDraft( @@ -136,20 +128,12 @@ export const useWeekShortcuts = ({ }, [isCurrentWeek, startOfView, dispatch]); const createSomedayMonthDraft = useCallback(() => { - if (moveDraftToSidebarByKeyboard(Categories_Event.SOMEDAY_MONTH)) { - return; - } - _createSomedayDraft(Categories_Event.SOMEDAY_MONTH); - }, [_createSomedayDraft, moveDraftToSidebarByKeyboard]); + }, [_createSomedayDraft]); const createSomedayWeekDraft = useCallback(() => { - if (moveDraftToSidebarByKeyboard(Categories_Event.SOMEDAY_WEEK)) { - return; - } - _createSomedayDraft(Categories_Event.SOMEDAY_WEEK); - }, [_createSomedayDraft, moveDraftToSidebarByKeyboard]); + }, [_createSomedayDraft]); const focusFirstCalendarEvent = useCallback(() => { const target = getFirstVisibleCalendarEventTarget(); diff --git a/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx b/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx index 645abad40..b527e5750 100644 --- a/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx +++ b/packages/web/src/views/Week/interaction/WeekInteractionCoordinator.tsx @@ -9,12 +9,14 @@ import { SOMEDAY_MONTH_LIMIT_MSG, SOMEDAY_WEEK_LIMIT_MSG, } from "@core/constants/core.constants"; +import { MapEvent } from "@core/mappers/map.event"; import { Categories_Event } from "@core/types/event.types"; import { CalendarInteractionPointerCaptureBoundary } from "@web/common/calendar-interaction/react/CalendarInteractionPointerCaptureBoundary"; import { COLUMN_MONTH, COLUMN_WEEK } from "@web/common/constants/web.constants"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; -import { assembleSomedayConversionEvent } from "@web/common/utils/event/someday.event.util"; +import { getDatesByCategory } from "@web/common/utils/datetime/web.date.util"; import { useSidebarContext } from "@web/components/PlannerSidebar/draft/context/useSidebarContext"; +import { type SomedaySidebarCommitResult } from "@web/components/PlannerSidebar/SomedayEventSections/interaction/adapter/SomedayInteractionAdapter.types"; import { type Payload_ConvertEvent } from "@web/ducks/events/event.types"; import { selectAllDayEvents, @@ -65,6 +67,14 @@ export const WeekInteractionCoordinator: FC = ({ const sidebarContext = useSidebarContext(true); const { actions, confirmation, setters, state } = useDraftContext(); const layoutSourcesRef = useRef(getLayoutSources); + // Tracks an in-flight grid event dragged over the sidebar. `draggedEvent` is + // stashed at drag start; `sidebarSource` is the synthetic source returned by + // `startCalendarSidebarDrag` (set once the pointer first enters the sidebar). + const draggedEventRef = useRef(null); + const sidebarSourceRef = useRef<{ + droppableId: string; + index: number; + } | null>(null); const timedEventsById = useMemo(() => { return mapEventsById(timedEvents); }, [timedEvents]); @@ -113,6 +123,15 @@ export const WeekInteractionCoordinator: FC = ({ dispatch(draftSlice.actions.startGridClick(event)); }; + // Tears down a sidebar drag that was started (pointer entered the sidebar), + // whatever the drag's final destination. Safe no-op if never started. + const endSidebarDrag = () => { + if (!sidebarSourceRef.current) return; + + sidebarSourceRef.current = null; + sidebarContext?.actions.cancelSomedayInteraction(); + }; + const commitSavedMutation = ( result: | WeekAllDayDragCommitResult @@ -120,6 +139,8 @@ export const WeekInteractionCoordinator: FC = ({ | WeekTimedDragCommitResult | WeekTimedResizeCommitResult, ) => { + endSidebarDrag(); + if (!result.hasMoved) { if (result.event.isAllDay) { openAllDayEvent(result.event); @@ -142,55 +163,71 @@ export const WeekInteractionCoordinator: FC = ({ result: WeekCalendarToSidebarCommitResult, ) => { const isWeekDrop = result.category === Categories_Event.SOMEDAY_WEEK; + const isBlocked = isWeekDrop ? isAtWeeklyLimit : isAtMonthlyLimit; - if (isWeekDrop && isAtWeeklyLimit) { - alert(SOMEDAY_WEEK_LIMIT_MSG); - return; - } - - if (!isWeekDrop && isAtMonthlyLimit) { - alert(SOMEDAY_MONTH_LIMIT_MSG); + if (isBlocked) { + alert(isWeekDrop ? SOMEDAY_WEEK_LIMIT_MSG : SOMEDAY_MONTH_LIMIT_MSG); + endSidebarDrag(); return; } + const { startDate, endDate } = getDatesByCategory( + result.category, + weekProps.component.startOfView, + weekProps.component.endOfView, + ); const event: Payload_ConvertEvent["event"] = { - ...assembleSomedayConversionEvent(result.event, { + ...MapEvent.toSomeday(result.event, { category: result.category, + endDate, order: isWeekDrop ? somedayWeekCount : somedayMonthCount, - viewEnd: weekProps.component.endOfView, - viewStart: weekProps.component.startOfView, + startDate, }), _id: result.eventId, }; dispatch(getWeekEventsSlice.actions.convert({ event })); - actions.discard(); + endSidebarDrag(); }; + // Drives the native someday reorder pipeline for a grid event hovering the + // sidebar: it injects the event into the list on first entry, then reuses the + // same preview/blocked actions a someday-to-someday drag uses. const previewCalendarToSidebar: WeekInteractionRuntime["onPreviewCalendarToSidebar"] = (preview) => { - if (!preview) { - sidebarContext?.actions.setCalendarSidebarDropPreview(null); + const sidebarActions = sidebarContext?.actions; + const draggedEvent = draggedEventRef.current; + + if (!preview || !sidebarActions || !draggedEvent?._id) { + sidebarActions?.previewSomedaySidebarDrop(null); return; } + if (!sidebarSourceRef.current) { + sidebarSourceRef.current = + sidebarActions.startCalendarSidebarDrag(draggedEvent); + } + + if (!sidebarSourceRef.current) return; + const isWeek = preview.category === Categories_Event.SOMEDAY_WEEK; - // Build the someday-shaped placeholder the list renders while hovering, - // so existing rows animate to make room. It mirrors what the drop will - // commit (assembleSomedayConversionEvent is also used at commit time). - const placeholder = assembleSomedayConversionEvent(preview.event, { - category: preview.category, - order: preview.index, - viewEnd: weekProps.component.endOfView, - viewStart: weekProps.component.startOfView, - }); - - sidebarContext?.actions.setCalendarSidebarDropPreview({ - column: isWeek ? COLUMN_WEEK : COLUMN_MONTH, - event: { ...placeholder, _id: preview.event._id! }, - index: preview.index, - isBlocked: isWeek ? isAtWeeklyLimit : isAtMonthlyLimit, - }); + const result: SomedaySidebarCommitResult = { + destination: { + droppableId: isWeek ? COLUMN_WEEK : COLUMN_MONTH, + index: preview.index, + }, + eventId: draggedEvent._id, + source: sidebarSourceRef.current, + type: "sidebarDrop", + }; + + // Use the live redux limits, not the snapshot (which the injected event + // inflates), to decide whether the destination column is full. + if (isWeek ? isAtWeeklyLimit : isAtMonthlyLimit) { + sidebarActions.previewBlockedSomedaySidebarDrop(result); + } else { + sidebarActions.previewSomedaySidebarDrop(result); + } }; runtimeRef.current = { @@ -198,6 +235,7 @@ export const WeekInteractionCoordinator: FC = ({ getTimedEventById: (eventId) => timedEventsById.get(eventId) ?? null, isEventPending: (eventId) => pendingEventIdSet.has(eventId), isFormOpen: () => state.isFormOpen, + onCancelInteraction: endSidebarDrag, onClickAllDayEvent: openAllDayEvent, onClickTimedEvent: openTimedEvent, onCommitAllDayDrag: commitSavedMutation, @@ -206,6 +244,11 @@ export const WeekInteractionCoordinator: FC = ({ onCommitTimedDrag: commitSavedMutation, onCommitTimedResize: commitSavedMutation, onMotionActivation: (target) => { + // Reset per-drag sidebar tracking and stash the event for a possible + // sidebar entry later in this drag. + draggedEventRef.current = target.event; + sidebarSourceRef.current = null; + if (target.hadFormOpenBeforeInteraction) { actions.closeForm(); } diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts index 5fa918864..243e671b1 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.allDayDrag.test.ts @@ -509,7 +509,7 @@ describe("WeekInteractionAdapter all-day drag", () => { }); it("previews the Someday Month sidebar drop zone while an all-day drag hovers it", () => { - const { adapter, child, event, flushFrame, onPreviewCalendarToSidebar } = + const { adapter, child, flushFrame, onPreviewCalendarToSidebar } = createHarness(); adapter.handlePointerDown( @@ -522,7 +522,6 @@ describe("WeekInteractionAdapter all-day drag", () => { expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith({ category: Categories_Event.SOMEDAY_MONTH, - event: expect.objectContaining({ _id: event._id }), index: 0, }); diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts index 13fc48336..78e2dec64 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.timedDrag.test.ts @@ -581,7 +581,7 @@ describe("WeekInteractionAdapter timed drag", () => { }); it("previews the Someday sidebar drop zone as the pointer enters, leaves, and commits", () => { - const { adapter, child, event, flushFrame, onPreviewCalendarToSidebar } = + const { adapter, child, flushFrame, onPreviewCalendarToSidebar } = createHarness(); adapter.handlePointerDown( @@ -601,7 +601,6 @@ describe("WeekInteractionAdapter timed drag", () => { expect(onPreviewCalendarToSidebar).toHaveBeenCalledTimes(1); expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith({ category: Categories_Event.SOMEDAY_WEEK, - event: expect.objectContaining({ _id: event._id }), index: 0, }); @@ -627,7 +626,7 @@ describe("WeekInteractionAdapter timed drag", () => { }); it("clears the sidebar preview when the drag is cancelled", () => { - const { adapter, child, event, flushFrame, onPreviewCalendarToSidebar } = + const { adapter, child, flushFrame, onPreviewCalendarToSidebar } = createHarness(); adapter.handlePointerDown( @@ -640,7 +639,6 @@ describe("WeekInteractionAdapter timed drag", () => { expect(onPreviewCalendarToSidebar).toHaveBeenLastCalledWith({ category: Categories_Event.SOMEDAY_WEEK, - event: expect.objectContaining({ _id: event._id }), index: 0, }); diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts index 9338ab42e..3cc0e17e1 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.ts @@ -15,7 +15,6 @@ import { createCalendarInteractionEngine, } from "@web/common/calendar-interaction/CalendarInteractionEngine"; import { isEligibleCalendarInteractionPointerDown } from "@web/common/calendar-interaction/calendarInteractionPointer"; -import { type Schema_GridEvent } from "@web/common/types/web.event.types"; import { somedayDropTargetRegistry } from "@web/components/PlannerSidebar/SomedayEventSections/interaction/registry/somedayDropTargetRegistry"; import { type SomedayInteractionCategory, @@ -109,6 +108,10 @@ const SHAPE_SNAP_TRANSITION = "height 160ms cubic-bezier(0.16, 1, 0.3, 1), width 160ms cubic-bezier(0.16, 1, 0.3, 1)"; const DEFAULT_CONVERTED_TIMED_DURATION_MINUTES = 60; const MINUTES_PER_DAY = 24 * 60; +// Fallbacks for the floating overlay's size while it hovers the sidebar, used +// only when no existing Someday row can be measured for its real dimensions. +const SOMEDAY_OVERLAY_FALLBACK_ROW_HEIGHT = 34; +const SOMEDAY_OVERLAY_FALLBACK_SIDE_INSET = 8; interface WeekSidebarDrop { category: SomedayInteractionCategory; @@ -274,6 +277,7 @@ export const createWeekInteractionAdapter = ({ clearInteractionState(); resetWeekInteractionEdgeNavigationState(); setWeekInteractionMotionActive(false); + runtime().onCancelInteraction?.(); }, commit: ({ target, visual }) => { let result: WeekInteractionCommitResult; @@ -404,12 +408,11 @@ export const createWeekInteractionAdapter = ({ const draggedEventId = isDragTarget(target) ? (target.event._id ?? null) : null; - const draggedEvent = isDragTarget(target) ? target.event : null; if (visual.type === "allDayDrag") { const sidebarDrop = resolveSidebarDrop(pointer, draggedEventId); - reportSidebarPreview(sidebarDrop, draggedEvent); + reportSidebarPreview(sidebarDrop); if (sidebarDrop) { return updateSidebarDropVisual(visual, pointer, sidebarDrop); @@ -519,7 +522,7 @@ export const createWeekInteractionAdapter = ({ const sidebarDrop = resolveSidebarDrop(pointer, draggedEventId); - reportSidebarPreview(sidebarDrop, draggedEvent); + reportSidebarPreview(sidebarDrop); if (sidebarDrop) { return updateSidebarDropVisual(visual, pointer, sidebarDrop); @@ -847,7 +850,7 @@ export const createWeekInteractionAdapter = ({ clearCrossSurfaceLayouts(); // Runs on both commit and cancel, so it clears the sidebar drop-zone // styling for Escape, pointercancel, regular grid drops, and sidebar drops. - reportSidebarPreview(null, null); + reportSidebarPreview(null); resetEdgeNavigation(); isLayoutRebuildPending = false; } @@ -900,28 +903,75 @@ export const createWeekInteractionAdapter = ({ resetEdgeNavigation(); setWeekInteractionEdgeNavigationState(activeEdgeNavigationIndicatorState); + // Shrink the floating overlay to a Someday-row size while it's over the + // sidebar (the grid event's clone is otherwise a tall block that covers + // the list and hides the live reorder). Mirrors how the native someday + // drag keeps its overlay at its small source-row size. Center the chip on + // the pointer so it reads as the item being carried. + const overlaySize = + getSidebarOverlaySize(sidebarDrop.category, visual.eventId) ?? + visual.sourceRect; + const transform = { + x: pointer.x - visual.sourceRect.left - overlaySize.width / 2, + y: pointer.y - visual.sourceRect.top - overlaySize.height / 2, + }; const nextVisual = { ...visual, crossSurfaceDrop: null, sidebarDrop, - transform: { - x: pointer.x - visual.pointerStart.x, - y: pointer.y - visual.pointerStart.y, - }, + transform, }; return { overlay: { - height: visual.sourceRect.height, + height: overlaySize.height, mutate: applyShapeSnapTransition, - transform: nextVisual.transform, - width: visual.sourceRect.width, + transform, + width: overlaySize.width, }, shouldContinue: false, visual: nextVisual, }; } + // Measures a real Someday row in the hovered column for the overlay size, so + // the carried chip matches the list. Excludes the drag's own placeholder + // row. Falls back to the drop zone's width and a constant row height. + function getSidebarOverlaySize( + category: SomedayInteractionCategory, + excludeEventId: string, + ): { height: number; width: number } | null { + const sample = somedayEventRegistry + .getEvents(category) + .find((event) => event.eventId !== excludeEventId); + + if (sample) { + const rect = sample.element.getBoundingClientRect(); + + if (rect.width > 0 && rect.height > 0) { + return { height: rect.height, width: rect.width }; + } + } + + const target = somedayDropTargetRegistry + .getTargets() + .find((candidate) => candidate.category === category); + + if (target) { + const rect = target.element.getBoundingClientRect(); + + return { + height: SOMEDAY_OVERLAY_FALLBACK_ROW_HEIGHT, + width: Math.max( + 0, + rect.width - SOMEDAY_OVERLAY_FALLBACK_SIDE_INSET * 2, + ), + }; + } + + return null; + } + function resolveAllDayCrossSurfaceDrop(pointer: VisualPoint) { const crossLayout = allDayLayout; @@ -984,7 +1034,6 @@ export const createWeekInteractionAdapter = ({ // animation frame. function reportSidebarPreview( preview: { category: SomedayInteractionCategory; index: number } | null, - event: Schema_GridEvent | null, ) { const key = preview ? `${preview.category}:${preview.index}` : null; @@ -994,9 +1043,7 @@ export const createWeekInteractionAdapter = ({ lastReportedSidebarKey = key; runtime().onPreviewCalendarToSidebar?.( - preview && event - ? { category: preview.category, event, index: preview.index } - : null, + preview ? { category: preview.category, index: preview.index } : null, ); } diff --git a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts index 9a1f5c68d..8887bfffb 100644 --- a/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts +++ b/packages/web/src/views/Week/interaction/adapter/WeekInteractionAdapter.types.ts @@ -33,6 +33,7 @@ export interface WeekInteractionRuntime { getTimedEventById(eventId: string): Schema_GridEvent | null; isEventPending: (eventId: string) => boolean; isFormOpen?: () => boolean; + onCancelInteraction?: () => void; onClickAllDayEvent?: (event: Schema_GridEvent) => void; onClickTimedEvent: (event: Schema_GridEvent) => void; onCommitAllDayDrag?: (result: WeekAllDayDragCommitResult) => void; @@ -44,11 +45,7 @@ export interface WeekInteractionRuntime { onCommitTimedResize?: (result: WeekTimedResizeCommitResult) => void; onMotionActivation?: (target: WeekInteractionTarget) => void; onPreviewCalendarToSidebar?: ( - preview: { - category: SomedayInteractionCategory; - event: Schema_GridEvent; - index: number; - } | null, + preview: { category: SomedayInteractionCategory; index: number } | null, ) => void; onRequestWeekNavigation?: (direction: "next" | "prev") => void; }