diff --git a/src/app/(main)/map/_components/useMobileRouteView.test.tsx b/src/app/(main)/map/_components/useMobileRouteView.test.tsx index d722f444..06cb756f 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.test.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.test.tsx @@ -22,6 +22,7 @@ vi.mock("@/hooks/use-room-id", () => ({ useCurrentRoomId: () => ({ roomId: "room vi.mock("@/hooks/useRooms", () => ({ useRoomSchedules: () => ({ data: fixtures.schedules, isSuccess: true }), useSchedulePlanPlaces: () => ({ data: fixtures.places, isSuccess: true, isFetching: false }), + useScheduleItemMoveSavingDays: () => [], })); vi.mock("@/hooks/usePrefetchScheduleRoutes", () => ({ usePrefetchScheduleRoutes: () => true })); vi.mock("@/components/expenses/ExpenseProvider", () => ({ @@ -128,7 +129,7 @@ it.each([ document.body.append(host); const root = createRoot(host); function Route() { return useMobileRouteView({ active: true, day: 1, initialItemId: 101 }).overlay; } - const drag = { editing: false, dragDisabled: false, pending: null, dropTargetScheduleId: null, + const drag = { editing: false, dropTargetScheduleId: null, registerList: vi.fn(), registerRow: vi.fn(), registerPlaces: vi.fn(), startDrag: vi.fn(), rowStyle: () => undefined }; try { await act(async () => root.render( diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx index eed0eb6c..c33f3683 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx @@ -13,7 +13,6 @@ import { Switch } from "@/components/ui/switch"; import { MAIN_CARD_INNER_PADDING_X_CLASS } from "@/lib/layout-tokens"; import { usePlanItineraryExpandedStore } from "@/stores/plan-itinerary-expanded-store"; import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store"; -import { usePlanItemCrossDayDragStore } from "@/stores/plan-item-cross-day-drag-store"; import { cn } from "@/lib/utils"; import { ChevronDown, ChevronUp, GripVertical } from "lucide-react"; import { useCallback, useId, useState } from "react"; @@ -54,11 +53,14 @@ export type PlanDaySectionProps = { sectionRef?: Ref; sectionStyle?: CSSProperties; dragHandleProps?: PlanDaySectionDragHandleProps; - /** item cross-day D&D — 헤더·접힌 일차 drop (PlanItinerary list와 병행) */ - crossDaySectionDropProps?: { - onDragOver: (e: DragEvent) => void; - onDragLeave: (e: DragEvent) => void; - onDrop: (e: DragEvent) => void; + /** 다른 일차 장소 놓기 — 헤더·접힌 일차 놓을 영역(목록은 PlanItinerary가 받는다)과 놓을 대상 강조 */ + crossDayDrop?: { + props?: { + onDragOver: (e: DragEvent) => void; + onDragLeave: (e: DragEvent) => void; + onDrop: (e: DragEvent) => void; + }; + isTarget: boolean; }; }; @@ -88,7 +90,7 @@ export function PlanDaySection({ sectionRef, sectionStyle, dragHandleProps, - crossDaySectionDropProps, + crossDayDrop, }: PlanDaySectionProps) { const panelId = useId(); @@ -142,18 +144,6 @@ export function PlanDaySection({ const dragDisabled = dragHandleProps?.dragDisabled ?? true; const isDragging = dragHandleProps?.isDragging ?? false; - const sourceScheduleId = usePlanItemCrossDayDragStore( - (s) => s.sourceScheduleId, - ); - const hoverTargetScheduleId = usePlanItemCrossDayDragStore( - (s) => s.hoverTargetScheduleId, - ); - const isCrossDayItemDropTarget = - trackedSid !== undefined && - sourceScheduleId !== null && - sourceScheduleId !== trackedSid && - hoverTargetScheduleId === trackedSid; - const showScheduleMenu = onRequestAddExpense != null || onRequestDeleteSchedule != null || onRequestInsertScheduleAfter != null; @@ -163,11 +153,11 @@ export function PlanDaySection({
({ + places: [] as PlanPlace[], + savingDays: [] as number[], + travel: [] as { segmentSourceItemId: number; routeQueryEnabled?: boolean }[], +})); +vi.mock("@/hooks/useRooms", () => ({ + useSchedulePlanPlaces: () => ({ data: fixtures.places, isLoading: false, isFetching: false, isError: false }), + useCreateScheduleItem: () => ({ mutateAsync: vi.fn(), isPending: false }), + useScheduleItemMoveSavingDays: () => fixtures.savingDays, +})); +vi.mock("@/hooks/usePrefetchScheduleRoutes", () => ({ usePrefetchScheduleRoutes: () => true })); +vi.mock("../travel-time/PlanTravelTime", () => ({ + PlanTravelTime: (props: { segmentSourceItemId: number; routeQueryEnabled?: boolean }) => { + fixtures.travel.push(props); + return null; + }, +})); +vi.mock("./PlanPlaceCard", () => ({ PlanPlaceCard: () => null })); +vi.mock("./PlanAddPlaceControls", () => ({ PlanAddPlaceControls: () => null })); +vi.mock("./AddFromBookmarkModal", () => ({ AddFromBookmarkModal: () => null })); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const drag: PlanItemDragValue = { + getListProps: () => ({ + dropZoneProps: { "data-plan-itinerary-drop-zone": "", onDragOver: () => {}, onDragLeave: () => {}, onDrop: () => {} }, + isItemDragActive: false, + reservePaddingBottom: 0, + getRowProps: () => ({ + ref: () => {}, + style: undefined, + placeCardDragProps: { displayOrderIndex: 1, isDragging: false, dragDisabled: false, onDragStart: () => {}, onDragEnd: () => {} }, + }), + }), + getSectionDrop: () => ({ props: { onDragOver: () => {}, onDragLeave: () => {}, onDrop: () => {} }, isTarget: false }), +}; + +const place = (itemId: number): PlanPlace => ({ id: `item-${itemId}`, itemId, googlePlaceId: `g${itemId}`, title: `장소 ${itemId}` }); + +it("does not query segment routes of a day while its place move is saving — the server still has the old order", async () => { + fixtures.places = [place(2), place(1)]; + const host = document.createElement("div"); + const root = createRoot(host); + const render = () => + act(async () => { + root.render( + + + , + ); + }); + try { + // 순서 변경을 저장하는 중 — 캐시는 이미 새 순서(2 → 1)다 + fixtures.savingDays = [10]; + fixtures.travel = []; + await render(); + expect(fixtures.travel.at(-1)).toMatchObject({ segmentSourceItemId: 2, routeQueryEnabled: false }); + + // 다른 일차를 저장하는 중이면 이 일차는 조회한다 + fixtures.savingDays = [20]; + await render(); + expect(fixtures.travel.at(-1)).toMatchObject({ segmentSourceItemId: 2, routeQueryEnabled: true }); + + // 저장이 끝나면 조회한다 + fixtures.savingDays = []; + await render(); + expect(fixtures.travel.at(-1)).toMatchObject({ segmentSourceItemId: 2, routeQueryEnabled: true }); + } finally { + await act(async () => root.unmount()); + } +}); diff --git a/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx b/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx index e3c07d3a..7283df27 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx @@ -2,26 +2,24 @@ import { Spinner } from "@/components/ui/spinner"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useReducedMotion } from "framer-motion"; import { PlanLoadingSkeleton } from "../PlanLoadingSkeleton"; import { toast } from "sonner"; import { useCreateScheduleItem, + useScheduleItemMoveSavingDays, useSchedulePlanPlaces, } from "@/hooks/useRooms"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; import { bucketItemCount } from "@/lib/analytics/context"; import { usePrefetchScheduleRoutes } from "@/hooks/usePrefetchScheduleRoutes"; -import { usePlanItineraryReorder } from "@/hooks/usePlanItineraryReorder"; -import { PLAN_ITEM_ITINERARY_DROP_ZONE_ATTR } from "@/lib/plan/planItemReorder"; +import { usePlanItemDrag } from "@/hooks/usePlanItemDragController"; import { useMapCenterStore } from "@/stores/map-center-store"; import { schedulePlacesFingerprint } from "@/lib/plan/planTravelLocalStorage"; import { scheduleIdsToRouteColors, sortedScheduleIdsForRouteColors, } from "@/lib/plan/planRouteDayColors"; -import { usePlanItemCrossDayDragStore } from "@/stores/plan-item-cross-day-drag-store"; import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store"; import { cn } from "@/lib/utils"; @@ -86,6 +84,10 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { routeQueryEnabled, ); + // 장소 이동을 저장하는 동안 캐시는 새 순서지만 서버는 아직 옛 순서다 — 지금 새 구간 경로를 받으면 + // 옛 이웃까지의 경로가 새 구간 key에 남는다. 저장이 끝나면(다시 받은 목록으로) 조회한다 + const isSavingPlaceMove = useScheduleItemMoveSavingDays(roomId).includes(scheduleId); + const existingItemIds = useMemo( () => new Set( @@ -105,20 +107,8 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { insertIndex?: number; } | null>(null); - const prefersReducedMotion = useReducedMotion(); - - const { - getRowProps, - listContainerProps, - isDraggingActive, - listReservePaddingBottom, - } = usePlanItineraryReorder({ - roomId, - scheduleId, - places, - interactionLocked: - isAdding || activeInsertIndex !== null, - }); + const { dropZoneProps, isItemDragActive, reservePaddingBottom, getRowProps } = + usePlanItemDrag().getListProps(scheduleId, places); useEffect(() => { if (activeInsertIndex === null) return; @@ -171,18 +161,13 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { const addControlsDisabled = isAdding; - const crossDayItemDragActive = usePlanItemCrossDayDragStore( - (s) => s.sourceScheduleId !== null, - ); - return (
@@ -205,17 +190,13 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) {
0 - ? { paddingBottom: listReservePaddingBottom } + reservePaddingBottom > 0 + ? { paddingBottom: reservePaddingBottom } : undefined } > {places.map((place, index) => { - const motionEnabled = isDraggingActive && !prefersReducedMotion; - const { ref, style, placeCardDragProps } = getRowProps( - index, - motionEnabled, - ); + const { ref, style, placeCardDragProps } = getRowProps(index); const insertIndex = index + 1; const segmentActive = activeInsertIndex === insertIndex; const nextItemId = places[index + 1]?.itemId; @@ -275,6 +256,7 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { routeQueryEnabled={ placesData !== undefined && !isFetchingPlaces && + !isSavingPlaceMove && routesBatchSettled && existingItemIds.has(place.itemId) && existingItemIds.has(nextItemId) diff --git a/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx b/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx index 9c7bc2f0..9b787014 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx @@ -25,6 +25,10 @@ import { MainPageHeader } from "@/components/layout/MainPageHeader"; import { ConfirmDialog } from "@/components/settings/ConfirmDialog"; import { usePlanScheduleDayReorder } from "@/hooks/usePlanScheduleDayReorder"; +import { + PlanItemDragProvider, + usePlanItemDragController, +} from "@/hooks/usePlanItemDragController"; import { planCopy } from "@/lib/plan/planCopy"; import { useRoomDetail } from "@/hooks/useRoomDetail"; import { @@ -93,9 +97,11 @@ export function PlanPageView() { usePlanScheduleDayReorder({ roomId, schedules: sortedSchedules, - interactionLocked: isCreatingSchedule || isDeletingSchedule, }); + // 장소 드래그는 여러 일차에 걸치므로 페이지에서 한 벌만 둔다(모바일은 MobilePlanView의 컨트롤러) + const planItemDrag = usePlanItemDragController(roomId); + const scheduleExpansionSyncKey = useMemo( () => sortedSchedules.map((s) => s.scheduleId).join(","), [sortedSchedules], @@ -257,37 +263,38 @@ export function PlanPageView() { ) : null} {planDays.length > 0 ? ( -
- {planDays.map((day, dayIndex) => { - const sectionProps = getSectionProps(dayIndex); + +
+ {planDays.map((day, dayIndex) => { + const sectionProps = getSectionProps(dayIndex); - return ( - handleDeleteScheduleDay(dayIndex) - : undefined - } - onRequestInsertScheduleAfter={ - sortedSchedules[dayIndex] - ? () => handleInsertScheduleAfter(dayIndex) - : undefined - } - isScheduleMenuDisabled={ - isCreatingSchedule || isDeletingSchedule || isMovePending - } - interactionLocked={isCreatingSchedule || isDeletingSchedule} - {...sectionProps} - /> - ); - })} -
+ return ( + handleDeleteScheduleDay(dayIndex) + : undefined + } + onRequestInsertScheduleAfter={ + sortedSchedules[dayIndex] + ? () => handleInsertScheduleAfter(dayIndex) + : undefined + } + isScheduleMenuDisabled={ + isCreatingSchedule || isDeletingSchedule || isMovePending + } + {...sectionProps} + /> + ); + })} +
+ ) : null}
{deleteDayDialog} diff --git a/src/app/(main)/plan/_components/itinerary/PlanScheduleDayBlock.tsx b/src/app/(main)/plan/_components/itinerary/PlanScheduleDayBlock.tsx index ffb08212..1341937d 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanScheduleDayBlock.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanScheduleDayBlock.tsx @@ -5,7 +5,7 @@ import { ExpenseEntryButton, useExpenseContext } from "@/components/expenses/Exp import type { CSSProperties, Ref } from "react"; -import { usePlanDaySectionCrossDayDrop } from "@/hooks/usePlanDaySectionCrossDayDrop"; +import { usePlanItemDrag } from "@/hooks/usePlanItemDragController"; import { useSchedulePlanPlaces } from "@/hooks/useRooms"; import { @@ -28,7 +28,6 @@ type PlanScheduleDayBlockProps = { sectionRef?: Ref; sectionStyle?: CSSProperties; dragHandleProps?: PlanDaySectionDragHandleProps; - interactionLocked?: boolean; }; export function PlanScheduleDayBlock({ @@ -43,18 +42,11 @@ export function PlanScheduleDayBlock({ sectionRef, sectionStyle, dragHandleProps, - interactionLocked = false, }: PlanScheduleDayBlockProps) { const expenses = useExpenseContext(); const { data: placesData } = useSchedulePlanPlaces(roomId, scheduleId); - const placesCount = placesData?.length ?? 0; - - const { crossDaySectionDropProps } = usePlanDaySectionCrossDayDrop({ - roomId, - scheduleId, - placesCount, - interactionLocked, - }); + // 다른 일차의 장소를 헤더·접힌 일차에 놓으면 맨 앞에 넣는다 + const crossDayDrop = usePlanItemDrag().getSectionDrop(scheduleId, placesData); return ( diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx index 4ce706ae..fb4b83dd 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx @@ -9,7 +9,11 @@ import { PlusIcon } from "@/assets/icons"; import { DashedPlaceholderBox } from "@/components/mobile/DashedPlaceholderBox"; import { useExpenseContext } from "@/components/expenses/ExpenseProvider"; import { usePrefetchScheduleRoutes } from "@/hooks/usePrefetchScheduleRoutes"; -import { useRoomSchedules, useSchedulePlanPlaces } from "@/hooks/useRooms"; +import { + useRoomSchedules, + useScheduleItemMoveSavingDays, + useSchedulePlanPlaces, +} from "@/hooks/useRooms"; import { expensesInScope } from "@/lib/expenses/expense-scope"; import { buildMapRouteHref } from "@/lib/mobile-view"; import type { InsertAnchor } from "@/lib/plan/insertPosition"; @@ -28,7 +32,7 @@ import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-rout import { PlanTravelTime } from "../travel-time/PlanTravelTime"; import { MobilePlanPlaceCard } from "./MobilePlanPlaceCard"; import { MobilePlanPlaceSheets, openPlaceExpenses, type MobilePlanPlaceSheet } from "./MobilePlanPlaceSheets"; -import { applyPendingPlanMove, useMobilePlanDrag } from "./mobilePlanDrag"; +import { useMobilePlanDrag } from "./mobilePlanDrag"; const EMPTY_PLACES: PlanPlace[] = []; @@ -57,14 +61,10 @@ export function MobilePlanItinerary({ const router = useRouter(); const { data: schedules } = useRoomSchedules(roomId); const { data, isLoading, isFetching, isError } = useSchedulePlanPlaces(roomId, scheduleId); - const serverPlaces = data ?? EMPTY_PLACES; + // 옮긴 결과는 저장 응답 전에 캐시가 먼저 바뀐다(mutation onMutate) — 캐시를 그대로 그린다 + const places = data ?? EMPTY_PLACES; const drag = useMobilePlanDrag(); const { editing, registerPlaces } = drag; - // 옮긴 결과를 서버 반영 전까지 먼저 보여준다 - const places = useMemo( - () => applyPendingPlanMove(scheduleId, serverPlaces, drag.pending), - [drag.pending, scheduleId, serverPlaces], - ); useEffect(() => { registerPlaces(scheduleId, places); }, [places, registerPlaces, scheduleId]); @@ -79,16 +79,18 @@ export function MobilePlanItinerary({ [roomId, scheduleId, visibleByScheduleId], ); - const scheduleFingerprint = useMemo(() => schedulePlacesFingerprint(serverPlaces), [serverPlaces]); + const scheduleFingerprint = useMemo(() => schedulePlacesFingerprint(places), [places]); const routesBatchSettled = usePrefetchScheduleRoutes( roomId, scheduleId, - serverPlaces, - !isLoading && !isFetching && !isError && serverPlaces.length >= 2 && scheduleFingerprint.length > 0, + places, + !isLoading && !isFetching && !isError && places.length >= 2 && scheduleFingerprint.length > 0, ); + // 장소 이동을 저장하는 동안 캐시는 새 순서지만 서버는 아직 옛 순서다 — 저장이 끝날 때까지 새 구간 경로를 받지 않는다 + const isSavingPlaceMove = useScheduleItemMoveSavingDays(roomId).includes(scheduleId); const existingItemIds = useMemo( - () => new Set(serverPlaces.map((p) => p.itemId).filter((id): id is number => typeof id === "number")), - [serverPlaces], + () => new Set(places.map((p) => p.itemId).filter((id): id is number => typeof id === "number")), + [places], ); const [sheet, setSheet] = useState(null); @@ -144,7 +146,7 @@ export function MobilePlanItinerary({ key={place.id} data-plan-item-id={place.itemId} ref={(el) => drag.registerRow(scheduleId, index, el)} - style={drag.rowStyle(scheduleId, index)} + style={drag.rowStyle(scheduleId, index, places)} > setSheet({ kind: "memo", place })} onEditTime={() => setSheet({ kind: "time", place })} editing={editing} - dragDisabled={drag.dragDisabled || typeof place.itemId !== "number"} + dragDisabled={typeof place.itemId !== "number"} onHandlePointerDown={(e) => drag.startDrag(scheduleId, index, e)} />
diff --git a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.test.ts b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.test.ts index d44fa4f0..8bc406b5 100644 --- a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.test.ts +++ b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.test.ts @@ -1,61 +1,146 @@ -import { describe, expect, it, vi } from "vitest"; +// @vitest-environment jsdom +import { act, type PointerEvent as ReactPointerEvent } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PlanItemDrop } from "@/lib/plan/planItemDrop"; import type { PlanPlace } from "@/lib/plan/types"; +import { hookHarness } from "@/test/hook-harness"; -vi.mock("@/hooks/useRooms", () => ({ - useMoveScheduleItemToSchedule: vi.fn(), - useReorderScheduleItem: vi.fn(), +type Commit = (drop: PlanItemDrop, onSettled: () => void) => boolean; +const commit = vi.hoisted(() => ({ fn: vi.fn() })); +vi.mock("@/hooks/usePlanItemDropCommit", () => ({ + usePlanItemDropCommit: () => ({ + commitPlanItemDrop: commit.fn, + }), })); -vi.mock("@/lib/analytics/track", () => ({ AnalyticsEvents: {}, trackAnalyticsEvent: vi.fn() })); -vi.mock("@/hooks/useScheduleResyncPending", () => ({ useScheduleResyncPending: () => false })); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -const { applyPendingPlanMove, mobilePlanRowOffset } = await import("./mobilePlanDrag"); +const { mobilePlanRowOffset, useMobilePlanDragController, MOBILE_EDIT_ROW_GAP_PX } = await import("./mobilePlanDrag"); +const ROW_HEIGHT = 100; function place(itemId: number): PlanPlace { return { id: `item-${itemId}`, itemId, title: `장소 ${itemId}` }; } -const ids = (places: PlanPlace[]) => places.map((p) => p.itemId); - -describe("applyPendingPlanMove", () => { - const day1 = [place(1), place(2), place(3)]; - const day2 = [place(10)]; +function box(top: number, height: number) { + const el = document.createElement("div"); + el.getBoundingClientRect = () => + ({ top, bottom: top + height, height, left: 0, right: 300, width: 300, x: 0, y: top, toJSON: () => ({}) }) as DOMRect; + return el; +} +function pointer(type: string, clientY: number) { + return Object.assign(new Event(type, { cancelable: true }), { pointerId: 1, clientY }); +} - it("처리 중인 이동이 없으면 서버 목록 그대로", () => { - expect(applyPendingPlanMove(1, day1, null)).toBe(day1); +describe("useMobilePlanDragController", () => { + beforeEach(() => { + commit.fn.mockReset().mockReturnValue(true); }); - it("같은 일차 순서 변경은 그 일차에만 새 순서를 적용한다", () => { - const pending = { kind: "reorder" as const, scheduleId: 1, itemId: 3, newOrderIndex: 0 }; - expect(ids(applyPendingPlanMove(1, day1, pending))).toEqual([3, 1, 2]); - expect(applyPendingPlanMove(2, day2, pending)).toBe(day2); + function setup() { + const scrollRef = { current: box(0, 2000) }; + const hook = hookHarness(() => useMobilePlanDragController({ roomId: "room-a", editing: true, scrollRef })); + const register = (scheduleId: number, places: PlanPlace[], top: number) => { + hook.current.registerList(scheduleId, box(top, places.length * ROW_HEIGHT)); + places.forEach((_, index) => hook.current.registerRow(scheduleId, index, box(top + index * ROW_HEIGHT, ROW_HEIGHT))); + hook.current.registerPlaces(scheduleId, places); + }; + const drag = async (scheduleId: number, index: number, fromY: number, toY: number) => { + await act(async () => + hook.current.startDrag(scheduleId, index, { + pointerId: 1, + clientY: fromY, + preventDefault: () => {}, + } as unknown as ReactPointerEvent), + ); + await act(async () => { + window.dispatchEvent(pointer("pointermove", toY)); + }); + await act(async () => { + window.dispatchEvent(pointer("pointerup", toY)); + }); + }; + return { hook, register, drag }; + } + + it("같은 일차에 놓으면 놓기 결정에 시작·놓은 자리를 넘기고, 캐시가 바뀔 때까지 놓은 모양을 둔다", async () => { + const { hook, register, drag } = setup(); + const day = [place(1), place(2), place(3)]; + try { + await hook.render(undefined); + register(10, day, 0); + await drag(10, 0, 50, 300); + + expect(commit.fn).toHaveBeenCalledExactlyOnceWith( + { itemId: 1, from: { scheduleId: 10, index: 0 }, to: { scheduleId: 10, index: 2, length: 3 } }, + expect.any(Function), + ); + // 캐시가 아직 놓기 전 목록이면 손을 뗀 자리의 카드와 밀린 카드를 그대로 그린다 — 원래 자리로 튀지 않는다 + expect(hook.current.rowStyle(10, 0, day)?.transform).toBe("translateY(250px)"); + expect(hook.current.rowStyle(10, 1, day)?.transform).toBe(`translateY(-${ROW_HEIGHT + MOBILE_EDIT_ROW_GAP_PX}px)`); + // onMutate가 캐시를 새 순서로 바꾸면 캐시를 그대로 그린다 + expect(hook.current.rowStyle(10, 0, [place(2), place(3), place(1)])).toBeUndefined(); + + await act(async () => commit.fn.mock.lastCall![1]()); + expect(hook.current.rowStyle(10, 0, day)).toBeUndefined(); + expect(hook.current.dropTargetScheduleId).toBeNull(); + } finally { + await hook.unmount(); + } }); - it("서버 목록에 이미 새 순서가 반영됐으면 그대로 둔다", () => { - const pending = { kind: "reorder" as const, scheduleId: 1, itemId: 3, newOrderIndex: 0 }; - expect(ids(applyPendingPlanMove(1, [place(3), place(1), place(2)], pending))).toEqual([3, 1, 2]); + it("다른 일차에 놓으면 그 일차의 넣을 자리와 길이를 넘긴다", async () => { + const { hook, register, drag } = setup(); + try { + await hook.render(undefined); + register(10, [place(1), place(2)], 0); + register(20, [place(10), place(11)], 400); + await drag(10, 1, 150, 470); + + expect(commit.fn).toHaveBeenCalledExactlyOnceWith( + { itemId: 2, from: { scheduleId: 10, index: 1 }, to: { scheduleId: 20, index: 1, length: 2 } }, + expect.any(Function), + ); + } finally { + await hook.unmount(); + } }); - it("다른 일차로 이동하면 원래 일차에서 빼고 대상 일차의 위치에 넣는다", () => { - const pending = { - kind: "move" as const, - place: day1[1]!, - fromScheduleId: 1, - toScheduleId: 2, - toIndex: 0, - }; - expect(ids(applyPendingPlanMove(1, day1, pending))).toEqual([1, 3]); - expect(ids(applyPendingPlanMove(2, day2, pending))).toEqual([2, 10]); + it("저장하지 않은 놓기는 바로 푼다", async () => { + commit.fn.mockReturnValue(false); + const { hook, register, drag } = setup(); + const day = [place(1), place(2)]; + try { + await hook.render(undefined); + register(10, day, 0); + await drag(10, 0, 50, 190); + expect(hook.current.rowStyle(10, 0, day)).toBeUndefined(); + } finally { + await hook.unmount(); + } }); - it("서버 목록에 이미 반영됐으면 대상 일차에 중복으로 넣지 않는다", () => { - const pending = { - kind: "move" as const, - place: place(2), - fromScheduleId: 1, - toScheduleId: 2, - toIndex: 0, - }; - expect(ids(applyPendingPlanMove(2, [place(2), place(10)], pending))).toEqual([2, 10]); + it("앞서 놓은 장소를 저장하는 중에도 새로 끌 수 있고, 앞선 저장이 끝나도 새 드래그를 풀지 않는다", async () => { + const { hook, register, drag } = setup(); + const day = [place(1), place(2), place(3)]; + try { + await hook.render(undefined); + register(10, day, 0); + await drag(10, 0, 50, 300); + const [, firstSave] = commit.fn.mock.lastCall!; + + await act(async () => + hook.current.startDrag(10, 2, { + pointerId: 1, + clientY: 250, + preventDefault: () => {}, + } as unknown as ReactPointerEvent), + ); + await act(async () => firstSave()); + expect(hook.current.rowStyle(10, 2, day)?.zIndex).toBe(20); + expect(hook.current.dropTargetScheduleId).toBe(10); + } finally { + await hook.unmount(); + } }); }); diff --git a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx index 9ddde949..654fb5c1 100644 --- a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx +++ b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx @@ -5,26 +5,15 @@ import { useCallback, useContext, useEffect, + useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type RefObject, } from "react"; -import { toast } from "sonner"; -import { - useMoveScheduleItemToSchedule, - useReorderScheduleItem, -} from "@/hooks/useRooms"; -import { useScheduleResyncPending } from "@/hooks/useScheduleResyncPending"; -import { bucketItemCount } from "@/lib/analytics/context"; -import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; -import { - clampCrossDayTargetOrderIndex, - PLAN_ORDER_RESYNCING_MESSAGE, -} from "@/lib/plan/planItemReorder"; -import { newOrderIndexAfterMove } from "@/lib/plan/scheduleItemPlaces"; +import { usePlanItemDropCommit } from "@/hooks/usePlanItemDropCommit"; import type { PlanPlace } from "@/lib/plan/types"; /** 편집 모드 카드 사이 간격(px) — 드래그 중 다른 카드를 밀어낼 거리 */ @@ -50,17 +39,15 @@ type ActiveDrag = { dy: number; targetScheduleId: number; targetIndex: number; + /** + * 놓아서 저장하는 중 — 놓을 때 출발·대상 일차 목록. 캐시(onMutate)가 그 목록을 바꿀 때까지 놓은 모양을 그대로 둔다. + * 먼저 풀면 캐시가 바뀌기 전 한 프레임 동안 옛 순서가 보인다(캐시 알림은 setTimeout 0 뒤) + */ + dropped: ReadonlyMap | null; }; -/** 저장 중인 순서 변경·일차 이동 — 응답 전에 새 자리를 먼저 보여 준다(mutation 요청 값에서 계산) */ -export type PendingPlanMove = - | { kind: "reorder"; scheduleId: number; itemId: number; newOrderIndex: number } - | { kind: "move"; place: PlanPlace; fromScheduleId: number; toScheduleId: number; toIndex: number }; - type MobilePlanDragValue = { editing: boolean; - dragDisabled: boolean; - pending: PendingPlanMove | null; /** 드래그 중 카드가 놓일 일차 — 드래그 중이 아니면 null */ dropTargetScheduleId: number | null; registerList: (scheduleId: number, el: HTMLElement | null) => void; @@ -71,7 +58,8 @@ type MobilePlanDragValue = { index: number, e: ReactPointerEvent, ) => void; - rowStyle: (scheduleId: number, index: number) => CSSProperties | undefined; + /** 그 일차 목록(캐시)의 행 위치 — 놓아서 저장 중이면 캐시가 바뀌기 전까지만 놓은 모양 */ + rowStyle: (scheduleId: number, index: number, places: readonly PlanPlace[]) => CSSProperties | undefined; }; const MobilePlanDragContext = createContext(null); @@ -84,33 +72,6 @@ export function useMobilePlanDrag(): MobilePlanDragValue { return value; } -/** 서버 목록에 처리 중인 이동을 반영한 표시용 목록 */ -export function applyPendingPlanMove( - scheduleId: number, - places: PlanPlace[], - pending: PendingPlanMove | null, -): PlanPlace[] { - if (!pending) return places; - if (pending.kind === "reorder") { - if (pending.scheduleId !== scheduleId) return places; - const from = places.findIndex((p) => p.itemId === pending.itemId); - if (from < 0) return places; - const next = [...places]; - next.splice(pending.newOrderIndex, 0, ...next.splice(from, 1)); - return next; - } - const itemId = pending.place.itemId; - if (pending.fromScheduleId === scheduleId) { - return places.filter((p) => p.itemId !== itemId); - } - if (pending.toScheduleId === scheduleId && !places.some((p) => p.itemId === itemId)) { - const next = [...places]; - next.splice(clampCrossDayTargetOrderIndex(pending.toIndex, places.length), 0, pending.place); - return next; - } - return places; -} - type RowOffsetInput = Pick< ActiveDrag, "sourceScheduleId" | "sourceIndex" | "targetScheduleId" | "targetIndex" @@ -148,7 +109,7 @@ function autoScrollSpeed(y: number, top: number, bottom: number): number { /** * 모바일 일정 순서 편집 드래그 — 모든 일차의 카드를 한 번에 다루고, 다른 일차로도 옮길 수 있다. - * 같은 일차는 순서 변경, 다른 일차는 일차 간 이동 API로 저장한다. + * 놓으면 PC와 같은 놓기 결정(`usePlanItemDropCommit`)이 같은 일차는 순서 변경, 다른 일차는 일차 간 이동으로 저장한다. */ export function useMobilePlanDragController({ roomId, @@ -166,36 +127,7 @@ export function useMobilePlanDragController({ const [active, setActive] = useState(null); const rafRef = useRef(null); - const { - mutateAsync: reorderMutate, - isPending: isReorderPending, - variables: reorderVariables, - } = useReorderScheduleItem(); - const { - mutateAsync: moveMutate, - isPending: isMovePending, - variables: moveVariables, - } = useMoveScheduleItemToSchedule(); - const scheduleResyncPending = useScheduleResyncPending(roomId); - const dragDisabled = isReorderPending || isMovePending || scheduleResyncPending; - // 결과가 편집 목록에만 보이므로 캐시를 고치지 않고, 저장하는 동안 요청 값으로 새 자리를 그린다 - let pending: PendingPlanMove | null = null; - if (isReorderPending && reorderVariables) { - pending = { - kind: "reorder", - scheduleId: reorderVariables.scheduleId, - itemId: reorderVariables.itemId, - newOrderIndex: reorderVariables.body.newOrderIndex, - }; - } else if (isMovePending && moveVariables?.place) { - pending = { - kind: "move", - place: moveVariables.place, - fromScheduleId: moveVariables.sourceScheduleId, - toScheduleId: moveVariables.targetScheduleId, - toIndex: moveVariables.targetOrderIndex, - }; - } + const { commitPlanItemDrop } = usePlanItemDropCommit(roomId); const updateActive = useCallback((next: ActiveDrag | null) => { activeRef.current = next; @@ -234,7 +166,7 @@ export function useMobilePlanDragController({ const updateTarget = useCallback( (clientY: number) => { const drag = activeRef.current; - if (!drag) return; + if (!drag || drag.dropped) return; const dy = toContentY(clientY) - drag.startContentY; const sourceDay = drag.days.find((d) => d.scheduleId === drag.sourceScheduleId); const sourceRow = sourceDay?.rows[drag.sourceIndex]; @@ -259,61 +191,42 @@ export function useMobilePlanDragController({ [toContentY, updateActive], ); - const finishDrag = useCallback(async () => { + /** 놓기 — 저장을 시작했으면 캐시가 바뀔 때까지 놓은 모양을 두고, 아니면 바로 푼다 */ + const finishDrag = useCallback(() => { const drag = activeRef.current; - if (!drag) return; - updateActive(null); + if (!drag || drag.dropped) return; const { sourceScheduleId, sourceIndex, targetScheduleId, targetIndex, place } = drag; const itemId = place.itemId; - if (typeof itemId !== "number") return; - const sourcePlaces = placesBySchedule.current.get(sourceScheduleId) ?? []; - - // 드래그하는 동안 연결이 끊겼으면 놓은 자리를 서버 순서로 확인할 수 없다 - if (scheduleResyncPending) { - toast.error(PLAN_ORDER_RESYNCING_MESSAGE); - return; - } - - if (targetScheduleId === sourceScheduleId) { - if (targetIndex === sourceIndex) return; - try { - await reorderMutate({ - roomId, - scheduleId: sourceScheduleId, - itemId, - body: { newOrderIndex: newOrderIndexAfterMove(sourceIndex, targetIndex, sourcePlaces.length) }, - }); - trackAnalyticsEvent(AnalyticsEvents.reorderItinerary, { - room_id: roomId, - item_count_bucket: bucketItemCount(sourcePlaces.length), - method: "drag_drop", - }); - } catch { - toast.error("순서를 바꾸지 못했어요."); - } + if (typeof itemId !== "number") { + updateActive(null); return; } - - const targetLength = placesBySchedule.current.get(targetScheduleId)?.length ?? 0; - try { - await moveMutate({ - roomId, - sourceScheduleId, + const sourcePlaces = placesBySchedule.current.get(sourceScheduleId) ?? []; + const targetPlaces = placesBySchedule.current.get(targetScheduleId) ?? []; + const dropped: ActiveDrag = { + ...drag, + dropped: new Map([ + [sourceScheduleId, sourcePlaces], + [targetScheduleId, targetPlaces], + ]), + }; + const started = commitPlanItemDrop( + { itemId, - targetScheduleId, - targetOrderIndex: clampCrossDayTargetOrderIndex(targetIndex, targetLength), - place, - }); - } catch (err) { - toast.error( - err instanceof Error && err.message.trim() ? err.message : "다른 일차로 옮기지 못했어요.", - ); - } - }, [moveMutate, reorderMutate, roomId, scheduleResyncPending, updateActive]); + from: { scheduleId: sourceScheduleId, index: sourceIndex }, + to: { scheduleId: targetScheduleId, index: targetIndex, length: targetPlaces.length }, + }, + () => { + if (activeRef.current === dropped) updateActive(null); + }, + ); + updateActive(started ? dropped : null); + }, [commitPlanItemDrop, updateActive]); const startDrag = useCallback( (scheduleId: number, index: number, e: ReactPointerEvent) => { - if (!editing || dragDisabled || activeRef.current) return; + // 끄는 중이면 다른 손가락은 무시한다. 앞서 놓은 장소를 저장하는 중이면 새 드래그가 그 상태를 덮는다 + if (!editing || (activeRef.current && !activeRef.current.dropped)) return; const place = placesBySchedule.current.get(scheduleId)?.[index]; if (!place || typeof place.itemId !== "number") return; e.preventDefault(); @@ -343,13 +256,14 @@ export function useMobilePlanDragController({ dy: 0, targetScheduleId: scheduleId, targetIndex: index, + dropped: null, }); }, - [dragDisabled, editing, toContentY, updateActive], + [editing, toContentY, updateActive], ); - // 드래그 중에는 창 전체에서 포인터를 받고, 화면 끝 근처면 자동 스크롤한다 - const dragging = active !== null; + // 드래그 중에는 창 전체에서 포인터를 받고, 화면 끝 근처면 자동 스크롤한다(놓은 뒤 저장 중에는 받지 않는다) + const dragging = active !== null && active.dropped === null; useEffect(() => { if (!dragging) return; function onMove(ev: PointerEvent) { @@ -359,7 +273,7 @@ export function useMobilePlanDragController({ } function onUp(ev: PointerEvent) { if (ev.pointerId !== activeRef.current?.pointerId) return; - void finishDrag(); + finishDrag(); } function tick() { const drag = activeRef.current; @@ -389,8 +303,10 @@ export function useMobilePlanDragController({ }, [dragging, finishDrag, scrollRef, updateTarget]); const rowStyle = useCallback( - (scheduleId: number, index: number): CSSProperties | undefined => { + (scheduleId: number, index: number, places: readonly PlanPlace[]): CSSProperties | undefined => { if (!active) return undefined; + // 저장 중 캐시가 놓을 때 목록에서 바뀌었으면(onMutate) 캐시를 그대로 그린다 + if (active.dropped?.has(scheduleId) && active.dropped.get(scheduleId) !== places) return undefined; const { sourceScheduleId, sourceIndex, targetScheduleId, targetIndex, dy, days } = active; if (scheduleId === sourceScheduleId && index === sourceIndex) { return { transform: `translateY(${dy}px)`, position: "relative", zIndex: 20 }; @@ -411,15 +327,17 @@ export function useMobilePlanDragController({ [active], ); - return { - editing, - dragDisabled, - pending, - dropTargetScheduleId: active?.targetScheduleId ?? null, - registerList, - registerRow, - registerPlaces, - startDrag, - rowStyle, - }; + const dropTargetScheduleId = active?.targetScheduleId ?? null; + return useMemo( + () => ({ + editing, + dropTargetScheduleId, + registerList, + registerRow, + registerPlaces, + startDrag, + rowStyle, + }), + [dropTargetScheduleId, editing, registerList, registerPlaces, registerRow, rowStyle, startDrag], + ); } diff --git a/src/components/expenses/ExpenseProvider.test.tsx b/src/components/expenses/ExpenseProvider.test.tsx index 1e8b7ed1..32c32ba9 100644 --- a/src/components/expenses/ExpenseProvider.test.tsx +++ b/src/components/expenses/ExpenseProvider.test.tsx @@ -697,6 +697,7 @@ it("shares one room subscription across route children and resets it on selected vi.mock("@/hooks/useRoomDetail", () => ({ useRoomDetail: () => ({ data: undefined }) })); vi.mock("@/hooks/usePlanScheduleDayReorder", () => ({ usePlanScheduleDayReorder: () => ({ getSectionProps: () => ({}), listContainerProps: {}, isMovePending: false }) })); +vi.mock("@/hooks/usePlanItemDropCommit", () => ({ usePlanItemDropCommit: () => ({ commitPlanItemDrop: vi.fn() }) })); vi.mock("@/app/(main)/plan/_components/itinerary/PlanScheduleDayBlock", () => ({ PlanScheduleDayBlock: () => null })); import { PlanPageView } from "@/app/(main)/plan/_components/itinerary/PlanPageView"; diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx index 5d7ed6f5..71b78eb6 100644 --- a/src/components/map/PlanItineraryMapRoutes.tsx +++ b/src/components/map/PlanItineraryMapRoutes.tsx @@ -8,7 +8,7 @@ import { useMemo, type JSX } from "react"; import { useMobileView } from "@/contexts/MobileViewContext"; import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; -import { useRoomSchedules } from "@/hooks/useRooms"; +import { useRoomSchedules, useScheduleItemMoveSavingDays } from "@/hooks/useRooms"; import { buildPlanItineraryRouteConnectorDotIcons, buildPlanItineraryRouteConnectorPaths, @@ -140,6 +140,9 @@ export function PlanItineraryMapRoutes({ })), }); + // 장소 이동을 저장하는 동안 그 일차는 캐시만 새 순서고 서버는 옛 순서다 — 새 구간 경로를 받지 않는다(핀은 새 순서로 그린다) + const savingMoveScheduleIds = useScheduleItemMoveSavingDays(rid); + const buckets = orderedScheduleIdsForQueries.map( (_, idx) => placesQueries[idx]?.data ?? [], ); @@ -192,7 +195,10 @@ export function PlanItineraryMapRoutes({ ), }; }, - enabled: rid.length > 0 && segments.length > 0, + enabled: + rid.length > 0 && + segments.length > 0 && + !savingMoveScheduleIds.includes(seg.scheduleId), staleTime: Infinity, refetchOnMount: false, refetchOnWindowFocus: false, diff --git a/src/hooks/useExpenseInvalidation.test.ts b/src/hooks/useExpenseInvalidation.test.ts index 95d2b099..59deac74 100644 --- a/src/hooks/useExpenseInvalidation.test.ts +++ b/src/hooks/useExpenseInvalidation.test.ts @@ -16,9 +16,9 @@ vi.mock("@/lib/rooms", async (importOriginal) => ({ syncRoomDetailFromServer: vi.fn(), })); vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ - syncAfterCrossScheduleItemMove: vi.fn(), removeScheduleItemFromPlanPlacesCache: vi.fn(), })); +import { roomSchedulesQueryKey, scheduleItemsQueryKey } from "@/lib/query-keys"; import { useMoveRoomSchedule, useDeleteRoomSchedule, @@ -86,3 +86,22 @@ it("evicts expenses on room deletion", async () => { ).toBeUndefined(); expect(client.getQueryData(["room-expenses", "other", "list"])).toEqual([]); }); +it("refreshes both days after a place move, but leaves expenses alone when the move failed", async () => { + useMoveScheduleItemToSchedule(); + for (const scheduleId of [1, 2]) client.setQueryData(scheduleItemsQueryKey("r", scheduleId), []); + await mock.mutation.mock.lastCall![0].onSettled(undefined, new Error("failed"), { + roomId: "r", sourceScheduleId: 1, targetScheduleId: 2, itemId: 2, targetOrderIndex: 0, + }); + for (const scheduleId of [1, 2]) + expect(client.getQueryState(scheduleItemsQueryKey("r", scheduleId))?.isInvalidated).toBe(true); + expect(client.getQueryState(["room-expenses", "r", "list"])?.isInvalidated).toBe(false); +}); +it("refreshes the room schedules after day reordering whether or not it saved", async () => { + useMoveRoomSchedule(); + client.setQueryData(roomSchedulesQueryKey("r"), []); + await mock.mutation.mock.lastCall![0].onSettled(undefined, new Error("failed"), { + roomId: "r", scheduleId: 1, targetDayNumber: 2, + }); + expect(client.getQueryState(roomSchedulesQueryKey("r"))?.isInvalidated).toBe(true); + expect(client.getQueryState(["room-expenses", "r", "list"])?.isInvalidated).toBe(false); +}); diff --git a/src/hooks/usePlanDaySectionCrossDayDrop.ts b/src/hooks/usePlanDaySectionCrossDayDrop.ts deleted file mode 100644 index d94c8348..00000000 --- a/src/hooks/usePlanDaySectionCrossDayDrop.ts +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; - -import { useCallback, type DragEvent } from "react"; -import { toast } from "sonner"; - -import { useMoveScheduleItemToSchedule } from "@/hooks/useRooms"; -import { - clampCrossDayTargetOrderIndex, - executePlanItemCrossDayMove, - isPlanItemDrag, - PLAN_ITEM_ITINERARY_DROP_ZONE_ATTR, -} from "@/lib/plan/planItemReorder"; -import { usePlanItemCrossDayDragStore } from "@/stores/plan-item-cross-day-drag-store"; -import { usePlanItineraryExpandedStore } from "@/stores/plan-itinerary-expanded-store"; - -type UsePlanDaySectionCrossDayDropArgs = { - roomId: string; - scheduleId: number; - placesCount: number; - interactionLocked?: boolean; -}; - -export function usePlanDaySectionCrossDayDrop({ - roomId, - scheduleId, - placesCount, - interactionLocked = false, -}: UsePlanDaySectionCrossDayDropArgs) { - const { mutateAsync: moveMutate, isPending: isMovePending } = - useMoveScheduleItemToSchedule(); - const setHoverTarget = usePlanItemCrossDayDragStore((s) => s.setHoverTarget); - const endItemDrag = usePlanItemCrossDayDragStore((s) => s.endItemDrag); - const sourceScheduleId = usePlanItemCrossDayDragStore( - (s) => s.sourceScheduleId, - ); - - const locked = interactionLocked || isMovePending; - - const handleSectionDragOver = useCallback( - (e: DragEvent) => { - if (locked) return; - if (!isPlanItemDrag(e.dataTransfer)) return; - if (sourceScheduleId === scheduleId) return; - - const target = e.target; - if ( - target instanceof Element && - target.closest(`[${PLAN_ITEM_ITINERARY_DROP_ZONE_ATTR}]`) - ) { - return; - } - - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - usePlanItineraryExpandedStore - .getState() - .setScheduleExpanded(scheduleId, true); - setHoverTarget(scheduleId); - }, - [locked, scheduleId, setHoverTarget, sourceScheduleId], - ); - - const handleSectionDragLeave = useCallback( - (e: DragEvent) => { - const next = e.relatedTarget as Node | null; - if (next && e.currentTarget.contains(next)) return; - setHoverTarget(null); - }, - [setHoverTarget], - ); - - const handleSectionDrop = useCallback( - async (e: DragEvent) => { - if (locked) return; - if (!isPlanItemDrag(e.dataTransfer)) return; - - const target = e.target; - if ( - target instanceof Element && - target.closest(`[${PLAN_ITEM_ITINERARY_DROP_ZONE_ATTR}]`) - ) { - return; - } - - e.preventDefault(); - - const handled = await executePlanItemCrossDayMove({ - dataTransfer: e.dataTransfer, - roomId, - targetScheduleId: scheduleId, - targetOrderIndex: clampCrossDayTargetOrderIndex(0, placesCount), - moveMutate, - onError: (message) => toast.error(message), - }); - - endItemDrag(); - - if (!handled) return; - }, - [endItemDrag, locked, moveMutate, placesCount, roomId, scheduleId], - ); - - const crossDaySectionDropProps = locked - ? undefined - : { - onDragOver: handleSectionDragOver, - onDragLeave: handleSectionDragLeave, - onDrop: handleSectionDrop, - }; - - return { crossDaySectionDropProps }; -} diff --git a/src/hooks/usePlanItemDragController.test.tsx b/src/hooks/usePlanItemDragController.test.tsx new file mode 100644 index 00000000..48b252cf --- /dev/null +++ b/src/hooks/usePlanItemDragController.test.tsx @@ -0,0 +1,209 @@ +// @vitest-environment jsdom +import { act, type DragEvent } from "react"; +import { beforeEach, expect, it, vi } from "vitest"; + +import type { PlanItemDrop } from "@/lib/plan/planItemDrop"; +import type { PlanPlace } from "@/lib/plan/types"; +import { hookHarness } from "@/test/hook-harness"; + +import { usePlanItemDragController } from "./usePlanItemDragController"; + +type Commit = (drop: PlanItemDrop, onSettled: () => void) => boolean; +const commit = vi.hoisted(() => ({ fn: vi.fn() })); +vi.mock("@/hooks/usePlanItemDropCommit", () => ({ + usePlanItemDropCommit: () => ({ commitPlanItemDrop: commit.fn }), +})); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const ROW_HEIGHT = 100; +const place = (itemId: number): PlanPlace => ({ id: `item-${itemId}`, itemId, title: `장소 ${itemId}` }); + +function dataTransfer(): DataTransfer { + const data = new Map(); + return { + setData: (key: string, value: string) => data.set(key, value), + getData: (key: string) => data.get(key) ?? "", + setDragImage: () => {}, + dropEffect: "none", + effectAllowed: "all", + get types() { + return Array.from(data.keys()); + }, + } as unknown as DataTransfer; +} + +/** 목록 하나의 행 — `top`부터 100px씩 쌓인다 */ +function rows(count: number, top = 0) { + return Array.from({ length: count }, (_, index) => { + const row = document.createElement("div"); + const y = top + index * ROW_HEIGHT; + row.getBoundingClientRect = () => + ({ top: y, bottom: y + ROW_HEIGHT, height: ROW_HEIGHT, left: 0, right: 200, width: 200, x: 0, y, toJSON: () => ({}) }) as DOMRect; + return row; + }); +} + +function dragEvent(transfer: DataTransfer, target: Element, clientY: number) { + const event = { dataTransfer: transfer, currentTarget: target, target, relatedTarget: null, clientX: 10, clientY, preventDefault: () => {} }; + return { ...event, nativeEvent: event } as unknown as DragEvent; +} + +const nextFrame = () => act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); + +function renderController() { + return hookHarness((roomId: string) => usePlanItemDragController(roomId)); +} + +beforeEach(() => { + commit.fn.mockReset().mockReturnValue(true); +}); + +it("saves the dragged item at the dropped index even when the day's list changed during the drag", async () => { + const hook = renderController(); + const day = [place(1), place(2)]; + const els = rows(2); + const transfer = dataTransfer(); + try { + await hook.render("room-a"); + els.forEach((el, i) => hook.current.getListProps(10, day).getRowProps(i).ref(el)); + await act(async () => hook.current.getListProps(10, day).getRowProps(0).placeCardDragProps.onDragStart(dragEvent(transfer, els[0]!, 10))); + await act(async () => hook.current.getListProps(10, day).dropZoneProps.onDragOver(dragEvent(transfer, els[0]!, 190))); + await nextFrame(); + // 드래그하는 동안 다른 사람이 맨 앞에 장소를 추가했다 — 놓은 자리(몇 번째) 그대로 저장한다 + const changed = [place(3), ...day]; + await act(async () => hook.current.getListProps(10, changed).dropZoneProps.onDrop(dragEvent(transfer, els[0]!, 190))); + + expect(commit.fn).toHaveBeenCalledExactlyOnceWith( + { itemId: 1, from: { scheduleId: 10, index: 0 }, to: { scheduleId: 10, index: 1, length: 3 } }, + expect.any(Function), + ); + } finally { + await hook.unmount(); + } +}); + +it("keeps the dropped order on screen until the cache shows it, then draws the cache as is", async () => { + const hook = renderController(); + const day = [place(1), place(2), place(3)]; + const els = rows(3); + const transfer = dataTransfer(); + try { + await hook.render("room-a"); + els.forEach((el, i) => hook.current.getListProps(10, day).getRowProps(i).ref(el)); + await act(async () => hook.current.getListProps(10, day).getRowProps(0).placeCardDragProps.onDragStart(dragEvent(transfer, els[0]!, 10))); + await act(async () => hook.current.getListProps(10, day).dropZoneProps.onDragOver(dragEvent(transfer, els[0]!, 290))); + await nextFrame(); + await act(async () => hook.current.getListProps(10, day).dropZoneProps.onDrop(dragEvent(transfer, els[0]!, 290))); + // drop 바로 뒤의 dragend는 저장 중인 모양을 풀지 않는다 + await act(async () => hook.current.getListProps(10, day).getRowProps(0).placeCardDragProps.onDragEnd()); + + // 캐시가 아직 놓기 전 목록이면 놓은 모양(뒤 행을 당긴 미리보기)을 그대로 그린다 — 원래 자리로 튀지 않는다 + const before = hook.current.getListProps(10, day); + expect(before.getRowProps(1).style?.transform).toBe(`translateY(-${ROW_HEIGHT}px)`); + expect(before.getRowProps(0).placeCardDragProps.isDragging).toBe(true); + + // onMutate가 캐시를 새 순서로 바꾸면 캐시를 그대로 그린다 + const after = hook.current.getListProps(10, [place(2), place(3), place(1)]); + expect(after.getRowProps(0).style?.transform).toBeUndefined(); + expect(after.getRowProps(2).placeCardDragProps.isDragging).toBe(false); + + // 저장이 끝나면 드래그 상태를 비운다 + const [, onSettled] = commit.fn.mock.lastCall!; + await act(async () => onSettled()); + expect(hook.current.getListProps(10, day).isItemDragActive).toBe(false); + } finally { + await hook.unmount(); + } +}); + +it("moves to another day's list at the previewed index, and to the front when dropped on its header", async () => { + const hook = renderController(); + const day1 = [place(1), place(2)]; + const day2 = [place(10), place(11)]; + const day1Rows = rows(2); + const day2Rows = rows(2, 400); + const transfer = dataTransfer(); + try { + await hook.render("room-a"); + day1Rows.forEach((el, i) => hook.current.getListProps(10, day1).getRowProps(i).ref(el)); + day2Rows.forEach((el, i) => hook.current.getListProps(20, day2).getRowProps(i).ref(el)); + await act(async () => hook.current.getListProps(10, day1).getRowProps(1).placeCardDragProps.onDragStart(dragEvent(transfer, day1Rows[1]!, 110))); + await act(async () => hook.current.getListProps(20, day2).dropZoneProps.onDragOver(dragEvent(transfer, day2Rows[1]!, 520))); + await nextFrame(); + + // 대상 일차 헤더를 강조하고, 넣을 자리부터 아래로 민다 + expect(hook.current.getSectionDrop(20, day2).isTarget).toBe(true); + const target = hook.current.getListProps(20, day2); + expect(target.getRowProps(0).style?.transform).toBeUndefined(); + expect(target.getRowProps(1).style?.transform).toBe(`translateY(${ROW_HEIGHT}px)`); + expect(target.reservePaddingBottom).toBe(ROW_HEIGHT); + + await act(async () => target.dropZoneProps.onDrop(dragEvent(transfer, day2Rows[1]!, 520))); + expect(commit.fn).toHaveBeenLastCalledWith( + { itemId: 2, from: { scheduleId: 10, index: 1 }, to: { scheduleId: 20, index: 1, length: 2 } }, + expect.any(Function), + ); + // 출발 일차는 저장 중에도 끈 카드 자리를 비워 둔다(캐시에서 빠지면 그대로 사라진다) + expect(hook.current.getListProps(10, day1).getRowProps(1).placeCardDragProps.isDragging).toBe(true); + + await act(async () => commit.fn.mock.lastCall![1]()); + await act(async () => hook.current.getListProps(10, day1).getRowProps(0).placeCardDragProps.onDragStart(dragEvent(transfer, day1Rows[0]!, 10))); + const header = document.createElement("header"); + const section = hook.current.getSectionDrop(20, day2); + await act(async () => section.props.onDragOver(dragEvent(transfer, header, 300))); + await act(async () => section.props.onDrop(dragEvent(transfer, header, 300))); + expect(commit.fn).toHaveBeenLastCalledWith( + { itemId: 1, from: { scheduleId: 10, index: 0 }, to: { scheduleId: 20, index: 0, length: 2 } }, + expect.any(Function), + ); + } finally { + await hook.unmount(); + } +}); + +it("clears the preview right away when the drop is not saved", async () => { + commit.fn.mockReturnValue(false); + const hook = renderController(); + const day = [place(1), place(2)]; + const els = rows(2); + const transfer = dataTransfer(); + try { + await hook.render("room-a"); + els.forEach((el, i) => hook.current.getListProps(10, day).getRowProps(i).ref(el)); + await act(async () => hook.current.getListProps(10, day).getRowProps(0).placeCardDragProps.onDragStart(dragEvent(transfer, els[0]!, 10))); + await act(async () => hook.current.getListProps(10, day).dropZoneProps.onDrop(dragEvent(transfer, els[0]!, 190))); + expect(hook.current.getListProps(10, day).isItemDragActive).toBe(false); + } finally { + await hook.unmount(); + } +}); + +it("starts the next drag while the previous drop is still saving, and the earlier save does not clear it", async () => { + const hook = renderController(); + const day = [place(1), place(2), place(3)]; + const els = rows(3); + const transfer = dataTransfer(); + try { + await hook.render("room-a"); + els.forEach((el, i) => hook.current.getListProps(10, day).getRowProps(i).ref(el)); + // 첫 장소를 놓아 저장 중이다(손잡이는 막지 않는다) + await act(async () => hook.current.getListProps(10, day).getRowProps(0).placeCardDragProps.onDragStart(dragEvent(transfer, els[0]!, 10))); + await act(async () => hook.current.getListProps(10, day).dropZoneProps.onDrop(dragEvent(transfer, els[0]!, 190))); + const [, firstSave] = commit.fn.mock.lastCall!; + expect(hook.current.getListProps(10, day).getRowProps(1).placeCardDragProps.dragDisabled).toBe(false); + + // 저장이 끝나기 전에 다음 장소를 끈다 + await act(async () => hook.current.getListProps(10, day).getRowProps(2).placeCardDragProps.onDragStart(dragEvent(transfer, els[2]!, 250))); + expect(hook.current.getListProps(10, day).getRowProps(2).placeCardDragProps.isDragging).toBe(true); + await act(async () => firstSave()); + expect(hook.current.getListProps(10, day).isItemDragActive).toBe(true); + + await act(async () => hook.current.getListProps(10, day).dropZoneProps.onDrop(dragEvent(transfer, els[0]!, 10))); + expect(commit.fn).toHaveBeenLastCalledWith( + expect.objectContaining({ itemId: 3, from: { scheduleId: 10, index: 2 } }), + expect.any(Function), + ); + } finally { + await hook.unmount(); + } +}); diff --git a/src/hooks/usePlanItemDragController.ts b/src/hooks/usePlanItemDragController.ts new file mode 100644 index 00000000..8383131a --- /dev/null +++ b/src/hooks/usePlanItemDragController.ts @@ -0,0 +1,410 @@ +import { useReducedMotion } from "framer-motion"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type DragEvent, +} from "react"; + +import { useDragAutoScroll } from "@/hooks/useDragAutoScroll"; +import { usePlanItemDropCommit } from "@/hooks/usePlanItemDropCommit"; +import { + beginPlanItemDrag, + computePlanItemRowPreviewOrderIndex, + computePlanItemRowPreviewTranslateY, + getPlanItemRowReorderStyle, + PLAN_ITEM_ITINERARY_DROP_ZONE_ATTR, + PlanItemRowLayoutSnapshot, + type PlanItemRowPreviewMode, +} from "@/lib/plan/planItemReorder"; +import type { PlanPlace } from "@/lib/plan/types"; +import { usePlanItineraryExpandedStore } from "@/stores/plan-itinerary-expanded-store"; + +/** 드래그 한 번(페이지 전체에 걸침)의 상태 — 페이지에 한 벌만 둔다 */ +type PlanItemDragState = { + itemId: number; + sourceScheduleId: number; + /** 드래그를 시작할 때 출발 일차에서의 자리 */ + sourceIndex: number; + /** 드래그를 시작할 때 잰 출발 일차 행 높이 — 같은 일차 미리보기에서 밀어낼 거리 */ + sourceRowHeights: readonly number[]; + /** 출발 행 높이 — 다른 일차 목록에 넣을 틈의 높이 */ + rowHeight: number; + /** 지금 놓을 일차(목록·헤더 위). 어느 일차 위도 아니면 null */ + overScheduleId: number | null; + /** 목록 위면 미리보기 자리, 헤더·접힌 일차 위면 null(맨 앞에 넣는다) */ + overIndex: number | null; + /** + * 놓아서 저장하는 중 — 놓을 때 대상 일차 목록. 캐시(onMutate)가 그 목록을 바꿀 때까지 놓은 모양을 그대로 둔다. + * 미리보기를 먼저 풀면 캐시가 바뀌기 전 한 프레임 동안 옛 순서가 보인다(캐시 알림은 setTimeout 0 뒤) + */ + dropped: { scheduleId: number; places: readonly PlanPlace[] | undefined } | null; +}; + +type DropHandlers = { + onDragOver: (e: DragEvent) => void; + onDragLeave: (e: DragEvent) => void; + onDrop: (e: DragEvent) => void; +}; + +export type PlanItemDragRowProps = { + ref: (el: HTMLElement | null) => void; + style: CSSProperties | undefined; + placeCardDragProps: { + displayOrderIndex: number; + isDragging: boolean; + dragDisabled: boolean; + onDragStart: (e: DragEvent) => void; + onDragEnd: () => void; + }; +}; + +export type PlanItemDragListProps = { + /** 목록 놓을 영역 */ + dropZoneProps: DropHandlers & { [PLAN_ITEM_ITINERARY_DROP_ZONE_ATTR]: "" }; + /** 어느 일차에서든 장소를 끄는 중 — 빈 일차에 놓을 자리를 만든다 */ + isItemDragActive: boolean; + /** 이 목록에 넣을 틈만큼 아래 여백 — 밀린 행이 다음 요소를 덮지 않게 */ + reservePaddingBottom: number; + getRowProps: (index: number) => PlanItemDragRowProps; +}; + +export type PlanItemSectionDrop = { + /** 일차 헤더·접힌 일차 놓을 영역 */ + props: DropHandlers; + /** 다른 일차의 장소를 이 일차 위로 끌고 있다 — 헤더를 강조한다 */ + isTarget: boolean; +}; + +export type PlanItemDragValue = { + /** 일차 목록 하나 — 목록이 캐시 목록을 넘긴다 */ + getListProps: ( + scheduleId: number, + places: readonly PlanPlace[], + ) => PlanItemDragListProps; + /** 일차 헤더·접힌 일차 — 놓으면 그 일차 맨 앞에 넣는다 */ + getSectionDrop: ( + scheduleId: number, + places: readonly PlanPlace[] | undefined, + ) => PlanItemSectionDrop; +}; + +const PlanItemDragContext = createContext(null); + +export const PlanItemDragProvider = PlanItemDragContext.Provider; + +export function usePlanItemDrag(): PlanItemDragValue { + const value = useContext(PlanItemDragContext); + if (!value) throw new Error("PlanItemDragProvider가 필요해요."); + return value; +} + +/** 목록이 펼쳐진 직후에는 행이 아직 없을 수 있어 몇 프레임 기다렸다 잰다 */ +const CAPTURE_ROWS_MAX_FRAMES = 8; + +/** + * PC 일정 장소 드래그(HTML5 DnD) — 모든 일차의 장소 카드를 한 곳에서 다룬다. + * 무엇을 어디로 끄는지·헤더 강조·미리보기 위치·잠금을 갖고, 놓으면 놓기 결정(`usePlanItemDropCommit`)에 넘긴다. + * API·캐시는 직접 만지지 않는다. + */ +export function usePlanItemDragController(roomId: string): PlanItemDragValue { + const { commitPlanItemDrop } = usePlanItemDropCommit(roomId); + const prefersReducedMotion = useReducedMotion(); + + const [drag, setDrag] = useState(null); + // dragend는 drop 바로 뒤에 와서 렌더 전 값을 봐야 한다 — 핸들러는 ref로 읽는다 + const dragRef = useRef(null); + const rowEls = useRef(new Map()); + /** 드래그 시작(출발)·처음 올라온 때(대상) 잰 행 위치 — transform 전 위치로 미리보기 자리를 정한다(핸들러에서만 읽는다) */ + const snapshots = useRef(new Map()); + const pointerYRef = useRef(0); + const previewRafRef = useRef(0); + const captureRafRef = useRef(0); + + const updateDrag = useCallback((next: PlanItemDragState | null) => { + dragRef.current = next; + setDrag(next); + }, []); + + const cancelFrames = useCallback(() => { + if (previewRafRef.current) cancelAnimationFrame(previewRafRef.current); + if (captureRafRef.current) cancelAnimationFrame(captureRafRef.current); + previewRafRef.current = 0; + captureRafRef.current = 0; + }, []); + + useEffect(() => cancelFrames, [cancelFrames]); + + const resetDrag = useCallback(() => { + cancelFrames(); + snapshots.current.clear(); + updateDrag(null); + }, [cancelFrames, updateDrag]); + + useDragAutoScroll({ active: drag !== null && drag.dropped === null }); + + const setOver = useCallback( + (overScheduleId: number | null, overIndex: number | null) => { + const current = dragRef.current; + if (!current || current.dropped) return; + if (current.overScheduleId === overScheduleId && current.overIndex === overIndex) return; + updateDrag({ ...current, overScheduleId, overIndex }); + }, + [updateDrag], + ); + + const schedulePreview = useCallback( + (scheduleId: number) => { + if (previewRafRef.current) return; + previewRafRef.current = requestAnimationFrame(() => { + previewRafRef.current = 0; + const snapshot = snapshots.current.get(scheduleId); + if (!snapshot) return; + setOver(scheduleId, snapshot.isEmpty ? 0 : snapshot.previewIndexAt(pointerYRef.current)); + }); + }, + [setOver], + ); + + /** 다른 일차 목록에 처음 올라왔을 때 그 목록의 행 위치를 잰다(펼쳐지는 중이면 행이 생길 때까지) */ + const captureTargetRows = useCallback( + (scheduleId: number, placesLength: number) => { + if (snapshots.current.has(scheduleId) || captureRafRef.current) return; + const tryCapture = (frame: number) => { + captureRafRef.current = 0; + const els = (rowEls.current.get(scheduleId) ?? []).slice(0, placesLength); + const rowsReady = placesLength === 0 || els.some((el) => el instanceof HTMLElement); + if (!rowsReady && frame < CAPTURE_ROWS_MAX_FRAMES) { + captureRafRef.current = requestAnimationFrame(() => tryCapture(frame + 1)); + return; + } + const snapshot = new PlanItemRowLayoutSnapshot(); + snapshot.capture(els); + snapshots.current.set(scheduleId, snapshot); + setOver(scheduleId, snapshot.isEmpty ? 0 : snapshot.previewIndexAt(pointerYRef.current)); + }; + tryCapture(0); + }, + [setOver], + ); + + /** 놓기 — 저장을 시작했으면 캐시가 바뀔 때까지 놓은 모양을 두고, 아니면 바로 푼다 */ + const dropOn = useCallback( + (targetScheduleId: number, targetIndex: number, targetPlaces: readonly PlanPlace[] | undefined) => { + const current = dragRef.current; + if (!current || current.dropped) return; + cancelFrames(); + const dropped: PlanItemDragState = { + ...current, + overScheduleId: targetScheduleId, + overIndex: current.overScheduleId === targetScheduleId ? current.overIndex : null, + dropped: { scheduleId: targetScheduleId, places: targetPlaces }, + }; + const started = commitPlanItemDrop( + { + itemId: current.itemId, + from: { scheduleId: current.sourceScheduleId, index: current.sourceIndex }, + to: { scheduleId: targetScheduleId, index: targetIndex, length: targetPlaces?.length ?? 0 }, + }, + () => { + if (dragRef.current === dropped) resetDrag(); + }, + ); + if (started) updateDrag(dropped); + else resetDrag(); + }, + [cancelFrames, commitPlanItemDrop, resetDrag, updateDrag], + ); + + const expandDay = useCallback((scheduleId: number) => { + usePlanItineraryExpandedStore.getState().setScheduleExpanded(scheduleId, true); + }, []); + + const handleDragEnd = useCallback(() => { + // 놓아서 저장 중이면 저장이 끝날 때 푼다. 목록 밖에 놓았거나 취소(Esc)했으면 여기서 푼다 + if (dragRef.current?.dropped) return; + resetDrag(); + }, [resetDrag]); + + const getListProps = useCallback( + (scheduleId: number, places: readonly PlanPlace[]): PlanItemDragListProps => { + // 저장 중에는 캐시가 놓을 때 목록 그대로인 동안만 놓은 모양을 그린다 + const previewActive = + drag !== null && + (drag.dropped === null || + drag.dropped.scheduleId !== scheduleId || + drag.dropped.places === places); + const isSource = drag?.sourceScheduleId === scheduleId; + const isOver = drag?.overScheduleId === scheduleId; + + let previewMode: PlanItemRowPreviewMode | null = null; + if (previewActive && drag && isOver && drag.overIndex !== null) { + if (isSource) { + if (drag.overIndex !== drag.sourceIndex && drag.sourceRowHeights.length > 0) { + previewMode = { + kind: "reorder", + fromIndex: drag.sourceIndex, + previewIndex: drag.overIndex, + rowHeights: drag.sourceRowHeights, + }; + } + } else if (drag.rowHeight > 0) { + previewMode = { kind: "insert", previewIndex: drag.overIndex, insertHeight: drag.rowHeight }; + } + } + const motionEnabled = + previewActive && (isSource || isOver) && !prefersReducedMotion; + + const handleDragOver = (e: DragEvent) => { + const current = dragRef.current; + if (!current || current.dropped) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + pointerYRef.current = e.clientY; + if (scheduleId !== current.sourceScheduleId) { + expandDay(scheduleId); + captureTargetRows(scheduleId, places.length); + } + schedulePreview(scheduleId); + }; + + const handleDragLeave = (e: DragEvent) => { + const next = e.relatedTarget; + if (next instanceof Node && e.currentTarget.contains(next)) return; + const current = dragRef.current; + if (!current || current.dropped || current.overScheduleId !== scheduleId) return; + // 다른 일차 목록은 다시 올라오면 새로 잰다(그사이 펼침·스크롤이 바뀐다) + if (scheduleId !== current.sourceScheduleId) snapshots.current.delete(scheduleId); + setOver(null, null); + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + const current = dragRef.current; + if (!current || current.dropped) return; + const targetIndex = + current.overScheduleId === scheduleId && current.overIndex !== null + ? current.overIndex + : scheduleId === current.sourceScheduleId + ? current.sourceIndex + : 0; + dropOn(scheduleId, targetIndex, places); + }; + + return { + dropZoneProps: { + [PLAN_ITEM_ITINERARY_DROP_ZONE_ATTR]: "", + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }, + isItemDragActive: drag !== null, + reservePaddingBottom: previewMode?.kind === "insert" ? previewMode.insertHeight : 0, + getRowProps: (index) => { + const itemId = places[index]?.itemId; + const isDragging = previewActive && isSource && itemId === drag?.itemId; + return { + ref: (el) => { + const els = rowEls.current.get(scheduleId) ?? []; + els[index] = el; + rowEls.current.set(scheduleId, els); + }, + style: getPlanItemRowReorderStyle({ + translateY: computePlanItemRowPreviewTranslateY(index, previewMode), + isDragging, + motionEnabled, + }), + placeCardDragProps: { + displayOrderIndex: computePlanItemRowPreviewOrderIndex(index, places.length, previewMode), + isDragging, + dragDisabled: typeof itemId !== "number", + // 앞서 놓은 장소를 저장하는 중이어도 새로 끌 수 있다 — 새 드래그가 그 상태를 덮는다 + onDragStart: (e) => { + if (typeof itemId !== "number") return; + const target = e.currentTarget; + if (!(target instanceof HTMLElement)) return; + beginPlanItemDrag(e.nativeEvent, target, index); + const source = new PlanItemRowLayoutSnapshot(); + source.capture((rowEls.current.get(scheduleId) ?? []).slice(0, places.length)); + snapshots.current = new Map([[scheduleId, source]]); + const sourceRowHeights = source.heights(); + updateDrag({ + itemId, + sourceScheduleId: scheduleId, + sourceIndex: index, + sourceRowHeights, + rowHeight: sourceRowHeights[index] ?? target.getBoundingClientRect().height, + overScheduleId: scheduleId, + overIndex: index, + dropped: null, + }); + }, + onDragEnd: handleDragEnd, + }, + }; + }, + }; + }, + [ + captureTargetRows, + drag, + dropOn, + expandDay, + handleDragEnd, + prefersReducedMotion, + schedulePreview, + setOver, + updateDrag, + ], + ); + + const getSectionDrop = useCallback( + (scheduleId: number, places: readonly PlanPlace[] | undefined): PlanItemSectionDrop => { + // 목록 안의 이벤트는 목록이 처리한다(이벤트가 섹션까지 올라온다) + const fromItineraryList = (e: DragEvent) => + e.target instanceof Element && + e.target.closest(`[${PLAN_ITEM_ITINERARY_DROP_ZONE_ATTR}]`) !== null; + + const isTarget = + drag !== null && + drag.dropped === null && + drag.sourceScheduleId !== scheduleId && + drag.overScheduleId === scheduleId; + + return { + isTarget, + props: { + onDragOver: (e) => { + const current = dragRef.current; + if (!current || current.dropped || current.sourceScheduleId === scheduleId) return; + if (fromItineraryList(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + expandDay(scheduleId); + setOver(scheduleId, null); + }, + onDragLeave: (e) => { + const next = e.relatedTarget; + if (next instanceof Node && e.currentTarget.contains(next)) return; + if (dragRef.current?.overScheduleId === scheduleId) setOver(null, null); + }, + onDrop: (e) => { + const current = dragRef.current; + if (!current || current.dropped || current.sourceScheduleId === scheduleId) return; + if (fromItineraryList(e)) return; + e.preventDefault(); + dropOn(scheduleId, 0, places); + }, + }, + }; + }, + [drag, dropOn, expandDay, setOver], + ); + + return useMemo(() => ({ getListProps, getSectionDrop }), [getListProps, getSectionDrop]); +} diff --git a/src/hooks/usePlanItemDropCommit.test.tsx b/src/hooks/usePlanItemDropCommit.test.tsx new file mode 100644 index 00000000..50d9166e --- /dev/null +++ b/src/hooks/usePlanItemDropCommit.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, expect, it, vi } from "vitest"; + +import type { RoomScheduleItem } from "@/lib/api/rooms"; +import type { PlanItemDrop } from "@/lib/plan/planItemDrop"; +import { bucketItemCount } from "@/lib/analytics/context"; +import { trackAnalyticsEvent } from "@/lib/analytics/track"; +import { hookHarness } from "@/test/hook-harness"; + +import { usePlanItemDropCommit } from "./usePlanItemDropCommit"; + +const api = vi.hoisted(() => ({ reorder: vi.fn(), move: vi.fn() })); +vi.mock("@/lib/client-env", () => ({ clientEnv: { NEXT_PUBLIC_API_BASE_URL: "https://api.example.test" } })); +vi.mock("@/lib/api/rooms", async (original) => ({ + ...(await original()), + reorderScheduleItem: api.reorder, + moveScheduleItemToSchedule: api.move, +})); +vi.mock("@/lib/analytics/track", () => ({ + AnalyticsEvents: { reorderItinerary: "reorder_itinerary" }, + trackAnalyticsEvent: vi.fn(), +})); +const toastError = vi.hoisted(() => vi.fn()); +vi.mock("sonner", () => ({ toast: { error: toastError } })); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +/** 기본: 10일차(2곳)의 0번째를 1번째로 */ +const drop = (overrides: Partial = {}): PlanItemDrop => ({ + itemId: 1, + from: { scheduleId: 10, index: 0 }, + to: { scheduleId: 10, index: 1, length: 2 }, + ...overrides, +}); +const items = (count: number): RoomScheduleItem[] => + Array.from({ length: count }, (_, orderIndex) => ({ + itemId: orderIndex + 1, scheduleId: 10, googlePlaceId: `g${orderIndex + 1}`, orderIndex, + startTime: null, endTime: null, travelMode: "DRIVING", createdAt: "2026-11-08", + })); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function renderCommit(client: QueryClient) { + return hookHarness( + (roomId: string) => usePlanItemDropCommit(roomId), + (child) => {child}, + ); +} + +let client: QueryClient; +beforeEach(() => { + vi.clearAllMocks(); + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); +}); + +it("sends one reorder_itinerary for a saved same-day drop, attributed to the room the drop was made in", async () => { + const reorder = deferred(); + api.reorder.mockReturnValueOnce(reorder.promise); + const onSettled = vi.fn(); + const hook = renderCommit(client); + try { + await hook.render("room-a"); + let started = false; + await act(async () => { + started = hook.current.commitPlanItemDrop(drop(), onSettled); + }); + expect(started).toBe(true); + expect(api.reorder).toHaveBeenCalledWith("room-a", 10, 1, { newOrderIndex: 1 }); + // 낙관적 표시는 쓰기 이벤트가 아니다 — 저장이 끝나기 전에는 보내지 않는다 + expect(trackAnalyticsEvent).not.toHaveBeenCalled(); + await hook.render("room-b"); + await act(async () => { + reorder.resolve(items(2)); + await reorder.promise; + }); + await hook.flush(); + expect(trackAnalyticsEvent).toHaveBeenCalledExactlyOnceWith("reorder_itinerary", { + room_id: "room-a", + item_count_bucket: bucketItemCount(2), + method: "drag_drop", + }); + expect(onSettled).toHaveBeenCalledOnce(); + } finally { + await hook.unmount(); + client.clear(); + } +}); + +it("shows the failure toast and still settles when a same-day reorder fails", async () => { + api.reorder.mockRejectedValueOnce(new Error("서버 오류")); + const onSettled = vi.fn(); + const hook = renderCommit(client); + try { + await hook.render("room-a"); + await act(async () => { + hook.current.commitPlanItemDrop(drop(), onSettled); + }); + await hook.flush(); + expect(toastError).toHaveBeenCalledWith("순서를 바꾸지 못했어요."); + expect(trackAnalyticsEvent).not.toHaveBeenCalled(); + expect(onSettled).toHaveBeenCalledOnce(); + } finally { + await hook.unmount(); + client.clear(); + } +}); + +it("saves a drop on another day as a cross-day move without a reorder_itinerary event", async () => { + api.move.mockResolvedValueOnce({}); + const hook = renderCommit(client); + try { + await hook.render("room-a"); + await act(async () => { + hook.current.commitPlanItemDrop( + drop({ to: { scheduleId: 20, index: 9, length: 1 } }), + vi.fn(), + ); + }); + await hook.flush(); + expect(api.move).toHaveBeenCalledWith("room-a", 10, 1, { targetScheduleId: 20, targetOrderIndex: 1 }); + expect(api.reorder).not.toHaveBeenCalled(); + expect(trackAnalyticsEvent).not.toHaveBeenCalled(); + } finally { + await hook.unmount(); + client.clear(); + } +}); + +it("shows the server message when a cross-day move fails", async () => { + api.move.mockRejectedValueOnce(new Error("대상 일차를 찾을 수 없어요.")); + const hook = renderCommit(client); + try { + await hook.render("room-a"); + await act(async () => { + hook.current.commitPlanItemDrop(drop({ to: { scheduleId: 20, index: 1, length: 2 } }), vi.fn()); + }); + await hook.flush(); + expect(toastError).toHaveBeenCalledWith("대상 일차를 찾을 수 없어요."); + } finally { + await hook.unmount(); + client.clear(); + } +}); + +it("shows the plain message instead of the browser's network error when a cross-day move cannot reach the server", async () => { + api.move.mockRejectedValueOnce(new TypeError("Failed to fetch")); + const hook = renderCommit(client); + try { + await hook.render("room-a"); + await act(async () => { + hook.current.commitPlanItemDrop(drop({ to: { scheduleId: 20, index: 1, length: 2 } }), vi.fn()); + }); + await hook.flush(); + expect(toastError).toHaveBeenCalledWith("다른 일차로 옮기지 못했어요."); + } finally { + await hook.unmount(); + client.clear(); + } +}); + +it("does not save a drop made in place", async () => { + const hook = renderCommit(client); + try { + await hook.render("room-a"); + let started = true; + await act(async () => { + started = hook.current.commitPlanItemDrop(drop({ to: { scheduleId: 10, index: 0, length: 2 } }), vi.fn()); + }); + expect(started).toBe(false); + expect(api.reorder).not.toHaveBeenCalled(); + } finally { + await hook.unmount(); + client.clear(); + } +}); + +it("saves the next drop while the previous one is still saving, and still reports the earlier failure", async () => { + const first = deferred(); + api.reorder.mockReturnValueOnce(first.promise).mockResolvedValueOnce(items(2)); + const firstSettled = vi.fn(); + const hook = renderCommit(client); + try { + await hook.render("room-a"); + let second = false; + await act(async () => { + hook.current.commitPlanItemDrop(drop(), firstSettled); + }); + await hook.flush(); + // 잠금이 없어 앞 저장 중에 또 놓을 수 있다 + await act(async () => { + second = hook.current.commitPlanItemDrop( + drop({ itemId: 2, from: { scheduleId: 10, index: 1 }, to: { scheduleId: 10, index: 0, length: 2 } }), + vi.fn(), + ); + }); + expect(second).toBe(true); + expect(api.reorder).toHaveBeenNthCalledWith(2, "room-a", 10, 2, { newOrderIndex: 0 }); + // 앞 저장이 늦게 실패해도 그 실패 토스트와 onSettled는 받는다(호출마다 promise가 따로) + await act(async () => { + first.reject(new Error("서버 오류")); + await first.promise.catch(() => {}); + }); + await hook.flush(); + expect(toastError).toHaveBeenCalledWith("순서를 바꾸지 못했어요."); + expect(firstSettled).toHaveBeenCalledOnce(); + } finally { + await hook.unmount(); + client.clear(); + } +}); diff --git a/src/hooks/usePlanItemDropCommit.ts b/src/hooks/usePlanItemDropCommit.ts new file mode 100644 index 00000000..cc34509f --- /dev/null +++ b/src/hooks/usePlanItemDropCommit.ts @@ -0,0 +1,41 @@ +import { useCallback } from "react"; +import { toast } from "sonner"; + +import { + useMoveScheduleItemToSchedule, + useReorderScheduleItem, +} from "@/hooks/useRooms"; +import { messageForSaveError } from "@/lib/api/errors"; +import { resolvePlanItemDrop, type PlanItemDrop } from "@/lib/plan/planItemDrop"; + +/** + * 장소 카드 놓기 — PC·모바일 드래그 컨트롤러가 함께 쓴다. 놓은 결과를 순서 변경이나 일차 간 이동으로 보내고 + * 실패하면 토스트를 띄운다. 캐시 반영과 분석 이벤트는 각 mutation 훅이 맡는다. + * 저장 중·연결 끊김·다른 사람의 변경 중에도 막지 않는다 — 어긋나도 onSettled 재조회가 맞춘다. + */ +export function usePlanItemDropCommit(roomId: string) { + const { mutateAsync: reorderAsync } = useReorderScheduleItem(); + const { mutateAsync: moveAsync } = useMoveScheduleItemToSchedule(); + + /** + * 보냈으면 true(끝나면 성공·실패 모두 `onSettled`), 제자리라 보내지 않았으면 false. + * 잠금이 없어 앞 저장 중에 또 놓을 수 있다 — `mutate` 호출부 콜백은 마지막 호출만 따르므로 저장마다 promise가 따로인 `mutateAsync`로 받는다 + */ + const commitPlanItemDrop = useCallback( + (drop: PlanItemDrop, onSettled: () => void): boolean => { + const save = resolvePlanItemDrop(roomId, drop); + if (!save) return false; + const saving = + save.kind === "reorder" + ? reorderAsync(save.vars).catch(() => toast.error("순서를 바꾸지 못했어요.")) + : moveAsync(save.vars).catch((err: unknown) => + toast.error(messageForSaveError(err, "다른 일차로 옮기지 못했어요.")), + ); + void saving.finally(onSettled); + return true; + }, + [moveAsync, reorderAsync, roomId], + ); + + return { commitPlanItemDrop }; +} diff --git a/src/hooks/usePlanItineraryReorder.analytics.test.tsx b/src/hooks/usePlanItineraryReorder.analytics.test.tsx deleted file mode 100644 index 452d1cff..00000000 --- a/src/hooks/usePlanItineraryReorder.analytics.test.tsx +++ /dev/null @@ -1,150 +0,0 @@ -// @vitest-environment jsdom -import { act, type DragEvent } from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { expect, it, vi } from "vitest"; -import { hookHarness } from "@/test/hook-harness"; -import { trackAnalyticsEvent } from "@/lib/analytics/track"; -import { scheduleItemsQueryKey } from "@/lib/query-keys"; -import type { RoomScheduleItem } from "@/lib/api/rooms"; -import { usePlanItineraryReorder } from "./usePlanItineraryReorder"; - -const api = vi.hoisted(() => ({ reorder: vi.fn() })); -vi.mock("@/lib/client-env", () => ({ clientEnv: { NEXT_PUBLIC_API_BASE_URL: "https://api.example.test" } })); -vi.mock("@/lib/api/rooms", async (original) => ({ ...await original(), reorderScheduleItem: api.reorder })); -vi.mock("@/lib/analytics/track", () => ({ AnalyticsEvents: { reorderItinerary: "reorder_itinerary" }, trackAnalyticsEvent: vi.fn() })); -const toastError = vi.hoisted(() => vi.fn()); -vi.mock("sonner", () => ({ toast: { error: toastError } })); -const stomp = vi.hoisted(() => ({ connected: true })); -vi.mock("@/contexts/StompContext", () => ({ useStompContext: () => stomp })); -Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - -it("attributes a completed drag reorder to the request's room after the hook switches rooms", async () => { - let resolve!: (items: RoomScheduleItem[]) => void; - api.reorder.mockReturnValueOnce(new Promise((yes) => { resolve = yes; })); - const places = [{ id: "item-1", itemId: 1, title: "One" }, { id: "item-2", itemId: 2, title: "Two" }]; - const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); - client.setQueryData(scheduleItemsQueryKey("room-a", 1), places); - const hook = hookHarness((roomId: string) => usePlanItineraryReorder({ roomId, scheduleId: 1, places }), - (child) => {child}); - const data = new Map(); - const dataTransfer = { - setData: (key: string, value: string) => data.set(key, value), - getData: (key: string) => data.get(key) ?? "", - setDragImage: () => {}, - get types() { return Array.from(data.keys()); }, - } as unknown as DataTransfer; - const rows = [0, 1].map((index) => { - const row = document.createElement("div"); - row.getBoundingClientRect = () => ({ top: index * 100, left: 0, width: 200, height: 100, bottom: index * 100 + 100, right: 200, x: 0, y: index * 100, toJSON: () => ({}) }); - return row; - }); - const event = { dataTransfer, currentTarget: rows[0], clientX: 10, clientY: 10, preventDefault: () => {} }; - try { - await hook.render("room-a"); - rows.forEach((row, index) => hook.current.getRowProps(index, false).ref(row)); - await act(async () => hook.current.getRowProps(0, false).placeCardDragProps.onDragStart({ ...event, nativeEvent: event } as unknown as DragEvent)); - await act(async () => { - hook.current.listContainerProps.onDragOver!({ ...event, clientY: 190 } as unknown as DragEvent); - await new Promise((yes) => requestAnimationFrame(yes)); - }); - let operation!: Promise; - await act(async () => { operation = hook.current.listContainerProps.onDrop!(event as unknown as DragEvent); }); - expect(api.reorder).toHaveBeenCalledWith("room-a", 1, 1, { newOrderIndex: 1 }); - expect(trackAnalyticsEvent).not.toHaveBeenCalled(); - await hook.render("room-b"); - await act(async () => { - resolve([2, 1].map((itemId, orderIndex) => ({ itemId, scheduleId: 1, googlePlaceId: `place-${itemId}`, orderIndex, startTime: null, endTime: null, travelMode: "DRIVING", createdAt: "2026-10-10" }))); - await operation; - }); - expect(trackAnalyticsEvent).toHaveBeenCalledExactlyOnceWith("reorder_itinerary", expect.objectContaining({ room_id: "room-a" })); - } finally { - document.dispatchEvent(new Event("dragend")); - await hook.unmount(); - client.clear(); - } -}); - -it("saves the dragged item at the dropped index even when the day's list changed during the drag", async () => { - api.reorder.mockClear(); - type Place = { id: string; itemId: number; title: string }; - const placesAtStart: Place[] = [{ id: "item-1", itemId: 1, title: "One" }, { id: "item-2", itemId: 2, title: "Two" }]; - const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); - const hook = hookHarness((places: Place[]) => usePlanItineraryReorder({ roomId: "room-a", scheduleId: 1, places }), - (child) => {child}); - const data = new Map(); - const dataTransfer = { - setData: (key: string, value: string) => data.set(key, value), - getData: (key: string) => data.get(key) ?? "", - setDragImage: () => {}, - get types() { return Array.from(data.keys()); }, - } as unknown as DataTransfer; - const rows = [0, 1].map((index) => { - const row = document.createElement("div"); - row.getBoundingClientRect = () => ({ top: index * 100, left: 0, width: 200, height: 100, bottom: index * 100 + 100, right: 200, x: 0, y: index * 100, toJSON: () => ({}) }); - return row; - }); - const event = { dataTransfer, currentTarget: rows[0], clientX: 10, clientY: 10, preventDefault: () => {} }; - try { - await hook.render(placesAtStart); - rows.forEach((row, index) => hook.current.getRowProps(index, false).ref(row)); - await act(async () => hook.current.getRowProps(0, false).placeCardDragProps.onDragStart({ ...event, nativeEvent: event } as unknown as DragEvent)); - await act(async () => { - hook.current.listContainerProps.onDragOver!({ ...event, clientY: 190 } as unknown as DragEvent); - await new Promise((yes) => requestAnimationFrame(yes)); - }); - // 드래그하는 동안 다른 사람이 맨 앞에 장소를 추가했다 - await hook.render([{ id: "item-3", itemId: 3, title: "Three" }, ...placesAtStart]); - await act(async () => { await hook.current.listContainerProps.onDrop!(event as unknown as DragEvent); }); - - expect(api.reorder).toHaveBeenCalledWith("room-a", 1, 1, { newOrderIndex: 1 }); - } finally { - document.dispatchEvent(new Event("dragend")); - await hook.unmount(); - client.clear(); - } -}); - -it("does not save a drop made while the connection is down", async () => { - api.reorder.mockClear(); - toastError.mockClear(); - const places = [{ id: "item-1", itemId: 1, title: "One" }, { id: "item-2", itemId: 2, title: "Two" }]; - const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); - const hook = hookHarness((connected: boolean) => { - stomp.connected = connected; - return usePlanItineraryReorder({ roomId: "room-a", scheduleId: 1, places }); - }, (child) => {child}); - const data = new Map(); - const dataTransfer = { - setData: (key: string, value: string) => data.set(key, value), - getData: (key: string) => data.get(key) ?? "", - setDragImage: () => {}, - get types() { return Array.from(data.keys()); }, - } as unknown as DataTransfer; - const rows = [0, 1].map((index) => { - const row = document.createElement("div"); - row.getBoundingClientRect = () => ({ top: index * 100, left: 0, width: 200, height: 100, bottom: index * 100 + 100, right: 200, x: 0, y: index * 100, toJSON: () => ({}) }); - return row; - }); - const event = { dataTransfer, currentTarget: rows[0], clientX: 10, clientY: 10, preventDefault: () => {} }; - try { - await hook.render(true); - rows.forEach((row, index) => hook.current.getRowProps(index, false).ref(row)); - await act(async () => hook.current.getRowProps(0, false).placeCardDragProps.onDragStart({ ...event, nativeEvent: event } as unknown as DragEvent)); - await act(async () => { - hook.current.listContainerProps.onDragOver!({ ...event, clientY: 190 } as unknown as DragEvent); - await new Promise((yes) => requestAnimationFrame(yes)); - }); - // 드래그하는 동안 연결이 끊겼다 - await hook.render(false); - expect(hook.current.getRowProps(1, false).placeCardDragProps.dragDisabled).toBe(true); - await act(async () => { await hook.current.listContainerProps.onDrop!(event as unknown as DragEvent); }); - - expect(api.reorder).not.toHaveBeenCalled(); - expect(toastError).toHaveBeenCalledOnce(); - } finally { - stomp.connected = true; - document.dispatchEvent(new Event("dragend")); - await hook.unmount(); - client.clear(); - } -}); diff --git a/src/hooks/usePlanItineraryReorder.ts b/src/hooks/usePlanItineraryReorder.ts deleted file mode 100644 index 183de7aa..00000000 --- a/src/hooks/usePlanItineraryReorder.ts +++ /dev/null @@ -1,435 +0,0 @@ -"use client"; - -import { - useCallback, - useEffect, - useRef, - useState, - type DragEvent, -} from "react"; -import { toast } from "sonner"; - -import { useDragAutoScroll } from "@/hooks/useDragAutoScroll"; -import { useScheduleResyncPending } from "@/hooks/useScheduleResyncPending"; -import { - useMoveScheduleItemToSchedule, - useReorderScheduleItem, -} from "@/hooks/useRooms"; -import { bucketItemCount } from "@/lib/analytics/context"; -import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; -import { - beginPlanItemDrag, - clampCrossDayTargetOrderIndex, - computePlanItemRowPreviewOrderIndex, - computePlanItemRowPreviewTranslateY, - executePlanItemCrossDayMove, - getPlanItemRowReorderStyle, - isPlanItemDrag, - PLAN_ORDER_RESYNCING_MESSAGE, - type PlanItemRowPreviewMode, - PlanItemRowLayoutSnapshot, - readPlanItemDragIndex, - readPlanItemDragItemId, -} from "@/lib/plan/planItemReorder"; -import { newOrderIndexAfterMove } from "@/lib/plan/scheduleItemPlaces"; -import type { PlanPlace } from "@/lib/plan/types"; -import { usePlanItemCrossDayDragStore } from "@/stores/plan-item-cross-day-drag-store"; -import { usePlanItineraryExpandedStore } from "@/stores/plan-itinerary-expanded-store"; - -type UsePlanItineraryReorderArgs = { - roomId: string; - scheduleId: number; - places: PlanPlace[]; - /** 장소 추가 등 D&D 외 상호작용 잠금 */ - interactionLocked?: boolean; -}; - -function normalizeSameDayDropIndex( - previewIndex: number | null, - placesLength: number, -): number | null { - if (previewIndex === null || !Number.isFinite(previewIndex)) return null; - if (placesLength <= 0) return 0; - if (previewIndex >= placesLength) return placesLength - 1; - return previewIndex; -} - -export function usePlanItineraryReorder({ - roomId, - scheduleId, - places, - interactionLocked = false, -}: UsePlanItineraryReorderArgs) { - const { mutateAsync: reorderMutate, isPending: isReorderPending } = - useReorderScheduleItem(); - const { mutateAsync: moveMutate, isPending: isMovePending } = - useMoveScheduleItemToSchedule(); - const beginItemDrag = usePlanItemCrossDayDragStore((s) => s.beginItemDrag); - const draggedRowHeight = usePlanItemCrossDayDragStore( - (s) => s.draggedRowHeight, - ); - const setHoverTarget = usePlanItemCrossDayDragStore((s) => s.setHoverTarget); - const endItemDrag = usePlanItemCrossDayDragStore((s) => s.endItemDrag); - - const [dragFromIndex, setDragFromIndex] = useState(null); - const [previewIndex, setPreviewIndex] = useState(null); - const [incomingExternalDrag, setIncomingExternalDrag] = useState(false); - - const rowRefs = useRef<(HTMLElement | null)[]>([]); - const layoutSnapshotRef = useRef(new PlanItemRowLayoutSnapshot()); - const pointerYRef = useRef(0); - const dragOverRafRef = useRef(0); - const externalCaptureRafRef = useRef(0); - - const mutationLocked = - interactionLocked || isReorderPending || isMovePending; - const scheduleResyncPending = useScheduleResyncPending(roomId); - const isDraggingActive = dragFromIndex !== null || incomingExternalDrag; - - useDragAutoScroll({ active: isDraggingActive }); - - const resetDragPreview = useCallback(() => { - setDragFromIndex(null); - setPreviewIndex(null); - setIncomingExternalDrag(false); - layoutSnapshotRef.current.clear(); - if (externalCaptureRafRef.current) { - cancelAnimationFrame(externalCaptureRafRef.current); - externalCaptureRafRef.current = 0; - } - endItemDrag(); - }, [endItemDrag]); - - const setRowRef = useCallback( - (index: number) => (el: HTMLElement | null) => { - rowRefs.current[index] = el; - }, - [], - ); - - const updatePreviewFromPointer = useCallback(() => { - if (layoutSnapshotRef.current.isEmpty) return; - if (dragFromIndex === null && !incomingExternalDrag) return; - - const nextPreview = layoutSnapshotRef.current.previewIndexAt( - pointerYRef.current, - ); - setPreviewIndex((prev) => (prev === nextPreview ? prev : nextPreview)); - }, [dragFromIndex, incomingExternalDrag]); - - const schedulePreviewUpdate = useCallback(() => { - if (dragOverRafRef.current) return; - dragOverRafRef.current = requestAnimationFrame(() => { - dragOverRafRef.current = 0; - updatePreviewFromPointer(); - }); - }, [updatePreviewFromPointer]); - - const beginExternalDragCapture = useCallback( - (pointerY: number) => { - const tryCapture = (attempt = 0) => { - const rowsReady = - places.length === 0 || - rowRefs.current.some((el) => el instanceof HTMLElement); - - if (rowsReady || attempt >= 8) { - layoutSnapshotRef.current.capture(rowRefs.current); - setIncomingExternalDrag(true); - setPreviewIndex( - places.length > 0 - ? layoutSnapshotRef.current.previewIndexAt(pointerY) - : 0, - ); - externalCaptureRafRef.current = 0; - return; - } - - externalCaptureRafRef.current = requestAnimationFrame(() => - tryCapture(attempt + 1), - ); - }; - - if (externalCaptureRafRef.current) { - cancelAnimationFrame(externalCaptureRafRef.current); - } - tryCapture(); - }, - [places.length], - ); - - useEffect(() => { - return () => { - if (dragOverRafRef.current) { - cancelAnimationFrame(dragOverRafRef.current); - } - if (externalCaptureRafRef.current) { - cancelAnimationFrame(externalCaptureRafRef.current); - } - }; - }, []); - - const handleDragStart = useCallback( - (index: number) => (e: DragEvent) => { - const itemId = places[index]?.itemId; - if (mutationLocked || scheduleResyncPending || typeof itemId !== "number") { - return; - } - const target = e.currentTarget; - if (!(target instanceof HTMLElement)) return; - beginPlanItemDrag(e.nativeEvent, target, index, { - scheduleId, - itemId, - }); - layoutSnapshotRef.current.capture(rowRefs.current); - const rowHeight = - layoutSnapshotRef.current.heights()[index] ?? - target.getBoundingClientRect().height; - beginItemDrag(scheduleId, rowHeight); - setDragFromIndex(index); - setPreviewIndex(index); - }, - [beginItemDrag, mutationLocked, places, scheduleId, scheduleResyncPending], - ); - - const handleDragEnd = useCallback(() => { - if (dragOverRafRef.current) { - cancelAnimationFrame(dragOverRafRef.current); - dragOverRafRef.current = 0; - } - resetDragPreview(); - }, [resetDragPreview]); - - const handleListDragOver = useCallback( - (e: DragEvent) => { - if (mutationLocked) return; - - const external = dragFromIndex === null && isPlanItemDrag(e.dataTransfer); - if (dragFromIndex === null && !external) return; - - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - pointerYRef.current = e.clientY; - - if (external) { - usePlanItineraryExpandedStore - .getState() - .setScheduleExpanded(scheduleId, true); - setHoverTarget(scheduleId); - if (layoutSnapshotRef.current.isEmpty && !incomingExternalDrag) { - beginExternalDragCapture(e.clientY); - } - } - - schedulePreviewUpdate(); - }, - [ - beginExternalDragCapture, - dragFromIndex, - incomingExternalDrag, - mutationLocked, - scheduleId, - schedulePreviewUpdate, - setHoverTarget, - ], - ); - - const handleListDragLeave = useCallback( - (e: DragEvent) => { - const next = e.relatedTarget as Node | null; - if (next && e.currentTarget.contains(next)) return; - - if (incomingExternalDrag) { - setIncomingExternalDrag(false); - setPreviewIndex(null); - layoutSnapshotRef.current.clear(); - if (externalCaptureRafRef.current) { - cancelAnimationFrame(externalCaptureRafRef.current); - externalCaptureRafRef.current = 0; - } - setHoverTarget(null); - return; - } - - if (dragFromIndex === null) return; - setPreviewIndex(dragFromIndex); - }, - [dragFromIndex, incomingExternalDrag, setHoverTarget], - ); - - const handleListDrop = useCallback( - async (e: DragEvent) => { - e.preventDefault(); - - const dataTransfer = e.dataTransfer; - const fromIndex = readPlanItemDragIndex(dataTransfer); - const dropPreviewIndex = - previewIndex ?? - (Number.isFinite(fromIndex) ? fromIndex : null); - - // 드래그하는 동안 연결이 끊겼으면 놓은 자리를 서버 순서로 확인할 수 없다 - if (scheduleResyncPending) { - resetDragPreview(); - toast.error(PLAN_ORDER_RESYNCING_MESSAGE); - return; - } - - const crossDayHandled = await executePlanItemCrossDayMove({ - dataTransfer, - roomId, - targetScheduleId: scheduleId, - targetOrderIndex: clampCrossDayTargetOrderIndex( - dropPreviewIndex ?? 0, - places.length, - ), - moveMutate, - onError: (message) => toast.error(message), - }); - - resetDragPreview(); - - if (crossDayHandled) return; - - const toIndex = normalizeSameDayDropIndex(dropPreviewIndex, places.length); - - if ( - !Number.isFinite(fromIndex) || - fromIndex < 0 || - fromIndex >= places.length || - toIndex === null || - toIndex < 0 || - toIndex >= places.length || - fromIndex === toIndex - ) { - return; - } - - const itemId = - readPlanItemDragItemId(dataTransfer) ?? places[fromIndex]?.itemId; - if (typeof itemId !== "number") return; - - try { - await reorderMutate({ - roomId, - scheduleId, - itemId, - body: { - newOrderIndex: newOrderIndexAfterMove( - fromIndex, - toIndex, - places.length, - ), - }, - }); - trackAnalyticsEvent(AnalyticsEvents.reorderItinerary, { - room_id: roomId, - item_count_bucket: bucketItemCount(places.length), - method: "drag_drop", - }); - } catch { - toast.error("순서를 바꾸지 못했어요."); - } - }, - [ - moveMutate, - places, - previewIndex, - reorderMutate, - resetDragPreview, - roomId, - scheduleId, - scheduleResyncPending, - ], - ); - - const getRowProps = useCallback( - (index: number, motionEnabled: boolean) => { - const fromIdx = dragFromIndex; - const previewIdx = previewIndex; - const snapshot = layoutSnapshotRef.current; - const insertHeight = draggedRowHeight ?? 0; - - let previewMode: PlanItemRowPreviewMode | null = null; - if ( - incomingExternalDrag && - previewIdx !== null && - insertHeight > 0 - ) { - previewMode = { - kind: "insert", - previewIndex: previewIdx, - insertHeight, - }; - } else if ( - fromIdx !== null && - previewIdx !== null && - fromIdx !== previewIdx && - !snapshot.isEmpty - ) { - previewMode = { - kind: "reorder", - fromIndex: fromIdx, - previewIndex: previewIdx, - rowHeights: snapshot.heights(), - }; - } - - const translateY = computePlanItemRowPreviewTranslateY(index, previewMode); - const isDragging = fromIdx === index; - const displayOrderIndex = computePlanItemRowPreviewOrderIndex( - index, - places.length, - previewMode, - ); - - return { - ref: setRowRef(index), - style: getPlanItemRowReorderStyle({ - translateY, - isDragging, - motionEnabled, - }), - placeCardDragProps: { - displayOrderIndex, - isDragging, - dragDisabled: - mutationLocked || - scheduleResyncPending || - typeof places[index]?.itemId !== "number", - onDragStart: handleDragStart(index), - onDragEnd: handleDragEnd, - }, - }; - }, - [ - dragFromIndex, - draggedRowHeight, - handleDragEnd, - handleDragStart, - incomingExternalDrag, - mutationLocked, - places, - previewIndex, - scheduleResyncPending, - setRowRef, - ], - ); - - const listContainerProps = { - onDragOver: mutationLocked ? undefined : handleListDragOver, - onDragLeave: mutationLocked ? undefined : handleListDragLeave, - onDrop: mutationLocked ? undefined : handleListDrop, - }; - - const listReservePaddingBottom = - incomingExternalDrag && (draggedRowHeight ?? 0) > 0 - ? (draggedRowHeight ?? 0) - : 0; - - return { - getRowProps, - listContainerProps, - isDraggingActive, - isReorderSettling: isReorderPending || isMovePending, - listReservePaddingBottom, - }; -} diff --git a/src/hooks/usePlanScheduleDayReorder.test.tsx b/src/hooks/usePlanScheduleDayReorder.test.tsx new file mode 100644 index 00000000..55e7face --- /dev/null +++ b/src/hooks/usePlanScheduleDayReorder.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +import { act, type DragEvent } from "react"; +import { beforeEach, expect, it, vi } from "vitest"; + +import type { RoomSchedule } from "@/lib/api/rooms"; +import { hookHarness } from "@/test/hook-harness"; + +import { usePlanScheduleDayReorder } from "./usePlanScheduleDayReorder"; + +const move = vi.hoisted(() => ({ mutateAsync: vi.fn(), isPending: false })); +vi.mock("@/hooks/useRooms", () => ({ useMoveRoomSchedule: () => move })); +const toastError = vi.hoisted(() => vi.fn()); +vi.mock("sonner", () => ({ toast: { error: toastError } })); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const HEIGHT = 200; +const day = (scheduleId: number, dayNumber: number): RoomSchedule => ({ + scheduleId, + roomId: "room-a", + dayNumber, + date: `2026-10-0${dayNumber}`, + createdAt: "", +}); + +function dataTransfer(): DataTransfer { + const data = new Map(); + return { + setData: (key: string, value: string) => data.set(key, value), + getData: (key: string) => data.get(key) ?? "", + setDragImage: () => {}, + get types() { + return Array.from(data.keys()); + }, + } as unknown as DataTransfer; +} +function section(index: number) { + const el = document.createElement("section"); + const top = index * HEIGHT; + el.getBoundingClientRect = () => + ({ top, bottom: top + HEIGHT, height: HEIGHT, left: 0, right: 300, width: 300, x: 0, y: top, toJSON: () => ({}) }) as DOMRect; + return el; +} +function dragEvent(transfer: DataTransfer, target: Element, clientY: number) { + const event = { dataTransfer: transfer, currentTarget: target, target, relatedTarget: null, clientX: 10, clientY, preventDefault: () => {} }; + return { ...event, nativeEvent: event } as unknown as DragEvent; +} + +beforeEach(() => { + toastError.mockClear(); + move.mutateAsync.mockReset(); + move.isPending = false; +}); + +it("keeps the dropped day order on screen until the cache shows it, then draws the cache as is", async () => { + let finish!: () => void; + move.mutateAsync.mockReturnValueOnce(new Promise((resolve) => { finish = resolve; })); + const schedules = [day(10, 1), day(20, 2), day(30, 3)]; + const hook = hookHarness((list: RoomSchedule[]) => usePlanScheduleDayReorder({ roomId: "room-a", schedules: list })); + const sections = [0, 1, 2].map(section); + const transfer = dataTransfer(); + try { + await hook.render(schedules); + sections.forEach((el, i) => hook.current.getSectionProps(i).sectionRef(el)); + await act(async () => hook.current.getSectionProps(2).dragHandleProps.onDragStart(dragEvent(transfer, sections[2]!, 500))); + await act(async () => hook.current.listContainerProps.onDragOver!(dragEvent(transfer, sections[0]!, 50))); + await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); + + let dropping!: Promise; + await act(async () => { dropping = hook.current.listContainerProps.onDrop!(dragEvent(transfer, sections[0]!, 50)); }); + expect(move.mutateAsync).toHaveBeenCalledWith({ roomId: "room-a", scheduleId: 30, targetDayNumber: 1 }); + // drop 바로 뒤의 dragend는 저장 중인 모양을 풀지 않는다 + await act(async () => hook.current.getSectionProps(2).dragHandleProps.onDragEnd()); + + // 캐시가 아직 놓기 전 목록이면 놓은 모양(앞 일차를 민 미리보기)을 그대로 그린다 + expect(hook.current.getSectionProps(0).sectionStyle?.transform).toBe(`translateY(${HEIGHT}px)`); + + // onMutate가 캐시를 새 순서로 바꾸면 캐시를 그대로 그린다 + await hook.render([day(30, 1), day(10, 2), day(20, 3)]); + expect(hook.current.getSectionProps(0).sectionStyle).toBeUndefined(); + + await act(async () => { + finish(); + await dropping; + }); + expect(hook.current.isDraggingActive).toBe(false); + } finally { + await hook.unmount(); + } +}); + +it("starts the next day drag while the previous one is still saving, and the earlier save does not clear it", async () => { + let finish!: () => void; + move.mutateAsync.mockReturnValueOnce(new Promise((resolve) => { finish = resolve; })); + const schedules = [day(10, 1), day(20, 2), day(30, 3)]; + const hook = hookHarness((list: RoomSchedule[]) => usePlanScheduleDayReorder({ roomId: "room-a", schedules: list })); + const sections = [0, 1, 2].map(section); + const transfer = dataTransfer(); + try { + await hook.render(schedules); + sections.forEach((el, i) => hook.current.getSectionProps(i).sectionRef(el)); + await act(async () => hook.current.getSectionProps(2).dragHandleProps.onDragStart(dragEvent(transfer, sections[2]!, 500))); + await act(async () => hook.current.listContainerProps.onDragOver!(dragEvent(transfer, sections[0]!, 50))); + await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); + let dropping!: Promise; + await act(async () => { dropping = hook.current.listContainerProps.onDrop!(dragEvent(transfer, sections[0]!, 50)); }); + + // 저장 중에도 손잡이를 막지 않는다 + const moved = [day(30, 1), day(10, 2), day(20, 3)]; + await hook.render(moved); + expect(hook.current.getSectionProps(1).dragHandleProps.dragDisabled).toBe(false); + await act(async () => hook.current.getSectionProps(1).dragHandleProps.onDragStart(dragEvent(transfer, sections[1]!, 300))); + + // 앞선 저장이 끝나도 지금 끄는 일차는 그대로다 + await act(async () => { + finish(); + await dropping; + }); + expect(hook.current.isDraggingActive).toBe(true); + expect(hook.current.getSectionProps(1).dragHandleProps.isDragging).toBe(true); + } finally { + await hook.unmount(); + } +}); + +it("puts the days back with a plain message when the day move cannot reach the server", async () => { + move.mutateAsync.mockRejectedValueOnce(new TypeError("Failed to fetch")); + const schedules = [day(10, 1), day(20, 2), day(30, 3)]; + const hook = hookHarness((list: RoomSchedule[]) => usePlanScheduleDayReorder({ roomId: "room-a", schedules: list })); + const sections = [0, 1, 2].map(section); + const transfer = dataTransfer(); + try { + await hook.render(schedules); + sections.forEach((el, i) => hook.current.getSectionProps(i).sectionRef(el)); + await act(async () => hook.current.getSectionProps(2).dragHandleProps.onDragStart(dragEvent(transfer, sections[2]!, 500))); + await act(async () => hook.current.listContainerProps.onDragOver!(dragEvent(transfer, sections[0]!, 50))); + await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); }); + await act(async () => { await hook.current.listContainerProps.onDrop!(dragEvent(transfer, sections[0]!, 50)); }); + + expect(toastError).toHaveBeenCalledWith("일차 순서를 바꾸지 못했어요."); + expect(hook.current.isDraggingActive).toBe(false); + } finally { + await hook.unmount(); + } +}); diff --git a/src/hooks/usePlanScheduleDayReorder.ts b/src/hooks/usePlanScheduleDayReorder.ts index 710971fe..7fbe81ab 100644 --- a/src/hooks/usePlanScheduleDayReorder.ts +++ b/src/hooks/usePlanScheduleDayReorder.ts @@ -10,13 +10,12 @@ import { import { toast } from "sonner"; import { useDragAutoScroll } from "@/hooks/useDragAutoScroll"; -import { useScheduleResyncPending } from "@/hooks/useScheduleResyncPending"; import { useMoveRoomSchedule } from "@/hooks/useRooms"; +import { messageForSaveError } from "@/lib/api/errors"; import type { RoomSchedule } from "@/lib/api/rooms"; import { computeRowTranslateY, getPlanItemRowReorderStyle, - PLAN_ORDER_RESYNCING_MESSAGE, PlanItemRowLayoutSnapshot, } from "@/lib/plan/planItemReorder"; import { @@ -28,27 +27,33 @@ import { type UsePlanScheduleDayReorderArgs = { roomId: string; schedules: RoomSchedule[]; - interactionLocked?: boolean; }; export function usePlanScheduleDayReorder({ roomId, schedules, - interactionLocked = false, }: UsePlanScheduleDayReorderArgs) { const { mutateAsync: moveMutate, isPending: isMovePending } = useMoveRoomSchedule(); const [dragFromIndex, setDragFromIndex] = useState(null); const [previewIndex, setPreviewIndex] = useState(null); + /** + * 놓아서 저장하는 중 — 놓을 때 일정 목록. 캐시(onMutate)가 목록을 바꿀 때까지 놓은 모양을 그대로 둔다. + * 먼저 풀면 캐시가 바뀌기 전 한 프레임 동안 옛 순서가 보인다(캐시 알림은 setTimeout 0 뒤) + */ + const [droppedSchedules, setDroppedSchedules] = useState(null); + // 지금 저장 중인 놓기 — dragend는 drop 바로 뒤에 와서 렌더 전 값을 봐야 하고, + // 앞선 저장이 끝나도 그사이 시작한 새 드래그를 풀지 않도록 놓기마다 다른 값을 둔다 + const pendingDropRef = useRef(null); const sectionRefs = useRef<(HTMLElement | null)[]>([]); const layoutSnapshotRef = useRef(new PlanItemRowLayoutSnapshot()); const pointerYRef = useRef(0); const dragOverRafRef = useRef(0); - const scheduleResyncPending = useScheduleResyncPending(roomId); - const dragLocked = - interactionLocked || isMovePending || schedules.length < 2; + // 저장 중·일차 추가·삭제 중에도 끌 수 있다 — 어긋나도 onSettled 재조회가 서버 순서로 맞춘다. + // 일차가 1개면 옮길 자리가 없다 + const dragLocked = schedules.length < 2; const isDraggingActive = dragFromIndex !== null; useDragAutoScroll({ active: isDraggingActive }); @@ -93,7 +98,10 @@ export function usePlanScheduleDayReorder({ const handleDragStart = useCallback( (index: number) => (e: DragEvent) => { - if (dragLocked || scheduleResyncPending || schedules[index] == null) return; + if (dragLocked || schedules[index] == null) return; + // 앞서 놓은 일차를 저장하는 중이면 새 드래그가 그 모양을 덮는다 + pendingDropRef.current = null; + setDroppedSchedules(null); const sectionEl = sectionRefs.current[index]; if (sectionEl instanceof HTMLElement) { beginPlanScheduleDayDrag(e.nativeEvent, sectionEl, index); @@ -102,10 +110,12 @@ export function usePlanScheduleDayReorder({ setDragFromIndex(index); setPreviewIndex(index); }, - [dragLocked, scheduleResyncPending, schedules], + [dragLocked, schedules], ); const handleDragEnd = useCallback(() => { + // 놓아서 저장 중이면 저장이 끝날 때 푼다 + if (pendingDropRef.current) return; if (dragOverRafRef.current) { cancelAnimationFrame(dragOverRafRef.current); dragOverRafRef.current = 0; @@ -145,14 +155,6 @@ export function usePlanScheduleDayReorder({ previewIndex ?? (Number.isFinite(fromIndex) ? fromIndex : null); - resetDragPreview(); - - // 드래그하는 동안 연결이 끊겼으면 놓은 자리를 서버 순서로 확인할 수 없다 - if (scheduleResyncPending) { - toast.error(PLAN_ORDER_RESYNCING_MESSAGE); - return; - } - if ( !Number.isFinite(fromIndex) || fromIndex < 0 || @@ -162,14 +164,21 @@ export function usePlanScheduleDayReorder({ toIndex >= schedules.length || fromIndex === toIndex ) { + resetDragPreview(); return; } const scheduleId = schedules[fromIndex]?.scheduleId; - if (typeof scheduleId !== "number") return; + if (typeof scheduleId !== "number") { + resetDragPreview(); + return; + } const targetDayNumber = toIndex + 1; + const drop = Symbol("day-drop"); + pendingDropRef.current = drop; + setDroppedSchedules(schedules); try { await moveMutate({ roomId, @@ -177,19 +186,24 @@ export function usePlanScheduleDayReorder({ targetDayNumber, }); } catch (err) { - toast.error( - err instanceof Error && err.message.trim() - ? err.message - : "일차 순서를 바꾸지 못했어요.", - ); + toast.error(messageForSaveError(err, "일차 순서를 바꾸지 못했어요.")); + } finally { + if (pendingDropRef.current === drop) { + pendingDropRef.current = null; + setDroppedSchedules(null); + resetDragPreview(); + } } }, - [moveMutate, previewIndex, resetDragPreview, roomId, scheduleResyncPending, schedules], + [moveMutate, previewIndex, resetDragPreview, roomId, schedules], ); + // 저장 중에는 캐시가 놓을 때 목록 그대로인 동안만 놓은 모양을 그린다 + const previewActive = droppedSchedules === null || droppedSchedules === schedules; + const getSectionProps = useCallback( (index: number) => { - const fromIdx = dragFromIndex; + const fromIdx = previewActive ? dragFromIndex : null; const previewIdx = previewIndex; const snapshot = layoutSnapshotRef.current; @@ -215,11 +229,10 @@ export function usePlanScheduleDayReorder({ sectionStyle: getPlanItemRowReorderStyle({ translateY, isDragging, - motionEnabled: isDraggingActive, + motionEnabled: isDraggingActive && previewActive, }), dragHandleProps: { - dragDisabled: - dragLocked || scheduleResyncPending || schedules[index] == null, + dragDisabled: dragLocked || schedules[index] == null, isDragging, onDragStart: handleDragStart(index), onDragEnd: handleDragEnd, @@ -232,8 +245,8 @@ export function usePlanScheduleDayReorder({ handleDragEnd, handleDragStart, isDraggingActive, + previewActive, previewIndex, - scheduleResyncPending, schedules, setSectionRef, ], diff --git a/src/hooks/useRooms.schedule-items.test.tsx b/src/hooks/useRooms.schedule-items.test.tsx index 8018efd9..f613e891 100644 --- a/src/hooks/useRooms.schedule-items.test.tsx +++ b/src/hooks/useRooms.schedule-items.test.tsx @@ -10,14 +10,24 @@ import { expect, it, vi } from "vitest"; import type { RoomScheduleItem } from "@/lib/api/rooms/schedule-items"; import { schedulePlanPlacesQueryOptions } from "@/lib/plan/scheduleItemPlaces"; import type { PlanPlace } from "@/lib/plan/types"; -import { scheduleItemsQueryKey } from "@/lib/query-keys"; +import { roomSchedulesQueryKey, scheduleItemsQueryKey } from "@/lib/query-keys"; +import type { RoomSchedule } from "@/lib/api/rooms/schedules"; import { hookHarness } from "@/test/hook-harness"; -import { useDeleteScheduleItem } from "./useRooms"; +import { + useDeleteScheduleItem, + useMoveRoomSchedule, + useMoveScheduleItemToSchedule, + useReorderScheduleItem, + useScheduleItemMoveSavingDays, +} from "./useRooms"; const api = vi.hoisted(() => ({ deleteScheduleItem: vi.fn(), getScheduleItems: vi.fn(), + reorderScheduleItem: vi.fn(), + moveScheduleItemToSchedule: vi.fn(), + moveRoomSchedule: vi.fn(), })); vi.mock("@/lib/client-env", () => ({ clientEnv: { NEXT_PUBLIC_API_BASE_URL: "https://api.example.test" }, @@ -26,6 +36,12 @@ vi.mock("@/lib/api/rooms/schedule-items", async (original) => ({ ...(await original()), deleteScheduleItem: api.deleteScheduleItem, getScheduleItems: api.getScheduleItems, + reorderScheduleItem: api.reorderScheduleItem, + moveScheduleItemToSchedule: api.moveScheduleItemToSchedule, +})); +vi.mock("@/lib/api/rooms/schedules", async (original) => ({ + ...(await original()), + moveRoomSchedule: api.moveRoomSchedule, })); vi.mock("@/contexts/StompContext", () => ({ useStompContext: () => ({ connected: true }), @@ -92,3 +108,343 @@ it("refetches the day after the user's own delete so an older in-flight refetch client.clear(); } }); + +const ids = (places: PlanPlace[] | undefined) => places?.map((p) => p.itemId); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function newClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); +} + +it("shows the new order before the reorder response and puts the previous list back when saving fails", async () => { + const client = newClient(); + const key = scheduleItemsQueryKey("room-1", 10); + client.setQueryData(key, [place(100), place(101), place(102)]); + const reorder = deferred(); + api.reorderScheduleItem.mockReturnValueOnce(reorder.promise); + const hook = hookHarness( + () => ({ reorder: useReorderScheduleItem(), savingDays: useScheduleItemMoveSavingDays("room-1") }), + (child) => {child}, + ); + try { + await hook.render(undefined); + let saving!: Promise; + await act(async () => { + saving = hook.current.reorder + .mutateAsync({ roomId: "room-1", scheduleId: 10, itemId: 102, body: { newOrderIndex: 0 } }) + .catch((error: unknown) => error); + }); + await hook.flush(); + // 응답 전 — 캐시가 이미 새 순서이고, 그 일차는 저장 중이다 + expect(ids(client.getQueryData(key))).toEqual([102, 100, 101]); + expect(hook.current.savingDays).toEqual([10]); + + reorder.reject(new Error("저장 실패")); + await act(async () => { + await saving; + }); + expect(ids(client.getQueryData(key))).toEqual([100, 101, 102]); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + await hook.flush(); + expect(hook.current.savingDays).toEqual([]); + } finally { + await hook.unmount(); + client.clear(); + } +}); + +it("cancels an in-flight day refetch so its older list cannot overwrite the optimistic order", async () => { + const client = newClient(); + const key = scheduleItemsQueryKey("room-1", 10); + client.setQueryData(key, [place(100), place(101)]); + const unwatch = new QueryObserver( + client, + schedulePlanPlacesQueryOptions(client, "room-1", 10), + ).subscribe(() => {}); + const olderFetch = deferred(); + api.getScheduleItems + .mockReturnValueOnce(olderFetch.promise) + .mockResolvedValue(items(101, 100)); + api.reorderScheduleItem.mockResolvedValueOnce(items(101, 100)); + const hook = hookHarness( + () => useReorderScheduleItem(), + (child) => {child}, + ); + try { + await hook.render(undefined); + void client.invalidateQueries({ queryKey: key, exact: true }); + await act(async () => { + await hook.current.mutateAsync({ roomId: "room-1", scheduleId: 10, itemId: 101, body: { newOrderIndex: 0 } }); + }); + // 취소된 옛 조회가 늦게 끝나도 캐시를 덮지 않는다 + olderFetch.resolve(items(100, 101)); + await hook.flush(); + expect(ids(client.getQueryData(key))).toEqual([101, 100]); + } finally { + unwatch(); + await hook.unmount(); + client.clear(); + } +}); + +it("moves the place out of the source day and into the target day before the response, and restores both when saving fails", async () => { + const client = newClient(); + const sourceKey = scheduleItemsQueryKey("room-1", 10); + const targetKey = scheduleItemsQueryKey("room-1", 20); + client.setQueryData(sourceKey, [place(100), place(101)]); + client.setQueryData(targetKey, [place(200)]); + const move = deferred(); + api.moveScheduleItemToSchedule.mockReturnValueOnce(move.promise); + const hook = hookHarness( + () => ({ move: useMoveScheduleItemToSchedule(), savingDays: useScheduleItemMoveSavingDays("room-1") }), + (child) => {child}, + ); + try { + await hook.render(undefined); + let saving!: Promise; + await act(async () => { + saving = hook.current.move + .mutateAsync({ roomId: "room-1", sourceScheduleId: 10, itemId: 101, targetScheduleId: 20, targetOrderIndex: 0 }) + .catch((error: unknown) => error); + }); + await hook.flush(); + expect(ids(client.getQueryData(sourceKey))).toEqual([100]); + expect(ids(client.getQueryData(targetKey))).toEqual([101, 200]); + expect(hook.current.savingDays.sort()).toEqual([10, 20]); + + move.reject(new Error("저장 실패")); + await act(async () => { + await saving; + }); + expect(ids(client.getQueryData(sourceKey))).toEqual([100, 101]); + expect(ids(client.getQueryData(targetKey))).toEqual([200]); + expect(client.getQueryState(sourceKey)?.isInvalidated).toBe(true); + expect(client.getQueryState(targetKey)?.isInvalidated).toBe(true); + } finally { + await hook.unmount(); + client.clear(); + } +}); + +it("keeps a saved cross-day move as saved even when the follow-up day refetch fails", async () => { + const client = newClient(); + const sourceKey = scheduleItemsQueryKey("room-1", 10); + const targetKey = scheduleItemsQueryKey("room-1", 20); + client.setQueryData(sourceKey, [place(100), place(101)]); + client.setQueryData(targetKey, [place(200)]); + const unwatch = [10, 20].map((scheduleId) => + new QueryObserver(client, schedulePlanPlacesQueryOptions(client, "room-1", scheduleId)).subscribe(() => {}), + ); + api.moveScheduleItemToSchedule.mockResolvedValueOnce({}); + api.getScheduleItems.mockRejectedValue(new Error("조회 실패")); + const hook = hookHarness( + () => useMoveScheduleItemToSchedule(), + (child) => {child}, + ); + try { + await hook.render(undefined); + await act(async () => { + await hook.current.mutateAsync({ roomId: "room-1", sourceScheduleId: 10, itemId: 101, targetScheduleId: 20, targetOrderIndex: 1 }); + }); + expect(ids(client.getQueryData(sourceKey))).toEqual([100]); + expect(ids(client.getQueryData(targetKey))).toEqual([200, 101]); + } finally { + unwatch.forEach((stop) => stop()); + await hook.unmount(); + client.clear(); + } +}); + +it("moves the day and renumbers day numbers and dates before the response, and restores them when saving fails", async () => { + const client = newClient(); + const key = roomSchedulesQueryKey("room-1"); + const day = (scheduleId: number, dayNumber: number): RoomSchedule => ({ + scheduleId, + roomId: "room-1", + dayNumber, + date: `2026-10-0${dayNumber}`, + createdAt: "", + }); + client.setQueryData(key, [day(10, 1), day(20, 2), day(30, 3)]); + const move = deferred(); + api.moveRoomSchedule.mockReturnValueOnce(move.promise); + const hook = hookHarness( + () => useMoveRoomSchedule(), + (child) => {child}, + ); + const shown = () => + client.getQueryData(key)?.map((s) => [s.scheduleId, s.dayNumber, s.date]); + try { + await hook.render(undefined); + let saving!: Promise; + await act(async () => { + saving = hook.current + .mutateAsync({ roomId: "room-1", scheduleId: 30, targetDayNumber: 1 }) + .catch((error: unknown) => error); + }); + await hook.flush(); + // 날짜 라벨은 date, 정렬은 dayNumber로 하므로 둘 다 새 자리대로 — 날짜는 자리에 남는다 + expect(shown()).toEqual([ + [30, 1, "2026-10-01"], + [10, 2, "2026-10-02"], + [20, 3, "2026-10-03"], + ]); + + move.reject(new Error("저장 실패")); + await act(async () => { + await saving; + }); + expect(shown()).toEqual([ + [10, 1, "2026-10-01"], + [20, 2, "2026-10-02"], + [30, 3, "2026-10-03"], + ]); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + } finally { + await hook.unmount(); + client.clear(); + } +}); + +it("draws only the latest order when two reorders on the same day overlap, and refetches the day once after the last save", async () => { + const client = newClient(); + const key = scheduleItemsQueryKey("room-1", 10); + client.setQueryData(key, [place(100), place(101), place(102)]); + // 일정 화면처럼 일차 목록을 보고 있는 구독자 — 무효화하면 바로 다시 받는다 + const unwatch = new QueryObserver( + client, + schedulePlanPlacesQueryOptions(client, "room-1", 10), + ).subscribe(() => {}); + const first = deferred(); + const second = deferred(); + api.reorderScheduleItem.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + api.getScheduleItems.mockReset().mockResolvedValue(items(102, 100, 101)); + const hook = hookHarness( + () => useReorderScheduleItem(), + (child) => {child}, + ); + try { + await hook.render(undefined); + let firstSaving!: Promise; + let secondSaving!: Promise; + await act(async () => { + firstSaving = hook.current.mutateAsync({ roomId: "room-1", scheduleId: 10, itemId: 100, body: { newOrderIndex: 2 } }); + }); + await hook.flush(); + await act(async () => { + secondSaving = hook.current.mutateAsync({ roomId: "room-1", scheduleId: 10, itemId: 101, body: { newOrderIndex: 2 } }); + }); + await hook.flush(); + expect(ids(client.getQueryData(key))).toEqual([102, 100, 101]); + + // 앞 저장의 응답(서버에는 앞 저장만 반영)이 와도 그 순서를 그리지 않고, 아직 다시 받지 않는다 + await act(async () => { + first.resolve(items(101, 102, 100)); + await firstSaving; + }); + await hook.flush(); + expect(ids(client.getQueryData(key))).toEqual([102, 100, 101]); + expect(api.getScheduleItems).not.toHaveBeenCalled(); + + // 마지막 저장이 끝나면 한 번만 다시 받는다 + await act(async () => { + second.resolve(items(102, 100, 101)); + await secondSaving; + }); + await hook.flush(); + expect(api.getScheduleItems).toHaveBeenCalledTimes(1); + expect(ids(client.getQueryData(key))).toEqual([102, 100, 101]); + } finally { + unwatch(); + await hook.unmount(); + client.clear(); + } +}); + +it("refetches the room schedules once, after the last of two overlapping day moves", async () => { + const client = newClient(); + const key = roomSchedulesQueryKey("room-1"); + const day = (scheduleId: number, dayNumber: number): RoomSchedule => ({ + scheduleId, + roomId: "room-1", + dayNumber, + date: `2026-10-0${dayNumber}`, + createdAt: "", + }); + client.setQueryData(key, [day(10, 1), day(20, 2), day(30, 3)]); + const first = deferred(); + const second = deferred(); + api.moveRoomSchedule.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const hook = hookHarness( + () => useMoveRoomSchedule(), + (child) => {child}, + ); + try { + await hook.render(undefined); + let firstSaving!: Promise; + let secondSaving!: Promise; + await act(async () => { + firstSaving = hook.current.mutateAsync({ roomId: "room-1", scheduleId: 30, targetDayNumber: 1 }); + secondSaving = hook.current.mutateAsync({ roomId: "room-1", scheduleId: 10, targetDayNumber: 3 }); + }); + await hook.flush(); + + // 앞 저장이 끝나도 뒤 저장이 남았으면 다시 받지 않는다 + await act(async () => { + first.resolve({}); + await firstSaving; + }); + expect(client.getQueryState(key)?.isInvalidated).toBe(false); + + await act(async () => { + second.resolve({}); + await secondSaving; + }); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + } finally { + await hook.unmount(); + client.clear(); + } +}); + +it("rejects a failed reorder right away without waiting for the day refetch, so the failure toast is not late", async () => { + const client = newClient(); + const key = scheduleItemsQueryKey("room-1", 10); + client.setQueryData(key, [place(100), place(101)]); + const unwatch = new QueryObserver( + client, + schedulePlanPlacesQueryOptions(client, "room-1", 10), + ).subscribe(() => {}); + api.reorderScheduleItem.mockRejectedValueOnce(new Error("저장 실패")); + // 다시 받기가 끝나지 않아도 실패는 바로 알려야 한다 + api.getScheduleItems.mockReset().mockReturnValue(new Promise(() => {})); + const hook = hookHarness( + () => useReorderScheduleItem(), + (child) => {child}, + ); + try { + await hook.render(undefined); + await act(async () => { + await expect( + hook.current.mutateAsync({ roomId: "room-1", scheduleId: 10, itemId: 101, body: { newOrderIndex: 0 } }), + ).rejects.toThrow("저장 실패"); + }); + expect(ids(client.getQueryData(key))).toEqual([100, 101]); + expect(api.getScheduleItems).toHaveBeenCalledTimes(1); + } finally { + unwatch(); + await hook.unmount(); + client.clear(); + } +}); diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 5fb5ac77..6aa25025 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -1,10 +1,12 @@ -import { AnalyticsEvents } from "@/lib/analytics/track"; +import { bucketItemCount } from "@/lib/analytics/context"; +import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; import { trackRoomMutation } from "@/lib/analytics/room-mutation"; import { beginExpenseRoomAdmission } from "@/lib/expenses/expense-recovery"; import { joinStatusForWaitingUi } from "@/lib/join-room-workflow"; import { invalidateExpenses, expenseKeys } from "@/lib/expenses/expense-queries"; import { useMutation, + useMutationState, useQuery, useQueryClient, } from "@tanstack/react-query"; @@ -46,6 +48,7 @@ import { type RoomBookmark, type RoomCreateRequest, type RoomScheduleCreateRequest, + type RoomScheduleWithItems, type ReorderScheduleItemRequest, type RoomScheduleItemUpdateRequest, type RoomUpdateRequest, @@ -62,9 +65,13 @@ import { createScheduleItemAtPlanIndex, removeScheduleItemFromPlanPlacesCache, schedulePlanPlacesQueryOptions, - syncAfterCrossScheduleItemMove, - syncPlanPlacesAfterReorderSuccess, } from "@/lib/plan/scheduleItemPlaces"; +import { + insertPlanPlace, + movePlanPlaceWithinDay, + moveRoomScheduleToDay, + removePlanPlace, +} from "@/lib/plan/planListMove"; import { fetchAndSeedAllRoomBookmarks } from "@/lib/bookmarks/bookmark-bulk-cache"; import { appendBookmarkCategoryToCache, @@ -240,10 +247,17 @@ export function useCreateRoomSchedule() { }); } -/** 일차(schedule) 이동 — shift 후 전체 재조회로 갱신 (원격: STOMP `ROOM_SCHEDULES_RESYNCED`) */ +/** + * 일차(schedule) 이동 — 일차 순서·날짜 라벨이 일정 전체에 보여 응답 전에 `room-schedules`를 새 순서로 고치고(onMutate), + * 실패하면 되돌린다. 성공·실패 모두 마지막 저장이 끝날 때 일정을 다시 받는다 (원격: STOMP `ROOM_SCHEDULES_RESYNCED`) + */ +/** 일차 순서 변경 저장 — 마지막 저장이 끝날 때만 일정을 다시 받도록 진행 중인 저장을 이 key로 센다 */ +const ROOM_SCHEDULE_MOVE_MUTATION_KEY = ["room-schedule-move"] as const; + export function useMoveRoomSchedule() { const queryClient = useQueryClient(); return useMutation({ + mutationKey: ROOM_SCHEDULE_MOVE_MUTATION_KEY, mutationFn: ({ roomId, scheduleId, @@ -253,19 +267,99 @@ export function useMoveRoomSchedule() { scheduleId: number; targetDayNumber: number; }) => moveRoomSchedule(roomId, scheduleId, { targetDayNumber }), + onMutate: async ({ roomId, scheduleId, targetDayNumber }) => { + const key = roomSchedulesQueryKey(roomId.trim()); + // 진행 중인 일정 조회의 옛 순서가 먼저 고친 순서를 덮지 않게 취소한다 + await queryClient.cancelQueries({ queryKey: key, exact: true }); + const previous = queryClient.getQueryData(key); + queryClient.setQueryData( + key, + (old) => old && moveRoomScheduleToDay(old, scheduleId, targetDayNumber), + ); + return { previous }; + }, + // 되돌린 값은 그사이 들어온 다른 사람의 변경을 지울 수 있다 — onSettled가 일정을 다시 받아 맞춘다 + onError: (_error, { roomId }, context) => { + queryClient.setQueryData(roomSchedulesQueryKey(roomId.trim()), context?.previous); + }, onSuccess: async (_data, { roomId }) => { const id = roomId.trim(); if (!id.length) return; await invalidateExpenses(queryClient, id); - await refreshRoomSchedules(queryClient, id); + }, + // 연달아 옮기면 앞 저장이 끝날 때 다시 받은 일정이 뒤 저장 전 순서를 그린다 — 마지막 저장이 끝날 때 한 번만 받는다. + // onSettled 안에서는 끝나는 저장도 아직 진행 중으로 세므로 1 이하면 남은 저장이 없다. + // 다시 받기는 기다리지 않는다 — 기다리면 그 GET이 끝날 때까지 실패 토스트가 늦게 뜬다 + onSettled: (_data, _error, { roomId }) => { + if (queryClient.isMutating({ mutationKey: ROOM_SCHEDULE_MOVE_MUTATION_KEY }) <= 1) { + void queryClient.invalidateQueries({ + queryKey: roomSchedulesQueryKey(roomId.trim()), + exact: true, + }); + } }, }); } -/** 일정 항목을 다른 일차로 이동 — 같은 일차 내 순서는 `useReorderScheduleItem` 사용 */ +/** + * 장소 순서 변경·일차 간 이동 저장 — 진행 중인 저장을 이 key로 찾는다. + * 같은 일차의 마지막 저장이 끝날 때만 다시 받고(`isLastItemMoveOf`), 저장하는 동안 그 일차의 경로를 조회하지 않는다(`useScheduleItemMoveSavingDays`) + */ +const SCHEDULE_ITEM_MOVE_MUTATION_KEY = ["schedule-item-move"] as const; + +/** 진행 중인 장소 이동 저장이 바꾸는 이 방의 일차 — mutation 캐시는 variables 타입을 모르므로 필드를 확인해 읽는다 */ +function scheduleIdsOfItemMove(variables: unknown, roomId: string): number[] { + if (typeof variables !== "object" || variables === null) return []; + if (!("roomId" in variables) || typeof variables.roomId !== "string") return []; + if (variables.roomId.trim() !== roomId) return []; + const ids: unknown[] = []; + if ("scheduleId" in variables) ids.push(variables.scheduleId); + if ("sourceScheduleId" in variables) ids.push(variables.sourceScheduleId); + if ("targetScheduleId" in variables) ids.push(variables.targetScheduleId); + return ids.filter((id): id is number => typeof id === "number"); +} + +/** + * 이 일차를 바꾸는 장소 이동 저장이 지금 끝나는 것 하나뿐인지. 연달아 놓았을 때 앞 저장이 끝날 때마다 다시 받으면 + * 뒤 저장 전의 서버 순서가 잠깐 그려진다 — 같은 일차의 마지막 저장이 끝날 때 한 번만 다시 받는다(TanStack 동시 낙관적 갱신). + * onSettled 안에서는 끝나는 저장도 아직 진행 중으로 세므로 1 이하면 남은 저장이 없다 + */ +function isLastItemMoveOf( + queryClient: ReturnType, + roomId: string, + scheduleId: number, +): boolean { + const rid = roomId.trim(); + return ( + queryClient.isMutating({ + mutationKey: SCHEDULE_ITEM_MOVE_MUTATION_KEY, + predicate: (mutation) => + scheduleIdsOfItemMove(mutation.state.variables, rid).includes(scheduleId), + }) <= 1 + ); +} + +/** + * 이 방에서 장소 이동을 저장하는 중인 일차. 캐시는 저장 응답 전에 새 순서지만 서버는 아직 옛 순서라, + * 그 일차의 구간 경로를 지금 조회하면 옛 이웃까지의 경로가 새 구간 key에 저장된다 — 저장이 끝날 때까지 조회하지 않는다. + * (여러 일차를 한 번에 그리는 지도를 위해 일차 목록으로 돌려준다) + */ +export function useScheduleItemMoveSavingDays(roomId: string): number[] { + const rid = roomId.trim(); + return useMutationState({ + filters: { mutationKey: SCHEDULE_ITEM_MOVE_MUTATION_KEY, status: "pending" }, + select: (mutation) => scheduleIdsOfItemMove(mutation.state.variables, rid), + }).flat(); +} + +/** + * 일정 항목을 다른 일차로 이동 — 같은 일차 내 순서는 `useReorderScheduleItem` 사용. + * 결과가 두 일차 목록과 지도에 함께 보여 응답 전에 두 일차 캐시를 먼저 고치고(onMutate), 실패하면 되돌린다. + */ export function useMoveScheduleItemToSchedule() { const queryClient = useQueryClient(); return useMutation({ + mutationKey: SCHEDULE_ITEM_MOVE_MUTATION_KEY, mutationFn: ({ roomId, sourceScheduleId, @@ -278,8 +372,6 @@ export function useMoveScheduleItemToSchedule() { itemId: number; targetScheduleId: number; targetOrderIndex: number; - /** 저장하는 동안 대상 일차에 먼저 보여 줄 카드(모바일 편집 목록) — 요청에는 쓰지 않는다 */ - place?: PlanPlace; }) => { if (sourceScheduleId === targetScheduleId) { throw new Error("같은 일차 안에서는 순서 변경만 가능해요."); @@ -289,18 +381,61 @@ export function useMoveScheduleItemToSchedule() { targetOrderIndex, }); }, - onSuccess: async ( - _moved, - { roomId, sourceScheduleId, targetScheduleId }, - ) => { - await invalidateExpenses(queryClient, roomId); - await syncAfterCrossScheduleItemMove( - queryClient, - roomId, - sourceScheduleId, - targetScheduleId, + onMutate: async ({ + roomId, + sourceScheduleId, + itemId, + targetScheduleId, + targetOrderIndex, + }) => { + const rid = roomId.trim(); + const sourceKey = scheduleItemsQueryKey(rid, sourceScheduleId); + const targetKey = scheduleItemsQueryKey(rid, targetScheduleId); + // 진행 중인 조회의 옛 목록이 먼저 고친 목록을 덮지 않게 취소한다 + await Promise.all([ + queryClient.cancelQueries({ queryKey: sourceKey, exact: true }), + queryClient.cancelQueries({ queryKey: targetKey, exact: true }), + ]); + const previousSource = queryClient.getQueryData(sourceKey); + const previousTarget = queryClient.getQueryData(targetKey); + // 옮길 장소는 출발 일차 캐시에서 꺼낸다 + const moved = previousSource?.find((p) => p.itemId === itemId); + if (moved) { + queryClient.setQueryData( + sourceKey, + (old) => old && removePlanPlace(old, itemId), + ); + queryClient.setQueryData( + targetKey, + (old) => old && insertPlanPlace(old, moved, targetOrderIndex), + ); + } + return { previousSource, previousTarget }; + }, + // 되돌린 값은 그사이 들어온 다른 사람의 변경을 지울 수 있다 — onSettled가 두 일차를 다시 받아 맞춘다 + onError: (_error, { roomId, sourceScheduleId, targetScheduleId }, context) => { + const rid = roomId.trim(); + queryClient.setQueryData( + scheduleItemsQueryKey(rid, sourceScheduleId), + context?.previousSource, + ); + queryClient.setQueryData( + scheduleItemsQueryKey(rid, targetScheduleId), + context?.previousTarget, ); }, + onSuccess: (_moved, { roomId }) => invalidateExpenses(queryClient, roomId), + // 출발·대상 일차를 다시 받는다(성공·실패 모두). 그 일차를 바꾸는 저장이 더 남았으면 그 저장이 끝날 때 받는다. + // `syncAfterCrossScheduleItemMove`는 출발 일차 재조회가 실패하면 throw해서, 여기서 쓰면 저장된 이동이 실패로 바뀌고 + // onError가 화면을 되돌린다 + // 다시 받기는 기다리지 않는다 — 기다리면 그 GET이 끝날 때까지 mutateAsync가 끝나지 않아 실패 토스트가 늦게 뜬다 + onSettled: (_moved, _error, { roomId, sourceScheduleId, targetScheduleId }) => { + for (const scheduleId of [sourceScheduleId, targetScheduleId]) { + if (isLastItemMoveOf(queryClient, roomId, scheduleId)) { + void invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId); + } + } + }, }); } @@ -452,10 +587,15 @@ export function useDeleteScheduleItem() { }); } -/** 순서 변경 — 응답·경로 캐시 반영과 일차 재조회를 기다리므로 `isPending`이 그 끝까지 이어진다 */ +/** + * 순서 변경 — 결과가 일차 목록·지도에 함께 보여 응답 전에 그 일차 캐시를 새 순서로 고치고(onMutate), 실패하면 되돌린다. + * 응답은 캐시에 쓰지 않는다 — 이미 새 순서로 그렸고, 연달아 놓으면 앞 응답이 뒤 저장 전 순서를 다시 그린다. + * 서버 상태는 그 일차의 마지막 저장이 끝날 때 다시 받아 맞춘다(onSettled) + */ export function useReorderScheduleItem() { const queryClient = useQueryClient(); return useMutation({ + mutationKey: SCHEDULE_ITEM_MOVE_MUTATION_KEY, mutationFn: (vars: { roomId: string; scheduleId: number; @@ -468,15 +608,39 @@ export function useReorderScheduleItem() { vars.itemId, vars.body, ), - onSuccess: (items, { roomId, scheduleId }) => - syncPlanPlacesAfterReorderSuccess( - queryClient, - roomId, - scheduleId, - items, - ), - onSettled: (_items, _error, { roomId, scheduleId }) => - invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId), + onMutate: async ({ roomId, scheduleId, itemId, body }) => { + const key = scheduleItemsQueryKey(roomId.trim(), scheduleId); + // 진행 중인 조회의 옛 목록이 먼저 고친 목록을 덮지 않게 취소한다 + await queryClient.cancelQueries({ queryKey: key, exact: true }); + const previous = queryClient.getQueryData(key); + queryClient.setQueryData( + key, + (old) => old && movePlanPlaceWithinDay(old, itemId, body.newOrderIndex), + ); + return { previous }; + }, + // 되돌린 값은 그사이 들어온 다른 사람의 변경을 지울 수 있다 — onSettled가 그 일차를 다시 받아 맞춘다 + onError: (_error, { roomId, scheduleId }, context) => { + queryClient.setQueryData( + scheduleItemsQueryKey(roomId.trim(), scheduleId), + context?.previous, + ); + }, + onSuccess: (items, { roomId }) => { + // 서버가 확인한 순서 변경만 기록한다(낙관적 표시는 쓰기 이벤트가 아님). 순서 변경은 드래그로만 하고, + // 응답은 그 일차 전체라 길이가 곧 일차의 장소 수다 + trackAnalyticsEvent(AnalyticsEvents.reorderItinerary, { + room_id: roomId, + item_count_bucket: bucketItemCount(items.length), + method: "drag_drop", + }); + }, + // 다시 받기는 기다리지 않는다 — 기다리면 그 GET이 끝날 때까지 mutateAsync가 끝나지 않아 실패 토스트가 늦게 뜬다 + onSettled: (_items, _error, { roomId, scheduleId }) => { + if (isLastItemMoveOf(queryClient, roomId, scheduleId)) { + void invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId); + } + }, }); } diff --git a/src/hooks/useScheduleResyncPending.ts b/src/hooks/useScheduleResyncPending.ts deleted file mode 100644 index be3433ee..00000000 --- a/src/hooks/useScheduleResyncPending.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useIsFetching } from "@tanstack/react-query"; - -import { useStompContext } from "@/contexts/StompContext"; -import { roomSchedulesQueryKey } from "@/lib/query-keys"; - -/** - * 연결이 끊긴 동안이나 놓친 변경을 다시 받는 동안 — 이때 보이는 일정 순서는 서버와 다를 수 있다. - * 재정렬 API는 절대 위치만 받으므로 이 구간에는 순서를 바꾸지 않는다. - */ -export function useScheduleResyncPending(roomId: string): boolean { - const { connected } = useStompContext(); - const rid = roomId.trim(); - const isResyncing = - useIsFetching({ queryKey: roomSchedulesQueryKey(rid || null), exact: true }) > 0; - return !connected || isResyncing; -} diff --git a/src/lib/api/errors.ts b/src/lib/api/errors.ts index 843b1143..a567c871 100644 --- a/src/lib/api/errors.ts +++ b/src/lib/api/errors.ts @@ -242,6 +242,15 @@ export function isNetworkError(error: unknown): boolean { return error instanceof TypeError; } +/** + * 저장 실패 토스트 문구 — 서버가 준 문구가 있으면 그 문구, 응답을 받지 못한 네트워크 오류는 `fallback`. + * 네트워크 오류의 메시지는 브라우저 문구(`Failed to fetch`)라 사용자에게 보이지 않는다 + */ +export function messageForSaveError(error: unknown, fallback: string): string { + if (isNetworkError(error) || !(error instanceof Error)) return fallback; + return error.message.trim() || fallback; +} + /** 방 생성·수정 API의 여행지 관련 서버 에러(HTTP 4xx) → 한국어 사용자 메시지 */ export function messageForRoomDestinationError( body: unknown, diff --git a/src/lib/plan/planItemDrop.test.ts b/src/lib/plan/planItemDrop.test.ts new file mode 100644 index 00000000..b85ee108 --- /dev/null +++ b/src/lib/plan/planItemDrop.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { resolvePlanItemDrop, type PlanItemDrop } from "@/lib/plan/planItemDrop"; + +const sameDay = (fromIndex: number, toIndex: number, length = 4): PlanItemDrop => ({ + itemId: 7, + from: { scheduleId: 1, index: fromIndex }, + to: { scheduleId: 1, index: toIndex, length }, +}); + +describe("resolvePlanItemDrop", () => { + it("같은 일차는 옮긴 뒤 자리로 순서 변경 변수를 만든다", () => { + expect(resolvePlanItemDrop("room", sameDay(0, 2))).toEqual({ + kind: "reorder", + vars: { roomId: "room", scheduleId: 1, itemId: 7, body: { newOrderIndex: 2 } }, + }); + }); + + it("같은 일차 목록 끝 뒤에 놓으면 마지막 자리", () => { + expect(resolvePlanItemDrop("room", sameDay(0, 4))?.vars).toMatchObject({ body: { newOrderIndex: 3 } }); + }); + + it("제자리에 놓았거나 출발 자리가 목록 밖이면 보내지 않는다", () => { + expect(resolvePlanItemDrop("room", sameDay(2, 2))).toBeNull(); + expect(resolvePlanItemDrop("room", sameDay(3, 9))).toBeNull(); + expect(resolvePlanItemDrop("room", sameDay(5, 1))).toBeNull(); + }); + + it("드래그 중 목록이 바뀌어도 놓은 자리(몇 번째) 그대로 보낸다", () => { + // 시작 때 2곳이었는데 놓을 때 앞에 하나가 더 들어왔다 — 시작 자리와 놓은 자리를 그대로 쓴다 + expect(resolvePlanItemDrop("room", sameDay(0, 1, 3))?.vars).toMatchObject({ body: { newOrderIndex: 1 } }); + }); + + it("다른 일차는 대상 일차 길이 안으로 넣을 자리를 맞춰 일차 간 이동 변수를 만든다", () => { + const drop: PlanItemDrop = { itemId: 7, from: { scheduleId: 1, index: 1 }, to: { scheduleId: 2, index: 5, length: 2 } }; + expect(resolvePlanItemDrop("room", drop)).toEqual({ + kind: "move", + vars: { roomId: "room", sourceScheduleId: 1, itemId: 7, targetScheduleId: 2, targetOrderIndex: 2 }, + }); + expect(resolvePlanItemDrop("room", { ...drop, to: { scheduleId: 2, index: 0, length: 0 } })?.vars).toMatchObject({ + targetOrderIndex: 0, + }); + }); +}); diff --git a/src/lib/plan/planItemDrop.ts b/src/lib/plan/planItemDrop.ts new file mode 100644 index 00000000..7358c028 --- /dev/null +++ b/src/lib/plan/planItemDrop.ts @@ -0,0 +1,39 @@ +import { clampCrossDayTargetOrderIndex } from "@/lib/plan/planItemReorder"; + +/** 장소 카드를 놓은 결과 — PC(HTML5 DnD)·모바일(포인터) 드래그가 같은 모양으로 넘긴다 */ +export type PlanItemDrop = { + itemId: number; + /** 드래그를 시작한 일차와 그때의 자리 */ + from: { scheduleId: number; index: number }; + /** 놓은 일차와 자리(같은 일차면 옮긴 뒤 자리, 다른 일차면 넣을 자리), 놓을 때 그 일차의 장소 수 */ + to: { scheduleId: number; index: number; length: number }; +}; + +/** + * 놓은 자리를 보낼 mutation 변수로 바꾼다 — 같은 일차는 순서 변경, 다른 일차는 일차 간 이동. + * 제자리에 놓았거나 출발 자리가 목록 밖이면 null(보내지 않음). + * 드래그 중 다른 사람이 순서를 바꿔도 놓은 자리(몇 번째) 그대로 보낸다 — 시작·놓을 때 순서를 비교하지 않는다. + */ +export function resolvePlanItemDrop(roomId: string, { itemId, from, to }: PlanItemDrop) { + if (from.scheduleId !== to.scheduleId) { + return { + kind: "move", + vars: { + roomId, + sourceScheduleId: from.scheduleId, + itemId, + targetScheduleId: to.scheduleId, + targetOrderIndex: clampCrossDayTargetOrderIndex(to.index, to.length), + }, + } as const; + } + // 같은 일차면 놓을 때 목록이 곧 출발 일차다 — 목록 끝 뒤에 놓으면 마지막 자리 + const newOrderIndex = Math.min(to.index, to.length - 1); + if (from.index < 0 || from.index >= to.length || newOrderIndex < 0 || newOrderIndex === from.index) { + return null; + } + return { + kind: "reorder", + vars: { roomId, scheduleId: from.scheduleId, itemId, body: { newOrderIndex } }, + } as const; +} diff --git a/src/lib/plan/planItemReorder.ts b/src/lib/plan/planItemReorder.ts index 1b185ea4..e8aa9045 100644 --- a/src/lib/plan/planItemReorder.ts +++ b/src/lib/plan/planItemReorder.ts @@ -4,27 +4,23 @@ import { readMainContentScrollTop } from "@/lib/dnd/mainContentScroll"; import { setHtmlDragImageFromPointer } from "@/lib/dnd/setHtmlDragImageFromPointer"; export const PLAN_ITEM_DND_INDEX_MIME = "application/x-plan-item-index"; -export const PLAN_ITEM_DND_SCHEDULE_ID_MIME = - "application/x-plan-item-schedule-id"; -export const PLAN_ITEM_DND_ITEM_ID_MIME = "application/x-plan-item-id"; export const PLAN_ITEM_ITINERARY_DROP_ZONE_ATTR = "data-plan-itinerary-drop-zone"; -/** 연결이 끊긴 동안이나 놓친 변경을 다시 받는 동안 놓은 경우 */ -export const PLAN_ORDER_RESYNCING_MESSAGE = - "일정을 다시 맞추는 중이라 옮기지 않았어요. 잠시 후 다시 옮겨 주세요."; - export const PLAN_ITEM_REORDER_ROW_TRANSITION = "transform 200ms cubic-bezier(0.2, 0, 0, 1)"; /** 행 bounding rect — `getBoundingClientRect()` 결과와 호환 */ export type PlanItemRowRect = Pick; +/** + * HTML5 드래그 시작 — 끌기 이미지와 dataTransfer만 채운다. 무엇을 끄는지는 드래그 컨트롤러 상태가 갖고, + * dataTransfer 값은 읽지 않는다(Firefox는 setData가 없으면 드래그를 시작하지 않는다) + */ export function beginPlanItemDrag( event: DragEvent, source: HTMLElement, fromIndex: number, - meta?: { scheduleId: number; itemId: number }, ): void { setHtmlDragImageFromPointer(event, source); const dataTransfer = event.dataTransfer; @@ -33,43 +29,6 @@ export function beginPlanItemDrag( dataTransfer.effectAllowed = "move"; dataTransfer.setData(PLAN_ITEM_DND_INDEX_MIME, String(fromIndex)); dataTransfer.setData("text/plain", String(fromIndex)); - if (meta != null) { - dataTransfer.setData(PLAN_ITEM_DND_SCHEDULE_ID_MIME, String(meta.scheduleId)); - dataTransfer.setData(PLAN_ITEM_DND_ITEM_ID_MIME, String(meta.itemId)); - } -} - -function readFiniteDragNumber(dataTransfer: DataTransfer, mime: string): number | null { - const raw = dataTransfer.getData(mime); - if (!raw.length) return null; - const n = parseInt(raw, 10); - return Number.isFinite(n) ? n : null; -} - -export function readPlanItemDragScheduleId( - dataTransfer: DataTransfer, -): number | null { - return readFiniteDragNumber(dataTransfer, PLAN_ITEM_DND_SCHEDULE_ID_MIME); -} - -export function readPlanItemDragItemId(dataTransfer: DataTransfer): number | null { - return readFiniteDragNumber(dataTransfer, PLAN_ITEM_DND_ITEM_ID_MIME); -} - -export function isPlanItemDrag(dataTransfer: DataTransfer): boolean { - const types = Array.from(dataTransfer.types); - return ( - types.includes(PLAN_ITEM_DND_SCHEDULE_ID_MIME) || - types.includes(PLAN_ITEM_DND_ITEM_ID_MIME) || - types.includes(PLAN_ITEM_DND_INDEX_MIME) - ); -} - -export function readPlanItemDragIndex(dataTransfer: DataTransfer): number { - const raw = - dataTransfer.getData(PLAN_ITEM_DND_INDEX_MIME) || - dataTransfer.getData("text/plain"); - return parseInt(raw, 10); } export function clampCrossDayTargetOrderIndex( @@ -80,52 +39,6 @@ export function clampCrossDayTargetOrderIndex( return Math.max(0, Math.min(index, placesLength)); } -export type PlanItemCrossDayMoveMutate = (args: { - roomId: string; - sourceScheduleId: number; - itemId: number; - targetScheduleId: number; - targetOrderIndex: number; -}) => Promise; - -export async function executePlanItemCrossDayMove(args: { - dataTransfer: DataTransfer; - roomId: string; - targetScheduleId: number; - targetOrderIndex: number; - moveMutate: PlanItemCrossDayMoveMutate; - onError?: (message: string) => void; -}): Promise { - const sourceScheduleId = readPlanItemDragScheduleId(args.dataTransfer); - const draggedItemId = readPlanItemDragItemId(args.dataTransfer); - - if ( - sourceScheduleId == null || - sourceScheduleId === args.targetScheduleId || - typeof draggedItemId !== "number" - ) { - return false; - } - - try { - await args.moveMutate({ - roomId: args.roomId, - sourceScheduleId, - itemId: draggedItemId, - targetScheduleId: args.targetScheduleId, - targetOrderIndex: args.targetOrderIndex, - }); - return true; - } catch (err) { - args.onError?.( - err instanceof Error && err.message.trim() - ? err.message - : "다른 일차로 옮기지 못했어요.", - ); - return true; - } -} - function snapshotRowRects( rowElements: readonly (HTMLElement | null)[], ): PlanItemRowRect[] { diff --git a/src/lib/plan/planListMove.test.ts b/src/lib/plan/planListMove.test.ts new file mode 100644 index 00000000..5deabd07 --- /dev/null +++ b/src/lib/plan/planListMove.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import type { RoomSchedule } from "@/lib/api/rooms/schedules"; +import { + insertPlanPlace, + movePlanPlaceWithinDay, + moveRoomScheduleToDay, + removePlanPlace, +} from "@/lib/plan/planListMove"; +import type { PlanPlace } from "@/lib/plan/types"; + +const place = (itemId: number): PlanPlace => ({ id: `item-${itemId}`, itemId, title: `장소 ${itemId}` }); +const ids = (places: PlanPlace[] | undefined) => places?.map((p) => p.itemId); + +describe("movePlanPlaceWithinDay", () => { + const day = [place(1), place(2), place(3), place(4)]; + + it("옮긴 뒤 목록의 자리로 넣는다 — 서버 순서 API의 newOrderIndex와 같은 뜻", () => { + expect(ids(movePlanPlaceWithinDay(day, 1, 2))).toEqual([2, 3, 1, 4]); + expect(ids(movePlanPlaceWithinDay(day, 4, 0))).toEqual([4, 1, 2, 3]); + }); + + it("목록 밖 자리는 끝으로 붙인다", () => { + expect(ids(movePlanPlaceWithinDay(day, 2, 99))).toEqual([1, 3, 4, 2]); + }); + + it("원래 목록을 바꾸지 않는다", () => { + movePlanPlaceWithinDay(day, 1, 3); + expect(ids(day)).toEqual([1, 2, 3, 4]); + }); + + it("목록에 없는 장소면 바꾸지 않는다(undefined)", () => { + expect(movePlanPlaceWithinDay(day, 99, 0)).toBeUndefined(); + }); +}); + +describe("removePlanPlace · insertPlanPlace (일차 간 이동)", () => { + const day1 = [place(1), place(2), place(3)]; + const day2 = [place(10), place(11)]; + + it("출발 일차에서 빼고 대상 일차의 자리에 넣는다", () => { + expect(ids(removePlanPlace(day1, 2))).toEqual([1, 3]); + expect(ids(insertPlanPlace(day2, place(2), 1))).toEqual([10, 2, 11]); + }); + + it("대상 일차 끝 뒤 자리는 끝으로 붙인다", () => { + expect(ids(insertPlanPlace(day2, place(2), 5))).toEqual([10, 11, 2]); + }); + + it("빈 일차에도 넣는다", () => { + expect(ids(insertPlanPlace([], place(2), 0))).toEqual([2]); + }); + + it("대상 일차에 이미 있으면 두 번 넣지 않는다", () => { + expect(ids(insertPlanPlace([place(2), place(10)], place(2), 1))).toEqual([10, 2]); + }); + + it("출발 일차에 없으면 바꾸지 않는다(undefined)", () => { + expect(removePlanPlace(day1, 99)).toBeUndefined(); + }); +}); + +describe("moveRoomScheduleToDay", () => { + const schedule = (scheduleId: number, dayNumber: number, date: string): RoomSchedule => ({ + scheduleId, + roomId: "room", + dayNumber, + date, + createdAt: "", + }); + const schedules = [ + schedule(30, 3, "2026-10-03"), + schedule(10, 1, "2026-10-01"), + schedule(20, 2, "2026-10-02"), + ]; + + it("일차를 옮기고 dayNumber·date를 새 자리대로 다시 매긴다 — 날짜는 자리에 남는다", () => { + expect(moveRoomScheduleToDay(schedules, 30, 1)).toEqual([ + schedule(30, 1, "2026-10-01"), + schedule(10, 2, "2026-10-02"), + schedule(20, 3, "2026-10-03"), + ]); + }); + + it("뒤로 옮겨도 같다", () => { + expect( + moveRoomScheduleToDay(schedules, 10, 3)?.map((s) => [s.scheduleId, s.dayNumber, s.date]), + ).toEqual([ + [20, 1, "2026-10-01"], + [30, 2, "2026-10-02"], + [10, 3, "2026-10-03"], + ]); + }); + + it("일정의 다른 필드(장소 사본 등)는 그대로 둔다", () => { + const withItems = [{ ...schedule(10, 1, "2026-10-01"), items: [] }, schedule(20, 2, "2026-10-02")]; + expect(moveRoomScheduleToDay(withItems, 10, 2)?.[1]).toMatchObject({ scheduleId: 10, items: [] }); + }); + + it("원래 목록을 바꾸지 않는다", () => { + moveRoomScheduleToDay(schedules, 30, 1); + expect(schedules.map((s) => s.dayNumber)).toEqual([3, 1, 2]); + }); + + it("일정에 없는 일차면 바꾸지 않는다(undefined)", () => { + expect(moveRoomScheduleToDay(schedules, 99, 1)).toBeUndefined(); + }); +}); diff --git a/src/lib/plan/planListMove.ts b/src/lib/plan/planListMove.ts new file mode 100644 index 00000000..c9027df8 --- /dev/null +++ b/src/lib/plan/planListMove.ts @@ -0,0 +1,64 @@ +import type { RoomSchedule } from "@/lib/api/rooms/schedules"; +import { sortRoomSchedules } from "@/lib/plan/scheduleMerge"; +import type { PlanPlace } from "@/lib/plan/types"; + +// 저장 응답 전에 캐시에 먼저 그릴 목록을 계산한다. 서버 순서 API와 같은 뜻으로 옮긴다: +// 위치는 옮긴 뒤 목록에서의 자리(0부터)이고, 목록 밖이면 끝으로 붙인다. + +function clampIndex(index: number, length: number): number { + return Math.max(0, Math.min(index, length)); +} + +/** 같은 일차 순서 변경 — `itemId` 장소를 `newOrderIndex` 자리로 옮긴 새 목록. 목록에 없으면 `undefined` */ +export function movePlanPlaceWithinDay( + places: readonly PlanPlace[], + itemId: number, + newOrderIndex: number, +): PlanPlace[] | undefined { + const from = places.findIndex((p) => p.itemId === itemId); + const moved = places[from]; + if (from < 0 || !moved) return undefined; + const next = places.filter((_, i) => i !== from); + next.splice(clampIndex(newOrderIndex, next.length), 0, moved); + return next; +} + +/** 일차 간 이동의 출발 일차 — `itemId` 장소를 뺀 새 목록. 목록에 없으면 `undefined` */ +export function removePlanPlace( + places: readonly PlanPlace[], + itemId: number, +): PlanPlace[] | undefined { + if (!places.some((p) => p.itemId === itemId)) return undefined; + return places.filter((p) => p.itemId !== itemId); +} + +/** 일차 간 이동의 대상 일차 — `place`를 `orderIndex` 자리에 넣은 새 목록. 이미 있으면 그 자리로 옮긴다(두 번 넣지 않음) */ +export function insertPlanPlace( + places: readonly PlanPlace[], + place: PlanPlace, + orderIndex: number, +): PlanPlace[] { + const next = places.filter((p) => p.itemId !== place.itemId); + next.splice(clampIndex(orderIndex, next.length), 0, place); + return next; +} + +/** + * 일차 순서 변경 — `scheduleId` 일차를 `targetDayNumber`(1부터) 자리로 옮기고 위치대로 다시 매긴다. + * 날짜 라벨은 `date`, 정렬은 `dayNumber`로 하므로 둘 다 바꾼다. 날짜는 원래 자리의 날짜를 그대로 남긴다 + * (여행 날짜는 그대로고 일차만 바뀐다). 일정에 없으면 `undefined` + */ +export function moveRoomScheduleToDay( + schedules: readonly T[], + scheduleId: number, + targetDayNumber: number, +): T[] | undefined { + const sorted = sortRoomSchedules([...schedules]); + const from = sorted.findIndex((s) => s.scheduleId === scheduleId); + const moved = sorted[from]; + if (from < 0 || !moved) return undefined; + const dates = sorted.map((s) => s.date); + const next = sorted.filter((_, i) => i !== from); + next.splice(clampIndex(targetDayNumber - 1, next.length), 0, moved); + return next.map((s, i) => ({ ...s, dayNumber: i + 1, date: dates[i] })); +} diff --git a/src/lib/plan/scheduleItemPlaces.create.test.ts b/src/lib/plan/scheduleItemPlaces.create.test.ts index 1c3eca3c..e790fd10 100644 --- a/src/lib/plan/scheduleItemPlaces.create.test.ts +++ b/src/lib/plan/scheduleItemPlaces.create.test.ts @@ -155,28 +155,6 @@ describe("cross-schedule refresh sequencing", () => { }); }); -describe("reorder preserves user times", () => { - it.each([ - [null, null], ["12:45", null], ["23:00", "01:00"], ["00:00", "00:00"], - ])("keeps %s / %s and never sends a time PATCH", async (startTime, endTime) => { - vi.clearAllMocks(); - const { syncPlanPlacesAfterReorderSuccess } = await import("./scheduleItemPlaces"); - const client = new QueryClient(); - const key = scheduleItemsQueryKey("room", 10); - client.setQueryData(key, [1, 2].map((itemId) => ({ id: `item-${itemId}`, itemId, title: "Place" }))); - const items = [ - { ...scheduleItem(2, 0), startTime, endTime }, - { ...scheduleItem(1, 1), startTime: null, endTime: null }, - ]; - await syncPlanPlacesAfterReorderSuccess(client, "room", 10, items); - expect(mocks.updateScheduleItem).not.toHaveBeenCalled(); - expect(client.getQueryData>(key)?.map(p => - [p.itemId, p.startTime ?? null, p.endTime ?? null])).toEqual([[2, startTime, endTime], [1, null, null]]); - client.clear(); - }); -}); - - it("cross-day move refresh preserves unset and partial times without a time PATCH", async () => { vi.clearAllMocks(); const client = new QueryClient(); diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index b087ce82..778ec283 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -194,26 +194,6 @@ export async function invalidateSchedulePlanPlaces( await queryClient.invalidateQueries({ queryKey, exact: true }, { throwOnError: true }); } -/** - * `reorderScheduleItem` 응답(`RoomScheduleItem[]`)으로 캐시된 PlanPlace를 갱신하고, - * 머지 불가 시에만 전체 enrich를 다시 가져옵니다. - */ -export async function mergeOrRefetchSchedulePlanPlacesFromItems( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - items: RoomScheduleItem[], -): Promise { - const key = scheduleItemsQueryKey(roomId.trim(), scheduleId); - queryClient.setQueryData( - key, - (await mergeScheduleItemsIntoPlanPlaces( - queryClient.getQueryData(key), - items, - )) ?? (await buildPlanPlacesFromScheduleItems(items)), - ); -} - /** * 다른 일차로 옮긴 뒤 두 일차 장소 목록을 다시 받습니다. 경로는 도착 항목까지 key에 있어 * 순서가 바뀐 구간은 새 key로 받으므로 따로 무효화하지 않습니다. @@ -348,24 +328,6 @@ export async function fetchSchedulePlanPlacesFromCacheOrApi( return fetchScheduleItemsAsPlanPlaces(rid, scheduleId); } -/** 순서 응답을 반영합니다. 경로 캐시는 출발·도착 항목 키로 구분합니다. */ -export async function syncPlanPlacesAfterReorderSuccess( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - items: RoomScheduleItem[], -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - - await mergeOrRefetchSchedulePlanPlacesFromItems( - queryClient, - roomId, - scheduleId, - items, - ); -} - /** PATCH 응답 한 건으로 로컬 PlanPlace 캐시만 갱신(같은 목록에 itemId가 있을 때). */ export function applyRoomScheduleItemToPlanPlaces( prev: PlanPlace[] | undefined, @@ -509,23 +471,6 @@ export async function applyCreatedScheduleItemToPlanPlaces( ); } -/** 드래그를 `toIndex` 자리에 놓았을 때 PATCH에 넣을 `newOrderIndex`(0-based) */ -export function newOrderIndexAfterMove( - fromIndex: number, - toIndex: number, - length: number, -): number { - if (length <= 0 || fromIndex < 0 || toIndex < 0 || fromIndex >= length) { - return Math.min(Math.max(toIndex, 0), Math.max(length - 1, 0)); - } - const cappedTo = Math.min(toIndex, length - 1); - if (fromIndex === cappedTo) return fromIndex; - const order = Array.from({ length }, (_, i) => i); - const [moved] = order.splice(fromIndex, 1); - order.splice(cappedTo, 0, moved); - return order.indexOf(moved); -} - export async function fetchScheduleItemsAsPlanPlaces( roomId: string, scheduleId: number, diff --git a/src/stores/plan-item-cross-day-drag-store.ts b/src/stores/plan-item-cross-day-drag-store.ts deleted file mode 100644 index a126ec9a..00000000 --- a/src/stores/plan-item-cross-day-drag-store.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { create } from "zustand"; - -type PlanItemCrossDayDragState = { - sourceScheduleId: number | null; - hoverTargetScheduleId: number | null; - /** 소스 행 높이 — 대상 일차 삽입 gap 애니메이션용 */ - draggedRowHeight: number | null; - beginItemDrag: (sourceScheduleId: number, rowHeight: number) => void; - setHoverTarget: (scheduleId: number | null) => void; - endItemDrag: () => void; -}; - -export const usePlanItemCrossDayDragStore = create( - (set) => ({ - sourceScheduleId: null, - hoverTargetScheduleId: null, - draggedRowHeight: null, - beginItemDrag: (sourceScheduleId, rowHeight) => { - if (!Number.isFinite(sourceScheduleId)) return; - set({ - sourceScheduleId, - hoverTargetScheduleId: null, - draggedRowHeight: - Number.isFinite(rowHeight) && rowHeight > 0 ? rowHeight : null, - }); - }, - setHoverTarget: (scheduleId) => { - set((s) => { - if (scheduleId === s.hoverTargetScheduleId) return s; - return { hoverTargetScheduleId: scheduleId }; - }); - }, - endItemDrag: () => { - set({ - sourceScheduleId: null, - hoverTargetScheduleId: null, - draggedRowHeight: null, - }); - }, - }), -);