From dc555ec3bdbf66805f7c352ba30f32882541611c Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 30 Oct 2026 20:15:53 +0900 Subject: [PATCH 01/24] =?UTF-8?q?fix(schedule):=20=EB=8A=A6=EA=B2=8C=20?= =?UTF-8?q?=EC=98=A8=20=EC=9D=BC=EC=B0=A8=20=EC=9E=A5=EC=86=8C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=9D=91=EB=8B=B5=EC=9D=B4=20=EC=B5=9C=EC=8B=A0=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EB=8D=AE=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일차 목록 queryFn이 캐시만 읽던 것을 서버(GET …/items)에서 받도록 변경. 지도 등 같은 키를 읽는 곳도 같은 조회 옵션을 써서 무효화하면 항상 서버에서 받음 - STOMP 이벤트는 목록을 직접 받아 쓰지 않고 무효화. 같은 일차를 받는 중이면 TanStack이 옛 조회를 취소하고 그 응답을 버림 - 일정 전체 응답은 요청을 보낸 뒤 바뀐 일차 목록을 덮지 않음 Co-Authored-By: Claude Opus 5.5 --- .../map/_components/useMobileRouteView.tsx | 10 +- src/components/map/PlanItineraryMapRoutes.tsx | 12 +-- src/components/map/PlanRouteStepper.tsx | 10 +- .../usePlanItineraryStopNormalizedPlaceIds.ts | 8 +- src/hooks/useRooms.ts | 11 +- src/lib/api/rooms/schedule-items.ts | 3 +- src/lib/plan/schedule-bulk-hydration.ts | 21 +++- src/lib/plan/scheduleItemPlaces.ts | 101 +++++++++--------- src/lib/rooms.ts | 3 +- src/lib/stomp/schedules-dispatch.ts | 40 ++----- 10 files changed, 91 insertions(+), 128 deletions(-) diff --git a/src/app/(main)/map/_components/useMobileRouteView.tsx b/src/app/(main)/map/_components/useMobileRouteView.tsx index b9c4a39a..05c6dff1 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.tsx @@ -21,10 +21,9 @@ import { scheduleIdsToRouteColors, sortedScheduleIdsForRouteColors, } from "@/lib/plan/planRouteDayColors"; -import { readSchedulePlanPlacesFromCache } from "@/lib/plan/scheduleItemPlaces"; +import { schedulePlanPlacesQueryOptions } from "@/lib/plan/scheduleItemPlaces"; import { sortRoomSchedules } from "@/lib/plan/scheduleMerge"; import type { PlanPlace } from "@/lib/plan/types"; -import { scheduleItemsQueryKey } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import { @@ -118,15 +117,12 @@ export function useMobileRouteView({ const schedulesQuery = useRoomSchedules(rid || null); const schedules = useMemo(() => sortRoomSchedules(schedulesQuery.data ?? []), [schedulesQuery.data]); - // 일정 목록을 불러올 때 모든 일차의 장소(미리보기 포함)가 캐시에 채워지므로 캐시만 읽는다 + // 일정 목록을 불러올 때 모든 일차의 장소(미리보기 포함)가 캐시에 채워진다 — 일정 화면과 같은 조회라 무효화된 일차만 다시 받는다 const dayPlacesQueries = useQueries({ queries: schedules.map((s) => ({ - queryKey: scheduleItemsQueryKey(rid || null, s.scheduleId), - queryFn: () => readSchedulePlanPlacesFromCache(queryClient, rid, s.scheduleId), + ...schedulePlanPlacesQueryOptions(queryClient, rid, s.scheduleId), // 재조회가 실패해도(status 'error') 받아 둔 일정으로 계속 그린다 enabled: active && rid.length > 0 && schedulesQuery.data !== undefined, - staleTime: Infinity, - refetchOnMount: false, refetchOnWindowFocus: false, })), }); diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx index 7b116e40..49a499c4 100644 --- a/src/components/map/PlanItineraryMapRoutes.tsx +++ b/src/components/map/PlanItineraryMapRoutes.tsx @@ -18,7 +18,7 @@ import { } from "@/lib/maps"; import { persistedScheduleItemRouteQueryOptions } from "@/lib/plan/scheduleItemRoutePersistedQuery"; import { schedulePlacesFingerprint } from "@/lib/plan/planTravelLocalStorage"; -import { readSchedulePlanPlacesFromCache } from "@/lib/plan/scheduleItemPlaces"; +import { schedulePlanPlacesQueryOptions } from "@/lib/plan/scheduleItemPlaces"; import { scheduleIdsToRouteColors, sortedScheduleIdsForRouteColors, @@ -30,10 +30,7 @@ import { import { flattenPlanItinerarySegmentsFromPlaces, } from "@/lib/plan/planItineraryMapSegments"; -import { - planItinerarySegmentMapPathQueryKey, - scheduleItemsQueryKey, -} from "@/lib/query-keys"; +import { planItinerarySegmentMapPathQueryKey } from "@/lib/query-keys"; import { planMapSegmentEpochStoreKey, usePlanMapDirectionsEpochStore, @@ -143,14 +140,11 @@ export function PlanItineraryMapRoutes({ const placesQueries = useQueries({ queries: orderedScheduleIdsForQueries.map((scheduleId) => ({ - queryKey: scheduleItemsQueryKey(rid || null, scheduleId), - queryFn: () => readSchedulePlanPlacesFromCache(queryClient, rid, scheduleId), + ...schedulePlanPlacesQueryOptions(queryClient, rid, scheduleId), enabled: rid.length > 0 && orderedScheduleIdsForQueries.length > 0 && schedulesHydrated, - staleTime: Infinity, - refetchOnMount: false, refetchOnWindowFocus: false, })), }); diff --git a/src/components/map/PlanRouteStepper.tsx b/src/components/map/PlanRouteStepper.tsx index cb25a26d..b8f93d89 100644 --- a/src/components/map/PlanRouteStepper.tsx +++ b/src/components/map/PlanRouteStepper.tsx @@ -6,8 +6,7 @@ import { toast } from "sonner"; import { ChevronLeftIcon } from "@/assets/icons"; import { useSelectedPlace } from "@/contexts/SelectedPlaceContext"; import { useRoomSchedules } from "@/hooks/useRooms"; -import { readSchedulePlanPlacesFromCache } from "@/lib/plan/scheduleItemPlaces"; -import { scheduleItemsQueryKey } from "@/lib/query-keys"; +import { schedulePlanPlacesQueryOptions } from "@/lib/plan/scheduleItemPlaces"; import { PLAN_MAP_FOCUS_NO_LOCATION_MESSAGE, planPlaceFocusTarget, @@ -32,13 +31,10 @@ export function PlanRouteStepper({ roomId }: Readonly<{ roomId: string | null }> const focusPlace = usePlanMapFocusStore((s) => s.focusPlace); const scheduleId = focus?.scheduleId ?? null; - // 지도 경로(`PlanItineraryMapRoutes`)와 같은 일정 항목 캐시를 읽는다 — 따로 조회하지 않는다 + // 일정 목록·지도 경로(`PlanItineraryMapRoutes`)와 같은 일차 조회다 — 캐시가 있으면 따로 받지 않는다 const { data: places = [] } = useQuery({ - queryKey: scheduleItemsQueryKey(rid || null, scheduleId), - queryFn: () => (scheduleId === null ? [] : readSchedulePlanPlacesFromCache(queryClient, rid, scheduleId)), + ...schedulePlanPlacesQueryOptions(queryClient, rid, scheduleId), enabled: rid.length > 0 && scheduleId !== null && schedulesHydrated, - staleTime: Infinity, - refetchOnMount: false, refetchOnWindowFocus: false, }); diff --git a/src/hooks/usePlanItineraryStopNormalizedPlaceIds.ts b/src/hooks/usePlanItineraryStopNormalizedPlaceIds.ts index 46e866cb..1f5bd983 100644 --- a/src/hooks/usePlanItineraryStopNormalizedPlaceIds.ts +++ b/src/hooks/usePlanItineraryStopNormalizedPlaceIds.ts @@ -5,8 +5,7 @@ import { useMemo } from "react"; import { useRoomSchedules } from "@/hooks/useRooms"; import { normalizeGooglePlaceResourceId } from "@/lib/maps"; -import { readSchedulePlanPlacesFromCache } from "@/lib/plan/scheduleItemPlaces"; -import { scheduleItemsQueryKey } from "@/lib/query-keys"; +import { schedulePlanPlacesQueryOptions } from "@/lib/plan/scheduleItemPlaces"; import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store"; import { useSessionStore } from "@/stores/session-store"; @@ -41,15 +40,12 @@ export function usePlanItineraryStopNormalizedPlaceIds( const placesQueries = useQueries({ queries: orderedScheduleIdsForQueries.map((scheduleId) => ({ - queryKey: scheduleItemsQueryKey(rid || null, scheduleId), - queryFn: () => readSchedulePlanPlacesFromCache(queryClient, rid, scheduleId), + ...schedulePlanPlacesQueryOptions(queryClient, rid, scheduleId), enabled: enabled && rid.length > 0 && orderedScheduleIdsForQueries.length > 0 && schedulesHydrated, - staleTime: Infinity, - refetchOnMount: false, refetchOnWindowFocus: false, })), }); diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 94e923b7..a7b4c13d 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -61,12 +61,11 @@ import { applyRoomScheduleItemToPlanPlaces, applyScheduleItemDeletedOnClient, createScheduleItemAtPlanIndex, - ensureSchedulePlanPlacesEnriched, + schedulePlanPlacesQueryOptions, setSchedulePlanPlaces, syncAfterCrossScheduleItemMove, syncPlanPlacesAfterReorderSuccess, } from "@/lib/plan/scheduleItemPlaces"; -import { planPlacesNeedPreviewEnrich } from "@/lib/plan/schedule-bulk-hydration"; import { fetchAndSeedAllRoomBookmarks } from "@/lib/bookmarks/bookmark-bulk-cache"; import { appendBookmarkCategoryToCache, @@ -324,18 +323,12 @@ export function useSchedulePlanPlaces( const { isSuccess: schedulesHydrated } = useRoomSchedules(rid || null); return useQuery({ - queryKey: scheduleItemsQueryKey(rid || null, sid), - queryFn: () => ensureSchedulePlanPlacesEnriched(queryClient, rid, sid!), + ...schedulePlanPlacesQueryOptions(queryClient, rid, sid), enabled: rid.length > 0 && typeof sid === "number" && Number.isFinite(sid) && schedulesHydrated, - staleTime: Infinity, - refetchOnMount: (query) => { - const data = query.state.data; - return !Array.isArray(data) || planPlacesNeedPreviewEnrich(data); - }, refetchOnWindowFocus: false, }); } diff --git a/src/lib/api/rooms/schedule-items.ts b/src/lib/api/rooms/schedule-items.ts index daae5c2f..c66ae23d 100644 --- a/src/lib/api/rooms/schedule-items.ts +++ b/src/lib/api/rooms/schedule-items.ts @@ -183,10 +183,11 @@ export async function getScheduleItemRoutesBatch( export async function getScheduleItems( roomId: string, scheduleId: number, + signal?: AbortSignal, ): Promise { return requestJson( apiUrl(`/rooms/${roomId}/schedules/${scheduleId}/items`), - undefined, + { signal }, { errorMessage: "일정 장소 목록 조회 실패" }, ); } diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index d51c2aee..739b9415 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -223,18 +223,30 @@ export async function buildSchedulePlacesFromSchedulesWithItems( return placesBySchedule; } -/** 만든 일차 목록을 한 번에 씁니다. 순서가 바뀐 일차는 이웃이 바뀐 구간 경로만 무효화합니다. */ +/** + * 만든 일차 목록을 한 번에 씁니다. 순서가 바뀐 일차는 이웃이 바뀐 구간 경로만 무효화합니다. + * `startedAt`(일정 전체 조회를 보낸 시각) 뒤에 바뀐 일차는 그쪽이 더 새 상태라 덮지 않고, + * 그 일차를 받는 중이면 이 조회보다 먼저 보낸 요청일 수 있어 다시 받게 합니다. + */ export function writeSchedulePlacesFromHydrate( queryClient: QueryClient, roomId: string, placesBySchedule: ReadonlyMap, + startedAt: number, ): void { const rid = roomId.trim(); if (!rid.length) return; for (const [sid, places] of placesBySchedule) { + const key = scheduleItemsQueryKey(rid, sid); + const state = queryClient.getQueryState(key); + if (state && state.dataUpdatedAt > startedAt) continue; + const before = readOrderedItemIdsFromScheduleItemsCache(queryClient, rid, sid); - queryClient.setQueryData(scheduleItemsQueryKey(rid, sid), places); + queryClient.setQueryData(key, places); + if (state?.fetchStatus === "fetching") { + void queryClient.refetchQueries({ queryKey: key, exact: true }); + } const changed = segmentSourcesWithChangedNext( before, @@ -251,6 +263,7 @@ export async function hydrateScheduleItemsFromSchedulesWithItems( queryClient: QueryClient, roomId: string, schedules: readonly RoomScheduleWithItems[], + startedAt: number, ): Promise { const rid = roomId.trim(); if (!rid.length) return; @@ -259,6 +272,7 @@ export async function hydrateScheduleItemsFromSchedulesWithItems( queryClient, rid, await buildSchedulePlacesFromSchedulesWithItems(queryClient, schedules), + startedAt, ); } @@ -419,9 +433,10 @@ export async function hydrateRoomSchedulesWithItems( const rid = roomId.trim(); if (!rid.length) return []; + const startedAt = Date.now(); const schedules = await getRoomSchedules(rid, { includeItems: true }); const sorted = sortRoomSchedules(schedules); - await hydrateScheduleItemsFromSchedulesWithItems(queryClient, rid, sorted); + await hydrateScheduleItemsFromSchedulesWithItems(queryClient, rid, sorted, startedAt); return sorted; } diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index 264a5faf..d69860a0 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -1,4 +1,4 @@ -import type { QueryClient } from "@tanstack/react-query"; +import { queryOptions, type QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { awaitRoomSchedulesHydrated } from "@/lib/rooms"; @@ -13,7 +13,6 @@ import { import { fetchPlacePreview } from "@/lib/places/place-queries"; import { buildPlanPlacesFromItemsWithPreviewEnrichment, - planPlacesNeedPreviewEnrich, resolveScheduleItemsFromCacheOrHydrate, } from "@/lib/plan/schedule-bulk-hydration"; import { getQueryClient } from "@/lib/query-client"; @@ -249,27 +248,32 @@ export async function applyScheduleItemDeletedOnClient( ); } -/** STOMP·뮤테이션 동기화: 해당 일차만 `GET …/items`로 `schedule-items` 캐시를 갱신합니다. */ -export async function refetchSchedulePlanPlacesIntoCache( +/** + * STOMP·뮤테이션 동기화: 그 일차 장소 목록을 서버에서 다시 받게 합니다. 보는 화면이 있으면 바로 받고, + * 없으면 다시 받을 목록으로 표시해 두었다가 그 화면에 들어갈 때 받습니다. + * 같은 일차를 받는 중이면 그 조회는 이 변경 전에 보낸 요청이라 취소하고 새로 받습니다(늦게 온 옛 응답은 버려짐). + * 다시 받지 못하면 throw합니다 — 호출부는 목록이 바뀐 뒤에만 경로를 무효화합니다. + */ +export async function invalidateSchedulePlanPlaces( queryClient: QueryClient, roomId: string, scheduleId: number, -): Promise { - const rid = roomId.trim(); - if (!rid.length) return []; - const items = await getScheduleItems(rid, scheduleId); - await mergeOrRefetchSchedulePlanPlacesFromItems( - queryClient, - rid, - scheduleId, - items, +): Promise { + const queryKey = scheduleItemsQueryKey(roomId.trim(), scheduleId); + // 목록이 아직 없으면 TanStack은 진행 중인 첫 조회를 취소하지 않고 합류만 한다 + const state = queryClient.getQueryState(queryKey); + if (state?.data === undefined && state?.fetchStatus === "fetching") { + await queryClient.cancelQueries({ queryKey, exact: true }); + } + await queryClient.invalidateQueries( + { queryKey, exact: true }, + { throwOnError: true }, ); - return readSchedulePlanPlacesFromCache(queryClient, rid, scheduleId); } /** - * STOMP 리오더·`reorderScheduleItem` 응답 등 공통 경로: `RoomScheduleItem[]`로 - * 캐시된 PlanPlace를 갱신하고, 머지 불가 시에만 전체 enrich를 다시 가져옵니다. + * `reorderScheduleItem` 응답(`RoomScheduleItem[]`)으로 캐시된 PlanPlace를 갱신하고, + * 머지 불가 시에만 전체 enrich를 다시 가져옵니다. */ export async function mergeOrRefetchSchedulePlanPlacesFromItems( queryClient: QueryClient, @@ -317,13 +321,7 @@ export async function syncAfterCrossScheduleItemMove( : null; for (const sid of scheduleIds) { - const items = await getScheduleItems(rid, sid); - await mergeOrRefetchSchedulePlanPlacesFromItems( - queryClient, - rid, - sid, - items, - ); + await invalidateSchedulePlanPlaces(queryClient, rid, sid); } if (affectedRouteItemIds !== undefined) { @@ -389,45 +387,46 @@ export async function syncAfterCrossScheduleItemMove( usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); } -/** 캐시에 preview 에러 title이 있으면 enrich 후 `schedule-items`를 갱신합니다. */ -export async function ensureSchedulePlanPlacesEnriched( +/** + * 그 일차 항목을 서버에서 받아 캐시된 장소의 이름·주소와 합칩니다. 캐시는 TanStack이 씁니다. + * 같은 일차 조회가 겹치면 TanStack이 옛 조회를 취소하고 그 응답을 버립니다. + */ +async function fetchSchedulePlanPlaces( queryClient: QueryClient, roomId: string, scheduleId: number, + signal: AbortSignal, ): Promise { - const rid = roomId.trim(); - if (!rid.length) return []; - const key = scheduleItemsQueryKey(rid, scheduleId); - const cached = queryClient.getQueryData(key); - if (cached != null && !planPlacesNeedPreviewEnrich(cached)) { - return cached; - } - - const items = await resolveScheduleItemsFromCacheOrHydrate( - queryClient, - rid, - scheduleId, + const items = await getScheduleItems(roomId, scheduleId, signal); + const prev = queryClient.getQueryData( + scheduleItemsQueryKey(roomId, scheduleId), ); - const places = await buildPlanPlacesFromItemsWithPreviewEnrichment( - queryClient, - items, + return ( + (await mergeScheduleItemsIntoPlanPlaces(prev, items)) ?? + buildPlanPlacesFromItemsWithPreviewEnrichment(queryClient, items) ); - queryClient.setQueryData(key, places); - return places; } -/** hydrate가 채운 `schedule-items` 캐시를 읽습니다 (`[]` 포함). */ -export function readSchedulePlanPlacesFromCache( +/** + * `schedule-items` 조회 옵션. 일차 장소 목록을 읽는 곳(일정 목록·지도)은 모두 이 옵션을 쓴다 — + * 무효화로 다시 받을 때 TanStack은 마지막에 옵션을 넘긴 구독자의 queryFn을 쓰므로, 한 곳이라도 + * 캐시만 읽는 queryFn을 넘기면 서버를 부르지 않게 된다. + */ +export function schedulePlanPlacesQueryOptions( queryClient: QueryClient, roomId: string, - scheduleId: number, -): PlanPlace[] { + scheduleId: number | null, +) { const rid = roomId.trim(); - if (!rid.length) return []; - return ( - queryClient.getQueryData(scheduleItemsQueryKey(rid, scheduleId)) ?? - [] - ); + return queryOptions({ + queryKey: scheduleItemsQueryKey(rid || null, scheduleId), + queryFn: async ({ signal }): Promise => + rid.length > 0 && scheduleId !== null + ? fetchSchedulePlanPlaces(queryClient, rid, scheduleId, signal) + : [], + // 본인 저장 응답과 STOMP 무효화로만 갱신한다. 화면 밖에서 무효화된 목록은 다시 마운트될 때 받는다 + staleTime: Infinity, + }); } /** `schedule-items` 캐시가 있으면 반환, 없으면 room hydrate 후 재시도·최후에만 enrich. */ diff --git a/src/lib/rooms.ts b/src/lib/rooms.ts index 02c4ea60..fbe56da0 100644 --- a/src/lib/rooms.ts +++ b/src/lib/rooms.ts @@ -112,6 +112,7 @@ export async function hydrateRoomSchedulesFromServer( const rid = roomId.trim(); if (!rid.length) return []; + const startedAt = Date.now(); const schedules = await getRoomSchedules(rid, { includeItems: true, signal }); const sorted = sortRoomSchedules(schedules); const placesBySchedule = await buildSchedulePlacesFromSchedulesWithItems( @@ -140,7 +141,7 @@ export async function hydrateRoomSchedulesFromServer( } } - writeSchedulePlacesFromHydrate(queryClient, rid, placesBySchedule); + writeSchedulePlacesFromHydrate(queryClient, rid, placesBySchedule, startedAt); for (const s of sorted) { const key = scheduleItemsQueryKey(rid, s.scheduleId); diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index b359bd37..e6d56b02 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -2,11 +2,9 @@ import { invalidateExpenses } from "@/lib/expenses/expense-queries"; import type { QueryClient } from "@tanstack/react-query"; import { - mergeOrRefetchSchedulePlanPlacesFromItems, - refetchSchedulePlanPlacesIntoCache, + invalidateSchedulePlanPlaces, syncAfterCrossScheduleItemMove, } from "@/lib/plan/scheduleItemPlaces"; -import { getScheduleItems } from "@/lib/api/rooms/schedule-items"; import { invalidateScheduleItemRouteForSources, invalidateScheduleItemRouteForWholeSchedule, @@ -18,14 +16,6 @@ import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; import { readSessionUserId } from "@/lib/session-user-cache"; import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; -async function refetchScheduleItemsPlaces( - queryClient: QueryClient, - roomId: string, - scheduleId: number, -): Promise { - await refetchSchedulePlanPlacesIntoCache(queryClient, roomId, scheduleId); -} - function bumpMapForRouteSources( roomId: string, scheduleId: number, @@ -158,13 +148,7 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( syncSchedulePlacesRouteInvalidationPending.delete(mapKey); void (async () => { try { - const rows = await getScheduleItems(rid, sid); - await mergeOrRefetchSchedulePlanPlacesFromItems( - queryClient, - rid, - sid, - rows, - ); + await invalidateSchedulePlanPlaces(queryClient, rid, sid); if (routeBucket?.scope === "whole") { await invalidateScheduleItemRouteForWholeSchedule( queryClient, @@ -195,7 +179,7 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( /** * Plan 화면 등이 쓰는 캐시만, 스키마의 `type`별로 갱신합니다. * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화 (원격 탭; 액터는 mutation `onSuccess`) - * — `schedule-items`: 일차별 장소 목록 + * — `schedule-items`: 그 일차 장소 목록을 무효화해 다시 받는다(보는 화면이 없으면 그 화면에 들어갈 때) * — `schedule-item-route`: `itemId`·인접 구간의 `segmentSourceItemId`만 무효화(폴백 시 일정 전체) * — `SCHEDULE_ITEM_UPDATED`: 일정 목록만 동기화할 때 사용(본인 이벤트 포함); 경로(`schedule-item-route`)는 무효화하지 않음 * — `SCHEDULE_ITEM_TRAVEL_MODE_UPDATED`: 공유 이동수단 변경 — 일정 목록만 동기화(경로 캐시는 수단별 키로 유지) @@ -279,13 +263,7 @@ export async function dispatchRoomScheduleEvent( removeRouteQueriesForDeletedItemSource(queryClient, rid, sid, itemId); - const items = await getScheduleItems(rid, sid); - await mergeOrRefetchSchedulePlanPlacesFromItems( - queryClient, - rid, - sid, - items, - ); + await invalidateSchedulePlanPlaces(queryClient, rid, sid); const sources = event.affectedRouteItemIds; if (sources === null) { @@ -313,7 +291,7 @@ export async function dispatchRoomScheduleEvent( return; } - await refetchScheduleItemsPlaces(queryClient, rid, sid); + await invalidateSchedulePlanPlaces(queryClient, rid, sid); const sources = event.affectedRouteItemIds; if (sources === null) { @@ -341,13 +319,7 @@ export async function dispatchRoomScheduleEvent( return; } - const items = await getScheduleItems(rid, sid); - await mergeOrRefetchSchedulePlanPlacesFromItems( - queryClient, - rid, - sid, - items, - ); + await invalidateSchedulePlanPlaces(queryClient, rid, sid); const sources = event.affectedRouteItemIds; enqueueDebouncedInvalidateScheduleRoutes( queryClient, From 1704611866271f2c9a883f49d04cb8166c8e278c Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 30 Oct 2026 20:16:12 +0900 Subject: [PATCH 02/24] =?UTF-8?q?fix(schedule):=20=EB=B3=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EB=B3=80=EA=B2=BD=EC=9D=80=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20=EB=B0=98=EC=98=81=ED=95=9C=20=EB=92=A4=20?= =?UTF-8?q?=EA=B7=B8=20=EC=9D=BC=EC=B0=A8=EB=A5=BC=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장소 추가·시간·메모·이동수단·삭제·순서 변경은 응답을 setQueryData로 반영하고 onSettled에서 그 일차를 무효화 - 장소 추가는 mutationFn에서 요청만 하고 캐시 반영은 onSuccess로 옮김 - 본인 시간·메모 변경의 STOMP 반향(UPDATED)은 진행 중인 조회에 합류하고, 다른 사람 변경이 섞이면 새로 받음 - 손으로 넣었던 분기 삭제: 쓸 때마다 일정 전체 재조회, 못 찾으면 무효화, 실패 시 직접 GET Co-Authored-By: Claude Opus 5.5 --- .../itinerary/AddFromBookmarkModal.tsx | 2 +- .../mobile/useMobileAddPlaceSearch.tsx | 2 +- src/hooks/useRooms.ts | 83 ++++++---- src/lib/plan/scheduleItemPlaces.ts | 155 ++++++------------ src/lib/stomp/schedules-dispatch.ts | 24 ++- 5 files changed, 121 insertions(+), 145 deletions(-) diff --git a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx index bfef1d87..be7154c5 100644 --- a/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx +++ b/src/app/(main)/plan/_components/itinerary/AddFromBookmarkModal.tsx @@ -163,7 +163,7 @@ export function AddFromBookmarkModal({ setAddingGooglePlaceId(gid); const index = insertIndex ?? places.length; try { - const created = await createItem({ + const { createdItem: created } = await createItem({ roomId, scheduleId, googlePlaceId: gid, diff --git a/src/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch.tsx b/src/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch.tsx index a6873f03..e4aad83e 100644 --- a/src/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch.tsx +++ b/src/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch.tsx @@ -73,7 +73,7 @@ export function useMobileAddPlaceSearch({ queryClient.getQueryData(scheduleItemsQueryKey(roomId.trim() || null, target.scheduleId)) ?? []; const insertIndex = resolveInsertIndex(latest, target.anchor); try { - const created = await createItem({ + const { createdItem: created } = await createItem({ roomId, scheduleId: target.scheduleId, googlePlaceId, diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index a7b4c13d..ef846de6 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -58,11 +58,11 @@ import { type RoomListResponse, } from "@/lib/api/rooms"; import { + applyCreatedScheduleItemToPlanPlaces, applyRoomScheduleItemToPlanPlaces, applyScheduleItemDeletedOnClient, createScheduleItemAtPlanIndex, schedulePlanPlacesQueryOptions, - setSchedulePlanPlaces, syncAfterCrossScheduleItemMove, syncPlanPlacesAfterReorderSuccess, } from "@/lib/plan/scheduleItemPlaces"; @@ -333,10 +333,25 @@ export function useSchedulePlanPlaces( }); } +/** + * 본인이 일정 장소를 바꾼 뒤(성공·실패 모두) 그 일차를 다시 받는다. 여러 사람이 함께 고치는 목록이라 + * 응답을 쓰는 사이 끝난 옛 조회나 다른 사람의 변경을 서버 기준으로 맞춘다. 진행 중인 옛 조회는 TanStack이 취소한다. + */ +function invalidateDayPlacesAfterOwnChange( + queryClient: ReturnType, + roomId: string, + scheduleId: number, +) { + return queryClient.invalidateQueries({ + queryKey: scheduleItemsQueryKey(roomId.trim(), scheduleId), + exact: true, + }); +} + export function useCreateScheduleItem() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (vars: { + mutationFn: (vars: { roomId: string; scheduleId: number; googlePlaceId: string; @@ -344,16 +359,23 @@ export function useCreateScheduleItem() { placesSnapshot?: PlanPlace[]; }) => { const places = vars.placesSnapshot ?? []; - const insertIndex = vars.insertIndex ?? places.length; - - return createScheduleItemAtPlanIndex(queryClient, { + return createScheduleItemAtPlanIndex({ roomId: vars.roomId, scheduleId: vars.scheduleId, places, - insertIndex, + insertIndex: vars.insertIndex ?? places.length, googlePlaceId: vars.googlePlaceId, }); }, + onSuccess: (response, { roomId, scheduleId }) => + applyCreatedScheduleItemToPlanPlaces( + queryClient, + roomId, + scheduleId, + response, + ), + onSettled: (_response, _error, { roomId, scheduleId }) => + invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId), }); } @@ -373,28 +395,22 @@ export function useUpdateScheduleItem() { vars.body, ), onSuccess: (updated, { roomId, scheduleId, body }) => { - const rid = roomId.trim(); - if (!rid.length) return; - const key = scheduleItemsQueryKey(rid, scheduleId); - const prev = queryClient.getQueryData(key); - const merged = applyRoomScheduleItemToPlanPlaces(prev, updated, { - memoTouched: Object.prototype.hasOwnProperty.call(body, "memo"), - }); - if (merged) { - setSchedulePlanPlaces(queryClient, rid, scheduleId, merged); - } else { - void queryClient.invalidateQueries({ - queryKey: key, - refetchType: "active", - }); - } + queryClient.setQueryData( + scheduleItemsQueryKey(roomId.trim(), scheduleId), + (old) => + applyRoomScheduleItemToPlanPlaces(old, updated, { + memoTouched: Object.prototype.hasOwnProperty.call(body, "memo"), + }) ?? old, + ); }, + onSettled: (_updated, _error, { roomId, scheduleId }) => + invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId), }); } /** * 구간 공유 이동수단 변경 — 서버 DB 저장 후 `SCHEDULE_ITEM_TRAVEL_MODE_UPDATED`가 - * 브로드캐스트되며, 액터는 응답으로 `schedule-items` 캐시만 갱신합니다. + * 브로드캐스트되며, 액터는 응답으로 `schedule-items` 캐시를 갱신합니다. * 수단별 경로 캐시는 모드 키로 분리돼 있어 무효화가 필요 없습니다. */ export function useUpdateScheduleItemTravelMode() { @@ -413,20 +429,13 @@ export function useUpdateScheduleItemTravelMode() { { travelMode: vars.travelMode }, ), onSuccess: (updated, { roomId, scheduleId }) => { - const rid = roomId.trim(); - if (!rid.length) return; - const key = scheduleItemsQueryKey(rid, scheduleId); - const prev = queryClient.getQueryData(key); - const merged = applyRoomScheduleItemToPlanPlaces(prev, updated); - if (merged) { - setSchedulePlanPlaces(queryClient, rid, scheduleId, merged); - } else { - void queryClient.invalidateQueries({ - queryKey: key, - refetchType: "active", - }); - } + queryClient.setQueryData( + scheduleItemsQueryKey(roomId.trim(), scheduleId), + (old) => applyRoomScheduleItemToPlanPlaces(old, updated) ?? old, + ); }, + onSettled: (_updated, _error, { roomId, scheduleId }) => + invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId), }); } @@ -447,6 +456,8 @@ export function useDeleteScheduleItem() { itemId, ); }, + onSettled: (_data, _error, { roomId, scheduleId }) => + invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId), }); } @@ -486,6 +497,8 @@ export function useReorderScheduleItem() { setIsChainedTimeSyncPending(false); } }, + onSettled: (_items, _error, { roomId, scheduleId }) => + invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId), }); return { diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index d69860a0..3aa7b7cf 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -26,7 +26,7 @@ import { readOrderedItemIdsFromScheduleItemsCache, removeRouteQueriesForDeletedItemSource, } from "@/lib/plan/scheduleStompRouteScope"; -import { roomSchedulesQueryKey, scheduleItemsQueryKey } from "@/lib/query-keys"; +import { scheduleItemsQueryKey } from "@/lib/query-keys"; import { addMinutesToHm, computeDurationMinutesFromRange, @@ -175,41 +175,17 @@ async function buildPlanPlacesFromScheduleItems( return Promise.all(sorted.map((item) => planPlaceFromScheduleItem(item))); } -/** - * 일차 장소 목록을 씁니다. 그사이 일정 전체 재조회가 진행 중이면 그 응답은 이 변경 전 스냅샷이라 - * 끝나는 대로 방금 쓴 일차를 되돌립니다. 진행 중인 조회를 취소하고 다시 받게 합니다. - */ -export function setSchedulePlanPlaces( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - places: PlanPlace[], -): void { - const rid = roomId.trim(); - queryClient.setQueryData(scheduleItemsQueryKey(rid, scheduleId), places); - - const roomSchedulesKey = roomSchedulesQueryKey(rid); - if (queryClient.isFetching({ queryKey: roomSchedulesKey, exact: true }) > 0) { - void queryClient.refetchQueries({ queryKey: roomSchedulesKey, exact: true }); - } -} - /** DELETE 직후 `schedule-items`에서 항목만 제거(Preview·Photo 재조회 없음). */ export function removeScheduleItemFromPlanPlacesCache( queryClient: QueryClient, roomId: string, scheduleId: number, deletedItemId: number, -): boolean { - const rid = roomId.trim(); - if (!rid.length) return false; - const key = scheduleItemsQueryKey(rid, scheduleId); - const prev = queryClient.getQueryData(key); - if (!prev?.length) return false; - const next = prev.filter((p) => p.itemId !== deletedItemId); - if (next.length === prev.length) return false; - setSchedulePlanPlaces(queryClient, rid, scheduleId, next); - return true; +): void { + queryClient.setQueryData( + scheduleItemsQueryKey(roomId.trim(), scheduleId), + (old) => old?.filter((p) => p.itemId !== deletedItemId), + ); } /** 본인 DELETE mutation 직후 — 캐시 패치·삭제 구간 route·쿼리 정리 */ @@ -252,22 +228,24 @@ export async function applyScheduleItemDeletedOnClient( * STOMP·뮤테이션 동기화: 그 일차 장소 목록을 서버에서 다시 받게 합니다. 보는 화면이 있으면 바로 받고, * 없으면 다시 받을 목록으로 표시해 두었다가 그 화면에 들어갈 때 받습니다. * 같은 일차를 받는 중이면 그 조회는 이 변경 전에 보낸 요청이라 취소하고 새로 받습니다(늦게 온 옛 응답은 버려짐). + * 본인 저장의 반향처럼 받는 중인 조회가 이미 그 변경 뒤에 보낸 것이면 `joinInFlight`로 합류해 요청을 아낍니다. * 다시 받지 못하면 throw합니다 — 호출부는 목록이 바뀐 뒤에만 경로를 무효화합니다. */ export async function invalidateSchedulePlanPlaces( queryClient: QueryClient, roomId: string, scheduleId: number, + { joinInFlight = false }: { joinInFlight?: boolean } = {}, ): Promise { const queryKey = scheduleItemsQueryKey(roomId.trim(), scheduleId); // 목록이 아직 없으면 TanStack은 진행 중인 첫 조회를 취소하지 않고 합류만 한다 const state = queryClient.getQueryState(queryKey); - if (state?.data === undefined && state?.fetchStatus === "fetching") { + if (!joinInFlight && state?.data === undefined && state?.fetchStatus === "fetching") { await queryClient.cancelQueries({ queryKey, exact: true }); } await queryClient.invalidateQueries( { queryKey, exact: true }, - { throwOnError: true }, + { throwOnError: true, cancelRefetch: !joinInFlight }, ); } @@ -281,17 +259,14 @@ export async function mergeOrRefetchSchedulePlanPlacesFromItems( scheduleId: number, items: RoomScheduleItem[], ): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - const key = scheduleItemsQueryKey(rid, scheduleId); - const prev = queryClient.getQueryData(key); - const merged = await mergeScheduleItemsIntoPlanPlaces(prev, items); - if (merged) { - setSchedulePlanPlaces(queryClient, rid, scheduleId, merged); - return; - } - const places = await buildPlanPlacesFromScheduleItems(items); - setSchedulePlanPlaces(queryClient, rid, scheduleId, places); + const key = scheduleItemsQueryKey(roomId.trim(), scheduleId); + queryClient.setQueryData( + key, + (await mergeScheduleItemsIntoPlanPlaces( + queryClient.getQueryData(key), + items, + )) ?? (await buildPlanPlacesFromScheduleItems(items)), + ); } /** cross-day item 이동 후 source/target 일차 items·route 캐시 동기화 */ @@ -553,18 +528,8 @@ export async function syncPlanPlacesAfterReorderSuccess( byItemId.set(updated.itemId, updated); } } catch { + // 순서 변경은 저장됐다 — 실제 시각은 mutation이 끝날 때 그 일차를 다시 받아 맞춘다 toast.error("순서에 맞게 시작 시간을 바꾸지 못했어요."); - try { - const fresh = await getScheduleItems(rid, scheduleId); - await mergeOrRefetchSchedulePlanPlacesFromItems( - queryClient, - roomId, - scheduleId, - fresh, - ); - } catch { - // - } return; } @@ -694,77 +659,55 @@ export function mergeCreatedScheduleItemDelta( return next; } -/** 서버가 지정 위치 삽입과 이동수단 추천을 함께 처리하고 델타를 반환합니다. */ -export async function createScheduleItemAtPlanIndex( - queryClient: QueryClient, - args: { - roomId: string; - scheduleId: number; - places: PlanPlace[]; - insertIndex: number; - googlePlaceId: string; - }, -): Promise { +/** 놓은 자리(`insertIndex`)를 서버 `orderIndex`로 바꿔 장소를 추가합니다. 서버가 이동수단 추천까지 처리하고 델타를 돌려줍니다. */ +export function createScheduleItemAtPlanIndex(args: { + roomId: string; + scheduleId: number; + places: PlanPlace[]; + insertIndex: number; + googlePlaceId: string; +}): Promise { const rid = args.roomId.trim(); if (!rid.length) { throw new Error("createScheduleItemAtPlanIndex: empty roomId"); } - - const listLengthAfterCreate = args.places.length + 1; - const targetIndex = resolveInsertTargetIndex( - args.insertIndex, - listLengthAfterCreate, - ); - const response = await createScheduleItem(rid, args.scheduleId, { + return createScheduleItem(rid, args.scheduleId, { googlePlaceId: args.googlePlaceId, - orderIndex: targetIndex, + orderIndex: resolveInsertTargetIndex(args.insertIndex, args.places.length + 1), }); +} - const key = scheduleItemsQueryKey(rid, args.scheduleId); +/** + * 장소 추가 응답(추가된 항목·순서가 밀린 항목)을 일차 목록에 합치고, 응답이 알려 준 구간 경로만 무효화합니다. + * 추가는 이미 저장됐으므로 반영이 실패해도 던지지 않고 그 일차 경로를 모두 무효화합니다. + */ +export async function applyCreatedScheduleItemToPlanPlaces( + queryClient: QueryClient, + roomId: string, + scheduleId: number, + response: CreateScheduleItemResponse, +): Promise { + const rid = roomId.trim(); try { - const prev = queryClient.getQueryData(key); const createdPlace = await planPlaceFromScheduleItem(response.createdItem); - const merged = mergeCreatedScheduleItemDelta(prev, createdPlace, response); - if (merged) { - setSchedulePlanPlaces(queryClient, rid, args.scheduleId, merged); - } else { - const fresh = await getScheduleItems(rid, args.scheduleId); - await mergeOrRefetchSchedulePlanPlacesFromItems( - queryClient, - rid, - args.scheduleId, - fresh, - ); - } - + // 미리보기를 기다리는 사이 다른 사람의 변경을 받았을 수 있어 그 뒤의 목록에 합친다 + queryClient.setQueryData( + scheduleItemsQueryKey(rid, scheduleId), + (old) => mergeCreatedScheduleItemDelta(old, createdPlace, response) ?? old, + ); await invalidateScheduleItemRouteForSources( queryClient, rid, - args.scheduleId, - response.affectedRouteItemIds, - ); - bumpPlanMapRouteSegments( - rid, - args.scheduleId, + scheduleId, response.affectedRouteItemIds, ); + bumpPlanMapRouteSegments(rid, scheduleId, response.affectedRouteItemIds); } catch { await Promise.allSettled([ - queryClient.invalidateQueries({ - queryKey: key, - exact: true, - refetchType: "active", - }), - invalidateScheduleItemRouteForWholeSchedule( - queryClient, - rid, - args.scheduleId, - ), + invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, scheduleId), ]); usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); } - - return response.createdItem; } /** 드래그를 `toIndex` 자리에 놓았을 때 PATCH에 넣을 `newOrderIndex`(0-based) */ diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index e6d56b02..6f1bf699 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -118,15 +118,19 @@ const syncSchedulePlacesRouteInvalidationPending = new Map< string, DebouncedRouteInvBucket >(); +/** 모으는 동안 다른 사람의 변경이 하나라도 들어온 일차 */ +const syncSchedulePlacesRemoteChangePending = new Set(); const DEBOUNCE_ITEMS_GET_MS = 90; function enqueueDebouncedHydratePlacesFromScheduleItemsApi( queryClient: QueryClient, rid: string, sid: number, + ownEcho: boolean, affectedRouteItemIds?: number[] | null, ): void { const mapKey = `${rid}:${sid}`; + if (!ownEcho) syncSchedulePlacesRemoteChangePending.add(mapKey); if (affectedRouteItemIds !== undefined) { syncSchedulePlacesRouteInvalidationPending.set( mapKey, @@ -146,9 +150,14 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( const routeBucket = syncSchedulePlacesRouteInvalidationPending.get(mapKey); syncSchedulePlacesRouteInvalidationPending.delete(mapKey); + // 모은 이벤트가 모두 본인 반향이면, 본인 저장이 끝나며 보낸 조회가 이미 그 변경을 담고 있어 합류한다 + const joinInFlight = !syncSchedulePlacesRemoteChangePending.has(mapKey); + syncSchedulePlacesRemoteChangePending.delete(mapKey); void (async () => { try { - await invalidateSchedulePlanPlaces(queryClient, rid, sid); + await invalidateSchedulePlanPlaces(queryClient, rid, sid, { + joinInFlight, + }); if (routeBucket?.scope === "whole") { await invalidateScheduleItemRouteForWholeSchedule( queryClient, @@ -336,8 +345,18 @@ export async function dispatchRoomScheduleEvent( const itemId = event.itemId; if (sid == null || itemId == null) return; + const me = readSessionUserId(queryClient); + const actorIsMe = + typeof me === "number" && + Number.isFinite(me) && + event.actorUserId === me; // 같은 사용자의 다른 탭도 이 이벤트를 받으므로 HTTP 응답과 무관하게 목록을 동기화합니다. - enqueueDebouncedHydratePlacesFromScheduleItemsApi(queryClient, rid, sid); + enqueueDebouncedHydratePlacesFromScheduleItemsApi( + queryClient, + rid, + sid, + actorIsMe, + ); return; } @@ -361,6 +380,7 @@ export async function dispatchRoomScheduleEvent( queryClient, rid, sid, + false, event.affectedRouteItemIds, ); return; From 0fd9f0656411eca5ee054e3bea020ae1d8a62a19 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 30 Oct 2026 20:16:21 +0900 Subject: [PATCH 03/24] =?UTF-8?q?refactor(schedule):=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=A4=91=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=ED=95=9C=20=EC=83=81=ED=83=9C=20=EB=8C=80=EC=8B=A0=20mutation?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이동수단 선택: 서버 값을 useState로 복사·되돌리던 것을, 저장 중에는 고른 수단(variables), 끝나면 서버 값을 보여 주도록 변경 - 모바일 편집 목록의 순서 변경·일차 이동: pending useState 대신 variables·isPending으로 새 자리를 그림 - 순서 변경의 연쇄 PATCH 대기 useState 삭제. onSuccess가 끝날 때까지 isPending이 이어짐 Co-Authored-By: Claude Opus 5.5 --- .../_components/mobile/mobilePlanDrag.tsx | 59 ++++++++++++------- .../travel-time/PlanTravelTime.tsx | 36 ++++------- src/hooks/usePlanItineraryReorder.ts | 6 +- src/hooks/useRooms.ts | 42 ++++--------- 4 files changed, 64 insertions(+), 79 deletions(-) diff --git a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx index 5d0b3462..7961ef51 100644 --- a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx +++ b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx @@ -52,9 +52,9 @@ type ActiveDrag = { targetIndex: number; }; -/** 서버 반영 전 먼저 보여줄 이동 */ +/** 저장 중인 순서 변경·일차 이동 — 응답 전에 새 자리를 먼저 보여 준다(mutation 요청 값에서 계산) */ export type PendingPlanMove = - | { kind: "reorder"; scheduleId: number; order: string[] } + | { kind: "reorder"; scheduleId: number; itemId: number; newOrderIndex: number } | { kind: "move"; place: PlanPlace; fromScheduleId: number; toScheduleId: number; toIndex: number }; type MobilePlanDragValue = { @@ -93,9 +93,11 @@ export function applyPendingPlanMove( if (!pending) return places; if (pending.kind === "reorder") { if (pending.scheduleId !== scheduleId) return places; - const byId = new Map(places.map((p) => [p.id, p])); - const ordered = pending.order.map((id) => byId.get(id)).filter((p): p is PlanPlace => !!p); - return ordered.length === places.length ? ordered : 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) { @@ -162,14 +164,38 @@ export function useMobilePlanDragController({ const placesBySchedule = useRef(new Map()); const activeRef = useRef(null); const [active, setActive] = useState(null); - const [pending, setPending] = useState(null); const rafRef = useRef(null); - const { mutateAsync: reorderMutate, isReorderSettling } = useReorderScheduleItem(); - const { mutateAsync: moveMutate, isPending: isMovePending } = useMoveScheduleItemToSchedule(); + const { + mutateAsync: reorderMutate, + isPending: isReorderPending, + variables: reorderVariables, + } = useReorderScheduleItem(); + const { + mutateAsync: moveMutate, + isPending: isMovePending, + variables: moveVariables, + } = useMoveScheduleItemToSchedule(); const scheduleResyncPending = useScheduleResyncPending(roomId); - const dragDisabled = - isReorderSettling || isMovePending || pending !== null || scheduleResyncPending; + const dragDisabled = isReorderPending || isMovePending || scheduleResyncPending; + // 결과가 편집 목록에만 보이므로 캐시를 고치지 않고, 저장하는 동안 요청 값으로 새 자리를 그린다 + const pending: PendingPlanMove | null = + isReorderPending && reorderVariables + ? { + kind: "reorder", + scheduleId: reorderVariables.scheduleId, + itemId: reorderVariables.itemId, + newOrderIndex: reorderVariables.body.newOrderIndex, + } + : isMovePending && moveVariables?.place + ? { + kind: "move", + place: moveVariables.place, + fromScheduleId: moveVariables.sourceScheduleId, + toScheduleId: moveVariables.targetScheduleId, + toIndex: moveVariables.targetOrderIndex, + } + : null; const updateActive = useCallback((next: ActiveDrag | null) => { activeRef.current = next; @@ -250,10 +276,6 @@ export function useMobilePlanDragController({ if (targetScheduleId === sourceScheduleId) { if (targetIndex === sourceIndex) return; - const order = sourcePlaces.map((p) => p.id); - const [moved] = order.splice(sourceIndex, 1); - order.splice(targetIndex, 0, moved!); - setPending({ kind: "reorder", scheduleId: sourceScheduleId, order }); try { await reorderMutate({ roomId, @@ -268,29 +290,24 @@ export function useMobilePlanDragController({ }); } catch { toast.error("순서를 바꾸지 못했어요."); - } finally { - setPending(null); } return; } const targetLength = placesBySchedule.current.get(targetScheduleId)?.length ?? 0; - const toIndex = clampCrossDayTargetOrderIndex(targetIndex, targetLength); - setPending({ kind: "move", place, fromScheduleId: sourceScheduleId, toScheduleId: targetScheduleId, toIndex }); try { await moveMutate({ roomId, sourceScheduleId, itemId, targetScheduleId, - targetOrderIndex: toIndex, + targetOrderIndex: clampCrossDayTargetOrderIndex(targetIndex, targetLength), + place, }); } catch (err) { toast.error( err instanceof Error && err.message.trim() ? err.message : "다른 일차로 옮기지 못했어요.", ); - } finally { - setPending(null); } }, [moveMutate, reorderMutate, roomId, scheduleResyncPending, updateActive]); diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx index 62a9f71d..120a7711 100644 --- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx +++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx @@ -78,18 +78,16 @@ export function PlanTravelTime({ const serverMode = canonicalScheduleTravelMode(travelMode) ?? SCHEDULE_TRAVEL_MODE_DEFAULT; - const [selectedMode, setSelectedMode] = - useState(serverMode); - - /** 원격 변경(STOMP → schedule-items 갱신)·본인 PATCH 성공·구간 변경을 선택 상태에 반영 */ - const modeSyncKey = `${rid}:${scheduleId}:${segmentSourceItemId}:${serverMode}`; - const [prevModeSyncKey, setPrevModeSyncKey] = useState(modeSyncKey); - if (prevModeSyncKey !== modeSyncKey) { - setPrevModeSyncKey(modeSyncKey); - setSelectedMode(serverMode); - } - - const { mutate: mutateTravelMode } = useUpdateScheduleItemTravelMode(); + const { + mutate: mutateTravelMode, + variables: pendingTravelMode, + isPending: isTravelModePending, + } = useUpdateScheduleItemTravelMode(); + // 저장하는 동안은 고른 수단을 먼저 보여 주고, 끝나면(실패 포함) 다시 받은 서버 값을 보여 준다 + const selectedMode = + isTravelModePending && pendingTravelMode?.itemId === segmentSourceItemId + ? pendingTravelMode.travelMode + : serverMode; const googleMapsDirectionsUrl = buildGoogleMapsDirectionsUrl({ originPlaceId: originGooglePlaceId ?? "", @@ -234,9 +232,6 @@ export function PlanTravelTime({ (mode: ScheduleTravelModeValue) => { setMenuOpen(false); if (mode === selectedMode) return; - /** 낙관적 반영 — 실패 시 선택 시점의 서버 저장값으로 복귀 */ - const revertTo = serverMode; - setSelectedMode(mode); mutateTravelMode( { roomId: rid, @@ -246,21 +241,12 @@ export function PlanTravelTime({ }, { onError: () => { - setSelectedMode(revertTo); toast.error("이동 수단을 변경하지 못했어요."); }, }, ); }, - [ - rid, - scheduleId, - segmentSourceItemId, - selectedMode, - serverMode, - mutateTravelMode, - setSelectedMode, - ], + [rid, scheduleId, segmentSourceItemId, selectedMode, mutateTravelMode], ); if (directionsHidden) { diff --git a/src/hooks/usePlanItineraryReorder.ts b/src/hooks/usePlanItineraryReorder.ts index c248a8a2..183de7aa 100644 --- a/src/hooks/usePlanItineraryReorder.ts +++ b/src/hooks/usePlanItineraryReorder.ts @@ -60,7 +60,7 @@ export function usePlanItineraryReorder({ places, interactionLocked = false, }: UsePlanItineraryReorderArgs) { - const { mutateAsync: reorderMutate, isReorderSettling } = + const { mutateAsync: reorderMutate, isPending: isReorderPending } = useReorderScheduleItem(); const { mutateAsync: moveMutate, isPending: isMovePending } = useMoveScheduleItemToSchedule(); @@ -82,7 +82,7 @@ export function usePlanItineraryReorder({ const externalCaptureRafRef = useRef(0); const mutationLocked = - interactionLocked || isReorderSettling || isMovePending; + interactionLocked || isReorderPending || isMovePending; const scheduleResyncPending = useScheduleResyncPending(roomId); const isDraggingActive = dragFromIndex !== null || incomingExternalDrag; @@ -429,7 +429,7 @@ export function usePlanItineraryReorder({ getRowProps, listContainerProps, isDraggingActive, - isReorderSettling: isReorderSettling || isMovePending, + isReorderSettling: isReorderPending || isMovePending, listReservePaddingBottom, }; } diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index ef846de6..f9561ac3 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -9,7 +9,6 @@ import { useQueryClient, } from "@tanstack/react-query"; import { usePathname } from "next/navigation"; -import { useState } from "react"; import { useStompContext } from "@/contexts/StompContext"; @@ -288,6 +287,8 @@ export function useMoveScheduleItemToSchedule() { itemId: number; targetScheduleId: number; targetOrderIndex: number; + /** 저장하는 동안 대상 일차에 먼저 보여 줄 카드(모바일 편집 목록) — 요청에는 쓰지 않는다 */ + place?: PlanPlace; }) => { if (sourceScheduleId === targetScheduleId) { throw new Error("같은 일차 안에서는 순서 변경만 가능해요."); @@ -461,11 +462,10 @@ export function useDeleteScheduleItem() { }); } +/** 순서 변경 — 응답 반영과 시작 시각 연쇄 PATCH를 `onSuccess`에서 기다리므로 `isPending`이 그 끝까지 이어진다 */ export function useReorderScheduleItem() { const queryClient = useQueryClient(); - const [isChainedTimeSyncPending, setIsChainedTimeSyncPending] = - useState(false); - const mutation = useMutation({ + return useMutation({ mutationFn: (vars: { roomId: string; scheduleId: number; @@ -478,35 +478,17 @@ export function useReorderScheduleItem() { vars.itemId, vars.body, ), - onMutate: () => { - setIsChainedTimeSyncPending(true); - }, - onError: () => { - setIsChainedTimeSyncPending(false); - }, - onSuccess: async (items, { roomId, scheduleId, itemId }) => { - try { - await syncPlanPlacesAfterReorderSuccess( - queryClient, - roomId, - scheduleId, - items, - itemId, - ); - } finally { - setIsChainedTimeSyncPending(false); - } - }, + onSuccess: (items, { roomId, scheduleId, itemId }) => + syncPlanPlacesAfterReorderSuccess( + queryClient, + roomId, + scheduleId, + items, + itemId, + ), onSettled: (_items, _error, { roomId, scheduleId }) => invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId), }); - - return { - ...mutation, - /** 리오더 요청 + 시작 시각 연쇄 PATCH까지(캐시가 일관될 때까지) */ - isReorderSettling: - mutation.isPending || isChainedTimeSyncPending, - }; } export function useUpdateRoom() { From 105a66715c4d9d136191e101655b2ec1363f1544 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 30 Oct 2026 20:16:21 +0900 Subject: [PATCH 04/24] =?UTF-8?q?test(schedule):=20=EC=9D=BC=EC=B0=A8=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=EC=99=80=20=EB=B3=B8=EC=9D=B8=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=92=A4=20=EC=9E=AC=EC=A1=B0=ED=9A=8C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 늦게 온 옛 조회 응답, 다시 받는 중 본인 삭제, 일정 전체 응답이 더 새 일차 목록을 덮는지 확인 - 바뀐 함수 모양(공용 조회 옵션, 장소 추가 응답, 모바일 드래그 표시)에 맞춰 기존 테스트 수정 Co-Authored-By: Claude Opus 5.5 --- .../_components/useMobileRouteView.test.tsx | 11 +- .../_components/mobile/mobilePlanDrag.test.ts | 7 +- src/hooks/useRooms.schedule-items.test.tsx | 94 +++++++ .../plan/scheduleItemPlaces.create.test.ts | 86 +++--- src/lib/plan/scheduleItemPlaces.test.ts | 43 --- src/lib/rooms.hydrate.test.ts | 22 ++ .../stomp/schedules-dispatch.order.test.ts | 266 ++++++++++++++++++ src/lib/stomp/schedules-dispatch.test.ts | 37 +-- 8 files changed, 456 insertions(+), 110 deletions(-) create mode 100644 src/hooks/useRooms.schedule-items.test.tsx create mode 100644 src/lib/stomp/schedules-dispatch.order.test.ts diff --git a/src/app/(main)/map/_components/useMobileRouteView.test.tsx b/src/app/(main)/map/_components/useMobileRouteView.test.tsx index 3db17c64..d722f444 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.test.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.test.tsx @@ -31,7 +31,16 @@ vi.mock("@/contexts/SelectedPlaceContext", () => ({ useSelectedPlace: () => ({ setSelectedPlace: fixtures.setSelectedPlace }), })); vi.mock("@/components/place/usePlaceDetailData", () => ({ usePlaceDetailData: () => ({}) })); -vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ readSchedulePlanPlacesFromCache: () => fixtures.places })); +vi.mock("@/lib/plan/scheduleItemPlaces", async () => { + const { scheduleItemsQueryKey } = await vi.importActual("@/lib/query-keys"); + return { + schedulePlanPlacesQueryOptions: (_queryClient: unknown, roomId: string, scheduleId: number | null) => ({ + queryKey: scheduleItemsQueryKey(roomId, scheduleId), + queryFn: () => fixtures.places, + staleTime: Infinity, + }), + }; +}); vi.mock("@/app/(main)/plan/_components/mobile/useMobileAddPlaceSearch", () => ({ useMobileAddPlaceSearch: () => ({ recentlyAddedItemId: null, screen: null }), })); diff --git a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.test.ts b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.test.ts index 31bcd521..d44fa4f0 100644 --- a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.test.ts +++ b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.test.ts @@ -25,11 +25,16 @@ describe("applyPendingPlanMove", () => { }); it("같은 일차 순서 변경은 그 일차에만 새 순서를 적용한다", () => { - const pending = { kind: "reorder" as const, scheduleId: 1, order: ["item-3", "item-1", "item-2"] }; + 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); }); + 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("다른 일차로 이동하면 원래 일차에서 빼고 대상 일차의 위치에 넣는다", () => { const pending = { kind: "move" as const, diff --git a/src/hooks/useRooms.schedule-items.test.tsx b/src/hooks/useRooms.schedule-items.test.tsx new file mode 100644 index 00000000..8018efd9 --- /dev/null +++ b/src/hooks/useRooms.schedule-items.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { + QueryClient, + QueryClientProvider, + QueryObserver, +} from "@tanstack/react-query"; +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 { hookHarness } from "@/test/hook-harness"; + +import { useDeleteScheduleItem } from "./useRooms"; + +const api = vi.hoisted(() => ({ + deleteScheduleItem: vi.fn(), + getScheduleItems: vi.fn(), +})); +vi.mock("@/lib/client-env", () => ({ + clientEnv: { NEXT_PUBLIC_API_BASE_URL: "https://api.example.test" }, +})); +vi.mock("@/lib/api/rooms/schedule-items", async (original) => ({ + ...(await original()), + deleteScheduleItem: api.deleteScheduleItem, + getScheduleItems: api.getScheduleItems, +})); +vi.mock("@/contexts/StompContext", () => ({ + useStompContext: () => ({ connected: true }), +})); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const place = (itemId: number): PlanPlace => ({ + id: `item-${itemId}`, + itemId, + googlePlaceId: `g${itemId}`, + title: `장소 ${itemId}`, +}); +const items = (...itemIds: number[]): RoomScheduleItem[] => + itemIds.map((itemId, orderIndex) => ({ + itemId, + scheduleId: 10, + googlePlaceId: `g${itemId}`, + startTime: null, + endTime: null, + orderIndex, + travelMode: "DRIVING", + createdAt: "", + })); + +it("refetches the day after the user's own delete so an older in-flight refetch cannot bring the place back", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const key = scheduleItemsQueryKey("room-1", 10); + client.setQueryData(key, [place(100), place(101)]); + // 일정 화면처럼 일차 목록을 보고 있는 구독자 + const unwatch = new QueryObserver( + client, + schedulePlanPlacesQueryOptions(client, "room-1", 10), + ).subscribe(() => {}); + let resolveBeforeDelete!: (rows: RoomScheduleItem[]) => void; + api.getScheduleItems + .mockReturnValueOnce( + new Promise((resolve) => { + resolveBeforeDelete = resolve; + }), + ) + .mockResolvedValueOnce(items(100)); + api.deleteScheduleItem.mockResolvedValue(undefined); + const hook = hookHarness( + () => useDeleteScheduleItem(), + (child) => {child}, + ); + try { + await hook.render(undefined); + // 다른 사람의 변경으로 일차를 다시 받는 중에 본인이 101을 지운다 + void client.invalidateQueries({ queryKey: key, exact: true }); + await act(async () => { + await hook.current.mutateAsync({ roomId: "room-1", scheduleId: 10, itemId: 101 }); + }); + resolveBeforeDelete(items(100, 101)); + await hook.flush(); + + expect(api.getScheduleItems).toHaveBeenCalledTimes(2); + expect(client.getQueryData(key)?.map((p) => p.itemId)).toEqual([100]); + } finally { + unwatch(); + await hook.unmount(); + client.clear(); + } +}); diff --git a/src/lib/plan/scheduleItemPlaces.create.test.ts b/src/lib/plan/scheduleItemPlaces.create.test.ts index fd7c9ce5..4cf490d7 100644 --- a/src/lib/plan/scheduleItemPlaces.create.test.ts +++ b/src/lib/plan/scheduleItemPlaces.create.test.ts @@ -5,6 +5,7 @@ import type { CreateScheduleItemResponse, RoomScheduleItem, } from "@/lib/api/rooms/schedule-items"; +import type { PlanPlace } from "@/lib/plan/types"; import { scheduleItemsQueryKey } from "@/lib/query-keys"; const mocks = vi.hoisted(() => ({ @@ -61,6 +62,7 @@ vi.mock("@/stores/plan-map-directions-epoch-store", () => ({ })); let createScheduleItemAtPlanIndex: typeof import("@/lib/plan/scheduleItemPlaces").createScheduleItemAtPlanIndex; +let applyCreatedScheduleItemToPlanPlaces: typeof import("@/lib/plan/scheduleItemPlaces").applyCreatedScheduleItemToPlanPlaces; let syncAfterCrossScheduleItemMove: typeof import("@/lib/plan/scheduleItemPlaces").syncAfterCrossScheduleItemMove; beforeAll(async () => { @@ -69,8 +71,11 @@ beforeAll(async () => { process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI = "http://localhost/callback"; process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY = "test-maps-key"; - ({ createScheduleItemAtPlanIndex, syncAfterCrossScheduleItemMove } = - await import("@/lib/plan/scheduleItemPlaces")); + ({ + createScheduleItemAtPlanIndex, + applyCreatedScheduleItemToPlanPlaces, + syncAfterCrossScheduleItemMove, + } = await import("@/lib/plan/scheduleItemPlaces")); }); const scheduleItem = (itemId: number, orderIndex: number): RoomScheduleItem => ({ @@ -97,28 +102,45 @@ describe("createScheduleItemAtPlanIndex", () => { ); }); - it("invalidates the route IDs returned by the create response", async () => { - const queryClient = new QueryClient(); - const response: CreateScheduleItemResponse = { - createdItem: scheduleItem(3, 1), - updatedItems: [scheduleItem(2, 2)], - affectedRouteItemIds: [777], - }; + const response: CreateScheduleItemResponse = { + createdItem: scheduleItem(3, 1), + updatedItems: [scheduleItem(2, 2)], + affectedRouteItemIds: [777], + }; + const places = [ + { id: "item-1", itemId: 1, googlePlaceId: "place-1", title: "One" }, + { id: "item-2", itemId: 2, googlePlaceId: "place-2", title: "Two" }, + ]; + + it("sends the dropped position as the server order index", async () => { mocks.createScheduleItem.mockResolvedValue(response); - const places = [ - { id: "item-1", itemId: 1, googlePlaceId: "place-1", title: "One" }, - { id: "item-2", itemId: 2, googlePlaceId: "place-2", title: "Two" }, - ]; - queryClient.setQueryData(scheduleItemsQueryKey("room-1", 10), places); - await createScheduleItemAtPlanIndex(queryClient, { - roomId: "room-1", - scheduleId: 10, - places, - insertIndex: 1, + await expect( + createScheduleItemAtPlanIndex({ + roomId: " room-1 ", + scheduleId: 10, + places, + insertIndex: 5, + googlePlaceId: "place-3", + }), + ).resolves.toBe(response); + + expect(mocks.createScheduleItem).toHaveBeenCalledWith("room-1", 10, { googlePlaceId: "place-3", + orderIndex: 2, }); + }); + it("merges the create response and invalidates only the route IDs it returns", async () => { + const queryClient = new QueryClient(); + const key = scheduleItemsQueryKey("room-1", 10); + queryClient.setQueryData(key, places); + + await applyCreatedScheduleItemToPlanPlaces(queryClient, "room-1", 10, response); + + expect( + queryClient.getQueryData(key)?.map((place) => place.itemId), + ).toEqual([1, 3, 2]); expect( mocks.invalidateScheduleItemRoutesAfterItemCreated, ).not.toHaveBeenCalled(); @@ -135,35 +157,17 @@ describe("createScheduleItemAtPlanIndex", () => { ); }); - it("returns the created item when post-create route synchronization fails", async () => { + it("does not fail the saved create when post-create route synchronization fails", async () => { const queryClient = new QueryClient(); - const response: CreateScheduleItemResponse = { - createdItem: scheduleItem(3, 1), - updatedItems: [scheduleItem(2, 2)], - affectedRouteItemIds: [777], - }; - mocks.createScheduleItem.mockResolvedValue(response); mocks.invalidateScheduleItemRouteForSources.mockRejectedValueOnce( new Error("route sync failed"), ); - const places = [ - { id: "item-1", itemId: 1, googlePlaceId: "place-1", title: "One" }, - { id: "item-2", itemId: 2, googlePlaceId: "place-2", title: "Two" }, - ]; - const key = scheduleItemsQueryKey("room-1", 10); - queryClient.setQueryData(key, places); + queryClient.setQueryData(scheduleItemsQueryKey("room-1", 10), places); await expect( - createScheduleItemAtPlanIndex(queryClient, { - roomId: "room-1", - scheduleId: 10, - places, - insertIndex: 1, - googlePlaceId: "place-3", - }), - ).resolves.toEqual(response.createdItem); + applyCreatedScheduleItemToPlanPlaces(queryClient, "room-1", 10, response), + ).resolves.toBeUndefined(); - expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); expect( mocks.invalidateScheduleItemRouteForWholeSchedule, ).toHaveBeenCalledWith(queryClient, "room-1", 10); diff --git a/src/lib/plan/scheduleItemPlaces.test.ts b/src/lib/plan/scheduleItemPlaces.test.ts index c2bf4cd8..aff46a07 100644 --- a/src/lib/plan/scheduleItemPlaces.test.ts +++ b/src/lib/plan/scheduleItemPlaces.test.ts @@ -1,4 +1,3 @@ -import { QueryClient } from "@tanstack/react-query"; import { beforeAll, describe, expect, it } from "vitest"; import type { @@ -184,45 +183,3 @@ describe("end time cache and reorder", () => { ]); }); }); - -describe("setSchedulePlanPlaces", () => { - it("restarts an in-flight whole-room refetch so its older snapshot cannot overwrite the new day list", async () => { - const { setSchedulePlanPlaces } = await import("@/lib/plan/scheduleItemPlaces"); - const { roomSchedulesQueryKey, scheduleItemsQueryKey } = await import("@/lib/query-keys"); - const client = new QueryClient(); - const roomKey = roomSchedulesQueryKey("room-1"); - client.setQueryData(roomKey, []); - let fetches = 0; - const queryFn = () => { - fetches += 1; - return new Promise(() => {}); - }; - void client.fetchQuery({ queryKey: roomKey, queryFn, staleTime: 0 }).catch(() => null); - expect(fetches).toBe(1); - - setSchedulePlanPlaces(client, "room-1", 10, [planPlace(1)]); - - expect(fetches).toBe(2); - expect(client.getQueryData(scheduleItemsQueryKey("room-1", 10))).toEqual([planPlace(1)]); - client.clear(); - }); - - it("does not refetch the whole room when nothing is in flight", async () => { - const { setSchedulePlanPlaces } = await import("@/lib/plan/scheduleItemPlaces"); - const { roomSchedulesQueryKey } = await import("@/lib/query-keys"); - const client = new QueryClient(); - let fetches = 0; - client.setQueryDefaults(roomSchedulesQueryKey("room-1"), { - queryFn: () => { - fetches += 1; - return []; - }, - }); - client.setQueryData(roomSchedulesQueryKey("room-1"), []); - - setSchedulePlanPlaces(client, "room-1", 10, [planPlace(1)]); - - expect(fetches).toBe(0); - client.clear(); - }); -}); diff --git a/src/lib/rooms.hydrate.test.ts b/src/lib/rooms.hydrate.test.ts index 2e88de48..d8c9e6ab 100644 --- a/src/lib/rooms.hydrate.test.ts +++ b/src/lib/rooms.hydrate.test.ts @@ -70,6 +70,28 @@ describe("hydrateRoomSchedulesFromServer", () => { expect(client.getQueryData(roomSchedulesQueryKey(ROOM_ID))).toEqual(SERVER_SCHEDULES); client.clear(); }); + + it("keeps a day list that changed after the whole-room fetch was sent", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: 1_000 }); + try { + const client = new QueryClient(); + const response = deferred(); + mocks.getRoomSchedules.mockReturnValueOnce(response.promise); + mocks.build.mockResolvedValueOnce(new Map([[1, SERVER_PLACES]])); + + const pending = hydrateRoomSchedulesFromServer(client, ROOM_ID, new AbortController().signal); + vi.setSystemTime(2_000); + client.setQueryData(scheduleItemsQueryKey(ROOM_ID, 1), CACHED_PLACES); + response.resolve(SERVER_SCHEDULES); + await pending; + + expect(client.getQueryData(scheduleItemsQueryKey(ROOM_ID, 1))).toBe(CACHED_PLACES); + expect(client.getQueryData(roomSchedulesQueryKey(ROOM_ID))).toEqual(SERVER_SCHEDULES); + client.clear(); + } finally { + vi.useRealTimers(); + } + }); }); describe("refreshRoomSchedules", () => { diff --git a/src/lib/stomp/schedules-dispatch.order.test.ts b/src/lib/stomp/schedules-dispatch.order.test.ts new file mode 100644 index 00000000..d40fc62a --- /dev/null +++ b/src/lib/stomp/schedules-dispatch.order.test.ts @@ -0,0 +1,266 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RoomScheduleItem } from "@/lib/api/rooms/schedule-items"; +import type { PlanPlace } from "@/lib/plan/types"; +import { roomSchedulesQueryKey, scheduleItemsQueryKey } from "@/lib/query-keys"; +import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; + +const fixtures = vi.hoisted(() => ({ + getScheduleItems: vi.fn(), + place: (itemId: number) => ({ + id: `item-${itemId}`, + itemId, + googlePlaceId: `g${itemId}`, + title: `장소 ${itemId}`, + }), +})); + +vi.mock("@/lib/api/rooms/schedule-items", () => ({ + getScheduleItems: fixtures.getScheduleItems, +})); +vi.mock("@/lib/places/place-queries", () => ({ + fetchPlacePreview: async (googlePlaceId: string) => ({ + name: googlePlaceId, + formattedAddress: "", + }), +})); +vi.mock("@/lib/plan/schedule-bulk-hydration", () => ({ + buildPlanPlacesFromItemsWithPreviewEnrichment: async ( + _queryClient: unknown, + items: RoomScheduleItem[], + ) => + [...items] + .sort((a, b) => a.orderIndex - b.orderIndex) + .map((item) => fixtures.place(item.itemId)), + resolveScheduleItemsFromCacheOrHydrate: vi.fn(), +})); +vi.mock("@/lib/plan/scheduleStompRouteScope", () => ({ + bumpPlanMapRouteSegments: vi.fn(), + invalidateScheduleItemRouteForSources: vi.fn(async () => undefined), + invalidateScheduleItemRouteForWholeSchedule: vi.fn(async () => undefined), + invalidateScheduleItemRoutesAfterDelete: vi.fn(async () => undefined), + invalidateScheduleItemRoutesAfterItemCreated: vi.fn(async () => undefined), + invalidateScheduleItemRoutesAfterReorder: vi.fn(async () => undefined), + readOrderedItemIdsFromScheduleItemsCache: vi.fn(() => null), + removeRouteQueriesForDeletedItemSource: vi.fn(), +})); +vi.mock("@/lib/rooms", () => ({ + syncRoomDetailFromServer: vi.fn(), +})); +vi.mock("@/lib/query-client", () => ({ + getQueryClient: () => null, +})); +vi.mock("@/stores/plan-map-directions-epoch-store", () => ({ + usePlanMapDirectionsEpochStore: { + getState: () => ({ bumpForDirections: vi.fn(), bumpSegments: vi.fn() }), + }, +})); + +import { + removeScheduleItemFromPlanPlacesCache, + schedulePlanPlacesQueryOptions, +} from "@/lib/plan/scheduleItemPlaces"; +import { dispatchRoomScheduleEvent } from "@/lib/stomp/schedules-dispatch"; + +const ROOM_ID = "room-1"; +const SCHEDULE_ID = 10; +const DAY_KEY = scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID); + +/** 서버가 돌려주는 일차 항목 — 넘긴 순서가 곧 `orderIndex` */ +const items = (...itemIds: number[]): RoomScheduleItem[] => + itemIds.map((itemId, orderIndex) => ({ + itemId, + scheduleId: SCHEDULE_ID, + googlePlaceId: `g${itemId}`, + startTime: null, + endTime: null, + orderIndex, + travelMode: "DRIVING", + createdAt: "", + })); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +/** 늦게 도착한 응답이 캐시에 쓸 기회를 준다 */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function remoteEvent( + type: RoomScheduleChangedEvent["type"], + itemId: number, +): RoomScheduleChangedEvent { + return { + roomId: ROOM_ID, + actorUserId: 2, + type, + scheduleId: SCHEDULE_ID, + itemId, + affectedRouteItemIds: [], + scheduleIds: null, + }; +} + +describe("schedule day list refetch order", () => { + let client: QueryClient; + let unwatch: () => void; + + // 일정 화면처럼 일차 목록을 보고 있는 구독자 — 무효화되면 바로 다시 받는다 + const watchDayList = () => { + unwatch = new QueryObserver( + client, + schedulePlanPlacesQueryOptions(client, ROOM_ID, SCHEDULE_ID), + ).subscribe(() => {}); + }; + const dayItemIds = () => + client.getQueryData(DAY_KEY)?.map((place) => place.itemId); + + beforeEach(() => { + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + unwatch = () => {}; + fixtures.getScheduleItems.mockReset(); + }); + + afterEach(() => { + unwatch(); + client.clear(); + }); + + it("keeps the latest order when the refetch for an earlier reorder answers last", async () => { + client.setQueryData(DAY_KEY, [100, 101, 102].map(fixtures.place)); + watchDayList(); + const afterFirstReorder = deferred(); + const afterSecondReorder = deferred(); + fixtures.getScheduleItems + .mockReturnValueOnce(afterFirstReorder.promise) + .mockReturnValueOnce(afterSecondReorder.promise); + + const first = dispatchRoomScheduleEvent( + client, + remoteEvent("SCHEDULE_ITEMS_REORDERED", 101), + ); + const second = dispatchRoomScheduleEvent( + client, + remoteEvent("SCHEDULE_ITEMS_REORDERED", 100), + ); + await vi.waitFor(() => + expect(fixtures.getScheduleItems).toHaveBeenCalledTimes(2), + ); + afterSecondReorder.resolve(items(101, 102, 100)); + afterFirstReorder.resolve(items(101, 100, 102)); + await Promise.all([first, second]); + await settle(); + + expect(dayItemIds()).toEqual([101, 102, 100]); + // 앞선 조회는 취소되면서 요청도 끊긴다 + const firstSignal = fixtures.getScheduleItems.mock.calls[0]?.[2] as AbortSignal; + expect(firstSignal.aborted).toBe(true); + }); + + it("does not bring back a place deleted while the refetch for its creation was slow", async () => { + client.setQueryData(DAY_KEY, [100, 101, 102].map(fixtures.place)); + watchDayList(); + const afterCreate = deferred(); + const afterDelete = deferred(); + fixtures.getScheduleItems + .mockReturnValueOnce(afterCreate.promise) + .mockReturnValueOnce(afterDelete.promise); + + const created = dispatchRoomScheduleEvent( + client, + remoteEvent("SCHEDULE_ITEM_CREATED", 103), + ); + const deleted = dispatchRoomScheduleEvent( + client, + remoteEvent("SCHEDULE_ITEM_DELETED", 103), + ); + await vi.waitFor(() => + expect(fixtures.getScheduleItems).toHaveBeenCalledTimes(2), + ); + afterDelete.resolve(items(100, 101, 102)); + afterCreate.resolve(items(100, 101, 102, 103)); + await Promise.all([created, deleted]); + await settle(); + + expect(dayItemIds()).toEqual([100, 101, 102]); + }); + + it("restarts a first load that was sent before the event", async () => { + const beforeReorder = deferred(); + fixtures.getScheduleItems + .mockReturnValueOnce(beforeReorder.promise) + .mockResolvedValueOnce(items(101, 100)); + watchDayList(); + await vi.waitFor(() => + expect(fixtures.getScheduleItems).toHaveBeenCalledOnce(), + ); + + await dispatchRoomScheduleEvent( + client, + remoteEvent("SCHEDULE_ITEMS_REORDERED", 101), + ); + beforeReorder.resolve(items(100, 101)); + await settle(); + + expect(fixtures.getScheduleItems).toHaveBeenCalledTimes(2); + expect(dayItemIds()).toEqual([101, 100]); + }); + + it("keeps the user's delete when the mutation's settled refetch replaces an older in-flight refetch", async () => { + client.setQueryData(DAY_KEY, [100, 101].map(fixtures.place)); + watchDayList(); + const beforeDelete = deferred(); + fixtures.getScheduleItems + .mockReturnValueOnce(beforeDelete.promise) + .mockResolvedValueOnce(items(100)); + + // 다른 사람의 변경으로 다시 받는 중에 본인 삭제 응답이 들어온다(useDeleteScheduleItem의 onSuccess → onSettled) + void client.invalidateQueries({ queryKey: DAY_KEY, exact: true }); + removeScheduleItemFromPlanPlacesCache(client, ROOM_ID, SCHEDULE_ID, 101); + await client.invalidateQueries({ queryKey: DAY_KEY, exact: true }); + beforeDelete.resolve(items(100, 101)); + await settle(); + + expect(fixtures.getScheduleItems).toHaveBeenCalledTimes(2); + expect(dayItemIds()).toEqual([100]); + }); + + it("refetches a garbage-collected day list instead of rebuilding it from the whole-room copy", async () => { + // 101을 지우기 전에 받아 둔 일정 전체 — 일차 목록이 gc된 뒤에도 남아 있다 + client.setQueryData(roomSchedulesQueryKey(ROOM_ID), [ + { + scheduleId: SCHEDULE_ID, + roomId: ROOM_ID, + dayNumber: 1, + date: "2026-10-28", + createdAt: "", + items: items(100, 101, 102), + }, + ]); + fixtures.getScheduleItems.mockResolvedValue(items(100, 102)); + + watchDayList(); + + await vi.waitFor(() => expect(dayItemIds()).toEqual([100, 102])); + expect(fixtures.getScheduleItems).toHaveBeenCalledOnce(); + }); + + it("fetches from the server when the day list is invalidated", async () => { + client.setQueryData(DAY_KEY, [100, 102].map(fixtures.place)); + watchDayList(); + fixtures.getScheduleItems.mockResolvedValue(items(100, 102, 103)); + + await client.invalidateQueries({ + queryKey: DAY_KEY, + exact: true, + refetchType: "active", + }); + + expect(dayItemIds()).toEqual([100, 102, 103]); + }); +}); diff --git a/src/lib/stomp/schedules-dispatch.test.ts b/src/lib/stomp/schedules-dispatch.test.ts index 2001055b..cbd60a4f 100644 --- a/src/lib/stomp/schedules-dispatch.test.ts +++ b/src/lib/stomp/schedules-dispatch.test.ts @@ -9,24 +9,16 @@ const mocks = vi.hoisted(() => ({ collectSegmentSourcesForCreate: vi.fn(), collectSegmentSourcesForDelete: vi.fn(), collectSegmentSourcesForReorder: vi.fn(), - getScheduleItems: vi.fn(), + invalidateSchedulePlanPlaces: vi.fn(), invalidateScheduleItemRouteForSources: vi.fn(), invalidateScheduleItemRouteForWholeSchedule: vi.fn(), - mergeOrRefetchSchedulePlanPlacesFromItems: vi.fn(), readOrderedItemIdsFromScheduleItemsCache: vi.fn(), - refetchSchedulePlanPlacesIntoCache: vi.fn(), removeRouteQueriesForDeletedItemSource: vi.fn(), syncAfterCrossScheduleItemMove: vi.fn(), })); -vi.mock("@/lib/api/rooms/schedule-items", () => ({ - getScheduleItems: mocks.getScheduleItems, -})); - vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ - mergeOrRefetchSchedulePlanPlacesFromItems: - mocks.mergeOrRefetchSchedulePlanPlacesFromItems, - refetchSchedulePlanPlacesIntoCache: mocks.refetchSchedulePlanPlacesIntoCache, + invalidateSchedulePlanPlaces: mocks.invalidateSchedulePlanPlaces, syncAfterCrossScheduleItemMove: mocks.syncAfterCrossScheduleItemMove, })); @@ -80,13 +72,11 @@ describe("dispatchRoomScheduleEvent", () => { beforeEach(() => { queryClient = new QueryClient(); vi.clearAllMocks(); - mocks.getScheduleItems.mockResolvedValue([]); + mocks.invalidateSchedulePlanPlaces.mockResolvedValue(undefined); mocks.invalidateScheduleItemRouteForSources.mockResolvedValue(undefined); mocks.invalidateScheduleItemRouteForWholeSchedule.mockResolvedValue( undefined, ); - mocks.mergeOrRefetchSchedulePlanPlacesFromItems.mockResolvedValue(undefined); - mocks.refetchSchedulePlanPlacesIntoCache.mockResolvedValue(undefined); mocks.syncAfterCrossScheduleItemMove.mockResolvedValue(undefined); }); @@ -98,14 +88,14 @@ describe("dispatchRoomScheduleEvent", () => { it.each([false, true])("refetches ID-only time updates, including own-user tabs (%s)", async (ownUser) => { vi.useFakeTimers(); if (ownUser) queryClient.setQueryData(sessionUserQueryKey, { id: 99 }); - const rows = [{ itemId: 20, startTime: "23:00", endTime: "01:00" }]; - mocks.getScheduleItems.mockResolvedValue(rows); const event = itemEvent("SCHEDULE_ITEM_UPDATED", []); await dispatchRoomScheduleEvent(queryClient, event); await dispatchRoomScheduleEvent(queryClient, event); await vi.runAllTimersAsync(); - expect(mocks.getScheduleItems).toHaveBeenCalledExactlyOnceWith("room-1", 10); - expect(mocks.mergeOrRefetchSchedulePlanPlacesFromItems).toHaveBeenCalledWith(queryClient, "room-1", 10, rows); + // 본인 반향만 모였으면 본인 저장이 끝나며 다시 받는 조회에 합류한다 + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10, { + joinInFlight: ownUser, + }); expect(mocks.invalidateScheduleItemRouteForSources).not.toHaveBeenCalled(); expect(mocks.invalidateScheduleItemRouteForWholeSchedule).not.toHaveBeenCalled(); }); @@ -193,9 +183,8 @@ describe("dispatchRoomScheduleEvent", () => { it("refreshes travel mode before invalidating affectedRouteItemIds", async () => { vi.useFakeTimers(); const order: string[] = []; - mocks.getScheduleItems.mockImplementation(async () => { + mocks.invalidateSchedulePlanPlaces.mockImplementation(async () => { order.push("refetch"); - return []; }); mocks.invalidateScheduleItemRouteForSources.mockImplementation(async () => { order.push("invalidate"); @@ -219,14 +208,14 @@ describe("dispatchRoomScheduleEvent", () => { it("preserves a newer route bucket when an earlier travel-mode refresh fails", async () => { vi.useFakeTimers(); let rejectFirst!: (reason?: unknown) => void; - let resolveSecond!: (value: never[]) => void; - const first = new Promise((_, reject) => { + let resolveSecond!: () => void; + const first = new Promise((_, reject) => { rejectFirst = reject; }); - const second = new Promise((resolve) => { + const second = new Promise((resolve) => { resolveSecond = resolve; }); - mocks.getScheduleItems + mocks.invalidateSchedulePlanPlaces .mockReturnValueOnce(first) .mockReturnValueOnce(second); @@ -243,7 +232,7 @@ describe("dispatchRoomScheduleEvent", () => { await vi.advanceTimersByTimeAsync(90); rejectFirst(new Error("first refresh failed")); - resolveSecond([]); + resolveSecond(); await vi.runAllTimersAsync(); expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledTimes(1); From 4d440f61d22d70f5e4adee54b180ecbc3f4aeb63 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 30 Oct 2026 20:16:31 +0900 Subject: [PATCH 05/24] =?UTF-8?q?docs(conventions):=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A1=B0=ED=9A=8C=C2=B7=EA=B0=B1=EC=8B=A0=20?= =?UTF-8?q?=EC=9B=90=EC=B9=99=EC=9D=84=20=EC=84=9C=EB=B2=84=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 90f240c8..4c45e9d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ - 기본 흐름은 최초 조회 → 캐시 재사용 → 변경 이벤트에 따른 갱신이다. 탭 이동이나 컴포넌트 재마운트, 브라우저 포커스 복귀만으로 유효한 데이터를 무조건 재조회하지 않는다. - 공용 데이터는 브로드캐스트를 받아 해당 범위만 갱신한다. 이벤트가 변경 사실이나 ID만 전달하면 관련 캐시를 무효화하고 필요한 데이터를 재조회한다. 브로드캐스트 후 HTTP 조회도 이벤트 기반 갱신에 포함한다. - 비활성 탭에서 변경 알림을 받으면 캐시에 갱신 필요 상태를 남기고, 해당 탭 복귀 시 반영한다. 중복 이벤트와 연속 변경으로 같은 요청이 반복되지 않게 한다. -- 본인 수정은 서버 응답으로 캐시를 반영한다. 응답만으로 상태를 확정할 수 없거나 충돌·정합성 확인이 필요한 경우에만 관련 범위를 재조회한다. +- Tanstack Query기본 원칙에 따라 서버 상태를 Source Of Truth로 두고 제한적인 상황에서만 서버를 호출하지 않고 캐싱해서 사용한다. - 준비물 같은 개인 데이터에 팀 공동 편집용 실시간 동기화를 일괄 적용하지 않는다. 정상 조회한 상태는 재사용하고, 본인 수정·명시적 새로고침·오류 복구 등 실제 갱신 사유가 있을 때 조회한다. - 최초 진입 또는 캐시 부재, 연결 끊김 후 브로드캐스트 누락 복구, 오류 재시도, 명시적 새로고침은 재조회 사유가 될 수 있다. 예외를 추가할 때는 구체적인 갱신 사유와 범위를 코드에 명시한다. - 일반 탭 이탈을 이유로 목록과 캐시를 삭제하지 않는다. 로그아웃·계정 변경·접근 권한 상실 시에는 관련 데이터를 즉시 제거하고, 여행 변경 시에는 해당 데이터의 범위와 수명 정책에 맞게 정리한다. From cb0af693619da221d3bae6c2e63a10ea834c05ec Mon Sep 17 00:00:00 2001 From: minbros Date: Mon, 2 Nov 2026 19:00:26 +0900 Subject: [PATCH 06/24] =?UTF-8?q?refactor(schedule):=20Sonar=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=EC=99=80=20=EC=88=9C=EC=B0=A8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_components/mobile/mobilePlanDrag.tsx | 34 +++++++++---------- .../plan/schedule-preview-hydration.test.ts | 6 ++-- .../plan/scheduleItemPlaces.create.test.ts | 2 +- src/lib/plan/scheduleItemPlaces.ts | 5 +-- .../stomp/schedules-dispatch.order.test.ts | 3 +- 5 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx index 7961ef51..9ddde949 100644 --- a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx +++ b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx @@ -179,23 +179,23 @@ export function useMobilePlanDragController({ const scheduleResyncPending = useScheduleResyncPending(roomId); const dragDisabled = isReorderPending || isMovePending || scheduleResyncPending; // 결과가 편집 목록에만 보이므로 캐시를 고치지 않고, 저장하는 동안 요청 값으로 새 자리를 그린다 - const pending: PendingPlanMove | null = - isReorderPending && reorderVariables - ? { - kind: "reorder", - scheduleId: reorderVariables.scheduleId, - itemId: reorderVariables.itemId, - newOrderIndex: reorderVariables.body.newOrderIndex, - } - : isMovePending && moveVariables?.place - ? { - kind: "move", - place: moveVariables.place, - fromScheduleId: moveVariables.sourceScheduleId, - toScheduleId: moveVariables.targetScheduleId, - toIndex: moveVariables.targetOrderIndex, - } - : null; + 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 updateActive = useCallback((next: ActiveDrag | null) => { activeRef.current = next; diff --git a/src/lib/plan/schedule-preview-hydration.test.ts b/src/lib/plan/schedule-preview-hydration.test.ts index b03ceee5..c5aa29d3 100644 --- a/src/lib/plan/schedule-preview-hydration.test.ts +++ b/src/lib/plan/schedule-preview-hydration.test.ts @@ -101,7 +101,7 @@ describe("hydrateScheduleItemsFromSchedulesWithItems", () => { await hydrateScheduleItemsFromSchedulesWithItems(queryClient, ROOM_ID, [ schedule(1, ["a", "b"]), schedule(2, ["c", "d"]), - ]); + ], Date.now()); expect(requestPlacePreviewsBatch.mock.calls).toEqual([[["a", "b", "c", "d"]], [["b", "d"]]]); expect(requestPlacePreview).not.toHaveBeenCalled(); @@ -113,7 +113,7 @@ describe("hydrateScheduleItemsFromSchedulesWithItems", () => { const queryClient = createQueryClient(); requestPlacePreviewsBatch.mockRejectedValueOnce(new HttpError(429, "요청이 너무 많습니다")); - await hydrateScheduleItemsFromSchedulesWithItems(queryClient, ROOM_ID, [schedule(1, ["a", "b"])]); + await hydrateScheduleItemsFromSchedulesWithItems(queryClient, ROOM_ID, [schedule(1, ["a", "b"])], Date.now()); expect(requestPlacePreviewsBatch).toHaveBeenCalledTimes(1); expect(requestPlacePreview).not.toHaveBeenCalled(); @@ -207,7 +207,7 @@ describe("ensureSchedulePlanPlacesEnriched", () => { }); }); - describe("schedulePlanPlacesQueryOptions after preview recovery merge", () => { +describe("schedulePlanPlacesQueryOptions after preview recovery merge", () => { it("invalidated placeholder data fetches authoritative items instead of only enriching old items", async () => { const queryClient = createQueryClient(); const key = scheduleItemsQueryKey(ROOM_ID, 1); diff --git a/src/lib/plan/scheduleItemPlaces.create.test.ts b/src/lib/plan/scheduleItemPlaces.create.test.ts index 7914b8e1..52179b49 100644 --- a/src/lib/plan/scheduleItemPlaces.create.test.ts +++ b/src/lib/plan/scheduleItemPlaces.create.test.ts @@ -215,7 +215,7 @@ describe("createScheduleItemAtPlanIndex", () => { }); }); - describe("cross-schedule refresh sequencing", () => { +describe("cross-schedule refresh sequencing", () => { it("waits for source refresh before target and routes", async () => { const client = new QueryClient(); let finishSource!: () => void; diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index 374ffa00..a2aef4fa 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -301,8 +301,9 @@ export async function syncAfterCrossScheduleItemMove( ) : null; - for (const sid of scheduleIds) { - await invalidateSchedulePlanPlaces(queryClient, rid, sid); + await invalidateSchedulePlanPlaces(queryClient, rid, sourceScheduleId); + if (targetScheduleId !== sourceScheduleId) { + await invalidateSchedulePlanPlaces(queryClient, rid, targetScheduleId); } if (affectedRouteItemIds !== undefined) { diff --git a/src/lib/stomp/schedules-dispatch.order.test.ts b/src/lib/stomp/schedules-dispatch.order.test.ts index d40fc62a..6656e6d8 100644 --- a/src/lib/stomp/schedules-dispatch.order.test.ts +++ b/src/lib/stomp/schedules-dispatch.order.test.ts @@ -25,7 +25,8 @@ vi.mock("@/lib/places/place-queries", () => ({ formattedAddress: "", }), })); -vi.mock("@/lib/plan/schedule-bulk-hydration", () => ({ +vi.mock("@/lib/plan/schedule-bulk-hydration", async (importOriginal) => ({ + ...await importOriginal(), buildPlanPlacesFromItemsWithPreviewEnrichment: async ( _queryClient: unknown, items: RoomScheduleItem[], From b7c9bdbcf534799d423e47e7219483d03bc99f6d Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Tue, 3 Nov 2026 23:20:39 +0900 Subject: [PATCH 07/24] =?UTF-8?q?fix(route):=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20key=EC=97=90=20=EB=8F=84=EC=B0=A9=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EB=A5=BC=20=EB=84=A3=EC=96=B4=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=EB=A5=BC=20=EB=90=98=EB=8F=8C=EB=A6=AC=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=9B=EC=95=84=20=EB=91=94=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=93=B0=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 경로 key를 (방, 일차, 출발 항목, 도착 장소, 이동수단)으로 바꾼다 — 목록·지도·batch 시딩이 다음 장소의 googlePlaceId를 넘긴다 - 순서가 바뀐 구간은 key가 달라져 옛 도착지의 거리·선을 보여 주지 않는다 - 도착 장소가 없는 구간은 조회하지 않는다 Co-Authored-By: Claude Opus 5.5 --- .../travel-time/PlanTravelTime.tsx | 2 ++ src/components/map/PlanItineraryMapRoutes.tsx | 1 + src/hooks/useRooms.ts | 3 ++ src/lib/plan/schedule-bulk-hydration.test.ts | 33 ++++++++++++----- src/lib/plan/schedule-bulk-hydration.ts | 35 ++++++++++++++----- .../scheduleItemRoutePersistedQuery.test.ts | 27 +++++++++++++- .../plan/scheduleItemRoutePersistedQuery.ts | 12 +++++-- src/lib/plan/scheduleSegmentRoute.test.ts | 22 ++++++++---- src/lib/plan/scheduleSegmentRoute.ts | 6 +++- src/lib/query-keys.ts | 4 +++ 10 files changed, 117 insertions(+), 28 deletions(-) diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx index d1e7242c..f8e897e7 100644 --- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx +++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx @@ -106,6 +106,7 @@ export function PlanTravelTime({ roomId, scheduleId, segmentSourceItemId, + destinationGooglePlaceId ?? "", selectedMode, routeQueryEnabled, fpTrim, @@ -127,6 +128,7 @@ export function PlanTravelTime({ rid, scheduleId, segmentSourceItemId, + destinationGooglePlaceId ?? "", mode, fpTrim, { diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx index 084ee376..1aae5a77 100644 --- a/src/components/map/PlanItineraryMapRoutes.tsx +++ b/src/components/map/PlanItineraryMapRoutes.tsx @@ -203,6 +203,7 @@ export function PlanItineraryMapRoutes({ rid, seg.scheduleId, seg.segmentSourceItemId, + seg.destPlaceId, seg.travelModeCanon, fingerprintByScheduleId.get(seg.scheduleId) ?? "", { segmentReady: true }, diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index f9561ac3..8acf6fba 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -176,6 +176,8 @@ export function useScheduleItemRoute( roomId: string | null, scheduleId: number | null, segmentSourceItemId: number | null, + /** 다음 장소의 `googlePlaceId` */ + destinationPlaceId: string, travelMode: ScheduleTravelModeValue, /** 일정 장소 목록상 구간 소스 아이디가 존재·목록 안정 후에만 true */ routeQueryEnabled = true, @@ -189,6 +191,7 @@ export function useScheduleItemRoute( rid, scheduleId, segmentSourceItemId, + destinationPlaceId, travelMode, scheduleFingerprint, { segmentReady: Boolean(routeQueryEnabled) }, diff --git a/src/lib/plan/schedule-bulk-hydration.test.ts b/src/lib/plan/schedule-bulk-hydration.test.ts index 850cbe25..0830440e 100644 --- a/src/lib/plan/schedule-bulk-hydration.test.ts +++ b/src/lib/plan/schedule-bulk-hydration.test.ts @@ -84,7 +84,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), ), ).toMatchObject({ distanceMeters: 1200, @@ -125,7 +125,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), ), ).not.toHaveProperty("encodedPolyline"); }); @@ -171,6 +171,7 @@ describe("hydrateScheduleRoutesBatch", () => { ROOM_ID, SCHEDULE_ID, 2, + "places/c", "WALKING", ); expect(queryClient.getQueryData(failedKey)).toBeUndefined(); @@ -179,6 +180,7 @@ describe("hydrateScheduleRoutesBatch", () => { roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, + destinationPlaceId: "places/c", travelMode: "WALKING", }), ).rejects.toThrow("temporary single route failure"); @@ -188,6 +190,7 @@ describe("hydrateScheduleRoutesBatch", () => { roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, + destinationPlaceId: "places/c", travelMode: "WALKING", }), ).resolves.toEqual(fallbackRoute); @@ -209,13 +212,14 @@ describe("hydrateScheduleRoutesBatch", () => { await hydrateScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); expect( - queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "WALKING")), + queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "places/c", "WALKING")), ).toBeNull(); await expect( resolveScheduleSegmentRoute({ roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, + destinationPlaceId: "places/c", travelMode: "WALKING", }), ).resolves.toBeNull(); @@ -242,7 +246,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), ), ).toBeUndefined(); }); @@ -255,8 +259,8 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { function seededClient() { const queryClient = new QueryClient(); queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); - queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), route); - queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "DRIVING"), route); + queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), route); + queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "places/c", "DRIVING"), route); return queryClient; } @@ -268,7 +272,7 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { it("캐시에 없는 구간이 있으면 일괄로 다시 받는다", async () => { const queryClient = seededClient(); - queryClient.removeQueries({ queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "DRIVING") }); + queryClient.removeQueries({ queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "places/c", "DRIVING") }); getScheduleItemRoutesBatchMock.mockResolvedValue([]); await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); @@ -276,10 +280,23 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { expect(getScheduleItemRoutesBatchMock).toHaveBeenCalledOnce(); }); + it("순서를 바꿨다가 되돌리면 받아 둔 경로가 있어 일괄 요청을 보내지 않는다", async () => { + const queryClient = seededClient(); + const reordered = [place(1, "places/a"), place(3, "places/c"), place(2, "places/b")]; + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), reordered); + getScheduleItemRoutesBatchMock.mockResolvedValue([]); + + await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, reordered); + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); + await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); + + expect(getScheduleItemRoutesBatchMock).toHaveBeenCalledOnce(); + }); + it("일정 변경으로 무효화된 구간이 있으면 일괄로 다시 받는다", async () => { const queryClient = seededClient(); await queryClient.invalidateQueries({ - queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"), + queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), refetchType: "none", }); getScheduleItemRoutesBatchMock.mockResolvedValue([]); diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index c52a647e..adfa42c0 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -292,6 +292,19 @@ function scheduleRoutesBatchKey( return `${roomId.trim()}:${scheduleId}:${placesFingerprint}`; } +/** 구간 출발 항목 → 다음 장소의 `googlePlaceId` (마지막 장소와 장소 ID가 빈 구간은 뺀다) */ +function segmentDestinationsByItemId(places: readonly PlanPlace[]): Map { + const out = new Map(); + for (let i = 0; i < places.length - 1; i += 1) { + const itemId = places[i]?.itemId; + const destination = places[i + 1]?.googlePlaceId?.trim() ?? ""; + if (typeof itemId === "number" && Number.isFinite(itemId) && destination.length) { + out.set(itemId, destination); + } + } + return out; +} + /** * 일차의 모든 구간 경로가 이미 캐시에 있는지 — 일정 화면에서 받아 뒀거나, 화면을 다시 열었거나, * 지도 경로 보기로 넘어온 경우다. 캐시가 지워졌거나(gc) 일정 변경으로 무효화된 구간이 하나라도 있으면 false. @@ -303,13 +316,16 @@ export function scheduleSegmentRoutesCached( places: readonly PlanPlace[], ): boolean { const rid = roomId.trim(); - const sources = places - .slice(0, -1) - .filter((p): p is PlanPlace & { itemId: number } => typeof p.itemId === "number" && Number.isFinite(p.itemId)); - if (!rid.length || !sources.length) return false; - return sources.every((place) => { + const destinationByItemId = segmentDestinationsByItemId(places); + if (!rid.length || !destinationByItemId.size) return false; + return places.every((place) => { + if (typeof place.itemId !== "number") return true; + const destination = destinationByItemId.get(place.itemId); + if (destination === undefined) return true; const mode = canonicalScheduleTravelMode(place.travelMode) ?? SCHEDULE_TRAVEL_MODE_DEFAULT; - const state = queryClient.getQueryState(scheduleItemRouteQueryKey(rid, scheduleId, place.itemId, mode)); + const state = queryClient.getQueryState( + scheduleItemRouteQueryKey(rid, scheduleId, place.itemId, destination, mode), + ); return state?.status === "success" && !state.isInvalidated; }); } @@ -393,7 +409,7 @@ export async function hydrateScheduleRoutesBatch( /** * 응답 대기 중 일정이 바뀌었으면(지문 불일치) 늦게 도착한 결과를 버립니다 — - * 구간 캐시 키는 출발 항목만 담고 있어 옛 목적지의 경로가 새 구간에 시딩될 수 있음. + * 서버는 요청을 받은 순간의 순서로 다음 장소를 정하므로, 그사이 순서가 바뀌었으면 어느 도착 장소의 경로인지 알 수 없다. */ const snapshotFp = schedulePlacesFingerprint([...places]); const currentPlaces = queryClient.getQueryData( @@ -402,13 +418,16 @@ export async function hydrateScheduleRoutesBatch( const currentFp = schedulePlacesFingerprint([...(currentPlaces ?? [])]); if (!snapshotFp.length || snapshotFp !== currentFp) return; + const destinationByItemId = segmentDestinationsByItemId(places); for (const result of results) { const itemId = result.itemId; const mode = canonicalScheduleTravelMode(result.travelMode); if (!mode) continue; if (typeof itemId !== "number" || !Number.isFinite(itemId)) continue; + const destination = destinationByItemId.get(itemId); + if (destination === undefined) continue; - const routeKey = scheduleItemRouteQueryKey(rid, scheduleId, itemId, mode); + const routeKey = scheduleItemRouteQueryKey(rid, scheduleId, itemId, destination, mode); if (isBatchItemOk(result)) { queryClient.setQueryData(routeKey, batchRouteItemToResponse(result)); } else if (result.errorCode && ROUTE_ANSWER_ERROR_CODES.has(result.errorCode)) { diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts index 65afd3ff..f74b3ddc 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts @@ -42,6 +42,7 @@ describe("persistedScheduleItemRouteQueryOptions", () => { "room-1", 10, 1, + "place-b", "DRIVING", "", { segmentReady: true }, @@ -70,10 +71,34 @@ describe("persistedScheduleItemRouteQueryOptions", () => { }); }); +describe("persistedScheduleItemRouteQueryOptions key", () => { + it("도착 장소가 key에 들어가 같은 출발 항목이라도 도착 장소마다 다른 캐시를 쓴다", () => { + const toB = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "place-b", "DRIVING", "", { + segmentReady: true, + }); + const toC = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "place-c", "DRIVING", "", { + segmentReady: true, + }); + + expect(toB.queryKey).toEqual(["schedule-item-route", "room-1", 10, 1, "place-b", "DRIVING"]); + expect(toC.queryKey).not.toEqual(toB.queryKey); + }); + + it("도착 장소가 없으면 조회하지 않는다", () => { + const options = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, " ", "DRIVING", "", { + segmentReady: true, + }); + + expect(options.enabled).toBe(false); + }); +}); + describe("loadScheduleItemRouteForMap", () => { const route = { travelMode: "TRANSIT", distanceMeters: 1700, durationSeconds: 1140 }; const routeOptions = () => - persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "TRANSIT", "", { segmentReady: true }); + persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "place-b", "TRANSIT", "", { + segmentReady: true, + }); it("목록 요청이 방금 실패한 구간은 다시 보내지 않고 가진 값을 쓴다", async () => { const queryClient = new QueryClient(); diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.ts index 70d4346c..978ecbe1 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.ts @@ -15,7 +15,7 @@ type RouteCached = ScheduleItemRouteResponse | null; /** * — `segmentReady`: 네트워크 패치·sessionStorage 시드 허용(`routeQueryEnabled`). 쿼리 키는 이것과 무관하게 - * `(room, schedule, 구간, 모드)`로 고정 — 일정 refetch 중 잠깐 false가 되어도 구간끼리 캐시를 공유하지 않음 + * `(room, schedule, 구간, 도착 장소, 모드)`로 고정 — 일정 refetch 중 잠깐 false가 되어도 구간끼리 캐시를 공유하지 않음 * — `networkEnabled` 생략 시 `segmentReady`와 동일; 지연 수단은 메뉴 열림 시만 `true` */ export type PersistedScheduleRoutePersistFlags = { @@ -30,6 +30,8 @@ export function persistedScheduleItemRouteQueryOptions( roomIdTrimmed: string, scheduleId: number | null, segmentSourceItemId: number | null, + /** 다음 장소의 `googlePlaceId` — 비어 있으면 구간이 정해지지 않아 조회하지 않는다 */ + destinationPlaceId: string, travelMode: ScheduleTravelModeValue, fingerprintTrimmed: string, flags: PersistedScheduleRoutePersistFlags, @@ -43,10 +45,11 @@ export function persistedScheduleItemRouteQueryOptions( const itemOk = typeof segmentSourceItemId === "number" && Number.isFinite(segmentSourceItemId); + const dest = destinationPlaceId.trim(); const fp = fingerprintTrimmed.trim(); const keyEligible = - flags.segmentReady && rid.length > 0 && sidOk && itemOk; + flags.segmentReady && rid.length > 0 && sidOk && itemOk && dest.length > 0; const network = typeof flags.networkEnabled === "boolean" @@ -58,8 +61,9 @@ export function persistedScheduleItemRouteQueryOptions( const keyRoom = rid.length > 0 ? rid : null; const keySchedule = sidOk ? sid : null; const keySegment = itemOk ? segmentSourceItemId : null; + const keyDestination = dest.length > 0 ? dest : null; const keyMode = - sidOk && itemOk && rid.length > 0 ? travelMode : null; + sidOk && itemOk && rid.length > 0 && dest.length > 0 ? travelMode : null; const sessionSeed: RouteCached | undefined = fp.length > 0 && @@ -80,6 +84,7 @@ export function persistedScheduleItemRouteQueryOptions( keyRoom, keySchedule, keySegment, + keyDestination, keyMode, ), ...(sessionSeed !== undefined @@ -93,6 +98,7 @@ export function persistedScheduleItemRouteQueryOptions( roomId: rid, scheduleId: sid!, segmentSourceItemId: segmentSourceItemId!, + destinationPlaceId: dest, travelMode, scheduleFingerprint: fp, }), diff --git a/src/lib/plan/scheduleSegmentRoute.test.ts b/src/lib/plan/scheduleSegmentRoute.test.ts index 9fafad1a..6d9c0c6b 100644 --- a/src/lib/plan/scheduleSegmentRoute.test.ts +++ b/src/lib/plan/scheduleSegmentRoute.test.ts @@ -18,6 +18,7 @@ let getScheduleItemRouteMock: ReturnType; const ROOM_ID = "room-1"; const SCHEDULE_ID = 10; const ITEM_ID = 1; +const DESTINATION = "place-b"; const ROUTE = { distanceMeters: 1200, @@ -50,14 +51,15 @@ beforeEach(() => { registerQueryClient(queryClient); }); -const ROUTE_KEY = () => - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, ITEM_ID, "DRIVING"); +const ROUTE_KEY = (destination = DESTINATION) => + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, ITEM_ID, destination, "DRIVING"); -function resolve() { +function resolve(destinationPlaceId = DESTINATION) { return resolveScheduleSegmentRoute({ roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: ITEM_ID, + destinationPlaceId, travelMode: "DRIVING", }); } @@ -83,10 +85,16 @@ describe("resolveScheduleSegmentRoute", () => { await expect(resolve()).resolves.toBeNull(); }); - /** - * 재정렬로 무효화된 구간은 같은 키라도 목적지가 달라져 있습니다. - * 낡은 값을 그대로 돌려주면 지도에 이전 순서의 폴리라인이 남습니다. - */ + it("같은 출발 항목이라도 도착 장소가 다르면 그 도착 장소의 경로를 받고, 순서를 되돌리면 받아 둔 경로를 쓴다", async () => { + queryClient.setQueryData(ROUTE_KEY("place-b"), ROUTE); + const toC = { ...ROUTE, distanceMeters: 999, encodedPolyline: "zzz" }; + getScheduleItemRouteMock.mockResolvedValue(toC); + + await expect(resolve("place-c")).resolves.toEqual(toC); + await expect(resolve("place-b")).resolves.toEqual(ROUTE); + expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(1); + }); + it("캐시가 무효화된 상태면 낡은 값을 쓰지 않고 다시 조회한다", async () => { queryClient.setQueryData(ROUTE_KEY(), ROUTE); await queryClient.invalidateQueries({ diff --git a/src/lib/plan/scheduleSegmentRoute.ts b/src/lib/plan/scheduleSegmentRoute.ts index 6667d654..4f8b480b 100644 --- a/src/lib/plan/scheduleSegmentRoute.ts +++ b/src/lib/plan/scheduleSegmentRoute.ts @@ -13,13 +13,15 @@ export type ResolveScheduleSegmentRouteArgs = { roomId: string; scheduleId: number; segmentSourceItemId: number; + /** 다음 장소의 `googlePlaceId` — 캐시 key에만 쓴다(서버는 출발 항목의 다음 항목으로 계산) */ + destinationPlaceId: string; travelMode: ScheduleTravelModeValue; /** 일정 지문 — 비면 sessionStorage에 기록하지 않습니다. */ scheduleFingerprint?: string; }; /** - * 구간 `(room, schedule, 출발 항목, 이동수단)` 경로를 가져오는 fetch 본체입니다. + * 구간 `(room, schedule, 출발 항목, 도착 장소, 이동수단)` 경로를 가져오는 fetch 본체입니다. * 거리·소요 시간과 지도 폴리라인이 같은 캐시 항목을 공유하도록 일정 리스트와 지도가 모두 * 이 함수를 queryFn으로 쓰는 하나의 쿼리(`scheduleItemRouteQueryKey`)를 거칩니다. * @@ -31,6 +33,7 @@ export async function resolveScheduleSegmentRoute({ roomId, scheduleId, segmentSourceItemId, + destinationPlaceId, travelMode, scheduleFingerprint, }: ResolveScheduleSegmentRouteArgs): Promise { @@ -44,6 +47,7 @@ export async function resolveScheduleSegmentRoute({ rid, scheduleId, segmentSourceItemId, + destinationPlaceId, travelMode, ); const cached = getQueryClient()?.getQueryState(routeKey); diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index dee035b1..d7b73bdc 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -121,12 +121,15 @@ export const scheduleItemsQueryKey = ( /** * 구간별 길찾기 API 결과 (`GET …/route?travelMode=…`). + * 서버는 출발 항목에서 그 순간의 다음 항목까지 경로를 주므로 도착 장소를 key에 넣는다 — + * 순서가 바뀌면 다른 key가 되고, 순서를 되돌리면 받아 둔 값을 그대로 쓴다. * 무효화: `["schedule-item-route", roomId]` prefix. */ export const scheduleItemRouteQueryKey = ( roomId: string | null, scheduleId: number | null, segmentSourceItemId: number | null, + destinationPlaceId: string | null, travelMode: ScheduleTravelModeValue | null, ) => [ @@ -134,6 +137,7 @@ export const scheduleItemRouteQueryKey = ( roomId, scheduleId, segmentSourceItemId, + destinationPlaceId, travelMode, ] as const; From c3297070904091ca379f96c7c9d2c451789bb3d2 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Tue, 3 Nov 2026 23:24:52 +0900 Subject: [PATCH 08/24] =?UTF-8?q?refactor(route):=20=EB=8F=84=EC=B0=A9=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EA=B0=80=20key=EC=97=90=20=EB=93=A4=EC=96=B4?= =?UTF-8?q?=EA=B0=80=20=ED=95=84=EC=9A=94=20=EC=97=86=EC=96=B4=EC=A7=84=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=AC=B4=ED=9A=A8=ED=99=94=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 순서 변경·장소 추가·삭제·일차 간 이동·이동수단 변경(본인·STOMP)과 일정 전체 재조회 뒤의 구간 경로 무효화를 지운다 — 바뀐 구간은 새 key로 받는다 - 구간 계산·무효화 모듈(scheduleStompRouteScope.ts)과 STOMP 경로 무효화 디바운스 삭제 - 장소 삭제는 응답으로 일차 목록에서 그 장소만 빼고, 일차 삭제 뒤의 경로 무효화도 지운다 - 지도 에폭 증가 호출 삭제 (지도 key 정리는 다음 커밋) Co-Authored-By: Claude Opus 5.5 --- src/hooks/useExpenseInvalidation.test.ts | 2 +- src/hooks/usePrefetchScheduleRoutes.ts | 2 +- src/hooks/useRooms.ts | 26 +- src/lib/plan/planTravelLocalStorage.ts | 15 - src/lib/plan/schedule-bulk-hydration.ts | 21 +- .../plan/scheduleItemPlaces.create.test.ts | 112 +------ src/lib/plan/scheduleItemPlaces.ts | 195 +----------- src/lib/plan/scheduleSegmentRoute.test.ts | 5 +- src/lib/plan/scheduleSegmentRoute.ts | 4 +- src/lib/plan/scheduleStompRouteScope.test.ts | 30 -- src/lib/plan/scheduleStompRouteScope.ts | 301 ------------------ .../stomp/schedules-dispatch.order.test.ts | 15 - src/lib/stomp/schedules-dispatch.test.ts | 173 ++-------- src/lib/stomp/schedules-dispatch.ts | 183 +---------- 14 files changed, 63 insertions(+), 1021 deletions(-) delete mode 100644 src/lib/plan/scheduleStompRouteScope.test.ts delete mode 100644 src/lib/plan/scheduleStompRouteScope.ts diff --git a/src/hooks/useExpenseInvalidation.test.ts b/src/hooks/useExpenseInvalidation.test.ts index ff32c7c3..95d2b099 100644 --- a/src/hooks/useExpenseInvalidation.test.ts +++ b/src/hooks/useExpenseInvalidation.test.ts @@ -17,7 +17,7 @@ vi.mock("@/lib/rooms", async (importOriginal) => ({ })); vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ syncAfterCrossScheduleItemMove: vi.fn(), - applyScheduleItemDeletedOnClient: vi.fn(), + removeScheduleItemFromPlanPlacesCache: vi.fn(), })); import { useMoveRoomSchedule, diff --git a/src/hooks/usePrefetchScheduleRoutes.ts b/src/hooks/usePrefetchScheduleRoutes.ts index b4c52dd3..68373968 100644 --- a/src/hooks/usePrefetchScheduleRoutes.ts +++ b/src/hooks/usePrefetchScheduleRoutes.ts @@ -7,7 +7,7 @@ import type { PlanPlace } from "@/lib/plan/types"; /** * 페이지 진입·새로고침 시 일차별 구간 경로를 저장된 공유 이동수단 기준 `routes/batch`로 1회 시딩합니다. - * 리오더·추가·삭제 등으로 장소 목록이 바뀌면 batch는 재호출하지 않고, 무효화된 구간만 개별 GET합니다. + * 리오더·추가·삭제 등으로 장소 목록이 바뀌면 batch는 재호출하지 않고, 새로 생긴 구간(받은 적 없는 출발·도착 장소)만 개별 GET합니다. */ export function usePrefetchScheduleRoutes( roomId: string, diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 8acf6fba..c8a8de76 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -59,8 +59,8 @@ import { import { applyCreatedScheduleItemToPlanPlaces, applyRoomScheduleItemToPlanPlaces, - applyScheduleItemDeletedOnClient, createScheduleItemAtPlanIndex, + removeScheduleItemFromPlanPlacesCache, schedulePlanPlacesQueryOptions, syncAfterCrossScheduleItemMove, syncPlanPlacesAfterReorderSuccess, @@ -92,9 +92,7 @@ import { pathSuspendsStomp, } from "@/lib/stomp/stompPathPolicy"; import { persistedScheduleItemRouteQueryOptions } from "@/lib/plan/scheduleItemRoutePersistedQuery"; -import { invalidateScheduleItemRouteForWholeSchedule } from "@/lib/plan/scheduleStompRouteScope"; import type { ScheduleTravelModeValue } from "@/lib/plan/scheduleTravelMode"; -import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; import { useSessionUser } from "@/hooks/useSessionUser"; import { applyHostTransferToCache } from "@/lib/rooms/host-transfer-cache"; import { readSessionUserId } from "@/lib/session-user-cache"; @@ -209,22 +207,12 @@ export function useDeleteRoomSchedule() { roomId: string; scheduleId: number; }) => deleteRoomSchedule(roomId, scheduleId), - onSuccess: async (_data, { roomId, scheduleId }) => { + onSuccess: async (_data, { roomId }) => { await invalidateExpenses(queryClient, roomId); const id = roomId.trim(); if (!id.length) return; - const sorted = await refreshRoomSchedules(queryClient, id); - const scheduleStillExists = sorted.some((s) => s.scheduleId === scheduleId); - if (scheduleStillExists) { - await invalidateScheduleItemRouteForWholeSchedule( - queryClient, - id, - scheduleId, - ); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(id); - } - + await refreshRoomSchedules(queryClient, id); await syncRoomDetailFromServer(queryClient, id); }, }); @@ -303,7 +291,7 @@ export function useMoveScheduleItemToSchedule() { }, onSuccess: async ( _moved, - { roomId, sourceScheduleId, targetScheduleId, itemId }, + { roomId, sourceScheduleId, targetScheduleId }, ) => { await invalidateExpenses(queryClient, roomId); await syncAfterCrossScheduleItemMove( @@ -311,7 +299,6 @@ export function useMoveScheduleItemToSchedule() { roomId, sourceScheduleId, targetScheduleId, - itemId, ); }, }); @@ -453,7 +440,7 @@ export function useDeleteScheduleItem() { }) => deleteScheduleItem(vars.roomId, vars.scheduleId, vars.itemId), onSuccess: async (_, { roomId, scheduleId, itemId }) => { await invalidateExpenses(queryClient, roomId); - await applyScheduleItemDeletedOnClient( + removeScheduleItemFromPlanPlacesCache( queryClient, roomId, scheduleId, @@ -481,13 +468,12 @@ export function useReorderScheduleItem() { vars.itemId, vars.body, ), - onSuccess: (items, { roomId, scheduleId, itemId }) => + onSuccess: (items, { roomId, scheduleId }) => syncPlanPlacesAfterReorderSuccess( queryClient, roomId, scheduleId, items, - itemId, ), onSettled: (_items, _error, { roomId, scheduleId }) => invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId), diff --git a/src/lib/plan/planTravelLocalStorage.ts b/src/lib/plan/planTravelLocalStorage.ts index 4a385ff8..2908a5ce 100644 --- a/src/lib/plan/planTravelLocalStorage.ts +++ b/src/lib/plan/planTravelLocalStorage.ts @@ -75,21 +75,6 @@ function removeSessionStorageKeysMatchingPrefixes(prefixes: string[]): void { } } -/** 지정 구간(`segmentSourceItemId`)의 경로 sessionStorage만 제거 */ -export function clearPersistedScheduleRoutesForSegmentSources( - roomId: string, - scheduleId: number, - segmentSourceItemIds: number[], -): void { - const rid = roomId.trim(); - if (!rid.length || segmentSourceItemIds.length === 0) return; - removeSessionStorageKeysMatchingPrefixes( - segmentSourceItemIds.map( - (id) => `${NS_ROUTE}:${rid}:${scheduleId}:${id}:`, - ), - ); -} - /** 해당 일차 경로 sessionStorage 항목 일괄 제거 */ export function clearPersistedScheduleRoutesForSchedule( roomId: string, diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index adfa42c0..6eaad4f5 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -18,13 +18,6 @@ import { } from "@/lib/places/place-queries"; import { schedulePlacesFingerprint } from "@/lib/plan/planTravelLocalStorage"; import { sortRoomSchedules } from "@/lib/plan/scheduleMerge"; -import { - bumpPlanMapRouteSegments, - invalidateScheduleItemRouteForSources, - orderedItemIdsFromPlanPlaces, - readOrderedItemIdsFromScheduleItemsCache, - segmentSourcesWithChangedNext, -} from "@/lib/plan/scheduleStompRouteScope"; import { SCHEDULE_TRAVEL_MODE_DEFAULT, canonicalScheduleTravelMode, @@ -209,7 +202,7 @@ export async function buildSchedulePlacesFromSchedulesWithItems( } /** - * 만든 일차 목록을 한 번에 씁니다. 순서가 바뀐 일차는 이웃이 바뀐 구간 경로만 무효화합니다. + * 만든 일차 목록을 한 번에 씁니다. 경로는 도착 장소까지 key에 있어 순서가 바뀌어도 무효화하지 않습니다. * `startedAt`(일정 전체 조회를 보낸 시각) 뒤에 바뀐 일차는 그쪽이 더 새 상태라 덮지 않고, * 그 일차를 받는 중이면 이 조회보다 먼저 보낸 요청일 수 있어 다시 받게 합니다. */ @@ -227,20 +220,10 @@ export function writeSchedulePlacesFromHydrate( const state = queryClient.getQueryState(key); if (state && state.dataUpdatedAt > startedAt) continue; - const before = readOrderedItemIdsFromScheduleItemsCache(queryClient, rid, sid); queryClient.setQueryData(key, places); if (state?.fetchStatus === "fetching") { void queryClient.refetchQueries({ queryKey: key, exact: true }); } - - const changed = segmentSourcesWithChangedNext( - before, - orderedItemIdsFromPlanPlaces(places), - ); - if (changed.length) { - void invalidateScheduleItemRouteForSources(queryClient, rid, sid, changed); - bumpPlanMapRouteSegments(rid, sid, changed); - } } } @@ -307,7 +290,7 @@ function segmentDestinationsByItemId(places: readonly PlanPlace[]): Map ({ - bumpForDirections: vi.fn(), - bumpPlanMapRouteSegments: vi.fn(), createScheduleItem: vi.fn(), fetchPlacePreview: vi.fn(), getScheduleItems: vi.fn(), - invalidateScheduleItemRouteForSources: vi.fn(), - invalidateScheduleItemRouteForWholeSchedule: vi.fn(), - invalidateScheduleItemRoutesAfterDelete: vi.fn(), - invalidateScheduleItemRoutesAfterItemCreated: vi.fn(), - readOrderedItemIdsFromScheduleItemsCache: vi.fn(), })); vi.mock("@/lib/api/rooms/schedule-items", () => ({ @@ -31,22 +24,6 @@ vi.mock("@/lib/places/place-queries", () => ({ fetchPlacePreview: mocks.fetchPlacePreview, })); -vi.mock("@/lib/plan/scheduleStompRouteScope", () => ({ - bumpPlanMapRouteSegments: mocks.bumpPlanMapRouteSegments, - invalidateScheduleItemRouteForSources: - mocks.invalidateScheduleItemRouteForSources, - invalidateScheduleItemRouteForWholeSchedule: - mocks.invalidateScheduleItemRouteForWholeSchedule, - invalidateScheduleItemRoutesAfterDelete: - mocks.invalidateScheduleItemRoutesAfterDelete, - invalidateScheduleItemRoutesAfterItemCreated: - mocks.invalidateScheduleItemRoutesAfterItemCreated, - invalidateScheduleItemRoutesAfterReorder: vi.fn(), - readOrderedItemIdsFromScheduleItemsCache: - mocks.readOrderedItemIdsFromScheduleItemsCache, - removeRouteQueriesForDeletedItemSource: vi.fn(), -})); - vi.mock("@/lib/rooms", () => ({ awaitRoomSchedulesHydrated: vi.fn(), })); @@ -55,12 +32,6 @@ vi.mock("@/lib/query-client", () => ({ getQueryClient: vi.fn(), })); -vi.mock("@/stores/plan-map-directions-epoch-store", () => ({ - usePlanMapDirectionsEpochStore: { - getState: () => ({ bumpForDirections: mocks.bumpForDirections }), - }, -})); - let createScheduleItemAtPlanIndex: typeof import("@/lib/plan/scheduleItemPlaces").createScheduleItemAtPlanIndex; let applyCreatedScheduleItemToPlanPlaces: typeof import("@/lib/plan/scheduleItemPlaces").applyCreatedScheduleItemToPlanPlaces; let syncAfterCrossScheduleItemMove: typeof import("@/lib/plan/scheduleItemPlaces").syncAfterCrossScheduleItemMove; @@ -96,10 +67,6 @@ describe("createScheduleItemAtPlanIndex", () => { name: "Created place", formattedAddress: "Address", }); - mocks.invalidateScheduleItemRouteForSources.mockResolvedValue(undefined); - mocks.invalidateScheduleItemRoutesAfterItemCreated.mockResolvedValue( - undefined, - ); }); const response: CreateScheduleItemResponse = { @@ -131,7 +98,7 @@ describe("createScheduleItemAtPlanIndex", () => { }); }); - it("merges the create response and invalidates only the route IDs it returns", async () => { + it("merges the create response into the day list", async () => { const queryClient = new QueryClient(); const key = scheduleItemsQueryKey("room-1", 10); queryClient.setQueryData(key, places); @@ -141,82 +108,27 @@ describe("createScheduleItemAtPlanIndex", () => { expect( queryClient.getQueryData(key)?.map((place) => place.itemId), ).toEqual([1, 3, 2]); - expect( - mocks.invalidateScheduleItemRoutesAfterItemCreated, - ).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [777], - ); - expect(mocks.bumpPlanMapRouteSegments).toHaveBeenCalledWith( - "room-1", - 10, - [777], - ); }); - it("does not fail the saved create when post-create route synchronization fails", async () => { + it("merges a placeholder place when the created place preview fails", async () => { const queryClient = new QueryClient(); - mocks.invalidateScheduleItemRouteForSources.mockRejectedValueOnce( - new Error("route sync failed"), - ); - queryClient.setQueryData(scheduleItemsQueryKey("room-1", 10), places); + const key = scheduleItemsQueryKey("room-1", 10); + mocks.fetchPlacePreview.mockRejectedValueOnce(new Error("preview 429")); + queryClient.setQueryData(key, places); await expect( applyCreatedScheduleItemToPlanPlaces(queryClient, "room-1", 10, response), ).resolves.toBeUndefined(); - expect( - mocks.invalidateScheduleItemRouteForWholeSchedule, - ).toHaveBeenCalledWith(queryClient, "room-1", 10); - expect(mocks.bumpForDirections).toHaveBeenCalledWith("room-1"); - }); - - it("maps server-provided move route IDs to the refreshed schedules", async () => { - const queryClient = new QueryClient(); - mocks.getScheduleItems.mockResolvedValue([]); - mocks.readOrderedItemIdsFromScheduleItemsCache.mockImplementation( - (_queryClient: QueryClient, _roomId: string, scheduleId: number) => - scheduleId === 10 ? [11] : [20, 21], - ); - - await syncAfterCrossScheduleItemMove( - queryClient, - "room-1", - 10, - 20, - 20, - [11, 21, 20], - ); - - expect(mocks.invalidateScheduleItemRoutesAfterDelete).not.toHaveBeenCalled(); - expect( - mocks.invalidateScheduleItemRoutesAfterItemCreated, - ).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenNthCalledWith( - 1, - queryClient, - "room-1", - 10, - [11], - ); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenNthCalledWith( - 2, - queryClient, - "room-1", - 20, - [21, 20], - ); - expect( - mocks.invalidateScheduleItemRouteForWholeSchedule, - ).not.toHaveBeenCalled(); + expect(queryClient.getQueryData(key)?.[1]).toMatchObject({ + itemId: 3, + title: "장소 정보를 불러올 수 없음", + }); }); }); describe("cross-schedule refresh sequencing", () => { - it("waits for source refresh before target and routes", async () => { + it("waits for source refresh before target", async () => { const client = new QueryClient(); let finishSource!: () => void; const source = new Promise((resolve) => { finishSource = resolve; }); @@ -234,12 +146,10 @@ describe("cross-schedule refresh sequencing", () => { await syncAfterCrossScheduleItemMove(client, "room-1", 10, 10); expect(invalidate).toHaveBeenCalledTimes(1); }); - it("does not refresh target or routes when source refresh fails", async () => { - vi.clearAllMocks(); + it("does not refresh target when source refresh fails", async () => { const client = new QueryClient(); const invalidate = vi.spyOn(client, "invalidateQueries").mockRejectedValue(new Error("source failed")); await expect(syncAfterCrossScheduleItemMove(client, "room-1", 10, 20)).rejects.toThrow("source failed"); expect(invalidate).toHaveBeenCalledTimes(1); - expect(mocks.invalidateScheduleItemRouteForWholeSchedule).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index a2aef4fa..1443483f 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -22,16 +22,6 @@ import { resolveScheduleItemsFromCacheOrHydrate, } from "@/lib/plan/schedule-bulk-hydration"; import { getQueryClient } from "@/lib/query-client"; -import { - bumpPlanMapRouteSegments, - invalidateScheduleItemRouteForSources, - invalidateScheduleItemRouteForWholeSchedule, - invalidateScheduleItemRoutesAfterDelete, - invalidateScheduleItemRoutesAfterItemCreated, - invalidateScheduleItemRoutesAfterReorder, - readOrderedItemIdsFromScheduleItemsCache, - removeRouteQueriesForDeletedItemSource, -} from "@/lib/plan/scheduleStompRouteScope"; import { scheduleItemsQueryKey } from "@/lib/query-keys"; import { addMinutesToHm, @@ -41,7 +31,6 @@ import { normalizeStartTimeToHm, } from "@/lib/plan/scheduleTime"; import type { PlanPlace } from "@/lib/plan/types"; -import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; /** `orderIndex` 기준 정렬(비변형) */ export function sortRoomScheduleItemsByOrder( @@ -194,48 +183,12 @@ export function removeScheduleItemFromPlanPlacesCache( ); } -/** 본인 DELETE mutation 직후 — 캐시 패치·삭제 구간 route·쿼리 정리 */ -export async function applyScheduleItemDeletedOnClient( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - deletedItemId: number, -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - - const oldIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - scheduleId, - ); - removeScheduleItemFromPlanPlacesCache( - queryClient, - rid, - scheduleId, - deletedItemId, - ); - removeRouteQueriesForDeletedItemSource( - queryClient, - rid, - scheduleId, - deletedItemId, - ); - await invalidateScheduleItemRoutesAfterDelete( - queryClient, - rid, - scheduleId, - deletedItemId, - oldIds, - ); -} - /** * STOMP·뮤테이션 동기화: 그 일차 장소 목록을 서버에서 다시 받게 합니다. 보는 화면이 있으면 바로 받고, * 없으면 다시 받을 목록으로 표시해 두었다가 그 화면에 들어갈 때 받습니다. * 같은 일차를 받는 중이면 그 조회는 이 변경 전에 보낸 요청이라 취소하고 새로 받습니다(늦게 온 옛 응답은 버려짐). * 본인 저장의 반향처럼 받는 중인 조회가 이미 그 변경 뒤에 보낸 것이면 `joinInFlight`로 합류해 요청을 아낍니다. - * 다시 받지 못하면 throw합니다 — 호출부는 목록이 바뀐 뒤에만 경로를 무효화합니다. + * 다시 받지 못하면 throw합니다. */ export async function invalidateSchedulePlanPlaces( queryClient: QueryClient, @@ -275,98 +228,23 @@ export async function mergeOrRefetchSchedulePlanPlacesFromItems( ); } -/** cross-day item 이동 후 source/target 일차 items·route 캐시 동기화 */ +/** + * 다른 일차로 옮긴 뒤 두 일차 장소 목록을 다시 받습니다. 경로는 도착 장소까지 key에 있어 + * 순서가 바뀐 구간은 새 key로 받으므로 따로 무효화하지 않습니다. + */ export async function syncAfterCrossScheduleItemMove( queryClient: QueryClient, roomId: string, sourceScheduleId: number, targetScheduleId: number, - movedItemId?: number, - affectedRouteItemIds?: number[] | null, ): Promise { const rid = roomId.trim(); if (!rid.length) return; - const scheduleIds = - sourceScheduleId === targetScheduleId - ? [sourceScheduleId] - : [sourceScheduleId, targetScheduleId]; - - const oldSourceOrderedIds = - movedItemId != null - ? readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - sourceScheduleId, - ) - : null; - await invalidateSchedulePlanPlaces(queryClient, rid, sourceScheduleId); if (targetScheduleId !== sourceScheduleId) { await invalidateSchedulePlanPlaces(queryClient, rid, targetScheduleId); } - - if (affectedRouteItemIds !== undefined) { - if (affectedRouteItemIds === null) { - for (const sid of scheduleIds) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - } - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - return; - } - - const remaining = new Set(affectedRouteItemIds); - for (const sid of scheduleIds) { - const orderedIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - sid, - ); - const scheduleItemIds = new Set(orderedIds ?? []); - const sources = affectedRouteItemIds.filter( - (id) => remaining.has(id) && scheduleItemIds.has(id), - ); - for (const id of sources) remaining.delete(id); - await invalidateScheduleItemRouteForSources( - queryClient, - rid, - sid, - sources, - ); - bumpPlanMapRouteSegments(rid, sid, sources); - } - - if (remaining.size > 0) { - for (const sid of scheduleIds) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - } - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - } - return; - } - - if (movedItemId != null) { - await invalidateScheduleItemRoutesAfterDelete( - queryClient, - rid, - sourceScheduleId, - movedItemId, - oldSourceOrderedIds, - ); - await invalidateScheduleItemRoutesAfterItemCreated( - queryClient, - rid, - targetScheduleId, - movedItemId, - ); - return; - } - - for (const sid of scheduleIds) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - } - - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); } /** 캐시에 preview 에러 title이 있으면 enrich 후 `schedule-items`를 갱신합니다. */ @@ -541,17 +419,10 @@ export async function syncPlanPlacesAfterReorderSuccess( roomId: string, scheduleId: number, items: RoomScheduleItem[], - movedItemId: number, ): Promise { const rid = roomId.trim(); if (!rid.length) return; - const oldOrderedIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - scheduleId, - ); - await mergeOrRefetchSchedulePlanPlacesFromItems( queryClient, roomId, @@ -560,17 +431,7 @@ export async function syncPlanPlacesAfterReorderSuccess( ); const patches = buildChainedStartPatchesForReorder(items); - if (patches.length === 0) { - await invalidateScheduleItemRoutesAfterReorder( - queryClient, - rid, - scheduleId, - movedItemId, - oldOrderedIds, - items, - ); - return; - } + if (patches.length === 0) return; const snapshot = sortRoomScheduleItemsByOrder(items); const byItemId = new Map(snapshot.map((it) => [it.itemId, it])); @@ -589,20 +450,11 @@ export async function syncPlanPlacesAfterReorderSuccess( return; } - const finalItems = sortRoomScheduleItemsByOrder([...byItemId.values()]); await mergeOrRefetchSchedulePlanPlacesFromItems( queryClient, roomId, scheduleId, - finalItems, - ); - await invalidateScheduleItemRoutesAfterReorder( - queryClient, - rid, - scheduleId, - movedItemId, - oldOrderedIds, - finalItems, + sortRoomScheduleItemsByOrder([...byItemId.values()]), ); } @@ -733,37 +585,20 @@ export function createScheduleItemAtPlanIndex(args: { }); } -/** - * 장소 추가 응답(추가된 항목·순서가 밀린 항목)을 일차 목록에 합치고, 응답이 알려 준 구간 경로만 무효화합니다. - * 추가는 이미 저장됐으므로 반영이 실패해도 던지지 않고 그 일차 경로를 모두 무효화합니다. - */ +/** 장소 추가 응답(추가된 항목·순서가 밀린 항목)을 일차 목록에 합칩니다. */ export async function applyCreatedScheduleItemToPlanPlaces( queryClient: QueryClient, roomId: string, scheduleId: number, response: CreateScheduleItemResponse, ): Promise { - const rid = roomId.trim(); - try { - const createdPlace = await planPlaceFromScheduleItem(response.createdItem); - // 미리보기를 기다리는 사이 다른 사람의 변경을 받았을 수 있어 그 뒤의 목록에 합친다 - queryClient.setQueryData( - scheduleItemsQueryKey(rid, scheduleId), - (old) => mergeCreatedScheduleItemDelta(old, createdPlace, response) ?? old, - ); - await invalidateScheduleItemRouteForSources( - queryClient, - rid, - scheduleId, - response.affectedRouteItemIds, - ); - bumpPlanMapRouteSegments(rid, scheduleId, response.affectedRouteItemIds); - } catch { - await Promise.allSettled([ - invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, scheduleId), - ]); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - } + // 미리보기를 못 받으면 자리표시자 장소로 합친다(planPlaceFromScheduleItem은 던지지 않음) + const createdPlace = await planPlaceFromScheduleItem(response.createdItem); + // 미리보기를 기다리는 사이 다른 사람의 변경을 받았을 수 있어 그 뒤의 목록에 합친다 + queryClient.setQueryData( + scheduleItemsQueryKey(roomId.trim(), scheduleId), + (old) => mergeCreatedScheduleItemDelta(old, createdPlace, response) ?? old, + ); } /** 드래그를 `toIndex` 자리에 놓았을 때 PATCH에 넣을 `newOrderIndex`(0-based) */ diff --git a/src/lib/plan/scheduleSegmentRoute.test.ts b/src/lib/plan/scheduleSegmentRoute.test.ts index 6d9c0c6b..ba2a171e 100644 --- a/src/lib/plan/scheduleSegmentRoute.test.ts +++ b/src/lib/plan/scheduleSegmentRoute.test.ts @@ -109,10 +109,7 @@ describe("resolveScheduleSegmentRoute", () => { expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(1); }); - /** - * 캐시 쓰기는 React Query가 소유합니다. 리졸버가 직접 setQueryData 하면 - * 무효화 플래그까지 지워져 재정렬 뒤 낡은 경로가 유효한 값으로 굳습니다. - */ + /** 캐시 쓰기는 이 리졸버를 queryFn으로 쓰는 쿼리가 맡는다 */ it("캐시에 직접 쓰지 않는다", async () => { getScheduleItemRouteMock.mockResolvedValue(ROUTE); diff --git a/src/lib/plan/scheduleSegmentRoute.ts b/src/lib/plan/scheduleSegmentRoute.ts index 4f8b480b..355ae04f 100644 --- a/src/lib/plan/scheduleSegmentRoute.ts +++ b/src/lib/plan/scheduleSegmentRoute.ts @@ -25,9 +25,7 @@ export type ResolveScheduleSegmentRouteArgs = { * 거리·소요 시간과 지도 폴리라인이 같은 캐시 항목을 공유하도록 일정 리스트와 지도가 모두 * 이 함수를 queryFn으로 쓰는 하나의 쿼리(`scheduleItemRouteQueryKey`)를 거칩니다. * - * in-flight 합치기와 재정렬 중 뒤늦게 도착한 응답 폐기는 React Query가 담당합니다. - * 여기서 직접 캐시에 쓰지 않습니다 — `setQueryData`는 무효화 플래그까지 지워서 - * 순서가 바뀐 뒤의 낡은 경로를 유효한 값으로 만들어 버립니다. + * in-flight 합치기와 캐시 쓰기는 이 함수를 queryFn으로 쓰는 쿼리(React Query)가 맡아, 여기서 직접 캐시에 쓰지 않습니다. */ export async function resolveScheduleSegmentRoute({ roomId, diff --git a/src/lib/plan/scheduleStompRouteScope.test.ts b/src/lib/plan/scheduleStompRouteScope.test.ts deleted file mode 100644 index 08b4e576..00000000 --- a/src/lib/plan/scheduleStompRouteScope.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { segmentSourcesWithChangedNext } from "@/lib/plan/scheduleStompRouteScope"; - -const sorted = (ids: number[]) => [...ids].sort((a, b) => a - b); - -describe("segmentSourcesWithChangedNext", () => { - it("returns nothing when there was no earlier list to route from", () => { - expect(segmentSourcesWithChangedNext(null, [1, 2, 3])).toEqual([]); - }); - - it("returns nothing when the order is unchanged", () => { - expect(segmentSourcesWithChangedNext([1, 2, 3], [1, 2, 3])).toEqual([]); - }); - - it("collects the sources whose next stop changed after a reorder", () => { - // [A,X,B,C] → [A,B,X,C]: A·X·B의 다음 장소가 바뀌었고 C는 마지막이라 구간이 없다 - expect(sorted(segmentSourcesWithChangedNext([1, 9, 2, 3], [1, 2, 9, 3]))).toEqual([ - 1, 2, 9, - ]); - }); - - it("includes the removed stop and its predecessor after a deletion", () => { - expect(sorted(segmentSourcesWithChangedNext([1, 2, 3], [1, 3]))).toEqual([1, 2]); - }); - - it("includes the new stop and its predecessor after an insertion", () => { - expect(sorted(segmentSourcesWithChangedNext([1, 3], [1, 2, 3]))).toEqual([1, 2]); - }); -}); diff --git a/src/lib/plan/scheduleStompRouteScope.ts b/src/lib/plan/scheduleStompRouteScope.ts deleted file mode 100644 index 8e69ea1d..00000000 --- a/src/lib/plan/scheduleStompRouteScope.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type { QueryClient } from "@tanstack/react-query"; - -import type { RoomScheduleItem } from "@/lib/api/rooms/schedule-items"; -import { - clearPersistedScheduleRoutesForSchedule, - clearPersistedScheduleRoutesForSegmentSources, -} from "@/lib/plan/planTravelLocalStorage"; -import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; -import type { PlanPlace } from "@/lib/plan/types"; -import { scheduleItemsQueryKey } from "@/lib/query-keys"; - -function asOrderedItemIds(places: unknown): number[] | null { - if (!Array.isArray(places)) return null; - const out: number[] = []; - for (const p of places) { - const row = p as PlanPlace; - if (typeof row?.itemId === "number" && Number.isFinite(row.itemId)) { - out.push(row.itemId); - } - } - return out.length > 0 || places.length === 0 ? out : null; -} - -/** `schedule-items` 캐시의 `PlanPlace[]`에서 순서대로 `itemId`를 뽑습니다. */ -export function readOrderedItemIdsFromScheduleItemsCache( - queryClient: QueryClient, - roomId: string, - scheduleId: number, -): number[] | null { - const data = queryClient.getQueryData( - scheduleItemsQueryKey(roomId, scheduleId), - ); - return asOrderedItemIds(data ?? null); -} - -/** `PlanPlace[]` → 순서대로 `itemId` 목록 */ -export function orderedItemIdsFromPlanPlaces( - places: readonly PlanPlace[], -): number[] { - return asOrderedItemIds(places) ?? []; -} - -/** - * 재조회 전후 순서를 비교해 다음 장소가 바뀐 구간의 출발 `itemId`를 모읍니다. - * 이전 캐시가 없으면 받아 둔 구간 경로도 없으므로 비웁니다. - */ -export function segmentSourcesWithChangedNext( - before: number[] | null, - after: number[], -): number[] { - if (!before) return []; - const nextOf = (ids: number[]) => - new Map(ids.map((id, i) => [id, ids[i + 1]])); - const beforeNext = nextOf(before); - const afterNext = nextOf(after); - const changed = new Set(); - for (const [id, next] of beforeNext) { - if (afterNext.get(id) !== next) changed.add(id); - } - for (const [id, next] of afterNext) { - if (beforeNext.get(id) !== next) changed.add(id); - } - return [...changed]; -} - -/** 리오더 API 응답 `RoomScheduleItem[]` → `orderIndex` 순 `itemId` 목록 */ -export function orderedItemIdsFromRoomScheduleItems( - items: RoomScheduleItem[], -): number[] { - return [...items] - .sort((a, b) => a.orderIndex - b.orderIndex) - .map((it) => it.itemId) - .filter((id): id is number => typeof id === "number" && Number.isFinite(id)); -} - -/** 항목 `itemId`에 인접한 구간의 출발 `segmentSourceItemId` (최대 prev, 자신). */ -function segmentSourcesTouchingItem( - orderedItemIds: number[], - itemId: number, -): Set { - const s = new Set(); - const i = orderedItemIds.indexOf(itemId); - if (i < 0) return s; - if (i > 0) s.add(orderedItemIds[i - 1]!); - if (i < orderedItemIds.length - 1) s.add(orderedItemIds[i]!); - return s; -} - -export function collectSegmentSourcesForCreate( - newOrderedIds: number[] | null, - itemId: number, -): { sources: number[]; useFallback: boolean } { - if (!newOrderedIds?.length) { - return { sources: [], useFallback: true }; - } - if (newOrderedIds.indexOf(itemId) < 0) { - return { sources: [], useFallback: true }; - } - return { - sources: [...segmentSourcesTouchingItem(newOrderedIds, itemId)], - useFallback: false, - }; -} - -export function removeRouteQueriesForDeletedItemSource( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - itemId: number, -): void { - const rid = roomId.trim(); - queryClient.removeQueries({ - predicate: (q) => { - const key = q.queryKey; - if (!Array.isArray(key) || key[0] !== "schedule-item-route") { - return false; - } - if (String(key[1] ?? "").trim() !== rid) return false; - if (key[2] !== scheduleId) return false; - return key[3] === itemId; - }, - }); -} - -export function collectSegmentSourcesForDelete( - oldOrderedIds: number[] | null, - deletedItemId: number, -): { sources: number[]; useFallback: boolean } { - if (!oldOrderedIds?.length) { - return { sources: [], useFallback: true }; - } - const i = oldOrderedIds.indexOf(deletedItemId); - if (i < 0) { - return { sources: [], useFallback: true }; - } - if (i === 0) { - return { sources: [], useFallback: false }; - } - return { sources: [oldOrderedIds[i - 1]!], useFallback: false }; -} - -export function collectSegmentSourcesForReorder( - oldOrderedIds: number[] | null, - newOrderedIds: number[] | null, - itemId: number, -): { sources: number[]; useFallback: boolean } { - if (!newOrderedIds?.length) { - return { sources: [], useFallback: true }; - } - if (newOrderedIds.indexOf(itemId) < 0) { - return { sources: [], useFallback: true }; - } - const s = new Set(segmentSourcesTouchingItem(newOrderedIds, itemId)); - if (oldOrderedIds?.length) { - segmentSourcesTouchingItem(oldOrderedIds, itemId).forEach((id) => - s.add(id), - ); - } - return { sources: [...s], useFallback: false }; -} - -export function bumpPlanMapRouteSegments( - roomId: string, - scheduleId: number, - sourceItemIds: number[], -): void { - if (sourceItemIds.length === 0) return; - const rid = roomId.trim(); - if (!rid.length) return; - usePlanMapDirectionsEpochStore.getState().bumpSegments( - rid, - sourceItemIds.map((segmentSourceItemId) => ({ - scheduleId, - segmentSourceItemId, - })), - ); -} - -/** 항목 삭제 직후 — 삭제 전 순서 스냅샷 기준 인접 구간 route만 무효화(본인 mutation). */ -export async function invalidateScheduleItemRoutesAfterDelete( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - deletedItemId: number, - oldOrderedIds: number[] | null, -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - const { sources, useFallback } = collectSegmentSourcesForDelete( - oldOrderedIds, - deletedItemId, - ); - if (useFallback) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, scheduleId); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - return; - } - await invalidateScheduleItemRouteForSources(queryClient, rid, scheduleId, sources); - bumpPlanMapRouteSegments(rid, scheduleId, sources); -} - -/** 항목 생성 후 캐시 반영 직후 — 인접 구간 route만 무효화(본인 mutation). */ -export async function invalidateScheduleItemRoutesAfterItemCreated( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - itemId: number, -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - const orderedIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - scheduleId, - ); - const { sources, useFallback } = collectSegmentSourcesForCreate( - orderedIds, - itemId, - ); - if (useFallback) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, scheduleId); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - return; - } - await invalidateScheduleItemRouteForSources(queryClient, rid, scheduleId, sources); - bumpPlanMapRouteSegments(rid, scheduleId, sources); -} - -/** - * 리오더·중간 삽입 직후 — STOMP보다 먼저 캐시·서버 순서를 알 때 구간만 무효화. - * `oldOrderedIds`는 merge 전 `schedule-items` 스냅샷, `items`는 리오더(또는 최종 PATCH) 응답. - */ -export async function invalidateScheduleItemRoutesAfterReorder( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - movedItemId: number, - oldOrderedIds: number[] | null, - items: RoomScheduleItem[], -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - const newOrderedIds = orderedItemIdsFromRoomScheduleItems(items); - const { sources, useFallback } = collectSegmentSourcesForReorder( - oldOrderedIds, - newOrderedIds, - movedItemId, - ); - if (useFallback) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, scheduleId); - usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); - return; - } - await invalidateScheduleItemRouteForSources(queryClient, rid, scheduleId, sources); - bumpPlanMapRouteSegments(rid, scheduleId, sources); -} - -export async function invalidateScheduleItemRouteForSources( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - sourceItemIds: number[], -): Promise { - if (sourceItemIds.length === 0) return; - const rid = roomId.trim(); - clearPersistedScheduleRoutesForSegmentSources(rid, scheduleId, sourceItemIds); - const want = new Set(sourceItemIds); - await queryClient.invalidateQueries({ - predicate: (q) => { - const key = q.queryKey; - if (!Array.isArray(key) || key[0] !== "schedule-item-route") { - return false; - } - if (String(key[1] ?? "").trim() !== rid) return false; - if (key[2] !== scheduleId) return false; - return typeof key[3] === "number" && want.has(key[3]); - }, - /** `all`이면 옵저버 없는 캐시(재정렬로 구간이 사라진 뒤)도 refetch되어 모드 4개가 동시에 나감 */ - refetchType: "active", - }); -} - -export async function invalidateScheduleItemRouteForWholeSchedule( - queryClient: QueryClient, - roomId: string, - scheduleId: number, -): Promise { - const rid = roomId.trim(); - clearPersistedScheduleRoutesForSchedule(rid, scheduleId); - await queryClient.invalidateQueries({ - predicate: (q) => { - const key = q.queryKey; - if (!Array.isArray(key) || key[0] !== "schedule-item-route") { - return false; - } - if (String(key[1] ?? "").trim() !== rid) return false; - return key[2] === scheduleId; - }, - refetchType: "active", - }); -} diff --git a/src/lib/stomp/schedules-dispatch.order.test.ts b/src/lib/stomp/schedules-dispatch.order.test.ts index 6656e6d8..91f15bfe 100644 --- a/src/lib/stomp/schedules-dispatch.order.test.ts +++ b/src/lib/stomp/schedules-dispatch.order.test.ts @@ -36,27 +36,12 @@ vi.mock("@/lib/plan/schedule-bulk-hydration", async (importOriginal) => ({ .map((item) => fixtures.place(item.itemId)), resolveScheduleItemsFromCacheOrHydrate: vi.fn(), })); -vi.mock("@/lib/plan/scheduleStompRouteScope", () => ({ - bumpPlanMapRouteSegments: vi.fn(), - invalidateScheduleItemRouteForSources: vi.fn(async () => undefined), - invalidateScheduleItemRouteForWholeSchedule: vi.fn(async () => undefined), - invalidateScheduleItemRoutesAfterDelete: vi.fn(async () => undefined), - invalidateScheduleItemRoutesAfterItemCreated: vi.fn(async () => undefined), - invalidateScheduleItemRoutesAfterReorder: vi.fn(async () => undefined), - readOrderedItemIdsFromScheduleItemsCache: vi.fn(() => null), - removeRouteQueriesForDeletedItemSource: vi.fn(), -})); vi.mock("@/lib/rooms", () => ({ syncRoomDetailFromServer: vi.fn(), })); vi.mock("@/lib/query-client", () => ({ getQueryClient: () => null, })); -vi.mock("@/stores/plan-map-directions-epoch-store", () => ({ - usePlanMapDirectionsEpochStore: { - getState: () => ({ bumpForDirections: vi.fn(), bumpSegments: vi.fn() }), - }, -})); import { removeScheduleItemFromPlanPlacesCache, diff --git a/src/lib/stomp/schedules-dispatch.test.ts b/src/lib/stomp/schedules-dispatch.test.ts index cbd60a4f..6f93d322 100644 --- a/src/lib/stomp/schedules-dispatch.test.ts +++ b/src/lib/stomp/schedules-dispatch.test.ts @@ -1,19 +1,12 @@ import { QueryClient } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { sessionUserQueryKey } from "@/lib/query-keys"; +import { scheduleItemRouteQueryKey, sessionUserQueryKey } from "@/lib/query-keys"; import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; const mocks = vi.hoisted(() => ({ - collectSegmentSourcesForCreate: vi.fn(), - collectSegmentSourcesForDelete: vi.fn(), - collectSegmentSourcesForReorder: vi.fn(), invalidateSchedulePlanPlaces: vi.fn(), - invalidateScheduleItemRouteForSources: vi.fn(), - invalidateScheduleItemRouteForWholeSchedule: vi.fn(), - readOrderedItemIdsFromScheduleItemsCache: vi.fn(), - removeRouteQueriesForDeletedItemSource: vi.fn(), syncAfterCrossScheduleItemMove: vi.fn(), })); @@ -22,33 +15,10 @@ vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ syncAfterCrossScheduleItemMove: mocks.syncAfterCrossScheduleItemMove, })); -vi.mock("@/lib/plan/scheduleStompRouteScope", () => ({ - collectSegmentSourcesForCreate: mocks.collectSegmentSourcesForCreate, - collectSegmentSourcesForDelete: mocks.collectSegmentSourcesForDelete, - collectSegmentSourcesForReorder: mocks.collectSegmentSourcesForReorder, - invalidateScheduleItemRouteForSources: - mocks.invalidateScheduleItemRouteForSources, - invalidateScheduleItemRouteForWholeSchedule: - mocks.invalidateScheduleItemRouteForWholeSchedule, - readOrderedItemIdsFromScheduleItemsCache: - mocks.readOrderedItemIdsFromScheduleItemsCache, - removeRouteQueriesForDeletedItemSource: - mocks.removeRouteQueriesForDeletedItemSource, -})); - vi.mock("@/lib/rooms", () => ({ syncRoomDetailFromServer: vi.fn(), })); -vi.mock("@/stores/plan-map-directions-epoch-store", () => ({ - usePlanMapDirectionsEpochStore: { - getState: () => ({ - bumpForDirections: vi.fn(), - bumpSegments: vi.fn(), - }), - }, -})); - import { dispatchRoomScheduleEvent } from "@/lib/stomp/schedules-dispatch"; function itemEvent( @@ -68,15 +38,14 @@ function itemEvent( describe("dispatchRoomScheduleEvent", () => { let queryClient: QueryClient; + // 경로는 도착 장소까지 key에 있어 일정이 바뀌어도 무효화하지 않는다 — 순서가 바뀐 구간은 새 key로 받는다 + const routeKey = scheduleItemRouteQueryKey("room-1", 10, 20, "place-b", "DRIVING"); beforeEach(() => { queryClient = new QueryClient(); + queryClient.setQueryData(routeKey, { travelMode: "DRIVING", distanceMeters: 100, durationSeconds: 60 }); vi.clearAllMocks(); mocks.invalidateSchedulePlanPlaces.mockResolvedValue(undefined); - mocks.invalidateScheduleItemRouteForSources.mockResolvedValue(undefined); - mocks.invalidateScheduleItemRouteForWholeSchedule.mockResolvedValue( - undefined, - ); mocks.syncAfterCrossScheduleItemMove.mockResolvedValue(undefined); }); @@ -96,74 +65,20 @@ describe("dispatchRoomScheduleEvent", () => { expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10, { joinInFlight: ownUser, }); - expect(mocks.invalidateScheduleItemRouteForSources).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForWholeSchedule).not.toHaveBeenCalled(); - }); - - it("uses affectedRouteItemIds instead of calculating routes for a created item", async () => { - mocks.readOrderedItemIdsFromScheduleItemsCache.mockReturnValue([10, 20]); - mocks.collectSegmentSourcesForCreate.mockReturnValue({ - sources: [10, 20], - useFallback: false, - }); - - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEM_CREATED", [777]), - ); - - expect(mocks.collectSegmentSourcesForCreate).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [777], - ); + expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); }); - it("uses affectedRouteItemIds instead of calculating routes for a deleted item", async () => { - mocks.collectSegmentSourcesForDelete.mockReturnValue({ - sources: [10], - useFallback: false, - }); + it.each(["SCHEDULE_ITEM_CREATED", "SCHEDULE_ITEM_DELETED", "SCHEDULE_ITEMS_REORDERED"] as const)( + "refetches the day list without invalidating route caches for %s", + async (type) => { + await dispatchRoomScheduleEvent(queryClient, itemEvent(type, [20])); - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEM_DELETED", [888]), - ); - - expect(mocks.collectSegmentSourcesForDelete).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [888], - ); - }); - - it("uses affectedRouteItemIds instead of calculating routes after reorder", async () => { - vi.useFakeTimers(); - mocks.collectSegmentSourcesForReorder.mockReturnValue({ - sources: [10, 20], - useFallback: false, - }); - - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEMS_REORDERED", [999]), - ); - await vi.runAllTimersAsync(); + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10); + expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); + }, + ); - expect(mocks.collectSegmentSourcesForReorder).not.toHaveBeenCalled(); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [999], - ); - }); - - it("passes affectedRouteItemIds through a cross-schedule move", async () => { + it("refetches both days after a cross-schedule move", async () => { const event = itemEvent("SCHEDULE_ITEM_MOVED", [11, 21, 20]); event.scheduleId = 20; event.scheduleIds = [10, 20]; @@ -175,20 +90,12 @@ describe("dispatchRoomScheduleEvent", () => { "room-1", 10, 20, - 20, - [11, 21, 20], ); + expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); }); - it("refreshes travel mode before invalidating affectedRouteItemIds", async () => { + it("refetches the day list after another member changes a travel mode, leaving route caches as they are", async () => { vi.useFakeTimers(); - const order: string[] = []; - mocks.invalidateSchedulePlanPlaces.mockImplementation(async () => { - order.push("refetch"); - }); - mocks.invalidateScheduleItemRouteForSources.mockImplementation(async () => { - order.push("invalidate"); - }); await dispatchRoomScheduleEvent( queryClient, @@ -196,52 +103,10 @@ describe("dispatchRoomScheduleEvent", () => { ); await vi.runAllTimersAsync(); - expect(order).toEqual(["refetch", "invalidate"]); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [20], - ); - }); - - it("preserves a newer route bucket when an earlier travel-mode refresh fails", async () => { - vi.useFakeTimers(); - let rejectFirst!: (reason?: unknown) => void; - let resolveSecond!: () => void; - const first = new Promise((_, reject) => { - rejectFirst = reject; - }); - const second = new Promise((resolve) => { - resolveSecond = resolve; + expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10, { + joinInFlight: false, }); - mocks.invalidateSchedulePlanPlaces - .mockReturnValueOnce(first) - .mockReturnValueOnce(second); - - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEM_TRAVEL_MODE_UPDATED", [20]), - ); - await vi.advanceTimersByTimeAsync(90); - - await dispatchRoomScheduleEvent( - queryClient, - itemEvent("SCHEDULE_ITEM_TRAVEL_MODE_UPDATED", [30]), - ); - await vi.advanceTimersByTimeAsync(90); - - rejectFirst(new Error("first refresh failed")); - resolveSecond(); - await vi.runAllTimersAsync(); - - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledTimes(1); - expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( - queryClient, - "room-1", - 10, - [30], - ); + expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false); }); }); diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index 6f1bf699..89e87c48 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -5,119 +5,16 @@ import { invalidateSchedulePlanPlaces, syncAfterCrossScheduleItemMove, } from "@/lib/plan/scheduleItemPlaces"; -import { - invalidateScheduleItemRouteForSources, - invalidateScheduleItemRouteForWholeSchedule, - removeRouteQueriesForDeletedItemSource, -} from "@/lib/plan/scheduleStompRouteScope"; import { roomSchedulesQueryKey } from "@/lib/query-keys"; import { syncRoomDetailFromServer } from "@/lib/rooms"; import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; import { readSessionUserId } from "@/lib/session-user-cache"; -import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store"; - -function bumpMapForRouteSources( - roomId: string, - scheduleId: number, - sourceItemIds: number[], -): void { - if (sourceItemIds.length === 0) return; - usePlanMapDirectionsEpochStore.getState().bumpSegments( - roomId, - sourceItemIds.map((segmentSourceItemId) => ({ - scheduleId, - segmentSourceItemId, - })), - ); -} - -type DebouncedRouteInvBucket = - | { scope: "whole" } - | { scope: "sources"; ids: Set }; - -/** 리오더 시 같은 구간 무효화를 짧게 묶어 `/route` 이중 패치를 줄임 */ -const scheduleRouteInvalidateDebounceTimers = new Map< - string, - ReturnType ->(); -const scheduleRouteInvalidatePending = new Map(); -const DEBOUNCE_SCHEDULE_ROUTE_INVALIDATE_MS = 100; - -function mergeRouteInvalidateBucket( - prev: DebouncedRouteInvBucket | undefined, - sourcesToAdd: number[], - forceWholeSchedule: boolean, -): DebouncedRouteInvBucket { - if (forceWholeSchedule || prev?.scope === "whole") { - return { scope: "whole" }; - } - const ids = - prev?.scope === "sources" ? prev.ids : new Set(); - for (const id of sourcesToAdd) { - ids.add(id); - } - return { scope: "sources", ids }; -} - -function enqueueDebouncedInvalidateScheduleRoutes( - queryClient: QueryClient, - rid: string, - sid: number, - sourcesForInvalidation: number[], - forceWholeSchedule: boolean, -): void { - const mapKey = `${rid}:${sid}`; - scheduleRouteInvalidatePending.set( - mapKey, - mergeRouteInvalidateBucket( - scheduleRouteInvalidatePending.get(mapKey), - sourcesForInvalidation, - forceWholeSchedule, - ), - ); - const existing = scheduleRouteInvalidateDebounceTimers.get(mapKey); - if (existing !== undefined) clearTimeout(existing); - - scheduleRouteInvalidateDebounceTimers.set( - mapKey, - setTimeout(() => { - scheduleRouteInvalidateDebounceTimers.delete(mapKey); - const epochStore = usePlanMapDirectionsEpochStore.getState(); - const bucket = scheduleRouteInvalidatePending.get(mapKey); - scheduleRouteInvalidatePending.delete(mapKey); - if (!bucket) return; - void (async () => { - if (bucket.scope === "whole") { - await invalidateScheduleItemRouteForWholeSchedule( - queryClient, - rid, - sid, - ); - epochStore.bumpForDirections(rid); - } else if (bucket.ids.size > 0) { - const sources = [...bucket.ids]; - await invalidateScheduleItemRouteForSources( - queryClient, - rid, - sid, - sources, - ); - bumpMapForRouteSources(rid, sid, sources); - } - })(); - }, DEBOUNCE_SCHEDULE_ROUTE_INVALIDATE_MS), - ); -} /** 연속 일정 항목 동기화(GET items)를 한 번으로 묶음 — `SCHEDULE_ITEM_UPDATED` 전용 */ const syncSchedulePlacesFromItemsDebouncers = new Map< string, ReturnType >(); -const syncSchedulePlacesRouteInvalidationPending = new Map< - string, - DebouncedRouteInvBucket ->(); /** 모으는 동안 다른 사람의 변경이 하나라도 들어온 일차 */ const syncSchedulePlacesRemoteChangePending = new Set(); const DEBOUNCE_ITEMS_GET_MS = 90; @@ -127,29 +24,15 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( rid: string, sid: number, ownEcho: boolean, - affectedRouteItemIds?: number[] | null, ): void { const mapKey = `${rid}:${sid}`; if (!ownEcho) syncSchedulePlacesRemoteChangePending.add(mapKey); - if (affectedRouteItemIds !== undefined) { - syncSchedulePlacesRouteInvalidationPending.set( - mapKey, - mergeRouteInvalidateBucket( - syncSchedulePlacesRouteInvalidationPending.get(mapKey), - affectedRouteItemIds ?? [], - affectedRouteItemIds === null, - ), - ); - } const pending = syncSchedulePlacesFromItemsDebouncers.get(mapKey); if (pending !== undefined) clearTimeout(pending); syncSchedulePlacesFromItemsDebouncers.set( mapKey, setTimeout(() => { syncSchedulePlacesFromItemsDebouncers.delete(mapKey); - const routeBucket = - syncSchedulePlacesRouteInvalidationPending.get(mapKey); - syncSchedulePlacesRouteInvalidationPending.delete(mapKey); // 모은 이벤트가 모두 본인 반향이면, 본인 저장이 끝나며 보낸 조회가 이미 그 변경을 담고 있어 합류한다 const joinInFlight = !syncSchedulePlacesRemoteChangePending.has(mapKey); syncSchedulePlacesRemoteChangePending.delete(mapKey); @@ -158,25 +41,6 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( await invalidateSchedulePlanPlaces(queryClient, rid, sid, { joinInFlight, }); - if (routeBucket?.scope === "whole") { - await invalidateScheduleItemRouteForWholeSchedule( - queryClient, - rid, - sid, - ); - usePlanMapDirectionsEpochStore - .getState() - .bumpForDirections(rid); - } else if (routeBucket && routeBucket.ids.size > 0) { - const sources = [...routeBucket.ids]; - await invalidateScheduleItemRouteForSources( - queryClient, - rid, - sid, - sources, - ); - bumpMapForRouteSources(rid, sid, sources); - } } catch { // } @@ -189,10 +53,9 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( * Plan 화면 등이 쓰는 캐시만, 스키마의 `type`별로 갱신합니다. * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화 (원격 탭; 액터는 mutation `onSuccess`) * — `schedule-items`: 그 일차 장소 목록을 무효화해 다시 받는다(보는 화면이 없으면 그 화면에 들어갈 때) - * — `schedule-item-route`: `itemId`·인접 구간의 `segmentSourceItemId`만 무효화(폴백 시 일정 전체) - * — `SCHEDULE_ITEM_UPDATED`: 일정 목록만 동기화할 때 사용(본인 이벤트 포함); 경로(`schedule-item-route`)는 무효화하지 않음 - * — `SCHEDULE_ITEM_TRAVEL_MODE_UPDATED`: 공유 이동수단 변경 — 일정 목록만 동기화(경로 캐시는 수단별 키로 유지) - * — 맵 polyline: 구간별 에폭(`bumpSegments`); 폴백 시 방 단위(`bumpForDirections`) + * — `schedule-item-route`: 무효화하지 않는다 — key에 도착 장소·이동수단이 있어, 순서·수단이 바뀐 구간은 + * 새 목록을 그릴 때 새 key로 받고 되돌리면 받아 둔 값을 쓴다 + * — `SCHEDULE_ITEM_UPDATED`: 일정 목록만 동기화할 때 사용(본인 이벤트 포함) */ export async function dispatchRoomScheduleEvent( queryClient: QueryClient, @@ -207,7 +70,6 @@ export async function dispatchRoomScheduleEvent( ) { await invalidateExpenses(queryClient, rid); } - const epochStore = usePlanMapDirectionsEpochStore.getState(); switch (event.type) { case "ROOM_SCHEDULES_RESYNCED": { @@ -233,7 +95,6 @@ export async function dispatchRoomScheduleEvent( const sourceScheduleId = scheduleIds[0]!; const targetScheduleId = scheduleIds[1]!; - const itemId = event.itemId; const me = readSessionUserId(queryClient); const actorIsMe = @@ -249,8 +110,6 @@ export async function dispatchRoomScheduleEvent( rid, sourceScheduleId, targetScheduleId, - itemId ?? undefined, - event.affectedRouteItemIds, ); return; } @@ -265,23 +124,12 @@ export async function dispatchRoomScheduleEvent( typeof me === "number" && Number.isFinite(me) && me === event.actorUserId; - /** 본인 mutation은 `applyScheduleItemDeletedOnClient`에서 캐시·route 처리 */ + /** 본인 삭제는 mutation이 응답으로 캐시를 고치고 그 일차를 다시 받는다 */ if (actorIsMe) { return; } - removeRouteQueriesForDeletedItemSource(queryClient, rid, sid, itemId); - await invalidateSchedulePlanPlaces(queryClient, rid, sid); - - const sources = event.affectedRouteItemIds; - if (sources === null) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - epochStore.bumpForDirections(rid); - } else { - await invalidateScheduleItemRouteForSources(queryClient, rid, sid, sources); - bumpMapForRouteSources(rid, sid, sources); - } return; } @@ -295,21 +143,12 @@ export async function dispatchRoomScheduleEvent( typeof me === "number" && Number.isFinite(me) && me === event.actorUserId; - /** 본인 mutation은 append/reorder 직후 구간 단위 무효화 — STOMP 선도착 시 일정 전체 `/route` 폭주 방지 */ + /** 본인 추가는 mutation이 응답으로 캐시를 고치고 그 일차를 다시 받는다 */ if (actorIsMe) { return; } await invalidateSchedulePlanPlaces(queryClient, rid, sid); - - const sources = event.affectedRouteItemIds; - if (sources === null) { - await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); - epochStore.bumpForDirections(rid); - } else { - await invalidateScheduleItemRouteForSources(queryClient, rid, sid, sources); - bumpMapForRouteSources(rid, sid, sources); - } return; } @@ -323,20 +162,12 @@ export async function dispatchRoomScheduleEvent( typeof me === "number" && Number.isFinite(me) && me === event.actorUserId; - /** 본인 mutation은 `syncPlanPlacesAfterReorderSuccess` 직후 구간 무효화 */ + /** 본인 순서 변경은 mutation이 응답으로 캐시를 고치고 그 일차를 다시 받는다 */ if (actorIsMe) { return; } await invalidateSchedulePlanPlaces(queryClient, rid, sid); - const sources = event.affectedRouteItemIds; - enqueueDebouncedInvalidateScheduleRoutes( - queryClient, - rid, - sid, - sources ?? [], - sources === null, - ); return; } @@ -375,13 +206,11 @@ export async function dispatchRoomScheduleEvent( return; } - /** 항목 재조회 후 서버가 지정한 출발 항목의 경로만 갱신합니다. */ enqueueDebouncedHydratePlacesFromScheduleItemsApi( queryClient, rid, sid, false, - event.affectedRouteItemIds, ); return; } From c76978de106c6f0bac9aead86ec4d0e29d127bfd Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Tue, 3 Nov 2026 23:25:39 +0900 Subject: [PATCH 09/24] =?UTF-8?q?refactor(map):=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=AC=B4=ED=9A=A8=ED=99=94=EA=B0=80=20=EC=97=86=EC=96=B4?= =?UTF-8?q?=EC=A0=B8=20=EC=A7=80=EB=8F=84=20=EC=84=A0=20key=EC=9D=98=20?= =?UTF-8?q?=EC=97=90=ED=8F=AD=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지도 선 key는 (방, 일차, 출발 항목, 출발·도착 장소, 이동수단)만 쓴다 — 도착 장소가 바뀌면 key가 달라져 새 선을 그린다 - 에폭 스토어(plan-map-directions-epoch-store) 삭제 - 선의 React key도 에폭 대신 도착 장소로 구분한다 Co-Authored-By: Claude Opus 5.5 --- src/components/map/PlanItineraryMapRoutes.tsx | 107 +++++++----------- src/lib/query-keys.ts | 4 - src/stores/plan-map-directions-epoch-store.ts | 50 -------- 3 files changed, 40 insertions(+), 121 deletions(-) delete mode 100644 src/stores/plan-map-directions-epoch-store.ts diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx index 1aae5a77..3fb9fc89 100644 --- a/src/components/map/PlanItineraryMapRoutes.tsx +++ b/src/components/map/PlanItineraryMapRoutes.tsx @@ -34,10 +34,6 @@ import { flattenPlanItinerarySegmentsFromPlaces, } from "@/lib/plan/planItineraryMapSegments"; import { planItinerarySegmentMapPathQueryKey } from "@/lib/query-keys"; -import { - planMapSegmentEpochStoreKey, - usePlanMapDirectionsEpochStore, -} from "@/stores/plan-map-directions-epoch-store"; import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store"; import { usePlanMapFocusStore } from "@/stores/plan-map-focus-store"; import { useSessionStore } from "@/stores/session-store"; @@ -116,13 +112,6 @@ export function PlanItineraryMapRoutes({ [singleDay, singleDayScheduleId, visibleByScheduleId], ); - const directionsEpoch = usePlanMapDirectionsEpochStore((s) => - rid.length > 0 ? (s.epochByRoomId[rid] ?? 0) : 0, - ); - const epochBySegmentKey = usePlanMapDirectionsEpochStore( - (s) => s.epochBySegmentKey, - ); - const orderedScheduleIdsForQueries = useMemo( () => [...visibleScheduleIds].sort((a, b) => a - b), [visibleScheduleIds], @@ -173,56 +162,45 @@ export function PlanItineraryMapRoutes({ ); const pathsPerSegmentQueries = useQueries({ - queries: segments.map((seg) => { - const epochKey = planMapSegmentEpochStoreKey( + queries: segments.map((seg) => ({ + queryKey: planItinerarySegmentMapPathQueryKey( rid, seg.scheduleId, seg.segmentSourceItemId, - ); - const segmentEpoch = epochBySegmentKey[epochKey] ?? 0; - - return { - queryKey: planItinerarySegmentMapPathQueryKey( - rid, - seg.scheduleId, - seg.segmentSourceItemId, - seg.originPlaceId, - seg.destPlaceId, - seg.travelModeCanon, - directionsEpoch, - segmentEpoch, - ), - /** - * 구간 경로는 일정 리스트와 같은 쿼리를 통해 가져옵니다. 그 쿼리에 맡겨야 - * in-flight 합치기와 재정렬 중 뒤늦게 온 응답 폐기를 React Query가 처리합니다. - */ - queryFn: async () => { - const route = await loadScheduleItemRouteForMap( - queryClient, - persistedScheduleItemRouteQueryOptions( - rid, - seg.scheduleId, - seg.segmentSourceItemId, - seg.destPlaceId, - seg.travelModeCanon, - fingerprintByScheduleId.get(seg.scheduleId) ?? "", - { segmentReady: true }, - ), - ); - return { - path: await decodeOrientedPlanItinerarySegmentPath( - seg, - route?.encodedPolyline, - ), - }; - }, - enabled: rid.length > 0 && segments.length > 0, - staleTime: Infinity, - refetchOnMount: false, - refetchOnWindowFocus: false, - retry: false, - }; - }), + seg.originPlaceId, + seg.destPlaceId, + seg.travelModeCanon, + ), + /** + * 구간 경로는 일정 리스트와 같은 쿼리를 통해 가져옵니다. 그 쿼리에 맡겨야 + * in-flight 합치기와 재정렬 중 뒤늦게 온 응답 폐기를 React Query가 처리합니다. + */ + queryFn: async () => { + const route = await loadScheduleItemRouteForMap( + queryClient, + persistedScheduleItemRouteQueryOptions( + rid, + seg.scheduleId, + seg.segmentSourceItemId, + seg.destPlaceId, + seg.travelModeCanon, + fingerprintByScheduleId.get(seg.scheduleId) ?? "", + { segmentReady: true }, + ), + ); + return { + path: await decodeOrientedPlanItinerarySegmentPath( + seg, + route?.encodedPolyline, + ), + }; + }, + enabled: rid.length > 0 && segments.length > 0, + staleTime: Infinity, + refetchOnMount: false, + refetchOnWindowFocus: false, + retry: false, + })), }); const polylines: Array = []; @@ -230,13 +208,8 @@ export function PlanItineraryMapRoutes({ const query = pathsPerSegmentQueries[segIdx]; if (!query || query.isPending || query.isFetching) return; const pts = query.data?.path ?? []; - - const segEpochKey = planMapSegmentEpochStoreKey( - rid, - seg.scheduleId, - seg.segmentSourceItemId, - ); - const segEpoch = epochBySegmentKey[segEpochKey] ?? 0; + // 도착 장소가 바뀌면 다른 선이라 새로 그린다 + const polylineKey = `${seg.scheduleId}-${seg.segmentSourceItemId}-${seg.destPlaceId}-${seg.travelModeCanon}`; const routeColor = routeColorByScheduleId.get(seg.scheduleId) ?? fallbackRouteStroke; @@ -244,7 +217,7 @@ export function PlanItineraryMapRoutes({ if (pts.length) { polylines.push( { polylines.push( ; - epochBySegmentKey: Record; - bumpForDirections: (roomId: string) => void; - bumpSegments: (roomId: string, entries: PlanMapSegmentEpochEntry[]) => void; -}; - -export const usePlanMapDirectionsEpochStore = create((set) => ({ - epochByRoomId: {}, - epochBySegmentKey: {}, - bumpForDirections: (roomId) => { - const rid = String(roomId ?? "").trim(); - if (!rid.length) return; - set((s) => ({ - epochByRoomId: { - ...s.epochByRoomId, - [rid]: (s.epochByRoomId[rid] ?? 0) + 1, - }, - })); - }, - bumpSegments: (roomId, entries) => { - const rid = String(roomId ?? "").trim(); - if (!rid.length || entries.length === 0) return; - set((s) => { - const next = { ...s.epochBySegmentKey }; - for (const e of entries) { - const k = planMapSegmentEpochStoreKey(rid, e.scheduleId, e.segmentSourceItemId); - next[k] = (next[k] ?? 0) + 1; - } - return { epochBySegmentKey: next }; - }); - }, -})); From 92728877598bb751bae78bb0758cd4455d54816d Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Wed, 4 Nov 2026 15:13:54 +0900 Subject: [PATCH 10/24] =?UTF-8?q?test(route):=20=EC=A7=84=EC=9E=85=20batch?= =?UTF-8?q?=C2=B7=EC=9D=BC=EC=A0=95=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=A7=80?= =?UTF-8?q?=EB=8F=84=EA=B0=80=20=EA=B0=99=EC=9D=80=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=EB=A5=BC=20=EC=93=B0=EB=8A=94=EC=A7=80=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - batch가 받아 둔 구간 경로를 목록 칸(다음 장소 googlePlaceId)과 지도(지도 구간 계산)가 단건 요청 없이 쓰는지 본다 Co-Authored-By: Claude Opus 5.5 --- src/lib/plan/schedule-bulk-hydration.test.ts | 70 ++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/lib/plan/schedule-bulk-hydration.test.ts b/src/lib/plan/schedule-bulk-hydration.test.ts index 0830440e..0812f409 100644 --- a/src/lib/plan/schedule-bulk-hydration.test.ts +++ b/src/lib/plan/schedule-bulk-hydration.test.ts @@ -16,6 +16,9 @@ vi.mock("@/lib/api/rooms/schedule-items", async (importOriginal) => { let hydrateScheduleRoutesBatch: typeof import("@/lib/plan/schedule-bulk-hydration").hydrateScheduleRoutesBatch; let fetchAndSeedScheduleRoutesBatch: typeof import("@/lib/plan/schedule-bulk-hydration").fetchAndSeedScheduleRoutesBatch; let resolveScheduleSegmentRoute: typeof import("@/lib/plan/scheduleSegmentRoute").resolveScheduleSegmentRoute; +let persistedScheduleItemRouteQueryOptions: typeof import("@/lib/plan/scheduleItemRoutePersistedQuery").persistedScheduleItemRouteQueryOptions; +let loadScheduleItemRouteForMap: typeof import("@/lib/plan/scheduleItemRoutePersistedQuery").loadScheduleItemRouteForMap; +let flattenPlanItinerarySegmentsFromPlaces: typeof import("@/lib/plan/planItineraryMapSegments").flattenPlanItinerarySegmentsFromPlaces; let scheduleItemsQueryKey: typeof import("@/lib/query-keys").scheduleItemsQueryKey; let scheduleItemRouteQueryKey: typeof import("@/lib/query-keys").scheduleItemRouteQueryKey; let registerQueryClient: typeof import("@/lib/query-client").registerQueryClient; @@ -46,6 +49,12 @@ beforeAll(async () => { ({ resolveScheduleSegmentRoute } = await import( "@/lib/plan/scheduleSegmentRoute" )); + ({ persistedScheduleItemRouteQueryOptions, loadScheduleItemRouteForMap } = await import( + "@/lib/plan/scheduleItemRoutePersistedQuery" + )); + ({ flattenPlanItinerarySegmentsFromPlaces } = await import( + "@/lib/plan/planItineraryMapSegments" + )); ({ scheduleItemsQueryKey, scheduleItemRouteQueryKey } = await import( "@/lib/query-keys" )); @@ -306,3 +315,64 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { expect(getScheduleItemRoutesBatchMock).toHaveBeenCalledOnce(); }); }); + +describe("진입 batch·일정 목록·지도가 같은 구간 경로 캐시를 쓴다", () => { + const places = [place(1, "places/a"), place(2, "places/b"), place(3, "places/c")]; + const batchRoute = (itemId: number, distanceMeters: number) => ({ + status: "OK" as const, + itemId, + travelMode: "DRIVING", + distanceMeters, + durationSeconds: 60, + encodedPolyline: "_p~iF~ps|U_ulLnnqC", + }); + + it("진입 batch가 받아 둔 경로를 목록 칸과 지도가 다시 요청하지 않고 쓴다", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); + getScheduleItemRoutesBatchMock.mockResolvedValue([batchRoute(1, 100), batchRoute(2, 200)]); + + await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); + + // 목록 칸 — PlanItinerary가 넘기는 것처럼 다음 장소의 googlePlaceId로 조회한다 + const listDistances = await Promise.all( + places.slice(0, -1).map((origin, index) => + queryClient + .fetchQuery( + persistedScheduleItemRouteQueryOptions( + ROOM_ID, + SCHEDULE_ID, + origin.itemId ?? null, + places[index + 1]?.googlePlaceId ?? "", + "DRIVING", + "", + { segmentReady: true }, + ), + ) + .then((route) => route?.distanceMeters), + ), + ); + // 지도 — 지도가 쓰는 구간 계산 그대로 조회한다 + const mapDistances = await Promise.all( + flattenPlanItinerarySegmentsFromPlaces([SCHEDULE_ID], [places]).map((segment) => + loadScheduleItemRouteForMap( + queryClient, + persistedScheduleItemRouteQueryOptions( + ROOM_ID, + segment.scheduleId, + segment.segmentSourceItemId, + segment.destPlaceId, + segment.travelModeCanon, + "", + { segmentReady: true }, + ), + ).then((route) => route?.distanceMeters), + ), + ); + + expect(listDistances).toEqual([100, 200]); + expect(mapDistances).toEqual([100, 200]); + expect(getScheduleItemRoutesBatchMock).toHaveBeenCalledOnce(); + expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); + }); +}); From 6bc914fe4dc00e1dc3d933c4a51dd89f4139c60e Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Wed, 4 Nov 2026 16:55:01 +0900 Subject: [PATCH 11/24] =?UTF-8?q?fix(route):=20=EB=8B=A8=EA=B1=B4=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=A1=B0=ED=9A=8C=EC=97=90=20=EB=8F=84?= =?UTF-8?q?=EC=B0=A9=20=ED=95=AD=EB=AA=A9=EC=9D=84=20=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B3=A0=20=EA=B2=BD=EB=A1=9C=20key=EC=9D=98=20=EB=8F=84?= =?UTF-8?q?=EC=B0=A9=20=EA=B0=92=EC=9D=84=20=ED=95=AD=EB=AA=A9=20ID?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버가 그 순간의 순서로 다음 항목을 고르던 단건 GET에 destinationItemId를 보낸다 — 요청한 구간과 응답 구간이 항상 같다 - 경로 key·지도 선 key·sessionStorage key의 도착 값을 다음 장소의 itemId로 바꾼다 - 서버에 이 파라미터가 배포되기 전에는 서버가 무시하고 지금처럼 다음 항목으로 계산한다 Co-Authored-By: Claude Opus 5.5 --- .../_components/itinerary/PlanItinerary.tsx | 6 ++- .../mobile/MobilePlanItinerary.tsx | 1 + .../travel-time/PlanTravelTime.test.tsx | 1 + .../travel-time/PlanTravelTime.tsx | 7 ++- src/components/map/PlanItineraryMapRoutes.tsx | 13 ++--- src/hooks/useRooms.ts | 6 +-- src/lib/api/rooms/schedule-items.time.test.ts | 10 +++- src/lib/api/rooms/schedule-items.ts | 6 ++- src/lib/plan/planItineraryMapSegments.ts | 5 +- src/lib/plan/planTravelLocalStorage.test.ts | 49 +++++++++++++++++++ src/lib/plan/planTravelLocalStorage.ts | 9 +++- src/lib/plan/schedule-bulk-hydration.test.ts | 30 ++++++------ src/lib/plan/schedule-bulk-hydration.ts | 14 ++++-- .../scheduleItemRoutePersistedQuery.test.ts | 16 +++--- .../plan/scheduleItemRoutePersistedQuery.ts | 22 +++++---- src/lib/plan/scheduleSegmentRoute.test.ts | 24 +++++---- src/lib/plan/scheduleSegmentRoute.ts | 12 +++-- src/lib/query-keys.ts | 13 ++--- src/lib/stomp/schedules-dispatch.test.ts | 4 +- 19 files changed, 167 insertions(+), 81 deletions(-) create mode 100644 src/lib/plan/planTravelLocalStorage.test.ts diff --git a/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx b/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx index 43e1b919..21b6ecb0 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx @@ -218,6 +218,7 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { ); const insertIndex = index + 1; const segmentActive = activeInsertIndex === insertIndex; + const nextItemId = places[index + 1]?.itemId; return ( {index < places.length - 1 && typeof place.itemId === "number" && - typeof places[index + 1]?.itemId === "number" ? + typeof nextItemId === "number" ? @@ -264,6 +265,7 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { roomId={roomId} scheduleId={scheduleId} segmentSourceItemId={place.itemId} + destinationItemId={nextItemId} scheduleFingerprint={scheduleFingerprint} travelMode={place.travelMode} originGooglePlaceId={place.googlePlaceId} @@ -275,7 +277,7 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { !isFetchingPlaces && routesBatchSettled && existingItemIds.has(place.itemId) && - existingItemIds.has(places[index + 1].itemId!) + existingItemIds.has(nextItemId) } /> diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx index 0b8d765c..4ce706ae 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx @@ -240,6 +240,7 @@ function PlaceTravelSegment({ roomId={roomId} scheduleId={scheduleId} segmentSourceItemId={place.itemId} + destinationItemId={next.itemId} scheduleFingerprint={scheduleFingerprint} travelMode={place.travelMode} originGooglePlaceId={place.googlePlaceId} diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx index 90bd5f8d..a35386f2 100644 --- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx +++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx @@ -23,6 +23,7 @@ function renderSummary() { roomId: "room-1", scheduleId: 1, segmentSourceItemId: 100, + destinationItemId: 101, scheduleFingerprint: "100:a:|101:b:", travelMode: "DRIVING", contentOnly: true, diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx index f8e897e7..644b4a21 100644 --- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx +++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx @@ -41,6 +41,8 @@ export type PlanTravelTimeProps = { roomId: string; scheduleId: number; segmentSourceItemId: number; + /** 다음 장소의 `itemId` — 경로를 이 항목까지 계산한다 */ + destinationItemId: number; scheduleFingerprint: string; /** 서버 저장 공유 이동수단 (`schedule_items.travel_mode`) — 없으면 기본 `DRIVING` */ travelMode?: string; @@ -58,6 +60,7 @@ export function PlanTravelTime({ roomId, scheduleId, segmentSourceItemId, + destinationItemId, scheduleFingerprint, travelMode, routeQueryEnabled = true, @@ -106,7 +109,7 @@ export function PlanTravelTime({ roomId, scheduleId, segmentSourceItemId, - destinationGooglePlaceId ?? "", + destinationItemId, selectedMode, routeQueryEnabled, fpTrim, @@ -128,7 +131,7 @@ export function PlanTravelTime({ rid, scheduleId, segmentSourceItemId, - destinationGooglePlaceId ?? "", + destinationItemId, mode, fpTrim, { diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx index 3fb9fc89..5d7ed6f5 100644 --- a/src/components/map/PlanItineraryMapRoutes.tsx +++ b/src/components/map/PlanItineraryMapRoutes.tsx @@ -45,7 +45,6 @@ import { PlanItineraryStopMapPin } from "./PlanItineraryStopMapPin"; * 일차만 지도에 경로(polyline)와 정류장 마커를 그립니다. * `routeDay`가 있으면(모바일 경로 보기) 토글과 관계없이 그 일차만 그립니다. * 폴리라인 travelMode는 서버에 저장된 구간별 공유 이동수단을 따릅니다. - * STOMP 에폭은 {@link usePlanMapDirectionsEpochStore} 참고. */ /** 모바일 경로 보기 — 고른 일차 하나만 그린다 */ export type PlanItineraryRouteDay = { @@ -167,14 +166,12 @@ export function PlanItineraryMapRoutes({ rid, seg.scheduleId, seg.segmentSourceItemId, + seg.destinationItemId, seg.originPlaceId, seg.destPlaceId, seg.travelModeCanon, ), - /** - * 구간 경로는 일정 리스트와 같은 쿼리를 통해 가져옵니다. 그 쿼리에 맡겨야 - * in-flight 합치기와 재정렬 중 뒤늦게 온 응답 폐기를 React Query가 처리합니다. - */ + /** 구간 경로는 일정 리스트와 같은 쿼리로 가져와, 같은 구간을 받는 중이면 그 요청에 합류한다 */ queryFn: async () => { const route = await loadScheduleItemRouteForMap( queryClient, @@ -182,7 +179,7 @@ export function PlanItineraryMapRoutes({ rid, seg.scheduleId, seg.segmentSourceItemId, - seg.destPlaceId, + seg.destinationItemId, seg.travelModeCanon, fingerprintByScheduleId.get(seg.scheduleId) ?? "", { segmentReady: true }, @@ -208,8 +205,8 @@ export function PlanItineraryMapRoutes({ const query = pathsPerSegmentQueries[segIdx]; if (!query || query.isPending || query.isFetching) return; const pts = query.data?.path ?? []; - // 도착 장소가 바뀌면 다른 선이라 새로 그린다 - const polylineKey = `${seg.scheduleId}-${seg.segmentSourceItemId}-${seg.destPlaceId}-${seg.travelModeCanon}`; + // 도착 항목이 바뀌면 다른 선이라 새로 그린다 + const polylineKey = `${seg.scheduleId}-${seg.segmentSourceItemId}-${seg.destinationItemId}-${seg.travelModeCanon}`; const routeColor = routeColorByScheduleId.get(seg.scheduleId) ?? fallbackRouteStroke; diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index c8a8de76..d6284f65 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -174,8 +174,8 @@ export function useScheduleItemRoute( roomId: string | null, scheduleId: number | null, segmentSourceItemId: number | null, - /** 다음 장소의 `googlePlaceId` */ - destinationPlaceId: string, + /** 다음 장소의 `itemId` */ + destinationItemId: number | null, travelMode: ScheduleTravelModeValue, /** 일정 장소 목록상 구간 소스 아이디가 존재·목록 안정 후에만 true */ routeQueryEnabled = true, @@ -189,7 +189,7 @@ export function useScheduleItemRoute( rid, scheduleId, segmentSourceItemId, - destinationPlaceId, + destinationItemId, travelMode, scheduleFingerprint, { segmentReady: Boolean(routeQueryEnabled) }, diff --git a/src/lib/api/rooms/schedule-items.time.test.ts b/src/lib/api/rooms/schedule-items.time.test.ts index 0e911ac8..6cd61797 100644 --- a/src/lib/api/rooms/schedule-items.time.test.ts +++ b/src/lib/api/rooms/schedule-items.time.test.ts @@ -66,8 +66,16 @@ describe("schedule time JSON contract", () => { }), ), ); - expect(await api.getScheduleItemRoute("room", 10, 1)).toMatchObject({ + expect(await api.getScheduleItemRoute("room", 10, 1, 2)).toMatchObject({ durationSeconds: 1200, }); }); + it("asks the route to the given destination item", async () => { + apiFetch.mockResolvedValue(new Response(null, { status: 204 })); + await api.getScheduleItemRoute("room", 10, 1, 2, "WALKING"); + const url = new URL(String(apiFetch.mock.calls.at(-1)?.[0])); + expect(url.pathname).toMatch(/\/rooms\/room\/schedules\/10\/items\/1\/route$/); + expect(url.searchParams.get("destinationItemId")).toBe("2"); + expect(url.searchParams.get("travelMode")).toBe("WALKING"); + }); }); diff --git a/src/lib/api/rooms/schedule-items.ts b/src/lib/api/rooms/schedule-items.ts index c66ae23d..8afa1f23 100644 --- a/src/lib/api/rooms/schedule-items.ts +++ b/src/lib/api/rooms/schedule-items.ts @@ -118,11 +118,13 @@ function readScheduleItemRouteBatchItems( export async function getScheduleItemRoute( roomId: string, scheduleId: number, - /** 구간 시작(현재) 일정 항목 ID — 명세상 ‘현재 항목에서 다음 항목으로’ */ + /** 구간 출발 일정 항목 ID */ itemId: number, + /** 구간 도착 일정 항목 ID — 보내면 서버가 그 순간의 순서와 관계없이 이 항목까지 계산한다 */ + destinationItemId: number, travelMode?: string | null, ): Promise { - const params = new URLSearchParams(); + const params = new URLSearchParams({ destinationItemId: String(destinationItemId) }); const tm = typeof travelMode === "string" && travelMode.trim().length > 0 ? travelMode.trim() diff --git a/src/lib/plan/planItineraryMapSegments.ts b/src/lib/plan/planItineraryMapSegments.ts index a37aad67..85701c06 100644 --- a/src/lib/plan/planItineraryMapSegments.ts +++ b/src/lib/plan/planItineraryMapSegments.ts @@ -11,6 +11,8 @@ import { export type PlanItinerarySegmentDescriptor = { scheduleId: number; segmentSourceItemId: number; + /** 다음 장소의 `itemId` — 경로를 이 항목까지 계산한다 */ + destinationItemId: number; originPlaceId: string; destPlaceId: string; travelModeCanon: ScheduleTravelModeValue; @@ -36,12 +38,13 @@ export function flattenPlanItinerarySegmentsFromPlaces( typeof a.googlePlaceId === "string" ? a.googlePlaceId.trim() : ""; const d = typeof b.googlePlaceId === "string" ? b.googlePlaceId.trim() : ""; - if (typeof a.itemId !== "number" || !o.length || !d.length) { + if (typeof a.itemId !== "number" || typeof b.itemId !== "number" || !o.length || !d.length) { continue; } segments.push({ scheduleId, segmentSourceItemId: a.itemId, + destinationItemId: b.itemId, originPlaceId: o, destPlaceId: d, travelModeCanon: diff --git a/src/lib/plan/planTravelLocalStorage.test.ts b/src/lib/plan/planTravelLocalStorage.test.ts new file mode 100644 index 00000000..1d32417c --- /dev/null +++ b/src/lib/plan/planTravelLocalStorage.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + readScheduleRouteFromSessionStorage, + writeScheduleRouteToSessionStorage, +} from "./planTravelLocalStorage"; + +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + key: (index) => [...map.keys()][index] ?? null, + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => { + map.set(key, String(value)); + }, + removeItem: (key) => { + map.delete(key); + }, + clear: () => map.clear(), + }; +} + +describe("구간 경로 sessionStorage", () => { + beforeEach(() => { + const storage = memoryStorage(); + vi.stubGlobal("window", { sessionStorage: storage }); + vi.stubGlobal("sessionStorage", storage); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("출발 항목과 일정 지문이 같아도 도착 항목이 다르면 저장한 경로를 쓰지 않는다", () => { + const toC = { distanceMeters: 999, durationSeconds: 60, travelMode: "DRIVING" }; + // 순서 저장 중 새 순서([1,3,2])로 그린 칸이 옛 순서([1,2,3])의 지문으로 1→3 경로를 저장한 경우 + writeScheduleRouteToSessionStorage("room-1", 10, 1, 3, "DRIVING", "fp-1-2-3", toC); + + expect( + readScheduleRouteFromSessionStorage("room-1", 10, 1, 2, "DRIVING", "fp-1-2-3"), + ).toBeUndefined(); + expect( + readScheduleRouteFromSessionStorage("room-1", 10, 1, 3, "DRIVING", "fp-1-2-3"), + ).toEqual(toC); + }); +}); diff --git a/src/lib/plan/planTravelLocalStorage.ts b/src/lib/plan/planTravelLocalStorage.ts index 2908a5ce..e93eeda7 100644 --- a/src/lib/plan/planTravelLocalStorage.ts +++ b/src/lib/plan/planTravelLocalStorage.ts @@ -8,13 +8,15 @@ function roundCoord(n: number): string { const NS_ROUTE = "hau:plan:route:v1"; +/** 구간 = 출발 항목 → 도착 항목. 도착 항목이 없던 예전 형식(`…:출발:수단`)은 이 key와 겹치지 않아 읽지 않는다 */ function routeStorageKey( roomId: string, scheduleId: number, segmentSourceItemId: number, + destinationItemId: number, travelMode: string, ): string { - return `${NS_ROUTE}:${roomId.trim()}:${scheduleId}:${segmentSourceItemId}:${travelMode.trim()}`; + return `${NS_ROUTE}:${roomId.trim()}:${scheduleId}:${segmentSourceItemId}:${destinationItemId}:${travelMode.trim()}`; } type PersistedRouteV1 = { @@ -95,13 +97,14 @@ export function readScheduleRouteFromSessionStorage( roomId: string, scheduleId: number, segmentSourceItemId: number, + destinationItemId: number, travelMode: string, expectedFp: string, ): ScheduleItemRouteResponse | null | undefined { if (typeof window === "undefined" || !expectedFp) return undefined; try { const raw = sessionStorage.getItem( - routeStorageKey(roomId, scheduleId, segmentSourceItemId, travelMode), + routeStorageKey(roomId, scheduleId, segmentSourceItemId, destinationItemId, travelMode), ); if (!raw) return undefined; const o = JSON.parse(raw) as PersistedRouteV1; @@ -118,6 +121,7 @@ export function writeScheduleRouteToSessionStorage( roomId: string, scheduleId: number, segmentSourceItemId: number, + destinationItemId: number, travelMode: string, fp: string, data: ScheduleItemRouteResponse | null, @@ -128,6 +132,7 @@ export function writeScheduleRouteToSessionStorage( roomId, scheduleId, segmentSourceItemId, + destinationItemId, travelMode, ); if (data === null) { diff --git a/src/lib/plan/schedule-bulk-hydration.test.ts b/src/lib/plan/schedule-bulk-hydration.test.ts index 0812f409..b65f90e8 100644 --- a/src/lib/plan/schedule-bulk-hydration.test.ts +++ b/src/lib/plan/schedule-bulk-hydration.test.ts @@ -93,7 +93,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), ), ).toMatchObject({ distanceMeters: 1200, @@ -134,7 +134,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), ), ).not.toHaveProperty("encodedPolyline"); }); @@ -180,7 +180,7 @@ describe("hydrateScheduleRoutesBatch", () => { ROOM_ID, SCHEDULE_ID, 2, - "places/c", + 3, "WALKING", ); expect(queryClient.getQueryData(failedKey)).toBeUndefined(); @@ -189,7 +189,7 @@ describe("hydrateScheduleRoutesBatch", () => { roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, - destinationPlaceId: "places/c", + destinationItemId: 3, travelMode: "WALKING", }), ).rejects.toThrow("temporary single route failure"); @@ -199,7 +199,7 @@ describe("hydrateScheduleRoutesBatch", () => { roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, - destinationPlaceId: "places/c", + destinationItemId: 3, travelMode: "WALKING", }), ).resolves.toEqual(fallbackRoute); @@ -221,14 +221,14 @@ describe("hydrateScheduleRoutesBatch", () => { await hydrateScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); expect( - queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "places/c", "WALKING")), + queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING")), ).toBeNull(); await expect( resolveScheduleSegmentRoute({ roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: 2, - destinationPlaceId: "places/c", + destinationItemId: 3, travelMode: "WALKING", }), ).resolves.toBeNull(); @@ -255,7 +255,7 @@ describe("hydrateScheduleRoutesBatch", () => { expect( queryClient.getQueryData( - scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), ), ).toBeUndefined(); }); @@ -268,8 +268,8 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { function seededClient() { const queryClient = new QueryClient(); queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); - queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), route); - queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "places/c", "DRIVING"), route); + queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), route); + queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "DRIVING"), route); return queryClient; } @@ -281,7 +281,7 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { it("캐시에 없는 구간이 있으면 일괄로 다시 받는다", async () => { const queryClient = seededClient(); - queryClient.removeQueries({ queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, "places/c", "DRIVING") }); + queryClient.removeQueries({ queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "DRIVING") }); getScheduleItemRoutesBatchMock.mockResolvedValue([]); await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); @@ -305,7 +305,7 @@ describe("fetchAndSeedScheduleRoutesBatch", () => { it("일정 변경으로 무효화된 구간이 있으면 일괄로 다시 받는다", async () => { const queryClient = seededClient(); await queryClient.invalidateQueries({ - queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "places/b", "DRIVING"), + queryKey: scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), refetchType: "none", }); getScheduleItemRoutesBatchMock.mockResolvedValue([]); @@ -334,7 +334,7 @@ describe("진입 batch·일정 목록·지도가 같은 구간 경로 캐시를 await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); - // 목록 칸 — PlanItinerary가 넘기는 것처럼 다음 장소의 googlePlaceId로 조회한다 + // 목록 칸 — PlanItinerary가 넘기는 것처럼 다음 장소의 itemId로 조회한다 const listDistances = await Promise.all( places.slice(0, -1).map((origin, index) => queryClient @@ -343,7 +343,7 @@ describe("진입 batch·일정 목록·지도가 같은 구간 경로 캐시를 ROOM_ID, SCHEDULE_ID, origin.itemId ?? null, - places[index + 1]?.googlePlaceId ?? "", + places[index + 1]?.itemId ?? null, "DRIVING", "", { segmentReady: true }, @@ -361,7 +361,7 @@ describe("진입 batch·일정 목록·지도가 같은 구간 경로 캐시를 ROOM_ID, segment.scheduleId, segment.segmentSourceItemId, - segment.destPlaceId, + segment.destinationItemId, segment.travelModeCanon, "", { segmentReady: true }, diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index 6eaad4f5..f9b5e9a6 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -275,19 +275,23 @@ function scheduleRoutesBatchKey( return `${roomId.trim()}:${scheduleId}:${placesFingerprint}`; } -/** 구간 출발 항목 → 다음 장소의 `googlePlaceId` (마지막 장소와 장소 ID가 빈 구간은 뺀다) */ -function segmentDestinationsByItemId(places: readonly PlanPlace[]): Map { - const out = new Map(); +/** 구간 출발 항목 → 다음 장소의 `itemId` (마지막 장소와 항목 ID가 없는 구간은 뺀다) */ +function segmentDestinationsByItemId(places: readonly PlanPlace[]): Map { + const out = new Map(); for (let i = 0; i < places.length - 1; i += 1) { const itemId = places[i]?.itemId; - const destination = places[i + 1]?.googlePlaceId?.trim() ?? ""; - if (typeof itemId === "number" && Number.isFinite(itemId) && destination.length) { + const destination = places[i + 1]?.itemId; + if (isFiniteItemId(itemId) && isFiniteItemId(destination)) { out.set(itemId, destination); } } return out; } +function isFiniteItemId(id: number | undefined): id is number { + return typeof id === "number" && Number.isFinite(id); +} + /** * 일차의 모든 구간 경로가 이미 캐시에 있는지 — 일정 화면에서 받아 뒀거나, 화면을 다시 열었거나, * 지도 경로 보기로 넘어온 경우다. 받은 적 없거나(새 순서·gc) 무효화된 구간이 하나라도 있으면 false. diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts index f74b3ddc..7171e8c4 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts @@ -42,7 +42,7 @@ describe("persistedScheduleItemRouteQueryOptions", () => { "room-1", 10, 1, - "place-b", + 2, "DRIVING", "", { segmentReady: true }, @@ -72,20 +72,20 @@ describe("persistedScheduleItemRouteQueryOptions", () => { }); describe("persistedScheduleItemRouteQueryOptions key", () => { - it("도착 장소가 key에 들어가 같은 출발 항목이라도 도착 장소마다 다른 캐시를 쓴다", () => { - const toB = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "place-b", "DRIVING", "", { + it("도착 항목이 key에 들어가 같은 출발 항목이라도 도착 항목마다 다른 캐시를 쓴다", () => { + const toB = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, 2, "DRIVING", "", { segmentReady: true, }); - const toC = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "place-c", "DRIVING", "", { + const toC = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, 3, "DRIVING", "", { segmentReady: true, }); - expect(toB.queryKey).toEqual(["schedule-item-route", "room-1", 10, 1, "place-b", "DRIVING"]); + expect(toB.queryKey).toEqual(["schedule-item-route", "room-1", 10, 1, 2, "DRIVING"]); expect(toC.queryKey).not.toEqual(toB.queryKey); }); - it("도착 장소가 없으면 조회하지 않는다", () => { - const options = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, " ", "DRIVING", "", { + it("도착 항목이 없으면 조회하지 않는다", () => { + const options = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, null, "DRIVING", "", { segmentReady: true, }); @@ -96,7 +96,7 @@ describe("persistedScheduleItemRouteQueryOptions key", () => { describe("loadScheduleItemRouteForMap", () => { const route = { travelMode: "TRANSIT", distanceMeters: 1700, durationSeconds: 1140 }; const routeOptions = () => - persistedScheduleItemRouteQueryOptions("room-1", 10, 1, "place-b", "TRANSIT", "", { + persistedScheduleItemRouteQueryOptions("room-1", 10, 1, 2, "TRANSIT", "", { segmentReady: true, }); diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.ts index 978ecbe1..3a134cfe 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.ts @@ -15,7 +15,7 @@ type RouteCached = ScheduleItemRouteResponse | null; /** * — `segmentReady`: 네트워크 패치·sessionStorage 시드 허용(`routeQueryEnabled`). 쿼리 키는 이것과 무관하게 - * `(room, schedule, 구간, 도착 장소, 모드)`로 고정 — 일정 refetch 중 잠깐 false가 되어도 구간끼리 캐시를 공유하지 않음 + * `(room, schedule, 출발 항목, 도착 항목, 모드)`로 고정 — 일정 refetch 중 잠깐 false가 되어도 구간끼리 캐시를 공유하지 않음 * — `networkEnabled` 생략 시 `segmentReady`와 동일; 지연 수단은 메뉴 열림 시만 `true` */ export type PersistedScheduleRoutePersistFlags = { @@ -25,13 +25,13 @@ export type PersistedScheduleRoutePersistFlags = { networkEnabled?: boolean; }; -/** `GET …/route?travelMode=…` — sessionStorage(`scheduleFingerprint` 일치) ↔ React Query */ +/** `GET …/route?destinationItemId=…&travelMode=…` — sessionStorage(`scheduleFingerprint` 일치) ↔ React Query */ export function persistedScheduleItemRouteQueryOptions( roomIdTrimmed: string, scheduleId: number | null, segmentSourceItemId: number | null, - /** 다음 장소의 `googlePlaceId` — 비어 있으면 구간이 정해지지 않아 조회하지 않는다 */ - destinationPlaceId: string, + /** 다음 장소의 `itemId` — 없으면 구간이 정해지지 않아 조회하지 않는다 */ + destinationItemId: number | null, travelMode: ScheduleTravelModeValue, fingerprintTrimmed: string, flags: PersistedScheduleRoutePersistFlags, @@ -45,11 +45,13 @@ export function persistedScheduleItemRouteQueryOptions( const itemOk = typeof segmentSourceItemId === "number" && Number.isFinite(segmentSourceItemId); - const dest = destinationPlaceId.trim(); + const destOk = + typeof destinationItemId === "number" && + Number.isFinite(destinationItemId); const fp = fingerprintTrimmed.trim(); const keyEligible = - flags.segmentReady && rid.length > 0 && sidOk && itemOk && dest.length > 0; + flags.segmentReady && rid.length > 0 && sidOk && itemOk && destOk; const network = typeof flags.networkEnabled === "boolean" @@ -61,9 +63,8 @@ export function persistedScheduleItemRouteQueryOptions( const keyRoom = rid.length > 0 ? rid : null; const keySchedule = sidOk ? sid : null; const keySegment = itemOk ? segmentSourceItemId : null; - const keyDestination = dest.length > 0 ? dest : null; - const keyMode = - sidOk && itemOk && rid.length > 0 && dest.length > 0 ? travelMode : null; + const keyDestination = destOk ? destinationItemId : null; + const keyMode = sidOk && itemOk && destOk && rid.length > 0 ? travelMode : null; const sessionSeed: RouteCached | undefined = fp.length > 0 && @@ -74,6 +75,7 @@ export function persistedScheduleItemRouteQueryOptions( rid, sid!, segmentSourceItemId!, + destinationItemId!, travelMode, fp, ) @@ -98,7 +100,7 @@ export function persistedScheduleItemRouteQueryOptions( roomId: rid, scheduleId: sid!, segmentSourceItemId: segmentSourceItemId!, - destinationPlaceId: dest, + destinationItemId: destinationItemId!, travelMode, scheduleFingerprint: fp, }), diff --git a/src/lib/plan/scheduleSegmentRoute.test.ts b/src/lib/plan/scheduleSegmentRoute.test.ts index ba2a171e..ce240784 100644 --- a/src/lib/plan/scheduleSegmentRoute.test.ts +++ b/src/lib/plan/scheduleSegmentRoute.test.ts @@ -18,7 +18,7 @@ let getScheduleItemRouteMock: ReturnType; const ROOM_ID = "room-1"; const SCHEDULE_ID = 10; const ITEM_ID = 1; -const DESTINATION = "place-b"; +const DESTINATION = 2; const ROUTE = { distanceMeters: 1200, @@ -54,12 +54,12 @@ beforeEach(() => { const ROUTE_KEY = (destination = DESTINATION) => scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, ITEM_ID, destination, "DRIVING"); -function resolve(destinationPlaceId = DESTINATION) { +function resolve(destinationItemId = DESTINATION) { return resolveScheduleSegmentRoute({ roomId: ROOM_ID, scheduleId: SCHEDULE_ID, segmentSourceItemId: ITEM_ID, - destinationPlaceId, + destinationItemId, travelMode: "DRIVING", }); } @@ -72,11 +72,17 @@ describe("resolveScheduleSegmentRoute", () => { expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); }); - it("캐시 miss면 단건 GET 결과를 반환한다", async () => { + it("캐시 miss면 도착 항목을 지정한 단건 GET 결과를 반환한다", async () => { getScheduleItemRouteMock.mockResolvedValue(ROUTE); await expect(resolve()).resolves.toEqual(ROUTE); - expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(1); + expect(getScheduleItemRouteMock).toHaveBeenCalledExactlyOnceWith( + ROOM_ID, + SCHEDULE_ID, + ITEM_ID, + DESTINATION, + "DRIVING", + ); }); it("경로 없음(204)은 null을 반환한다", async () => { @@ -85,13 +91,13 @@ describe("resolveScheduleSegmentRoute", () => { await expect(resolve()).resolves.toBeNull(); }); - it("같은 출발 항목이라도 도착 장소가 다르면 그 도착 장소의 경로를 받고, 순서를 되돌리면 받아 둔 경로를 쓴다", async () => { - queryClient.setQueryData(ROUTE_KEY("place-b"), ROUTE); + it("같은 출발 항목이라도 도착 항목이 다르면 그 도착 항목의 경로를 받고, 순서를 되돌리면 받아 둔 경로를 쓴다", async () => { + queryClient.setQueryData(ROUTE_KEY(2), ROUTE); const toC = { ...ROUTE, distanceMeters: 999, encodedPolyline: "zzz" }; getScheduleItemRouteMock.mockResolvedValue(toC); - await expect(resolve("place-c")).resolves.toEqual(toC); - await expect(resolve("place-b")).resolves.toEqual(ROUTE); + await expect(resolve(3)).resolves.toEqual(toC); + await expect(resolve(2)).resolves.toEqual(ROUTE); expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(1); }); diff --git a/src/lib/plan/scheduleSegmentRoute.ts b/src/lib/plan/scheduleSegmentRoute.ts index 355ae04f..e943810a 100644 --- a/src/lib/plan/scheduleSegmentRoute.ts +++ b/src/lib/plan/scheduleSegmentRoute.ts @@ -13,15 +13,15 @@ export type ResolveScheduleSegmentRouteArgs = { roomId: string; scheduleId: number; segmentSourceItemId: number; - /** 다음 장소의 `googlePlaceId` — 캐시 key에만 쓴다(서버는 출발 항목의 다음 항목으로 계산) */ - destinationPlaceId: string; + /** 다음 장소의 `itemId` — 서버가 그 순간의 순서와 관계없이 이 항목까지 계산한다 */ + destinationItemId: number; travelMode: ScheduleTravelModeValue; /** 일정 지문 — 비면 sessionStorage에 기록하지 않습니다. */ scheduleFingerprint?: string; }; /** - * 구간 `(room, schedule, 출발 항목, 도착 장소, 이동수단)` 경로를 가져오는 fetch 본체입니다. + * 구간 `(room, schedule, 출발 항목, 도착 항목, 이동수단)` 경로를 가져오는 fetch 본체입니다. * 거리·소요 시간과 지도 폴리라인이 같은 캐시 항목을 공유하도록 일정 리스트와 지도가 모두 * 이 함수를 queryFn으로 쓰는 하나의 쿼리(`scheduleItemRouteQueryKey`)를 거칩니다. * @@ -31,7 +31,7 @@ export async function resolveScheduleSegmentRoute({ roomId, scheduleId, segmentSourceItemId, - destinationPlaceId, + destinationItemId, travelMode, scheduleFingerprint, }: ResolveScheduleSegmentRouteArgs): Promise { @@ -45,7 +45,7 @@ export async function resolveScheduleSegmentRoute({ rid, scheduleId, segmentSourceItemId, - destinationPlaceId, + destinationItemId, travelMode, ); const cached = getQueryClient()?.getQueryState(routeKey); @@ -57,6 +57,7 @@ export async function resolveScheduleSegmentRoute({ rid, scheduleId, segmentSourceItemId, + destinationItemId, travelMode, ); @@ -66,6 +67,7 @@ export async function resolveScheduleSegmentRoute({ rid, scheduleId, segmentSourceItemId, + destinationItemId, travelMode, fp, fresh, diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 0009acbf..e63a7b29 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -51,6 +51,7 @@ export function planItinerarySegmentMapPathQueryKey( rid: string, scheduleId: number, segmentSourceItemId: number, + destinationItemId: number, originPlaceId: string, destPlaceId: string, travelModeCanon: string, @@ -61,6 +62,7 @@ export function planItinerarySegmentMapPathQueryKey( rid, scheduleId, segmentSourceItemId, + destinationItemId, originPlaceId.trim(), destPlaceId.trim(), travelModeCanon, @@ -116,16 +118,15 @@ export const scheduleItemsQueryKey = ( ) => ["schedule-items", roomId, scheduleId, "v2-loc"] as const; /** - * 구간별 길찾기 API 결과 (`GET …/route?travelMode=…`). - * 서버는 출발 항목에서 그 순간의 다음 항목까지 경로를 주므로 도착 장소를 key에 넣는다 — - * 순서가 바뀌면 다른 key가 되고, 순서를 되돌리면 받아 둔 값을 그대로 쓴다. - * 무효화: `["schedule-item-route", roomId]` prefix. + * 구간별 길찾기 API 결과 (`GET …/route?destinationItemId=…&travelMode=…`). + * 요청에 보내는 출발·도착 항목과 이동수단이 곧 key라, 순서가 바뀌면 다른 key가 되고 + * 순서를 되돌리면 받아 둔 값을 그대로 쓴다. 일정이 바뀌어도 무효화하지 않는다. */ export const scheduleItemRouteQueryKey = ( roomId: string | null, scheduleId: number | null, segmentSourceItemId: number | null, - destinationPlaceId: string | null, + destinationItemId: number | null, travelMode: ScheduleTravelModeValue | null, ) => [ @@ -133,7 +134,7 @@ export const scheduleItemRouteQueryKey = ( roomId, scheduleId, segmentSourceItemId, - destinationPlaceId, + destinationItemId, travelMode, ] as const; diff --git a/src/lib/stomp/schedules-dispatch.test.ts b/src/lib/stomp/schedules-dispatch.test.ts index 6f93d322..4ca587d9 100644 --- a/src/lib/stomp/schedules-dispatch.test.ts +++ b/src/lib/stomp/schedules-dispatch.test.ts @@ -38,8 +38,8 @@ function itemEvent( describe("dispatchRoomScheduleEvent", () => { let queryClient: QueryClient; - // 경로는 도착 장소까지 key에 있어 일정이 바뀌어도 무효화하지 않는다 — 순서가 바뀐 구간은 새 key로 받는다 - const routeKey = scheduleItemRouteQueryKey("room-1", 10, 20, "place-b", "DRIVING"); + // 경로는 도착 항목까지 key에 있어 일정이 바뀌어도 무효화하지 않는다 — 순서가 바뀐 구간은 새 key로 받는다 + const routeKey = scheduleItemRouteQueryKey("room-1", 10, 20, 21, "DRIVING"); beforeEach(() => { queryClient = new QueryClient(); From 7957b20a08dfa425c24b4d6764ec57ee54f4205f Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Wed, 4 Nov 2026 16:56:07 +0900 Subject: [PATCH 12/24] =?UTF-8?q?fix(route):=20batch=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=91=EB=8B=B5=EC=9D=98=20=EB=8F=84=EC=B0=A9=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=EC=9C=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=ED=95=B4=20=EA=B8=B0=EB=8B=A4=EB=A6=AC=EB=8A=94=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=20=EC=88=9C=EC=84=9C=EA=B0=80=20=EB=B0=94=EB=80=8C?= =?UTF-8?q?=EC=96=B4=EB=8F=84=20=EB=B2=84=EB=A6=AC=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - batch 응답의 toItemId(서버가 계산한 구간의 도착 항목)로 경로 key를 만든다 — 요청 때 목록에서 짐작하지 않는다 - 응답을 기다리는 사이 순서가 바뀌면 결과를 통째로 버리던 일정 지문 비교를 지운다 - 마지막 항목·항목 없음 답은 도착 항목이 없어 저장하지 않고, 그 칸이 도착 항목을 지정한 단건 조회로 받는다 Co-Authored-By: Claude Opus 5.5 --- src/lib/api/rooms/schedule-items.ts | 4 ++ src/lib/plan/schedule-bulk-hydration.test.ts | 55 ++++++++++++++++---- src/lib/plan/schedule-bulk-hydration.ts | 28 +++------- 3 files changed, 57 insertions(+), 30 deletions(-) diff --git a/src/lib/api/rooms/schedule-items.ts b/src/lib/api/rooms/schedule-items.ts index 8afa1f23..a2cdff64 100644 --- a/src/lib/api/rooms/schedule-items.ts +++ b/src/lib/api/rooms/schedule-items.ts @@ -87,6 +87,8 @@ export type ScheduleItemRouteBatchItem = | ({ status: "OK"; itemId: number; + /** 서버가 계산한 구간의 도착 항목(요청을 받은 순간의 다음 항목) */ + toItemId?: number; travelMode: string; distanceMeters: number; durationSeconds: number; @@ -96,6 +98,8 @@ export type ScheduleItemRouteBatchItem = | { status: "ERROR"; itemId: number; + /** 경로 없음(`NO_ROUTE`)이면 있고, 마지막 항목·항목 없음이면 서버가 생략 */ + toItemId?: number; travelMode: string; errorCode?: string; }; diff --git a/src/lib/plan/schedule-bulk-hydration.test.ts b/src/lib/plan/schedule-bulk-hydration.test.ts index b65f90e8..4faa6cdb 100644 --- a/src/lib/plan/schedule-bulk-hydration.test.ts +++ b/src/lib/plan/schedule-bulk-hydration.test.ts @@ -75,13 +75,14 @@ describe("hydrateScheduleRoutesBatch", () => { const routeResult = { status: "OK" as const, itemId: 1, + toItemId: 2, travelMode: "DRIVING", distanceMeters: 1200, durationSeconds: 300, encodedPolyline: "_p~iF~ps|U_ulLnnqC", }; - it("응답 시점에 일정 지문이 그대로면 경로를 시딩한다", async () => { + it("응답이 알려 준 출발·도착 항목 구간으로 경로를 시딩한다", async () => { const queryClient = new QueryClient(); queryClient.setQueryData( scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), @@ -156,6 +157,7 @@ describe("hydrateScheduleRoutesBatch", () => { { status: "ERROR", itemId: 2, + toItemId: 3, travelMode: "WALKING", errorCode: "UPSTREAM_TIMEOUT", }, @@ -206,8 +208,35 @@ describe("hydrateScheduleRoutesBatch", () => { expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(2); }); - it.each(["NO_ROUTE", "LAST_ITEM", "SCHEDULE_ITEM_NOT_FOUND"])( - "경로 없음 답(%s)은 null로 시딩해 단건으로 다시 묻지 않는다", + it("경로 없음 답(NO_ROUTE)은 그 구간을 null로 시딩해 단건으로 다시 묻지 않는다", async () => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const places = [place(1, "places/a"), place(2, "places/b"), place(3, "places/c")]; + queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), places); + getScheduleItemRoutesBatchMock.mockResolvedValue([ + routeResult, + { status: "FAILED", itemId: 2, toItemId: 3, travelMode: "WALKING", errorCode: "NO_ROUTE" }, + ]); + + await hydrateScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); + + expect( + queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING")), + ).toBeNull(); + await expect( + resolveScheduleSegmentRoute({ + roomId: ROOM_ID, + scheduleId: SCHEDULE_ID, + segmentSourceItemId: 2, + destinationItemId: 3, + travelMode: "WALKING", + }), + ).resolves.toBeNull(); + expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); + }); + + it.each(["LAST_ITEM", "SCHEDULE_ITEM_NOT_FOUND"])( + "도착 항목이 없는 답(%s)은 시딩하지 않고 그 칸이 도착 항목을 지정한 단건으로 받는다", async (errorCode) => { const queryClient = new QueryClient(); registerQueryClient(queryClient); @@ -217,12 +246,14 @@ describe("hydrateScheduleRoutesBatch", () => { routeResult, { status: "FAILED", itemId: 2, travelMode: "WALKING", errorCode }, ]); + const toThree = { travelMode: "WALKING", distanceMeters: 800, durationSeconds: 600 }; + getScheduleItemRouteMock.mockResolvedValue(toThree); await hydrateScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places); expect( - queryClient.getQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING")), - ).toBeNull(); + queryClient.getQueryState(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING")), + ).toBeUndefined(); await expect( resolveScheduleSegmentRoute({ roomId: ROOM_ID, @@ -231,19 +262,19 @@ describe("hydrateScheduleRoutesBatch", () => { destinationItemId: 3, travelMode: "WALKING", }), - ).resolves.toBeNull(); - expect(getScheduleItemRouteMock).not.toHaveBeenCalled(); + ).resolves.toEqual(toThree); + expect(getScheduleItemRouteMock).toHaveBeenCalledExactlyOnceWith(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING"); }, ); - it("응답 대기 중 일정이 바뀌면(지문 불일치) 늦게 도착한 결과를 버린다", async () => { + it("응답 대기 중 순서가 바뀌어도 응답이 알려 준 구간에 저장하고 새 순서의 구간에는 넣지 않는다", async () => { const queryClient = new QueryClient(); queryClient.setQueryData( scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), snapshot, ); getScheduleItemRoutesBatchMock.mockImplementation(async () => { - // batch 응답이 도착하기 전에 목적지가 B → C 로 교체된 상황 + // batch 응답이 도착하기 전에 1번 다음 장소가 2 → 3으로 바뀐 상황 — 서버는 요청을 받은 순간의 1→2를 계산했다 queryClient.setQueryData(scheduleItemsQueryKey(ROOM_ID, SCHEDULE_ID), [ place(1, "places/a"), place(3, "places/c"), @@ -257,6 +288,11 @@ describe("hydrateScheduleRoutesBatch", () => { queryClient.getQueryData( scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"), ), + ).toMatchObject({ distanceMeters: 1200 }); + expect( + queryClient.getQueryState( + scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 3, "DRIVING"), + ), ).toBeUndefined(); }); }); @@ -321,6 +357,7 @@ describe("진입 batch·일정 목록·지도가 같은 구간 경로 캐시를 const batchRoute = (itemId: number, distanceMeters: number) => ({ status: "OK" as const, itemId, + toItemId: itemId + 1, travelMode: "DRIVING", distanceMeters, durationSeconds: 60, diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index f9b5e9a6..cca4a1c9 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -261,8 +261,6 @@ const inFlightScheduleRoutesBatch = new Map>(); * 실패가 아니라 "경로 없음"이라는 답인 batch 항목 — 단건으로 다시 물어도 같은 답(204·404)이 오므로 * 경로 없음(`null`)으로 시딩하고 다시 묻지 않는다. */ -const ROUTE_ANSWER_ERROR_CODES = new Set(["NO_ROUTE", "LAST_ITEM", "SCHEDULE_ITEM_NOT_FOUND"]); - /** * in-flight dedup 키 — 일정 지문(`itemId` 순서·장소 정체성)을 포함해 * 목적지만 바뀐 일정(`[A,B]`→`[A,C]`)이 이전 batch Promise를 공유하지 않게 합니다. @@ -394,30 +392,18 @@ export async function hydrateScheduleRoutesBatch( const results = await getScheduleItemRoutesBatch(rid, scheduleId, requestItems); - /** - * 응답 대기 중 일정이 바뀌었으면(지문 불일치) 늦게 도착한 결과를 버립니다 — - * 서버는 요청을 받은 순간의 순서로 다음 장소를 정하므로, 그사이 순서가 바뀌었으면 어느 도착 장소의 경로인지 알 수 없다. - */ - const snapshotFp = schedulePlacesFingerprint([...places]); - const currentPlaces = queryClient.getQueryData( - scheduleItemsQueryKey(rid, scheduleId), - ); - const currentFp = schedulePlacesFingerprint([...(currentPlaces ?? [])]); - if (!snapshotFp.length || snapshotFp !== currentFp) return; - - const destinationByItemId = segmentDestinationsByItemId(places); + // 서버는 요청을 받은 순간의 순서로 다음 항목을 정하므로, 응답이 알려 준 도착 항목(toItemId)의 구간으로 저장한다 — + // 기다리는 사이 순서가 바뀌었어도 그 구간의 값이라 버리지 않는다. 마지막 항목·항목 없음은 도착 항목이 없어 + // 저장하지 않고, 그 칸이 도착 항목을 지정한 단건 조회로 받는다 for (const result of results) { - const itemId = result.itemId; + const { itemId, toItemId } = result; const mode = canonicalScheduleTravelMode(result.travelMode); - if (!mode) continue; - if (typeof itemId !== "number" || !Number.isFinite(itemId)) continue; - const destination = destinationByItemId.get(itemId); - if (destination === undefined) continue; + if (!mode || !isFiniteItemId(itemId) || !isFiniteItemId(toItemId)) continue; - const routeKey = scheduleItemRouteQueryKey(rid, scheduleId, itemId, destination, mode); + const routeKey = scheduleItemRouteQueryKey(rid, scheduleId, itemId, toItemId, mode); if (isBatchItemOk(result)) { queryClient.setQueryData(routeKey, batchRouteItemToResponse(result)); - } else if (result.errorCode && ROUTE_ANSWER_ERROR_CODES.has(result.errorCode)) { + } else if (result.errorCode === "NO_ROUTE") { queryClient.setQueryData(routeKey, null); } } From d92cea3f8947c56392cc77f0aca3ca005e523b4a Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Wed, 4 Nov 2026 16:56:51 +0900 Subject: [PATCH 13/24] =?UTF-8?q?docs(route):=20=EA=B2=BD=EB=A1=9C=20key?= =?UTF-8?q?=20=EC=84=A4=EB=AA=85=20=EC=A3=BC=EC=84=9D=EC=9D=98=20=EB=8F=84?= =?UTF-8?q?=EC=B0=A9=20=EC=9E=A5=EC=86=8C=EB=A5=BC=20=EB=8F=84=EC=B0=A9=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/hooks/usePrefetchScheduleRoutes.ts | 2 +- src/lib/plan/schedule-bulk-hydration.ts | 2 +- src/lib/plan/scheduleItemPlaces.ts | 2 +- src/lib/stomp/schedules-dispatch.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hooks/usePrefetchScheduleRoutes.ts b/src/hooks/usePrefetchScheduleRoutes.ts index 68373968..93c6bd48 100644 --- a/src/hooks/usePrefetchScheduleRoutes.ts +++ b/src/hooks/usePrefetchScheduleRoutes.ts @@ -7,7 +7,7 @@ import type { PlanPlace } from "@/lib/plan/types"; /** * 페이지 진입·새로고침 시 일차별 구간 경로를 저장된 공유 이동수단 기준 `routes/batch`로 1회 시딩합니다. - * 리오더·추가·삭제 등으로 장소 목록이 바뀌면 batch는 재호출하지 않고, 새로 생긴 구간(받은 적 없는 출발·도착 장소)만 개별 GET합니다. + * 리오더·추가·삭제 등으로 장소 목록이 바뀌면 batch는 재호출하지 않고, 새로 생긴 구간(받은 적 없는 출발·도착 항목)만 개별 GET합니다. */ export function usePrefetchScheduleRoutes( roomId: string, diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts index cca4a1c9..fb0c8e60 100644 --- a/src/lib/plan/schedule-bulk-hydration.ts +++ b/src/lib/plan/schedule-bulk-hydration.ts @@ -202,7 +202,7 @@ export async function buildSchedulePlacesFromSchedulesWithItems( } /** - * 만든 일차 목록을 한 번에 씁니다. 경로는 도착 장소까지 key에 있어 순서가 바뀌어도 무효화하지 않습니다. + * 만든 일차 목록을 한 번에 씁니다. 경로는 도착 항목까지 key에 있어 순서가 바뀌어도 무효화하지 않습니다. * `startedAt`(일정 전체 조회를 보낸 시각) 뒤에 바뀐 일차는 그쪽이 더 새 상태라 덮지 않고, * 그 일차를 받는 중이면 이 조회보다 먼저 보낸 요청일 수 있어 다시 받게 합니다. */ diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index 1443483f..b180da11 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -229,7 +229,7 @@ export async function mergeOrRefetchSchedulePlanPlacesFromItems( } /** - * 다른 일차로 옮긴 뒤 두 일차 장소 목록을 다시 받습니다. 경로는 도착 장소까지 key에 있어 + * 다른 일차로 옮긴 뒤 두 일차 장소 목록을 다시 받습니다. 경로는 도착 항목까지 key에 있어 * 순서가 바뀐 구간은 새 key로 받으므로 따로 무효화하지 않습니다. */ export async function syncAfterCrossScheduleItemMove( diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index 89e87c48..1e218a4d 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -53,7 +53,7 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( * Plan 화면 등이 쓰는 캐시만, 스키마의 `type`별로 갱신합니다. * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화 (원격 탭; 액터는 mutation `onSuccess`) * — `schedule-items`: 그 일차 장소 목록을 무효화해 다시 받는다(보는 화면이 없으면 그 화면에 들어갈 때) - * — `schedule-item-route`: 무효화하지 않는다 — key에 도착 장소·이동수단이 있어, 순서·수단이 바뀐 구간은 + * — `schedule-item-route`: 무효화하지 않는다 — key에 도착 항목·이동수단이 있어, 순서·수단이 바뀐 구간은 * 새 목록을 그릴 때 새 key로 받고 되돌리면 받아 둔 값을 쓴다 * — `SCHEDULE_ITEM_UPDATED`: 일정 목록만 동기화할 때 사용(본인 이벤트 포함) */ From 7135a7595769166307b8846d948e142d1af02737 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Fri, 6 Nov 2026 17:08:28 +0900 Subject: [PATCH 14/24] =?UTF-8?q?feat(plan):=20=EB=93=9C=EB=9E=98=EA=B7=B8?= =?UTF-8?q?=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EC=BA=90=EC=8B=9C=EC=97=90=20?= =?UTF-8?q?=EB=A8=BC=EC=A0=80=20=EA=B7=B8=EB=A6=B4=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 일차 순서 변경(movePlanPlaceWithinDay), 일차 간 이동(removePlanPlace·insertPlanPlace) - 일차 순서 변경은 dayNumber·date를 새 자리대로 다시 매김(moveRoomScheduleToDay) - 위치는 서버 순서 API와 같은 뜻(옮긴 뒤 목록에서의 자리), 대상 일차에 이미 있으면 두 번 넣지 않음 Co-Authored-By: Claude Opus 5.5 --- src/lib/plan/planListMove.test.ts | 108 ++++++++++++++++++++++++++++++ src/lib/plan/planListMove.ts | 64 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 src/lib/plan/planListMove.test.ts create mode 100644 src/lib/plan/planListMove.ts 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] })); +} From 3463fd1be60c74a09e8b7dcb021b7ac0300d7b2e Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 9 Nov 2026 15:35:14 +0900 Subject: [PATCH 15/24] =?UTF-8?q?fix(plan):=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD=C2=B7=EC=9D=BC=EC=B0=A8?= =?UTF-8?q?=20=EA=B0=84=20=EC=9D=B4=EB=8F=99=EC=9D=84=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EC=A0=84=EC=97=90=20=EC=BA=90=EC=8B=9C=EC=97=90=20=EB=A8=BC?= =?UTF-8?q?=EC=A0=80=20=EB=B0=98=EC=98=81=ED=95=98=EA=B3=A0=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=ED=95=98=EB=A9=B4=20=EB=90=98=EB=8F=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useReorderScheduleItem·useMoveScheduleItemToSchedule: onMutate에서 cancelQueries → 이전 값 저장 → setQueryData, onError에서 이전 값으로 되돌림, onSettled에서 그 일차(들)를 다시 받음 - 순서 응답은 캐시에 쓰지 않음 — 이미 새 순서로 그렸고, 연달아 놓으면 앞 응답이 뒤 저장 전 순서를 다시 그림(syncPlanPlacesAfterReorderSuccess·mergeOrRefetchSchedulePlanPlacesFromItems 삭제) - 두 mutation에 같은 mutationKey를 두고, 같은 일차를 바꾸는 저장이 마지막일 때만 다시 받음(isMutating, TanStack 동시 낙관적 갱신) - 일차 간 이동은 옮길 장소를 출발 일차 캐시에서 꺼내고, 두 일차 무효화를 onSuccess에서 onSettled로 옮김(가계부 무효화는 onSuccess 유지) - 테스트: 응답 전 새 순서·실패 되돌림·진행 중 조회 취소·재조회 실패에도 저장 유지·연달아 저장해도 마지막 순서만 그리고 한 번만 다시 받음 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useExpenseInvalidation.test.ts | 12 +- src/hooks/useRooms.schedule-items.test.tsx | 217 +++++++++++++++++- src/hooks/useRooms.ts | 148 ++++++++++-- .../plan/scheduleItemPlaces.create.test.ts | 19 -- src/lib/plan/scheduleItemPlaces.ts | 38 --- 5 files changed, 353 insertions(+), 81 deletions(-) diff --git a/src/hooks/useExpenseInvalidation.test.ts b/src/hooks/useExpenseInvalidation.test.ts index 95d2b099..cb27b095 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 { scheduleItemsQueryKey } from "@/lib/query-keys"; import { useMoveRoomSchedule, useDeleteRoomSchedule, @@ -86,3 +86,13 @@ 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); +}); diff --git a/src/hooks/useRooms.schedule-items.test.tsx b/src/hooks/useRooms.schedule-items.test.tsx index 8018efd9..6a4caaf0 100644 --- a/src/hooks/useRooms.schedule-items.test.tsx +++ b/src/hooks/useRooms.schedule-items.test.tsx @@ -13,11 +13,17 @@ import type { PlanPlace } from "@/lib/plan/types"; import { scheduleItemsQueryKey } from "@/lib/query-keys"; import { hookHarness } from "@/test/hook-harness"; -import { useDeleteScheduleItem } from "./useRooms"; +import { + useDeleteScheduleItem, + useMoveScheduleItemToSchedule, + useReorderScheduleItem, +} from "./useRooms"; const api = vi.hoisted(() => ({ deleteScheduleItem: vi.fn(), getScheduleItems: vi.fn(), + reorderScheduleItem: vi.fn(), + moveScheduleItemToSchedule: vi.fn(), })); vi.mock("@/lib/client-env", () => ({ clientEnv: { NEXT_PUBLIC_API_BASE_URL: "https://api.example.test" }, @@ -26,6 +32,8 @@ 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("@/contexts/StompContext", () => ({ useStompContext: () => ({ connected: true }), @@ -92,3 +100,210 @@ 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( + () => useReorderScheduleItem(), + (child) => {child}, + ); + try { + await hook.render(undefined); + let saving!: Promise; + await act(async () => { + saving = hook.current + .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]); + + 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); + } 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( + () => useMoveScheduleItemToSchedule(), + (child) => {child}, + ); + try { + await hook.render(undefined); + let saving!: Promise; + await act(async () => { + saving = hook.current + .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]); + + 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("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; + }); + expect(api.getScheduleItems).toHaveBeenCalledTimes(1); + expect(ids(client.getQueryData(key))).toEqual([102, 100, 101]); + } finally { + unwatch(); + await hook.unmount(); + client.clear(); + } +}); diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 71db5eb8..035372d5 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -62,9 +62,12 @@ import { createScheduleItemAtPlanIndex, removeScheduleItemFromPlanPlacesCache, schedulePlanPlacesQueryOptions, - syncAfterCrossScheduleItemMove, - syncPlanPlacesAfterReorderSuccess, } from "@/lib/plan/scheduleItemPlaces"; +import { + insertPlanPlace, + movePlanPlaceWithinDay, + removePlanPlace, +} from "@/lib/plan/planListMove"; import { fetchAndSeedAllRoomBookmarks } from "@/lib/bookmarks/bookmark-bulk-cache"; import { appendBookmarkCategoryToCache, @@ -262,10 +265,52 @@ export function useMoveRoomSchedule() { }); } -/** 일정 항목을 다른 일차로 이동 — 같은 일차 내 순서는 `useReorderScheduleItem` 사용 */ +/** + * 장소 순서 변경·일차 간 이동 저장 — 진행 중인 저장을 이 key로 찾는다. + * 같은 일차의 마지막 저장이 끝날 때만 다시 받는다(`isLastItemMoveOf`) + */ +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 + ); +} + +/** + * 일정 항목을 다른 일차로 이동 — 같은 일차 내 순서는 `useReorderScheduleItem` 사용. + * 결과가 두 일차 목록과 지도에 함께 보여 응답 전에 두 일차 캐시를 먼저 고치고(onMutate), 실패하면 되돌린다. + */ export function useMoveScheduleItemToSchedule() { const queryClient = useQueryClient(); return useMutation({ + mutationKey: SCHEDULE_ITEM_MOVE_MUTATION_KEY, mutationFn: ({ roomId, sourceScheduleId, @@ -289,18 +334,59 @@ 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가 화면을 되돌린다 + onSettled: (_moved, _error, { roomId, sourceScheduleId, targetScheduleId }) => + Promise.all( + [sourceScheduleId, targetScheduleId] + .filter((scheduleId) => isLastItemMoveOf(queryClient, roomId, scheduleId)) + .map((scheduleId) => invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId)), + ), }); } @@ -452,10 +538,15 @@ export function useDeleteScheduleItem() { }); } -/** 순서 변경 — 응답 반영을 `onSuccess`에서 기다리므로 `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 +559,28 @@ export function useReorderScheduleItem() { vars.itemId, vars.body, ), - onSuccess: (items, { roomId, scheduleId }) => - syncPlanPlacesAfterReorderSuccess( - queryClient, - roomId, - scheduleId, - items, - ), + 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, + ); + }, onSettled: (_items, _error, { roomId, scheduleId }) => - invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId), + isLastItemMoveOf(queryClient, roomId, scheduleId) + ? invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId) + : undefined, }); } diff --git a/src/lib/plan/scheduleItemPlaces.create.test.ts b/src/lib/plan/scheduleItemPlaces.create.test.ts index 1bb831b3..bc70df9d 100644 --- a/src/lib/plan/scheduleItemPlaces.create.test.ts +++ b/src/lib/plan/scheduleItemPlaces.create.test.ts @@ -155,25 +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(); - client.clear(); - }); -}); - it("cross-day move refresh never sends 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 7f1472c2..55e76b56 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -199,26 +199,6 @@ export async function invalidateSchedulePlanPlaces( ); } -/** - * `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로 받으므로 따로 무효화하지 않습니다. @@ -353,24 +333,6 @@ export async function fetchSchedulePlanPlacesFromCacheOrApi( return fetchScheduleItemsAsPlanPlaces(rid, scheduleId); } -/** 순서 응답을 그 일차 캐시에 반영합니다(경로 key에 도착 항목이 있어 경로는 무효화하지 않는다). */ -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, From cf3a4b1536d2bcc8936e128ed5bd366cb3239d51 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 8 Nov 2026 18:01:10 +0900 Subject: [PATCH 16/24] =?UTF-8?q?feat(plan):=20PC=C2=B7=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=EC=9D=B4=20=ED=95=A8=EA=BB=98=20=EC=93=B0=EB=8A=94=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EC=B9=B4=EB=93=9C=20=EB=86=93=EA=B8=B0=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolvePlanItemDrop: 놓은 결과({ itemId, from, to })를 보낼 mutation 변수로 바꿈 — 같은 일차는 순서 변경(newOrderIndex), 다른 일차는 일차 간 이동(넣을 자리를 대상 일차 길이로 맞춤), 제자리면 null - usePlanItemDropCommit: 고른 mutation을 mutateAsync로 보내고 실패하면 토스트, 끝나면 onSettled(잠금이 없어 저장마다 promise를 따로 받음) - reorder_itinerary는 useReorderScheduleItem onSuccess에서 보냄(쓰기 분석 이벤트는 mutation 훅에), PC·모바일 호출부의 전송 삭제 - 저장 중·연결 끊김·다른 사람의 변경 중에도 막지 않음 — 어긋나도 onSettled 재조회가 서버 상태로 맞춤 Co-Authored-By: Claude Opus 5.5 --- .../_components/mobile/mobilePlanDrag.tsx | 7 - src/hooks/usePlanItemDropCommit.test.tsx | 203 ++++++++++++++++++ src/hooks/usePlanItemDropCommit.ts | 44 ++++ src/hooks/usePlanItineraryReorder.ts | 7 - src/hooks/useRooms.ts | 12 +- src/lib/plan/planItemDrop.test.ts | 44 ++++ src/lib/plan/planItemDrop.ts | 39 ++++ 7 files changed, 341 insertions(+), 15 deletions(-) create mode 100644 src/hooks/usePlanItemDropCommit.test.tsx create mode 100644 src/hooks/usePlanItemDropCommit.ts create mode 100644 src/lib/plan/planItemDrop.test.ts create mode 100644 src/lib/plan/planItemDrop.ts diff --git a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx index 9ddde949..ca1cee56 100644 --- a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx +++ b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx @@ -18,8 +18,6 @@ import { 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, @@ -283,11 +281,6 @@ export function useMobilePlanDragController({ 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("순서를 바꾸지 못했어요."); } diff --git a/src/hooks/usePlanItemDropCommit.test.tsx b/src/hooks/usePlanItemDropCommit.test.tsx new file mode 100644 index 00000000..87b0ed4c --- /dev/null +++ b/src/hooks/usePlanItemDropCommit.test.tsx @@ -0,0 +1,203 @@ +// @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("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..9371a4b0 --- /dev/null +++ b/src/hooks/usePlanItemDropCommit.ts @@ -0,0 +1,44 @@ +import { useCallback } from "react"; +import { toast } from "sonner"; + +import { + useMoveScheduleItemToSchedule, + useReorderScheduleItem, +} from "@/hooks/useRooms"; +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( + err instanceof Error && err.message.trim() + ? err.message + : "다른 일차로 옮기지 못했어요.", + ), + ); + void saving.finally(onSettled); + return true; + }, + [moveAsync, reorderAsync, roomId], + ); + + return { commitPlanItemDrop }; +} diff --git a/src/hooks/usePlanItineraryReorder.ts b/src/hooks/usePlanItineraryReorder.ts index 183de7aa..3d0e1f1d 100644 --- a/src/hooks/usePlanItineraryReorder.ts +++ b/src/hooks/usePlanItineraryReorder.ts @@ -15,8 +15,6 @@ import { useMoveScheduleItemToSchedule, useReorderScheduleItem, } from "@/hooks/useRooms"; -import { bucketItemCount } from "@/lib/analytics/context"; -import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; import { beginPlanItemDrag, clampCrossDayTargetOrderIndex, @@ -320,11 +318,6 @@ export function usePlanItineraryReorder({ ), }, }); - trackAnalyticsEvent(AnalyticsEvents.reorderItinerary, { - room_id: roomId, - item_count_bucket: bucketItemCount(places.length), - method: "drag_drop", - }); } catch { toast.error("순서를 바꾸지 못했어요."); } diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 035372d5..08a04d0d 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -1,4 +1,5 @@ -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"; @@ -577,6 +578,15 @@ export function useReorderScheduleItem() { context?.previous, ); }, + onSuccess: (items, { roomId }) => { + // 서버가 확인한 순서 변경만 기록한다(낙관적 표시는 쓰기 이벤트가 아님). 순서 변경은 드래그로만 하고, + // 응답은 그 일차 전체라 길이가 곧 일차의 장소 수다 + trackAnalyticsEvent(AnalyticsEvents.reorderItinerary, { + room_id: roomId, + item_count_bucket: bucketItemCount(items.length), + method: "drag_drop", + }); + }, onSettled: (_items, _error, { roomId, scheduleId }) => isLastItemMoveOf(queryClient, roomId, scheduleId) ? invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId) 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; +} From 82490868983d43b34333cfc2e4c082a22bac267a Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 8 Nov 2026 17:23:35 +0900 Subject: [PATCH 17/24] =?UTF-8?q?fix(plan):=20PC=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=EB=A5=BC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=A1=9C=20=EB=AA=A8=EC=95=84=20=EB=86=93=EC=9E=90?= =?UTF-8?q?=EB=A7=88=EC=9E=90=20=EC=83=88=20=EC=9E=90=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usePlanItemDragController: 드래그 상태 한 벌(무엇을·어디로·헤더 강조), 행 위치 측정, 미리보기를 갖고 놓으면 usePlanItemDropCommit에 넘김 - 놓은 뒤 캐시(onMutate)가 그 일차 목록을 바꿀 때까지 놓은 모양을 유지해 옛 순서가 한 프레임 보이지 않게 함 - 다른 일차로 놓아도 출발 일차 dragend가 미리보기를 풀지 않음(저장이 끝날 때 품, 그사이 시작한 새 드래그는 풀지 않음) - 손잡이·놓을 영역을 잠그지 않음 — 저장 중·재동기화 중·장소 추가 중·일차 추가·삭제 중에도 끌고 놓을 수 있고 onSettled 재조회가 맞춤 - PlanItinerary·PlanScheduleDayBlock은 컨트롤러가 준 props만 붙이고(PlanScheduleDayBlock의 interactionLocked prop 삭제), PlanDaySection은 헤더 강조를 props(crossDayDrop)로 받음(스토어 구독 제거) - ExpenseProvider 테스트: usePlanItemDropCommit mock 추가 Co-Authored-By: Claude Opus 5.5 --- .../_components/itinerary/PlanDaySection.tsx | 32 +- .../_components/itinerary/PlanItinerary.tsx | 40 +- .../_components/itinerary/PlanPageView.tsx | 68 +-- .../itinerary/PlanScheduleDayBlock.tsx | 16 +- .../expenses/ExpenseProvider.test.tsx | 1 + src/hooks/usePlanItemDragController.test.tsx | 209 +++++++++ src/hooks/usePlanItemDragController.ts | 410 ++++++++++++++++++ 7 files changed, 681 insertions(+), 95 deletions(-) create mode 100644 src/hooks/usePlanItemDragController.test.tsx create mode 100644 src/hooks/usePlanItemDragController.ts 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({
(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 +156,13 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { const addControlsDisabled = isAdding; - const crossDayItemDragActive = usePlanItemCrossDayDragStore( - (s) => s.sourceScheduleId !== null, - ); - return (
@@ -205,17 +185,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; diff --git a/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx b/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx index 9c7bc2f0..8c0d7116 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 { @@ -96,6 +100,9 @@ export function PlanPageView() { interactionLocked: isCreatingSchedule || isDeletingSchedule, }); + // 장소 드래그는 여러 일차에 걸치므로 페이지에서 한 벌만 둔다(모바일은 MobilePlanView의 컨트롤러) + const planItemDrag = usePlanItemDragController(roomId); + const scheduleExpansionSyncKey = useMemo( () => sortedSchedules.map((s) => s.scheduleId).join(","), [sortedSchedules], @@ -257,37 +264,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/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/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]); +} From 37f21581b537e3d346a8c22530d4063ccb57428e Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 9 Nov 2026 15:36:09 +0900 Subject: [PATCH 18/24] =?UTF-8?q?fix(route):=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=EC=9D=84=20=EC=A0=80=EC=9E=A5=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=8F=99=EC=95=88=20=EA=B7=B8=20=EC=9D=BC=EC=B0=A8?= =?UTF-8?q?=EC=9D=98=20=EA=B5=AC=EA=B0=84=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=98=EC=A7=80=20=EC=95=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캐시는 저장 응답 전에 새 순서지만 서버는 옛 순서라, 단건 경로가 옛 이웃까지 계산돼 새 구간 key에 남을 수 있음(destinationItemId를 무시하는 백엔드) - 저장 중인 일차를 useScheduleItemMoveSavingDays(장소 이동 mutationKey의 useMutationState)로 읽음 - PC 목록·모바일 목록의 routeQueryEnabled와 지도 구간 선 조회를 그 일차만 끔(다른 일차는 그대로) - 저장 중 새 구간은 이동 시간 자리에 Spinner, 지도는 새 구간 선 없이 핀만 새 순서 Co-Authored-By: Claude Opus 5.5 --- .../_components/useMobileRouteView.test.tsx | 1 + .../PlanItinerary.route-while-saving.test.tsx | 80 +++++++++++++++++++ .../_components/itinerary/PlanItinerary.tsx | 6 ++ .../mobile/MobilePlanItinerary.tsx | 10 ++- src/components/map/PlanItineraryMapRoutes.tsx | 10 ++- src/hooks/useRooms.schedule-items.test.tsx | 15 ++-- src/hooks/useRooms.ts | 16 +++- 7 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 src/app/(main)/plan/_components/itinerary/PlanItinerary.route-while-saving.test.tsx diff --git a/src/app/(main)/map/_components/useMobileRouteView.test.tsx b/src/app/(main)/map/_components/useMobileRouteView.test.tsx index d722f444..1c5e3874 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", () => ({ diff --git a/src/app/(main)/plan/_components/itinerary/PlanItinerary.route-while-saving.test.tsx b/src/app/(main)/plan/_components/itinerary/PlanItinerary.route-while-saving.test.tsx new file mode 100644 index 00000000..27441c93 --- /dev/null +++ b/src/app/(main)/plan/_components/itinerary/PlanItinerary.route-while-saving.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; + +import { PlanItemDragProvider, type PlanItemDragValue } from "@/hooks/usePlanItemDragController"; +import type { PlanPlace } from "@/lib/plan/types"; + +import { PlanItinerary } from "./PlanItinerary"; + +const fixtures = vi.hoisted(() => ({ + 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 90f3bda6..7283df27 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { useCreateScheduleItem, + useScheduleItemMoveSavingDays, useSchedulePlanPlaces, } from "@/hooks/useRooms"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; @@ -83,6 +84,10 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) { routeQueryEnabled, ); + // 장소 이동을 저장하는 동안 캐시는 새 순서지만 서버는 아직 옛 순서다 — 지금 새 구간 경로를 받으면 + // 옛 이웃까지의 경로가 새 구간 key에 남는다. 저장이 끝나면(다시 받은 목록으로) 조회한다 + const isSavingPlaceMove = useScheduleItemMoveSavingDays(roomId).includes(scheduleId); + const existingItemIds = useMemo( () => new Set( @@ -251,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/mobile/MobilePlanItinerary.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx index 4ce706ae..3e3ebc1f 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"; @@ -86,6 +90,8 @@ export function MobilePlanItinerary({ serverPlaces, !isLoading && !isFetching && !isError && serverPlaces.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], @@ -168,7 +174,7 @@ export function MobilePlanItinerary({ roomId={roomId} scheduleId={scheduleId} scheduleFingerprint={scheduleFingerprint} - routesReady={data !== undefined && !isFetching && routesBatchSettled} + routesReady={data !== undefined && !isFetching && !isSavingPlaceMove && routesBatchSettled} existingItemIds={existingItemIds} />
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/useRooms.schedule-items.test.tsx b/src/hooks/useRooms.schedule-items.test.tsx index 6a4caaf0..cc415358 100644 --- a/src/hooks/useRooms.schedule-items.test.tsx +++ b/src/hooks/useRooms.schedule-items.test.tsx @@ -17,6 +17,7 @@ import { useDeleteScheduleItem, useMoveScheduleItemToSchedule, useReorderScheduleItem, + useScheduleItemMoveSavingDays, } from "./useRooms"; const api = vi.hoisted(() => ({ @@ -126,20 +127,21 @@ it("shows the new order before the reorder response and puts the previous list b const reorder = deferred(); api.reorderScheduleItem.mockReturnValueOnce(reorder.promise); const hook = hookHarness( - () => useReorderScheduleItem(), + () => ({ reorder: useReorderScheduleItem(), savingDays: useScheduleItemMoveSavingDays("room-1") }), (child) => {child}, ); try { await hook.render(undefined); let saving!: Promise; await act(async () => { - saving = hook.current + 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 () => { @@ -147,6 +149,8 @@ it("shows the new order before the reorder response and puts the previous list b }); 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(); @@ -196,20 +200,21 @@ it("moves the place out of the source day and into the target day before the res const move = deferred(); api.moveScheduleItemToSchedule.mockReturnValueOnce(move.promise); const hook = hookHarness( - () => useMoveScheduleItemToSchedule(), + () => ({ move: useMoveScheduleItemToSchedule(), savingDays: useScheduleItemMoveSavingDays("room-1") }), (child) => {child}, ); try { await hook.render(undefined); let saving!: Promise; await act(async () => { - saving = hook.current + 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 () => { diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index 08a04d0d..b1e593aa 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -6,6 +6,7 @@ import { joinStatusForWaitingUi } from "@/lib/join-room-workflow"; import { invalidateExpenses, expenseKeys } from "@/lib/expenses/expense-queries"; import { useMutation, + useMutationState, useQuery, useQueryClient, } from "@tanstack/react-query"; @@ -268,7 +269,7 @@ export function useMoveRoomSchedule() { /** * 장소 순서 변경·일차 간 이동 저장 — 진행 중인 저장을 이 key로 찾는다. - * 같은 일차의 마지막 저장이 끝날 때만 다시 받는다(`isLastItemMoveOf`) + * 같은 일차의 마지막 저장이 끝날 때만 다시 받고(`isLastItemMoveOf`), 저장하는 동안 그 일차의 경로를 조회하지 않는다(`useScheduleItemMoveSavingDays`) */ const SCHEDULE_ITEM_MOVE_MUTATION_KEY = ["schedule-item-move"] as const; @@ -304,6 +305,19 @@ function isLastItemMoveOf( ); } +/** + * 이 방에서 장소 이동을 저장하는 중인 일차. 캐시는 저장 응답 전에 새 순서지만 서버는 아직 옛 순서라, + * 그 일차의 구간 경로를 지금 조회하면 옛 이웃까지의 경로가 새 구간 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), 실패하면 되돌린다. From e2da3253f1c685e1608a55ae8d0618ac76fda5f5 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 8 Nov 2026 17:09:44 +0900 Subject: [PATCH 19/24] =?UTF-8?q?refactor(plan):=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=EB=93=9C=EB=9E=98=EA=B7=B8=EB=A5=BC=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EB=82=99=EA=B4=80=EC=A0=81=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=EA=B3=BC=20=EA=B3=B5=EC=9A=A9=20=EB=86=93=EA=B8=B0=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useMobilePlanDragController: mutation·pending 계산 대신 usePlanItemDropCommit 사용, 손잡이를 잠그지 않음(저장 중에도 다음 카드를 끌 수 있음, 끄는 중 다른 손가락만 무시) - 놓은 뒤 캐시(onMutate)가 출발·대상 일차 목록을 바꿀 때까지 놓은 모양을 유지(rowStyle이 그 일차 캐시 목록을 받음) - MobilePlanItinerary는 캐시 목록을 그대로 그림(applyPendingPlanMove 삭제), move mutation의 place 변수 삭제 - context 값을 메모하고 dragDisabled 필드 삭제 - 테스트: pending 테스트를 컨트롤러 놓기·저장 중 유지·저장 중 다음 드래그 테스트로 바꿈, 경로 보기 테스트의 drag 값 모양 맞춤 Co-Authored-By: Claude Opus 5.5 --- .../_components/useMobileRouteView.test.tsx | 2 +- .../mobile/MobilePlanItinerary.tsx | 24 +-- .../_components/mobile/mobilePlanDrag.test.ts | 165 +++++++++++---- .../_components/mobile/mobilePlanDrag.tsx | 195 ++++++------------ src/hooks/useRooms.ts | 2 - 5 files changed, 196 insertions(+), 192 deletions(-) diff --git a/src/app/(main)/map/_components/useMobileRouteView.test.tsx b/src/app/(main)/map/_components/useMobileRouteView.test.tsx index 1c5e3874..06cb756f 100644 --- a/src/app/(main)/map/_components/useMobileRouteView.test.tsx +++ b/src/app/(main)/map/_components/useMobileRouteView.test.tsx @@ -129,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/mobile/MobilePlanItinerary.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx index 3e3ebc1f..fb4b83dd 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx @@ -32,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[] = []; @@ -61,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]); @@ -83,18 +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); @@ -150,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)} /> ({ - 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 ca1cee56..654fb5c1 100644 --- a/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx +++ b/src/app/(main)/plan/_components/mobile/mobilePlanDrag.tsx @@ -5,24 +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 { - 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) — 드래그 중 다른 카드를 밀어낼 거리 */ @@ -48,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; @@ -69,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); @@ -82,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" @@ -146,7 +109,7 @@ function autoScrollSpeed(y: number, top: number, bottom: number): number { /** * 모바일 일정 순서 편집 드래그 — 모든 일차의 카드를 한 번에 다루고, 다른 일차로도 옮길 수 있다. - * 같은 일차는 순서 변경, 다른 일차는 일차 간 이동 API로 저장한다. + * 놓으면 PC와 같은 놓기 결정(`usePlanItemDropCommit`)이 같은 일차는 순서 변경, 다른 일차는 일차 간 이동으로 저장한다. */ export function useMobilePlanDragController({ roomId, @@ -164,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; @@ -232,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]; @@ -257,56 +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); + if (typeof itemId !== "number") { + updateActive(null); return; } - - if (targetScheduleId === sourceScheduleId) { - if (targetIndex === sourceIndex) return; - try { - await reorderMutate({ - roomId, - scheduleId: sourceScheduleId, - itemId, - body: { newOrderIndex: newOrderIndexAfterMove(sourceIndex, targetIndex, sourcePlaces.length) }, - }); - } catch { - toast.error("순서를 바꾸지 못했어요."); - } - 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(); @@ -336,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) { @@ -352,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; @@ -382,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 }; @@ -404,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/hooks/useRooms.ts b/src/hooks/useRooms.ts index b1e593aa..c8ec4872 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -338,8 +338,6 @@ export function useMoveScheduleItemToSchedule() { itemId: number; targetScheduleId: number; targetOrderIndex: number; - /** 저장하는 동안 대상 일차에 먼저 보여 줄 카드(모바일 편집 목록) — 요청에는 쓰지 않는다 */ - place?: PlanPlace; }) => { if (sourceScheduleId === targetScheduleId) { throw new Error("같은 일차 안에서는 순서 변경만 가능해요."); From 124839d075a76b14fb2b990d9dca3b146960470e Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 9 Nov 2026 15:37:03 +0900 Subject: [PATCH 20/24] =?UTF-8?q?fix(plan):=20=EC=9D=BC=EC=B0=A8=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD=EC=9D=84=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=A0=84=EC=97=90=20=EC=BA=90=EC=8B=9C=EC=97=90=20?= =?UTF-8?q?=EB=A8=BC=EC=A0=80=20=EB=B0=98=EC=98=81=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=EB=9D=BC=EB=B2=A8=EB=8F=84=20=EC=83=88=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=EB=8C=80=EB=A1=9C=20=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useMoveRoomSchedule: onMutate에서 room-schedules를 옮기고 dayNumber·date를 자리대로 다시 매김, 실패하면 되돌림 - 일정 다시 받기를 onSuccess(refreshRoomSchedules)에서 onSettled(room-schedules 무효화)로 옮기고, 연달아 옮기면 마지막 저장이 끝날 때만 받음(mutationKey + isMutating). 가계부 무효화는 onSuccess 유지 - usePlanScheduleDayReorder: 놓은 뒤 캐시가 일정 목록을 바꿀 때까지 놓은 모양을 유지, drop 뒤 dragend가 풀지 않음(반환 모양은 그대로) - 테스트: 응답 전 새 순서·날짜, 실패 되돌림, 놓은 모양 유지, onSettled 무효화, 연달아 옮기면 한 번만 다시 받음 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useExpenseInvalidation.test.ts | 11 +- src/hooks/usePlanScheduleDayReorder.test.tsx | 87 +++++++++++++++ src/hooks/usePlanScheduleDayReorder.ts | 32 +++++- src/hooks/useRooms.schedule-items.test.tsx | 106 ++++++++++++++++++- src/hooks/useRooms.ts | 36 ++++++- 5 files changed, 263 insertions(+), 9 deletions(-) create mode 100644 src/hooks/usePlanScheduleDayReorder.test.tsx diff --git a/src/hooks/useExpenseInvalidation.test.ts b/src/hooks/useExpenseInvalidation.test.ts index cb27b095..59deac74 100644 --- a/src/hooks/useExpenseInvalidation.test.ts +++ b/src/hooks/useExpenseInvalidation.test.ts @@ -18,7 +18,7 @@ vi.mock("@/lib/rooms", async (importOriginal) => ({ vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ removeScheduleItemFromPlanPlacesCache: vi.fn(), })); -import { scheduleItemsQueryKey } from "@/lib/query-keys"; +import { roomSchedulesQueryKey, scheduleItemsQueryKey } from "@/lib/query-keys"; import { useMoveRoomSchedule, useDeleteRoomSchedule, @@ -96,3 +96,12 @@ it("refreshes both days after a place move, but leaves expenses alone when the m 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/usePlanScheduleDayReorder.test.tsx b/src/hooks/usePlanScheduleDayReorder.test.tsx new file mode 100644 index 00000000..9a081b5f --- /dev/null +++ b/src/hooks/usePlanScheduleDayReorder.test.tsx @@ -0,0 +1,87 @@ +// @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 })); +vi.mock("@/hooks/useScheduleResyncPending", () => ({ useScheduleResyncPending: () => false })); +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(() => { + 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(); + } +}); diff --git a/src/hooks/usePlanScheduleDayReorder.ts b/src/hooks/usePlanScheduleDayReorder.ts index 710971fe..b5422f88 100644 --- a/src/hooks/usePlanScheduleDayReorder.ts +++ b/src/hooks/usePlanScheduleDayReorder.ts @@ -40,6 +40,13 @@ export function usePlanScheduleDayReorder({ useMoveRoomSchedule(); const [dragFromIndex, setDragFromIndex] = useState(null); const [previewIndex, setPreviewIndex] = useState(null); + /** + * 놓아서 저장하는 중 — 놓을 때 일정 목록. 캐시(onMutate)가 목록을 바꿀 때까지 놓은 모양을 그대로 둔다. + * 먼저 풀면 캐시가 바뀌기 전 한 프레임 동안 옛 순서가 보인다(캐시 알림은 setTimeout 0 뒤) + */ + const [droppedSchedules, setDroppedSchedules] = useState(null); + // dragend는 drop 바로 뒤에 와서 렌더 전 값을 봐야 한다 + const droppedRef = useRef(false); const sectionRefs = useRef<(HTMLElement | null)[]>([]); const layoutSnapshotRef = useRef(new PlanItemRowLayoutSnapshot()); @@ -106,6 +113,8 @@ export function usePlanScheduleDayReorder({ ); const handleDragEnd = useCallback(() => { + // 놓아서 저장 중이면 저장이 끝날 때 푼다 + if (droppedRef.current) return; if (dragOverRafRef.current) { cancelAnimationFrame(dragOverRafRef.current); dragOverRafRef.current = 0; @@ -145,10 +154,9 @@ export function usePlanScheduleDayReorder({ previewIndex ?? (Number.isFinite(fromIndex) ? fromIndex : null); - resetDragPreview(); - // 드래그하는 동안 연결이 끊겼으면 놓은 자리를 서버 순서로 확인할 수 없다 if (scheduleResyncPending) { + resetDragPreview(); toast.error(PLAN_ORDER_RESYNCING_MESSAGE); return; } @@ -162,14 +170,20 @@ 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; + droppedRef.current = true; + setDroppedSchedules(schedules); try { await moveMutate({ roomId, @@ -182,14 +196,21 @@ export function usePlanScheduleDayReorder({ ? err.message : "일차 순서를 바꾸지 못했어요.", ); + } finally { + droppedRef.current = false; + setDroppedSchedules(null); + resetDragPreview(); } }, [moveMutate, previewIndex, resetDragPreview, roomId, scheduleResyncPending, 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,7 +236,7 @@ export function usePlanScheduleDayReorder({ sectionStyle: getPlanItemRowReorderStyle({ translateY, isDragging, - motionEnabled: isDraggingActive, + motionEnabled: isDraggingActive && previewActive, }), dragHandleProps: { dragDisabled: @@ -232,6 +253,7 @@ export function usePlanScheduleDayReorder({ handleDragEnd, handleDragStart, isDraggingActive, + previewActive, previewIndex, scheduleResyncPending, schedules, diff --git a/src/hooks/useRooms.schedule-items.test.tsx b/src/hooks/useRooms.schedule-items.test.tsx index cc415358..dcf46f56 100644 --- a/src/hooks/useRooms.schedule-items.test.tsx +++ b/src/hooks/useRooms.schedule-items.test.tsx @@ -10,11 +10,13 @@ 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, + useMoveRoomSchedule, useMoveScheduleItemToSchedule, useReorderScheduleItem, useScheduleItemMoveSavingDays, @@ -25,6 +27,7 @@ const api = vi.hoisted(() => ({ 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" }, @@ -36,6 +39,10 @@ vi.mock("@/lib/api/rooms/schedule-items", async (original) => ({ 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 }), })); @@ -259,6 +266,57 @@ it("keeps a saved cross-day move as saved even when the follow-up day refetch fa } }); +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); @@ -312,3 +370,49 @@ it("draws only the latest order when two reorders on the same day overlap, and r 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(); + } +}); diff --git a/src/hooks/useRooms.ts b/src/hooks/useRooms.ts index c8ec4872..c8d7a5f9 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -48,6 +48,7 @@ import { type RoomBookmark, type RoomCreateRequest, type RoomScheduleCreateRequest, + type RoomScheduleWithItems, type ReorderScheduleItemRequest, type RoomScheduleItemUpdateRequest, type RoomUpdateRequest, @@ -68,6 +69,7 @@ import { import { insertPlanPlace, movePlanPlaceWithinDay, + moveRoomScheduleToDay, removePlanPlace, } from "@/lib/plan/planListMove"; import { fetchAndSeedAllRoomBookmarks } from "@/lib/bookmarks/bookmark-bulk-cache"; @@ -245,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, @@ -258,12 +267,35 @@ 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 이하면 남은 저장이 없다 + onSettled: (_data, _error, { roomId }) => + queryClient.isMutating({ mutationKey: ROOM_SCHEDULE_MOVE_MUTATION_KEY }) <= 1 + ? queryClient.invalidateQueries({ + queryKey: roomSchedulesQueryKey(roomId.trim()), + exact: true, + }) + : undefined, }); } From 5a60138a3b2012c7f9690c1e95a843bb53409676 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 8 Nov 2026 18:02:11 +0900 Subject: [PATCH 21/24] =?UTF-8?q?refactor(plan):=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=EB=A1=9C=20=EC=98=AE=EA=B8=B4=20=EC=9D=BC?= =?UTF-8?q?=EC=B0=A8=EB=B3=84=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=ED=9B=85?= =?UTF-8?q?=C2=B7=EC=8A=A4=ED=86=A0=EC=96=B4=EC=99=80=20dataTransfer=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 삭제: plan-item-cross-day-drag-store, usePlanDaySectionCrossDayDrop, usePlanItineraryReorder(와 분석 테스트 — 놓기 결정·PC 컨트롤러 테스트로 옮김) - planItemReorder.ts: executePlanItemCrossDayMove와 dataTransfer에서 일차·장소·자리를 읽던 함수 삭제, beginPlanItemDrag는 끌기 이미지와 Firefox용 setData만 남김 - scheduleItemPlaces.ts: 쓰는 곳이 없어진 newOrderIndexAfterMove 삭제(놓기 결정이 놓은 자리를 그대로 씀) Co-Authored-By: Claude Opus 5.5 --- src/hooks/usePlanDaySectionCrossDayDrop.ts | 112 ----- ...usePlanItineraryReorder.analytics.test.tsx | 150 ------ src/hooks/usePlanItineraryReorder.ts | 428 ------------------ src/lib/plan/planItemReorder.ts | 91 +--- src/lib/plan/scheduleItemPlaces.ts | 17 - src/stores/plan-item-cross-day-drag-store.ts | 41 -- 6 files changed, 4 insertions(+), 835 deletions(-) delete mode 100644 src/hooks/usePlanDaySectionCrossDayDrop.ts delete mode 100644 src/hooks/usePlanItineraryReorder.analytics.test.tsx delete mode 100644 src/hooks/usePlanItineraryReorder.ts delete mode 100644 src/stores/plan-item-cross-day-drag-store.ts 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/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 3d0e1f1d..00000000 --- a/src/hooks/usePlanItineraryReorder.ts +++ /dev/null @@ -1,428 +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 { - 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, - ), - }, - }); - } 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/lib/plan/planItemReorder.ts b/src/lib/plan/planItemReorder.ts index 1b185ea4..a407d06f 100644 --- a/src/lib/plan/planItemReorder.ts +++ b/src/lib/plan/planItemReorder.ts @@ -4,9 +4,6 @@ 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"; @@ -20,11 +17,14 @@ export const PLAN_ITEM_REORDER_ROW_TRANSITION = /** 행 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 +33,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 +43,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/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index 55e76b56..17bf520a 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -476,23 +476,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, - }); - }, - }), -); From fd3350fc9c18b3bcad805f9cccd0be1fc0f6e093 Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Sun, 8 Nov 2026 17:23:50 +0900 Subject: [PATCH 22/24] =?UTF-8?q?refactor(plan):=20=EC=9D=BC=EC=B0=A8=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EB=93=9C=EB=9E=98=EA=B7=B8=EC=9D=98=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=EC=9D=84=20=EC=97=86=EC=95=A0=EA=B3=A0=20use?= =?UTF-8?q?ScheduleResyncPending=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 끊긴 동안·놓친 변경을 다시 받는 동안·앞선 일차 저장 중·일차 추가·삭제 중에도 일차를 끌고 놓을 수 있음 — 조금 틀어져도 onSettled 재조회가 서버 상태로 맞춤 - 앞선 저장이 끝나도 그사이 시작한 새 드래그는 풀지 않음(놓기마다 다른 값으로 비교) - usePlanScheduleDayReorder의 interactionLocked 인자 삭제, 일차가 1개일 때만 비활성 - 장소 드래그(PC·모바일)는 처음부터 잠금을 두지 않았으므로 쓰는 곳이 없어진 useScheduleResyncPending·PLAN_ORDER_RESYNCING_MESSAGE 삭제 Co-Authored-By: Claude Opus 5.5 --- .../_components/itinerary/PlanPageView.tsx | 3 +- src/hooks/usePlanScheduleDayReorder.test.tsx | 35 +++++++++++++- src/hooks/usePlanScheduleDayReorder.ts | 48 ++++++++----------- src/hooks/useScheduleResyncPending.ts | 16 ------- src/lib/plan/planItemReorder.ts | 4 -- 5 files changed, 56 insertions(+), 50 deletions(-) delete mode 100644 src/hooks/useScheduleResyncPending.ts diff --git a/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx b/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx index 8c0d7116..9b787014 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanPageView.tsx @@ -97,7 +97,6 @@ export function PlanPageView() { usePlanScheduleDayReorder({ roomId, schedules: sortedSchedules, - interactionLocked: isCreatingSchedule || isDeletingSchedule, }); // 장소 드래그는 여러 일차에 걸치므로 페이지에서 한 벌만 둔다(모바일은 MobilePlanView의 컨트롤러) @@ -290,7 +289,7 @@ export function PlanPageView() { isScheduleMenuDisabled={ isCreatingSchedule || isDeletingSchedule || isMovePending } - {...sectionProps} + {...sectionProps} /> ); })} diff --git a/src/hooks/usePlanScheduleDayReorder.test.tsx b/src/hooks/usePlanScheduleDayReorder.test.tsx index 9a081b5f..1b621f42 100644 --- a/src/hooks/usePlanScheduleDayReorder.test.tsx +++ b/src/hooks/usePlanScheduleDayReorder.test.tsx @@ -9,7 +9,6 @@ import { usePlanScheduleDayReorder } from "./usePlanScheduleDayReorder"; const move = vi.hoisted(() => ({ mutateAsync: vi.fn(), isPending: false })); vi.mock("@/hooks/useRooms", () => ({ useMoveRoomSchedule: () => move })); -vi.mock("@/hooks/useScheduleResyncPending", () => ({ useScheduleResyncPending: () => false })); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); const HEIGHT = 200; @@ -85,3 +84,37 @@ it("keeps the dropped day order on screen until the cache shows it, then draws t 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(); + } +}); diff --git a/src/hooks/usePlanScheduleDayReorder.ts b/src/hooks/usePlanScheduleDayReorder.ts index b5422f88..5d8dd84b 100644 --- a/src/hooks/usePlanScheduleDayReorder.ts +++ b/src/hooks/usePlanScheduleDayReorder.ts @@ -10,13 +10,11 @@ import { import { toast } from "sonner"; import { useDragAutoScroll } from "@/hooks/useDragAutoScroll"; -import { useScheduleResyncPending } from "@/hooks/useScheduleResyncPending"; import { useMoveRoomSchedule } from "@/hooks/useRooms"; import type { RoomSchedule } from "@/lib/api/rooms"; import { computeRowTranslateY, getPlanItemRowReorderStyle, - PLAN_ORDER_RESYNCING_MESSAGE, PlanItemRowLayoutSnapshot, } from "@/lib/plan/planItemReorder"; import { @@ -28,13 +26,11 @@ import { type UsePlanScheduleDayReorderArgs = { roomId: string; schedules: RoomSchedule[]; - interactionLocked?: boolean; }; export function usePlanScheduleDayReorder({ roomId, schedules, - interactionLocked = false, }: UsePlanScheduleDayReorderArgs) { const { mutateAsync: moveMutate, isPending: isMovePending } = useMoveRoomSchedule(); @@ -45,17 +41,18 @@ export function usePlanScheduleDayReorder({ * 먼저 풀면 캐시가 바뀌기 전 한 프레임 동안 옛 순서가 보인다(캐시 알림은 setTimeout 0 뒤) */ const [droppedSchedules, setDroppedSchedules] = useState(null); - // dragend는 drop 바로 뒤에 와서 렌더 전 값을 봐야 한다 - const droppedRef = useRef(false); + // 지금 저장 중인 놓기 — 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 }); @@ -100,7 +97,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); @@ -109,12 +109,12 @@ export function usePlanScheduleDayReorder({ setDragFromIndex(index); setPreviewIndex(index); }, - [dragLocked, scheduleResyncPending, schedules], + [dragLocked, schedules], ); const handleDragEnd = useCallback(() => { // 놓아서 저장 중이면 저장이 끝날 때 푼다 - if (droppedRef.current) return; + if (pendingDropRef.current) return; if (dragOverRafRef.current) { cancelAnimationFrame(dragOverRafRef.current); dragOverRafRef.current = 0; @@ -154,13 +154,6 @@ export function usePlanScheduleDayReorder({ previewIndex ?? (Number.isFinite(fromIndex) ? fromIndex : null); - // 드래그하는 동안 연결이 끊겼으면 놓은 자리를 서버 순서로 확인할 수 없다 - if (scheduleResyncPending) { - resetDragPreview(); - toast.error(PLAN_ORDER_RESYNCING_MESSAGE); - return; - } - if ( !Number.isFinite(fromIndex) || fromIndex < 0 || @@ -182,7 +175,8 @@ export function usePlanScheduleDayReorder({ const targetDayNumber = toIndex + 1; - droppedRef.current = true; + const drop = Symbol("day-drop"); + pendingDropRef.current = drop; setDroppedSchedules(schedules); try { await moveMutate({ @@ -197,12 +191,14 @@ export function usePlanScheduleDayReorder({ : "일차 순서를 바꾸지 못했어요.", ); } finally { - droppedRef.current = false; - setDroppedSchedules(null); - resetDragPreview(); + if (pendingDropRef.current === drop) { + pendingDropRef.current = null; + setDroppedSchedules(null); + resetDragPreview(); + } } }, - [moveMutate, previewIndex, resetDragPreview, roomId, scheduleResyncPending, schedules], + [moveMutate, previewIndex, resetDragPreview, roomId, schedules], ); // 저장 중에는 캐시가 놓을 때 목록 그대로인 동안만 놓은 모양을 그린다 @@ -239,8 +235,7 @@ export function usePlanScheduleDayReorder({ motionEnabled: isDraggingActive && previewActive, }), dragHandleProps: { - dragDisabled: - dragLocked || scheduleResyncPending || schedules[index] == null, + dragDisabled: dragLocked || schedules[index] == null, isDragging, onDragStart: handleDragStart(index), onDragEnd: handleDragEnd, @@ -255,7 +250,6 @@ export function usePlanScheduleDayReorder({ isDraggingActive, previewActive, previewIndex, - scheduleResyncPending, schedules, setSectionRef, ], 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/plan/planItemReorder.ts b/src/lib/plan/planItemReorder.ts index a407d06f..e8aa9045 100644 --- a/src/lib/plan/planItemReorder.ts +++ b/src/lib/plan/planItemReorder.ts @@ -7,10 +7,6 @@ export const PLAN_ITEM_DND_INDEX_MIME = "application/x-plan-item-index"; 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)"; From ba2222f1ef8807e3ea887f33822ee0890049eeda Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 9 Nov 2026 16:02:59 +0900 Subject: [PATCH 23/24] =?UTF-8?q?fix(plan):=20=EC=9E=A5=EC=86=8C=C2=B7?= =?UTF-8?q?=EC=9D=BC=EC=B0=A8=EB=A5=BC=20=EC=98=AE=EA=B8=B0=EC=A7=80=20?= =?UTF-8?q?=EB=AA=BB=ED=96=88=EC=9D=84=20=EB=95=8C=20=EB=B8=8C=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=EC=A0=80=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EB=AC=B8=EA=B5=AC=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=ED=95=9C=EA=B5=AD=EC=96=B4=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20?= =?UTF-8?q?=EB=9D=84=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버에 닿지 못하면 fetch가 TypeError("Failed to fetch")를 던져 그 문구가 토스트에 그대로 보였음(dev부터 있던 동작) - messageForSaveError(errors.ts): 서버가 준 문구가 있으면 그 문구, 네트워크 오류면 fallback - 일차 간 이동("다른 일차로 옮기지 못했어요.")·일차 순서 변경("일차 순서를 바꾸지 못했어요.")에 적용 - 테스트: 네트워크 오류일 때 두 문구 Co-Authored-By: Claude Opus 5.5 --- src/hooks/usePlanItemDropCommit.test.tsx | 16 +++++++++++++ src/hooks/usePlanItemDropCommit.ts | 7 ++---- src/hooks/usePlanScheduleDayReorder.test.tsx | 24 ++++++++++++++++++++ src/hooks/usePlanScheduleDayReorder.ts | 7 ++---- src/lib/api/errors.ts | 9 ++++++++ 5 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/hooks/usePlanItemDropCommit.test.tsx b/src/hooks/usePlanItemDropCommit.test.tsx index 87b0ed4c..50d9166e 100644 --- a/src/hooks/usePlanItemDropCommit.test.tsx +++ b/src/hooks/usePlanItemDropCommit.test.tsx @@ -151,6 +151,22 @@ it("shows the server message when a cross-day move fails", async () => { } }); +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 { diff --git a/src/hooks/usePlanItemDropCommit.ts b/src/hooks/usePlanItemDropCommit.ts index 9371a4b0..cc34509f 100644 --- a/src/hooks/usePlanItemDropCommit.ts +++ b/src/hooks/usePlanItemDropCommit.ts @@ -5,6 +5,7 @@ import { useMoveScheduleItemToSchedule, useReorderScheduleItem, } from "@/hooks/useRooms"; +import { messageForSaveError } from "@/lib/api/errors"; import { resolvePlanItemDrop, type PlanItemDrop } from "@/lib/plan/planItemDrop"; /** @@ -28,11 +29,7 @@ export function usePlanItemDropCommit(roomId: string) { save.kind === "reorder" ? reorderAsync(save.vars).catch(() => toast.error("순서를 바꾸지 못했어요.")) : moveAsync(save.vars).catch((err: unknown) => - toast.error( - err instanceof Error && err.message.trim() - ? err.message - : "다른 일차로 옮기지 못했어요.", - ), + toast.error(messageForSaveError(err, "다른 일차로 옮기지 못했어요.")), ); void saving.finally(onSettled); return true; diff --git a/src/hooks/usePlanScheduleDayReorder.test.tsx b/src/hooks/usePlanScheduleDayReorder.test.tsx index 1b621f42..55e7face 100644 --- a/src/hooks/usePlanScheduleDayReorder.test.tsx +++ b/src/hooks/usePlanScheduleDayReorder.test.tsx @@ -9,6 +9,8 @@ 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; @@ -44,6 +46,7 @@ function dragEvent(transfer: DataTransfer, target: Element, clientY: number) { } beforeEach(() => { + toastError.mockClear(); move.mutateAsync.mockReset(); move.isPending = false; }); @@ -118,3 +121,24 @@ it("starts the next day drag while the previous one is still saving, and the ear 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 5d8dd84b..7fbe81ab 100644 --- a/src/hooks/usePlanScheduleDayReorder.ts +++ b/src/hooks/usePlanScheduleDayReorder.ts @@ -11,6 +11,7 @@ import { toast } from "sonner"; import { useDragAutoScroll } from "@/hooks/useDragAutoScroll"; import { useMoveRoomSchedule } from "@/hooks/useRooms"; +import { messageForSaveError } from "@/lib/api/errors"; import type { RoomSchedule } from "@/lib/api/rooms"; import { computeRowTranslateY, @@ -185,11 +186,7 @@ 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; 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, From e9881ff7eff93e51f99fccbeba3694ef021e4b2b Mon Sep 17 00:00:00 2001 From: KangShinGyu Date: Mon, 9 Nov 2026 16:06:39 +0900 Subject: [PATCH 24/24] =?UTF-8?q?fix(plan):=20=EC=9E=A5=EC=86=8C=C2=B7?= =?UTF-8?q?=EC=9D=BC=EC=B0=A8=20=EC=98=AE=EA=B8=B0=EA=B8=B0=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=ED=86=A0=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=9B=EA=B8=B0=EB=A5=BC=20=EA=B8=B0=EB=8B=A4?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=EB=9C=A8=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onSettled가 다시 받기(invalidate) promise를 돌려주면 그 GET이 끝날 때까지 mutateAsync가 reject되지 않아, 화면은 바로 되돌아가는데 토스트만 늦게 떴음 - 순서 변경·일차 간 이동·일차 순서 변경의 onSettled에서 다시 받기를 기다리지 않음(같은 대상의 마지막 저장일 때만 받는 것은 그대로) - 테스트: 다시 받기가 끝나지 않아도 실패를 바로 알림 Co-Authored-By: Claude Opus 5.5 --- src/hooks/useRooms.schedule-items.test.tsx | 32 +++++++++++++++++ src/hooks/useRooms.ts | 42 ++++++++++++---------- 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/hooks/useRooms.schedule-items.test.tsx b/src/hooks/useRooms.schedule-items.test.tsx index dcf46f56..f613e891 100644 --- a/src/hooks/useRooms.schedule-items.test.tsx +++ b/src/hooks/useRooms.schedule-items.test.tsx @@ -362,6 +362,7 @@ it("draws only the latest order when two reorders on the same day overlap, and r 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 { @@ -416,3 +417,34 @@ it("refetches the room schedules once, after the last of two overlapping day mov 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 c8d7a5f9..6aa25025 100644 --- a/src/hooks/useRooms.ts +++ b/src/hooks/useRooms.ts @@ -288,14 +288,16 @@ export function useMoveRoomSchedule() { await invalidateExpenses(queryClient, id); }, // 연달아 옮기면 앞 저장이 끝날 때 다시 받은 일정이 뒤 저장 전 순서를 그린다 — 마지막 저장이 끝날 때 한 번만 받는다. - // onSettled 안에서는 끝나는 저장도 아직 진행 중으로 세므로 1 이하면 남은 저장이 없다 - onSettled: (_data, _error, { roomId }) => - queryClient.isMutating({ mutationKey: ROOM_SCHEDULE_MOVE_MUTATION_KEY }) <= 1 - ? queryClient.invalidateQueries({ - queryKey: roomSchedulesQueryKey(roomId.trim()), - exact: true, - }) - : undefined, + // 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, + }); + } + }, }); } @@ -426,12 +428,14 @@ export function useMoveScheduleItemToSchedule() { // 출발·대상 일차를 다시 받는다(성공·실패 모두). 그 일차를 바꾸는 저장이 더 남았으면 그 저장이 끝날 때 받는다. // `syncAfterCrossScheduleItemMove`는 출발 일차 재조회가 실패하면 throw해서, 여기서 쓰면 저장된 이동이 실패로 바뀌고 // onError가 화면을 되돌린다 - onSettled: (_moved, _error, { roomId, sourceScheduleId, targetScheduleId }) => - Promise.all( - [sourceScheduleId, targetScheduleId] - .filter((scheduleId) => isLastItemMoveOf(queryClient, roomId, scheduleId)) - .map((scheduleId) => invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId)), - ), + // 다시 받기는 기다리지 않는다 — 기다리면 그 GET이 끝날 때까지 mutateAsync가 끝나지 않아 실패 토스트가 늦게 뜬다 + onSettled: (_moved, _error, { roomId, sourceScheduleId, targetScheduleId }) => { + for (const scheduleId of [sourceScheduleId, targetScheduleId]) { + if (isLastItemMoveOf(queryClient, roomId, scheduleId)) { + void invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId); + } + } + }, }); } @@ -631,10 +635,12 @@ export function useReorderScheduleItem() { method: "drag_drop", }); }, - onSettled: (_items, _error, { roomId, scheduleId }) => - isLastItemMoveOf(queryClient, roomId, scheduleId) - ? invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId) - : undefined, + // 다시 받기는 기다리지 않는다 — 기다리면 그 GET이 끝날 때까지 mutateAsync가 끝나지 않아 실패 토스트가 늦게 뜬다 + onSettled: (_items, _error, { roomId, scheduleId }) => { + if (isLastItemMoveOf(queryClient, roomId, scheduleId)) { + void invalidateDayPlacesAfterOwnChange(queryClient, roomId, scheduleId); + } + }, }); }