diff --git a/packages/web/src/views/Week/components/Draft/hooks/actions/draft-resize.util.test.ts b/packages/web/src/views/Week/components/Draft/hooks/actions/draft-resize.util.test.ts new file mode 100644 index 000000000..470d5114b --- /dev/null +++ b/packages/web/src/views/Week/components/Draft/hooks/actions/draft-resize.util.test.ts @@ -0,0 +1,117 @@ +import dayjs from "@core/util/date/dayjs"; +import { type GridEventDraft } from "@web/events/event-draft.types"; +import { resizeDraft } from "./draft-resize.util"; +import { describe, expect, it } from "bun:test"; + +const timedDraft = ( + start = "2024-01-15T10:00:00.000Z", + end = "2024-01-15T11:00:00.000Z", +): GridEventDraft => + ({ + kind: "create", + values: { + schedule: { + kind: "timed", + start: new Date(start), + end: new Date(end), + timeZone: "UTC", + }, + }, + }) as GridEventDraft; + +const allDayDraft = (): GridEventDraft => + ({ + kind: "create", + values: { + schedule: { + kind: "allDay", + start: new Date("2024-01-15T00:00:00.000Z"), + end: new Date("2024-01-16T00:00:00.000Z"), + }, + }, + }) as GridEventDraft; + +describe("resizeDraft", () => { + it("updates a timed end edge without changing the active edge", () => { + const draft = timedDraft(); + const result = resizeDraft({ + currTime: dayjs("2024-01-15T12:00:00.000Z"), + dateBeingChanged: "endDate", + draft, + origin: draft, + }); + + expect(result?.flippedTo).toBeNull(); + expect(result?.hasMoved).toBe(true); + expect(dayjs(result?.draft.values.schedule.end).toISOString()).toBe( + "2024-01-15T12:00:00.000Z", + ); + }); + + it("flips a start-edge resize once it crosses the end", () => { + const draft = timedDraft(); + const result = resizeDraft({ + currTime: dayjs("2024-01-15T12:00:00.000Z"), + dateBeingChanged: "startDate", + draft, + origin: draft, + }); + + expect(result?.flippedTo).toBe("endDate"); + expect(dayjs(result?.draft.values.schedule.start).toISOString()).toBe( + "2024-01-15T11:00:00.000Z", + ); + expect(dayjs(result?.draft.values.schedule.end).toISOString()).toBe( + "2024-01-15T12:00:00.000Z", + ); + expect(result?.hasMoved).toBe(true); + }); + + it("flips an end-edge resize once it crosses the start", () => { + const draft = timedDraft(); + const result = resizeDraft({ + currTime: dayjs("2024-01-15T09:00:00.000Z"), + dateBeingChanged: "endDate", + draft, + origin: draft, + }); + + expect(result?.flippedTo).toBe("startDate"); + expect(dayjs(result?.draft.values.schedule.start).toISOString()).toBe( + "2024-01-15T09:00:00.000Z", + ); + expect(dayjs(result?.draft.values.schedule.end).toISOString()).toBe( + "2024-01-15T10:00:00.000Z", + ); + expect(result?.hasMoved).toBe(true); + }); + + it("resizes all-day events by calendar day", () => { + const draft = allDayDraft(); + const result = resizeDraft({ + currTime: dayjs("2024-01-17T00:00:00.000Z"), + dateBeingChanged: "endDate", + draft, + origin: draft, + }); + + expect(result?.flippedTo).toBeNull(); + expect(dayjs(result?.draft.values.schedule.end).format("YYYY-MM-DD")).toBe( + "2024-01-18", + ); + expect(result?.hasMoved).toBe(true); + }); + + it("rejects a timed resize into another day", () => { + const draft = timedDraft(); + + expect( + resizeDraft({ + currTime: dayjs("2024-01-16T12:00:00.000Z"), + dateBeingChanged: "endDate", + draft, + origin: draft, + }), + ).toBeNull(); + }); +}); diff --git a/packages/web/src/views/Week/components/Draft/hooks/actions/draft-resize.util.ts b/packages/web/src/views/Week/components/Draft/hooks/actions/draft-resize.util.ts new file mode 100644 index 000000000..f806321cd --- /dev/null +++ b/packages/web/src/views/Week/components/Draft/hooks/actions/draft-resize.util.ts @@ -0,0 +1,126 @@ +import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; +import dayjs, { type Dayjs } from "@core/util/date/dayjs"; +import { + type GridEventDraft, + type GridScheduleDraft, +} from "@web/events/event-draft.types"; +import { replaceGridDraftSchedule } from "@web/events/grid-event-draft.adapter"; +import { GRID_TIME_STEP } from "@web/grid/grid.constants"; + +type DateKey = "startDate" | "endDate"; + +type DraftDates = Record; + +const formatDraftDate = (date: Date, isAllDay: boolean) => + isAllDay ? dayjs(date).format(YEAR_MONTH_DAY_FORMAT) : dayjs(date).format(); + +const datesFor = (draft: GridEventDraft, isAllDay: boolean): DraftDates => ({ + startDate: formatDraftDate(draft.values.schedule.start, isAllDay), + endDate: formatDraftDate(draft.values.schedule.end, isAllDay), +}); + +export function isValidDraftResize( + currTime: Dayjs, + draft: GridEventDraft, + dateBeingChanged: DateKey, +): boolean { + if (draft.values.schedule.kind === "allDay") return true; + + const draftDate = + dateBeingChanged === "startDate" + ? draft.values.schedule.start + : draft.values.schedule.end; + const formattedCurrentTime = currTime.format(); + if (dayjs(draftDate).format() === formattedCurrentTime) return false; + + const diffDay = currTime.day() !== dayjs(draft.values.schedule.start).day(); + if (diffDay) return false; + + return formattedCurrentTime !== dayjs(draft.values.schedule.start).format(); +} + +export function resizeDraft({ + currTime, + dateBeingChanged, + draft, + origin, +}: { + currTime: Dayjs; + dateBeingChanged: DateKey; + draft: GridEventDraft; + origin: GridEventDraft; +}): { + draft: GridEventDraft; + flippedTo: DateKey | null; + hasMoved: boolean; +} | null { + if (!isValidDraftResize(currTime, draft, dateBeingChanged)) return null; + + const isAllDay = draft.values.schedule.kind === "allDay"; + const oppositeKey = + dateBeingChanged === "startDate" ? "endDate" : "startDate"; + const draftDates = datesFor(draft, isAllDay); + const originDates = datesFor(origin, isAllDay); + let startDate = draftDates.startDate; + let endDate = draftDates.endDate; + let changedDate = dateBeingChanged; + let flippedTo: DateKey | null = null; + + if ( + dateBeingChanged === "startDate" && + currTime.isAfter(dayjs(draftDates[oppositeKey])) + ) { + changedDate = oppositeKey; + startDate = draftDates.endDate; + flippedTo = changedDate; + } else if ( + dateBeingChanged === "endDate" && + currTime.isBefore(dayjs(draftDates[oppositeKey])) + ) { + changedDate = oppositeKey; + if (isAllDay) { + startDate = dayjs(startDate) + .subtract(1, "day") + .format(YEAR_MONTH_DAY_FORMAT); + endDate = dayjs(startDate).add(1, "day").format(YEAR_MONTH_DAY_FORMAT); + } else { + startDate = dayjs(startDate).subtract(GRID_TIME_STEP, "minutes").format(); + endDate = dayjs(startDate).add(GRID_TIME_STEP, "minutes").format(); + } + flippedTo = changedDate; + } + + const workingSchedule: GridScheduleDraft = isAllDay + ? { + kind: "allDay", + start: dayjs(startDate).toDate(), + end: dayjs(endDate).toDate(), + } + : { + ...draft.values.schedule, + start: dayjs(startDate).toDate(), + end: dayjs(endDate).toDate(), + }; + const workingDraft = replaceGridDraftSchedule(draft, workingSchedule); + const originTime = dayjs(originDates[changedDate]).subtract(1, "day"); + const hasMoved = isAllDay + ? currTime.diff(originTime, "day", true) !== 0 + : currTime.diff(originTime, "minute") !== 0; + const updatedTime = isAllDay + ? currTime + .add(changedDate === "endDate" ? 1 : 0, "day") + .format(YEAR_MONTH_DAY_FORMAT) + : originTime.add(currTime.diff(originTime, "minute"), "minutes").format(); + const schedule: GridScheduleDraft = { + ...workingDraft.values.schedule, + ...(changedDate === "startDate" + ? { start: dayjs(updatedTime).toDate() } + : { end: dayjs(updatedTime).toDate() }), + } as GridScheduleDraft; + + return { + draft: replaceGridDraftSchedule(workingDraft, schedule), + flippedTo, + hasMoved, + }; +} 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 c9f096084..2be2f9bc6 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 @@ -1,5 +1,4 @@ import { useCallback, useMemo, useRef } from "react"; -import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; import { type RecurrenceScope } from "@core/types/event-command.contracts"; import { devAlert } from "@core/util/app.util"; import dayjs, { type Dayjs } from "@core/util/date/dayjs"; @@ -9,10 +8,7 @@ import { type PartialMouseEvent } from "@web/common/types/util.types"; import { RecurringEventUpdateScope } from "@web/common/types/web.event.types"; import { repositionDraftByKeyboard as applyDraftKeyboardReposition } from "@web/common/utils/draft/reposition-draft-by-keyboard.util"; import { DirtyParser } from "@web/common/utils/parse/dirty.parser"; -import { - type GridEventDraft, - type GridScheduleDraft, -} from "@web/events/event-draft.types"; +import { type GridEventDraft } from "@web/events/event-draft.types"; import { parseGridEventDraft, replaceGridDraftSchedule, @@ -23,7 +19,6 @@ import { selectDraftStatus, useDraftStore, } from "@web/events/stores/draft.store"; -import { GRID_TIME_STEP } from "@web/grid/grid.constants"; import { clearGestureEphemera, useDraftEffects, @@ -37,6 +32,7 @@ import { import { type DateCalcs } from "@web/views/Week/hooks/grid/useDateCalcs"; import { type WeekProps } from "@web/views/Week/hooks/useWeek"; import { resolveDraftDragSchedule } from "./draft-drag-schedule.util"; +import { resizeDraft } from "./draft-resize.util"; const scopeFromApplyTo = ( applyTo: RecurringEventUpdateScope, @@ -315,133 +311,12 @@ export const useDraftActions = ( [applyDragPosition, dragOffset, dragStatus, isDragging], ); - const isValidMovement = useCallback( - (currTime: dayjs.Dayjs, liveDraft: GridEventDraft) => { - if (!dateBeingChanged) return false; - - const isAllDay = liveDraft.values.schedule.kind === "allDay"; - if (isAllDay) { - return true; - } - - const draftDate = - dateBeingChanged === "startDate" - ? liveDraft.values.schedule.start - : liveDraft.values.schedule.end; - const _currTime = currTime.format(); - const noChange = dayjs(draftDate).format() === _currTime; - - if (noChange) return false; - - const diffDay = - currTime.day() !== dayjs(liveDraft.values.schedule.start).day(); - if (diffDay) return false; - - const sameStart = - _currTime === dayjs(liveDraft.values.schedule.start).format(); - if (sameStart) return false; - - return true; - }, - [dateBeingChanged], - ); - const resize = useCallback( (e: MouseEvent) => { const liveDraft = readLiveDraft(); // Freeze the origin against the gesture snapshot so live store updates // mid-gesture must not shift the resize baseline. - if (!liveDraft || !gestureOriginDraft) return; - - const isAllDay = liveDraft.values.schedule.kind === "allDay"; - const _dateBeingChanged = dateBeingChanged as "startDate" | "endDate"; - const oppositeKey = - _dateBeingChanged === "startDate" ? "endDate" : "startDate"; - - // String mirrors of the draft's live schedule, formatted exactly as - // the legacy GridEvent draft stored them (all-day: day-only - // YEAR_MONTH_DAY_FORMAT strings; timed: full offset strings). The flip - // math below is unchanged dayjs-string arithmetic ported verbatim from - // before the GridEventDraft conversion, reading/writing through this - // mirror instead of native GridEvent fields. - const formatDraftDate = (date: Date) => - isAllDay - ? dayjs(date).format(YEAR_MONTH_DAY_FORMAT) - : dayjs(date).format(); - const draftDates: Record<"startDate" | "endDate", string> = { - startDate: formatDraftDate(liveDraft.values.schedule.start), - endDate: formatDraftDate(liveDraft.values.schedule.end), - }; - const originDates: Record<"startDate" | "endDate", string> = { - startDate: formatDraftDate(gestureOriginDraft.values.schedule.start), - endDate: formatDraftDate(gestureOriginDraft.values.schedule.end), - }; - - let workingDraft = liveDraft; - let workingDateBeingChanged = dateBeingChanged; - - const flipIfNeeded = (currTime: Dayjs) => { - let startDate = draftDates.startDate; - let endDate = draftDates.endDate; - - let justFlipped = false; - let dateKey = workingDateBeingChanged; - const opposite = dayjs(draftDates[oppositeKey]); - const comparisonKeyword = - workingDateBeingChanged === "startDate" ? "after" : "before"; - - if (comparisonKeyword === "after") { - if (currTime.isAfter(opposite)) { - dateKey = oppositeKey; - startDate = draftDates.endDate; - workingDateBeingChanged = dateKey; - setDateBeingChanged(dateKey); - - justFlipped = true; - } - } else if (comparisonKeyword === "before") { - if (currTime.isBefore(opposite)) { - workingDateBeingChanged = oppositeKey; - setDateBeingChanged(oppositeKey); - if (isAllDay) { - // For all-day events, move by day - startDate = dayjs(startDate) - .subtract(1, "day") - .format(YEAR_MONTH_DAY_FORMAT); - endDate = dayjs(startDate) - .add(1, "day") - .format(YEAR_MONTH_DAY_FORMAT); - } else { - // For timed events, move by time step - startDate = dayjs(startDate) - .subtract(GRID_TIME_STEP, "minutes") - .format(); - endDate = dayjs(startDate) - .add(GRID_TIME_STEP, "minutes") - .format(); - } - - justFlipped = true; - } - } - - setIsFormOpen(false); - - const schedule: GridScheduleDraft = isAllDay - ? { - kind: "allDay", - start: dayjs(startDate).toDate(), - end: dayjs(endDate).toDate(), - } - : { - ...workingDraft.values.schedule, - start: dayjs(startDate).toDate(), - end: dayjs(endDate).toDate(), - }; - - workingDraft = replaceGridDraftSchedule(workingDraft, schedule); - return justFlipped; - }; + if (!liveDraft || !gestureOriginDraft || !dateBeingChanged) return; e.preventDefault(); e.stopPropagation(); @@ -449,60 +324,33 @@ export const useDraftActions = ( if (!isResizing) return; // For all-day events, use a fixed Y coordinate (0) because Y positioning is irrelevant: - const y = isAllDay ? 0 : e.clientY; + const y = liveDraft.values.schedule.kind === "allDay" ? 0 : e.clientY; const currTime = dateCalcs.getDateByXY( e.clientX, y, weekProps.component.startOfView, ); - if (!isValidMovement(currTime, workingDraft)) { - return; - } - - const justFlipped = flipIfNeeded(currTime); - const dateChanged = justFlipped ? oppositeKey : _dateBeingChanged; - - const origTime = dayjs(originDates[dateChanged]).add(-1, "day"); - - let updatedTime: string; - let hasMoved: boolean; - - if (isAllDay) { - // For all-day events, work with day differences - const diffDays = currTime.diff(origTime, "day", true); - updatedTime = currTime - .add(dateChanged === "endDate" ? 1 : 0, "day") - .format(YEAR_MONTH_DAY_FORMAT); - hasMoved = diffDays !== 0; - } else { - // For timed events, work with minute differences - const diffMin = currTime.diff(origTime, "minute"); - updatedTime = origTime.add(diffMin, "minutes").format(); - hasMoved = diffMin !== 0; - } + const result = resizeDraft({ + currTime, + dateBeingChanged, + draft: liveDraft, + origin: gestureOriginDraft, + }); + if (!result) return; - if (!resizeStatus?.hasMoved && hasMoved) { + setIsFormOpen(false); + if (result.flippedTo) setDateBeingChanged(result.flippedTo); + if (!resizeStatus?.hasMoved && result.hasMoved) { setResizeStatus({ hasMoved: true }); } - - const nextSchedule: GridScheduleDraft = { - ...workingDraft.values.schedule, - ...(dateChanged === "startDate" - ? { start: dayjs(updatedTime).toDate() } - : { end: dayjs(updatedTime).toDate() }), - } as GridScheduleDraft; - - draftActions.setGridDraft( - replaceGridDraftSchedule(workingDraft, nextSchedule), - ); + draftActions.setGridDraft(result.draft); }, [ dateBeingChanged, dateCalcs, gestureOriginDraft, isResizing, - isValidMovement, resizeStatus?.hasMoved, setDateBeingChanged, setIsFormOpen,