diff --git a/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx b/src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx
index c239b562..e3c07d3a 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.summary.test.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.summary.test.tsx
index 90bd5f8d..a35386f2 100644
--- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.summary.test.tsx
+++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.summary.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.test.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx
index 495a9a47..69af9107 100644
--- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx
+++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx
@@ -15,7 +15,7 @@ let container: HTMLDivElement;
let client: QueryClient;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
- api.route.mockReset().mockImplementation(async (_r, _s, _i, travelMode) => ({ travelMode, distanceMeters: 100, durationSeconds: 60 }));
+ api.route.mockReset().mockImplementation(async (_r, _s, _i, _d, travelMode) => ({ travelMode, distanceMeters: 100, durationSeconds: 60 }));
api.updateMode.mockReset().mockImplementation(async (_r, _s, itemId, body) => ({ itemId, scheduleId: 10, googlePlaceId: "a", orderIndex: 0, startTime: null, endTime: null, createdAt: "2026-10-28", ...body }));
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
@@ -23,7 +23,7 @@ beforeEach(() => {
afterEach(async () => { await act(async () => root.unmount()); container.remove(); client.clear(); vi.unstubAllGlobals(); });
async function render(travelMode?: string) {
await act(async () => root.render());
}
@@ -35,11 +35,11 @@ it.each([undefined, "BICYCLING", "CYCLING", "BOAT"])("does not query or save uns
await act(async () => container.querySelector("button[aria-expanded]")!.click());
const choice = [...container.querySelectorAll("button[role=option]")].find(b => b.textContent?.includes("자동차"))!;
await act(async () => choice.click());
- await vi.waitFor(() => expect(api.route).toHaveBeenCalledWith("room", 10, 1, "DRIVING"));
+ await vi.waitFor(() => expect(api.route).toHaveBeenCalledWith("room", 10, 1, 2, "DRIVING"));
expect(api.updateMode).toHaveBeenCalledExactlyOnceWith("room", 10, 1, { travelMode: "DRIVING" });
});
it.each(["DRIVING", "WALKING", "TRANSIT"])("queries supported %s without saving it", async mode => {
await render(mode);
- await vi.waitFor(() => expect(api.route).toHaveBeenCalledWith("room", 10, 1, mode));
+ await vi.waitFor(() => expect(api.route).toHaveBeenCalledWith("room", 10, 1, 2, mode));
expect(api.updateMode).not.toHaveBeenCalled();
});
diff --git a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx
index cb1aff7e..806df07d 100644
--- a/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx
+++ b/src/app/(main)/plan/_components/travel-time/PlanTravelTime.tsx
@@ -40,6 +40,8 @@ export type PlanTravelTimeProps = {
roomId: string;
scheduleId: number;
segmentSourceItemId: number;
+ /** 다음 장소의 `itemId` — 경로를 이 항목까지 계산한다 */
+ destinationItemId: number;
scheduleFingerprint: string;
/** 서버 원본 공유 이동수단 — 미지원·미설정이면 선택을 기다립니다. */
travelMode?: string;
@@ -57,6 +59,7 @@ export function PlanTravelTime({
roomId,
scheduleId,
segmentSourceItemId,
+ destinationItemId,
scheduleFingerprint,
travelMode,
routeQueryEnabled = true,
@@ -105,6 +108,7 @@ export function PlanTravelTime({
roomId,
scheduleId,
segmentSourceItemId,
+ destinationItemId,
selectedMode,
routeQueryEnabled,
fpTrim,
@@ -126,6 +130,7 @@ export function PlanTravelTime({
rid,
scheduleId,
segmentSourceItemId,
+ destinationItemId,
mode,
fpTrim,
{
diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx
index 084ee376..5d7ed6f5 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";
@@ -49,7 +45,6 @@ import { PlanItineraryStopMapPin } from "./PlanItineraryStopMapPin";
* 일차만 지도에 경로(polyline)와 정류장 마커를 그립니다.
* `routeDay`가 있으면(모바일 경로 보기) 토글과 관계없이 그 일차만 그립니다.
* 폴리라인 travelMode는 서버에 저장된 구간별 공유 이동수단을 따릅니다.
- * STOMP 에폭은 {@link usePlanMapDirectionsEpochStore} 참고.
*/
/** 모바일 경로 보기 — 고른 일차 하나만 그린다 */
export type PlanItineraryRouteDay = {
@@ -116,13 +111,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,55 +161,43 @@ 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.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.destinationItemId,
+ seg.originPlaceId,
+ seg.destPlaceId,
+ seg.travelModeCanon,
+ ),
+ /** 구간 경로는 일정 리스트와 같은 쿼리로 가져와, 같은 구간을 받는 중이면 그 요청에 합류한다 */
+ queryFn: async () => {
+ const route = await loadScheduleItemRouteForMap(
+ queryClient,
+ persistedScheduleItemRouteQueryOptions(
+ rid,
+ seg.scheduleId,
+ seg.segmentSourceItemId,
+ seg.destinationItemId,
+ 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 = [];
@@ -229,13 +205,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.destinationItemId}-${seg.travelModeCanon}`;
const routeColor =
routeColorByScheduleId.get(seg.scheduleId) ?? fallbackRouteStroke;
@@ -243,7 +214,7 @@ export function PlanItineraryMapRoutes({
if (pts.length) {
polylines.push(
{
polylines.push(
({
}));
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..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/hooks/useRooms.ts b/src/hooks/useRooms.ts
index 14c8cda2..5fb5ac77 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";
@@ -176,6 +174,8 @@ export function useScheduleItemRoute(
roomId: string | null,
scheduleId: number | null,
segmentSourceItemId: number | null,
+ /** 다음 장소의 `itemId` */
+ destinationItemId: number | null,
travelMode: ScheduleTravelModeValue | null,
/** 일정 장소 목록상 구간 소스 아이디가 존재·목록 안정 후에만 true */
routeQueryEnabled = true,
@@ -189,6 +189,7 @@ export function useScheduleItemRoute(
rid,
scheduleId,
segmentSourceItemId,
+ destinationItemId,
travelMode,
scheduleFingerprint,
{ segmentReady: Boolean(routeQueryEnabled) },
@@ -206,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);
},
});
@@ -300,7 +291,7 @@ export function useMoveScheduleItemToSchedule() {
},
onSuccess: async (
_moved,
- { roomId, sourceScheduleId, targetScheduleId, itemId },
+ { roomId, sourceScheduleId, targetScheduleId },
) => {
await invalidateExpenses(queryClient, roomId);
await syncAfterCrossScheduleItemMove(
@@ -308,7 +299,6 @@ export function useMoveScheduleItemToSchedule() {
roomId,
sourceScheduleId,
targetScheduleId,
- itemId,
);
},
});
@@ -450,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,
@@ -478,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/api/client.test.ts b/src/lib/api/client.test.ts
new file mode 100644
index 00000000..52f2f852
--- /dev/null
+++ b/src/lib/api/client.test.ts
@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ authenticatedFetch: vi.fn(),
+ getTabId: vi.fn<() => string | null>(),
+}));
+
+vi.mock("@/lib/api/authenticated-fetch", () => ({
+ authenticatedFetch: mocks.authenticatedFetch,
+}));
+vi.mock("@/lib/auth-session-client", () => ({
+ expireClientSessionAndRedirect: vi.fn(),
+}));
+vi.mock("@/lib/auth", () => ({ fetchSessionUserRaw: vi.fn() }));
+vi.mock("@/lib/query-client", () => ({ getQueryClient: () => null }));
+vi.mock("@/lib/tab-id", () => ({ getTabId: mocks.getTabId }));
+
+import { apiFetch } from "./client";
+import { jsonBody } from "./http";
+
+const TAB_ID = "3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f";
+const REQUEST_URL = "http://localhost:8080/rooms/room-1/schedules/10/items";
+
+/** apiFetch가 실제로 보낸 요청의 탭 ID 헤더 */
+function sentTabId(): string | null {
+ const init: RequestInit | undefined = mocks.authenticatedFetch.mock.calls[0]?.[1];
+ return new Headers(init?.headers).get("X-Client-Tab-Id");
+}
+
+describe("apiFetch tab ID header", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.authenticatedFetch.mockResolvedValue({
+ response: new Response(null, { status: 204 }),
+ didRefresh: false,
+ });
+ mocks.getTabId.mockReturnValue(TAB_ID);
+ });
+
+ it.each([
+ ["JSON POST", { method: "POST", ...jsonBody({ placeId: "p" }) }],
+ ["PATCH", { method: "PATCH", ...jsonBody({ memo: "m" }) }],
+ ["PUT", { method: "PUT", ...jsonBody({ content: "c" }) }],
+ ["DELETE", { method: "DELETE" }],
+ ] satisfies [string, RequestInit][])("adds the header to a %s that is already preflighted", async (_, init) => {
+ await apiFetch(REQUEST_URL, init);
+
+ expect(sentTabId()).toBe(TAB_ID);
+ });
+
+ it.each([
+ ["GET without init", undefined],
+ ["GET", { method: "GET" }],
+ ["POST without body", { method: "POST" }],
+ ] satisfies [string, RequestInit | undefined][])("leaves a %s without the header", async (_, init) => {
+ await apiFetch(REQUEST_URL, init);
+
+ expect(sentTabId()).toBeNull();
+ });
+
+ it("does not add the header when the tab has no ID", async () => {
+ mocks.getTabId.mockReturnValue(null);
+
+ await apiFetch(REQUEST_URL, { method: "DELETE" });
+
+ expect(sentTabId()).toBeNull();
+ });
+
+ it.each([
+ ["object", { "Content-Type": "application/json", "X-Extra": "1" }],
+ ["Headers", new Headers({ "Content-Type": "application/json", "X-Extra": "1" })],
+ ["array", [["Content-Type", "application/json"], ["X-Extra", "1"]]],
+ ] satisfies [string, HeadersInit][])("keeps existing %s headers", async (_, headers) => {
+ await apiFetch(REQUEST_URL, { method: "POST", headers, body: "{}" });
+
+ const init: RequestInit | undefined = mocks.authenticatedFetch.mock.calls[0]?.[1];
+ const sent = new Headers(init?.headers);
+ expect(sent.get("Content-Type")).toBe("application/json");
+ expect(sent.get("X-Extra")).toBe("1");
+ expect(sent.get("X-Client-Tab-Id")).toBe(TAB_ID);
+ });
+});
diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts
index a71ca10c..1578a9af 100644
--- a/src/lib/api/client.ts
+++ b/src/lib/api/client.ts
@@ -6,6 +6,32 @@ import {
readSessionUserCache,
setSessionUserCache,
} from "@/lib/session-user-cache";
+import { getTabId } from "@/lib/tab-id";
+
+const CLIENT_TAB_ID_HEADER = "X-Client-Tab-Id";
+
+/**
+ * 일정·북마크 STOMP 이벤트의 `originTabId`로 돌아와, 이 탭이 보낸 변경의 반향을 가려낸다.
+ * 이미 CORS preflight를 하는 요청(PATCH·PUT·DELETE, JSON POST)에만 싣는다 — GET·body 없는 POST에
+ * 커스텀 헤더를 붙이면 preflight가 새로 생기고, 백엔드 CORS에 maxAge가 없어 Chrome은 그 결과를 약 5초만 기억한다.
+ */
+function withClientTabIdHeader(init: RequestInit | undefined): RequestInit | undefined {
+ const tabId = getTabId();
+ if (!tabId || !init) return init;
+
+ const method = (init.method ?? "GET").toUpperCase();
+ const headers = new Headers(init.headers);
+ const isPreflighted =
+ method === "PATCH" ||
+ method === "PUT" ||
+ method === "DELETE" ||
+ (method === "POST" &&
+ (headers.get("Content-Type") ?? "").toLowerCase().startsWith("application/json"));
+ if (!isPreflighted) return init;
+
+ headers.set(CLIENT_TAB_ID_HEADER, tabId);
+ return { ...init, headers };
+}
/**
* Drop-in replacement for fetch that automatically retries once after
@@ -15,7 +41,10 @@ export async function apiFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise {
- const { response: res, didRefresh } = await authenticatedFetch(input, init);
+ const { response: res, didRefresh } = await authenticatedFetch(
+ input,
+ withClientTabIdHeader(init),
+ );
if (res.status === 401) {
expireClientSessionAndRedirect({
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 fb4d4af9..6de10c06 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;
};
@@ -118,11 +122,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 0fdb9cda..22c91918 100644
--- a/src/lib/plan/planItineraryMapSegments.ts
+++ b/src/lib/plan/planItineraryMapSegments.ts
@@ -10,6 +10,8 @@ import {
export type PlanItinerarySegmentDescriptor = {
scheduleId: number;
segmentSourceItemId: number;
+ /** 다음 장소의 `itemId` — 경로를 이 항목까지 계산한다 */
+ destinationItemId: number;
originPlaceId: string;
destPlaceId: string;
travelModeCanon: ScheduleTravelModeValue;
@@ -35,7 +37,7 @@ 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;
}
const travelModeCanon = canonicalScheduleTravelMode(a.travelMode);
@@ -43,6 +45,7 @@ export function flattenPlanItinerarySegmentsFromPlaces(
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 4a385ff8..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 = {
@@ -75,21 +77,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,
@@ -110,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;
@@ -133,6 +121,7 @@ export function writeScheduleRouteToSessionStorage(
roomId: string,
scheduleId: number,
segmentSourceItemId: number,
+ destinationItemId: number,
travelMode: string,
fp: string,
data: ScheduleItemRouteResponse | null,
@@ -143,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 3b8d1c24..ab2f628b 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;
@@ -47,6 +50,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"
));
@@ -67,13 +76,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),
@@ -85,7 +95,7 @@ describe("hydrateScheduleRoutesBatch", () => {
expect(
queryClient.getQueryData(
- scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"),
+ scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"),
),
).toMatchObject({
distanceMeters: 1200,
@@ -125,7 +135,7 @@ describe("hydrateScheduleRoutesBatch", () => {
expect(
queryClient.getQueryData(
- scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"),
+ scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"),
),
).not.toHaveProperty("encodedPolyline");
});
@@ -147,6 +157,7 @@ describe("hydrateScheduleRoutesBatch", () => {
{
status: "ERROR",
itemId: 2,
+ toItemId: 3,
travelMode: "WALKING",
errorCode: "UPSTREAM_TIMEOUT",
},
@@ -171,6 +182,7 @@ describe("hydrateScheduleRoutesBatch", () => {
ROOM_ID,
SCHEDULE_ID,
2,
+ 3,
"WALKING",
);
expect(queryClient.getQueryData(failedKey)).toBeUndefined();
@@ -179,6 +191,7 @@ describe("hydrateScheduleRoutesBatch", () => {
roomId: ROOM_ID,
scheduleId: SCHEDULE_ID,
segmentSourceItemId: 2,
+ destinationItemId: 3,
travelMode: "WALKING",
}),
).rejects.toThrow("temporary single route failure");
@@ -188,14 +201,42 @@ describe("hydrateScheduleRoutesBatch", () => {
roomId: ROOM_ID,
scheduleId: SCHEDULE_ID,
segmentSourceItemId: 2,
+ destinationItemId: 3,
travelMode: "WALKING",
}),
).resolves.toEqual(fallbackRoute);
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);
@@ -205,32 +246,35 @@ 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, "WALKING")),
- ).toBeNull();
+ queryClient.getQueryState(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "WALKING")),
+ ).toBeUndefined();
await expect(
resolveScheduleSegmentRoute({
roomId: ROOM_ID,
scheduleId: SCHEDULE_ID,
segmentSourceItemId: 2,
+ 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"),
@@ -242,7 +286,12 @@ describe("hydrateScheduleRoutesBatch", () => {
expect(
queryClient.getQueryData(
- scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, "DRIVING"),
+ scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 2, "DRIVING"),
+ ),
+ ).toMatchObject({ distanceMeters: 1200 });
+ expect(
+ queryClient.getQueryState(
+ scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 1, 3, "DRIVING"),
),
).toBeUndefined();
});
@@ -255,8 +304,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, 2, "DRIVING"), route);
+ queryClient.setQueryData(scheduleItemRouteQueryKey(ROOM_ID, SCHEDULE_ID, 2, 3, "DRIVING"), route);
return queryClient;
}
@@ -268,7 +317,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, 3, "DRIVING") });
getScheduleItemRoutesBatchMock.mockResolvedValue([]);
await fetchAndSeedScheduleRoutesBatch(queryClient, ROOM_ID, SCHEDULE_ID, places);
@@ -276,10 +325,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, 2, "DRIVING"),
refetchType: "none",
});
getScheduleItemRoutesBatchMock.mockResolvedValue([]);
@@ -289,3 +351,65 @@ 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,
+ toItemId: itemId + 1,
+ 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가 넘기는 것처럼 다음 장소의 itemId로 조회한다
+ 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]?.itemId ?? null,
+ "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.destinationItemId,
+ 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();
+ });
+});
diff --git a/src/lib/plan/schedule-bulk-hydration.ts b/src/lib/plan/schedule-bulk-hydration.ts
index e33aeab5..47484427 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 {
canonicalScheduleTravelMode,
} from "@/lib/plan/scheduleTravelMode";
@@ -208,7 +201,7 @@ export async function buildSchedulePlacesFromSchedulesWithItems(
}
/**
- * 만든 일차 목록을 한 번에 씁니다. 순서가 바뀐 일차는 이웃이 바뀐 구간 경로만 무효화합니다.
+ * 만든 일차 목록을 한 번에 씁니다. 경로는 도착 항목까지 key에 있어 순서가 바뀌어도 무효화하지 않습니다.
* `startedAt`(일정 전체 조회를 보낸 시각) 뒤에 바뀐 일차는 그쪽이 더 새 상태라 덮지 않고,
* 그 일차를 받는 중이면 이 조회보다 먼저 보낸 요청일 수 있어 다시 받게 합니다.
*/
@@ -226,20 +219,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);
- }
}
}
@@ -277,8 +260,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를 공유하지 않게 합니다.
@@ -291,9 +272,26 @@ function scheduleRoutesBatchKey(
return `${roomId.trim()}:${scheduleId}:${placesFingerprint}`;
}
+/** 구간 출발 항목 → 다음 장소의 `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]?.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.
+ * 지도 경로 보기로 넘어온 경우다. 받은 적 없거나(새 순서·gc) 무효화된 구간이 하나라도 있으면 false.
*/
export function scheduleSegmentRoutesCached(
queryClient: QueryClient,
@@ -302,14 +300,17 @@ 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);
if (!mode) return true;
- 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;
});
}
@@ -390,27 +391,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;
-
+ // 서버는 요청을 받은 순간의 순서로 다음 항목을 정하므로, 응답이 알려 준 도착 항목(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;
+ if (!mode || !isFiniteItemId(itemId) || !isFiniteItemId(toItemId)) continue;
- const routeKey = scheduleItemRouteQueryKey(rid, scheduleId, itemId, 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);
}
}
diff --git a/src/lib/plan/scheduleItemPlaces.create.test.ts b/src/lib/plan/scheduleItemPlaces.create.test.ts
index 88b33283..1c3eca3c 100644
--- a/src/lib/plan/scheduleItemPlaces.create.test.ts
+++ b/src/lib/plan/scheduleItemPlaces.create.test.ts
@@ -9,17 +9,10 @@ import type { PlanPlace } from "@/lib/plan/types";
import { scheduleItemsQueryKey } from "@/lib/query-keys";
const mocks = vi.hoisted(() => ({
- bumpForDirections: vi.fn(),
- bumpPlanMapRouteSegments: vi.fn(),
createScheduleItem: vi.fn(),
updateScheduleItem: 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", () => ({
@@ -32,22 +25,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(),
}));
@@ -56,12 +33,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;
@@ -97,10 +68,6 @@ describe("createScheduleItemAtPlanIndex", () => {
name: "Created place",
formattedAddress: "Address",
});
- mocks.invalidateScheduleItemRouteForSources.mockResolvedValue(undefined);
- mocks.invalidateScheduleItemRoutesAfterItemCreated.mockResolvedValue(
- undefined,
- );
});
const response: CreateScheduleItemResponse = {
@@ -132,7 +99,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);
@@ -142,82 +109,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; });
@@ -235,13 +147,11 @@ 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();
});
});
@@ -258,7 +168,7 @@ describe("reorder preserves user times", () => {
{ ...scheduleItem(2, 0), startTime, endTime },
{ ...scheduleItem(1, 1), startTime: null, endTime: null },
];
- await syncPlanPlacesAfterReorderSuccess(client, "room", 10, items, 1);
+ await syncPlanPlacesAfterReorderSuccess(client, "room", 10, items);
expect(mocks.updateScheduleItem).not.toHaveBeenCalled();
expect(client.getQueryData>(key)?.map(p =>
[p.itemId, p.startTime ?? null, p.endTime ?? null])).toEqual([[2, startTime, endTime], [1, null, null]]);
@@ -280,7 +190,7 @@ it("cross-day move refresh preserves unset and partial times without a time PATC
new QueryObserver(client, schedulePlanPlacesQueryOptions(client, "room", scheduleId))
.subscribe(() => {}),
);
- await syncAfterCrossScheduleItemMove(client, "room", 10, 20, 2, []);
+ await syncAfterCrossScheduleItemMove(client, "room", 10, 20);
expect(mocks.updateScheduleItem).not.toHaveBeenCalled();
expect(client.getQueryData>(scheduleItemsQueryKey("room", 10))?.[0]).not.toHaveProperty("startTime");
expect(client.getQueryData>(scheduleItemsQueryKey("room", 20))?.[0]).toMatchObject({ startTime: "12:45" });
diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts
index ae294e75..b087ce82 100644
--- a/src/lib/plan/scheduleItemPlaces.ts
+++ b/src/lib/plan/scheduleItemPlaces.ts
@@ -20,19 +20,8 @@ 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 type { PlanPlace } from "@/lib/plan/types";
-import { usePlanMapDirectionsEpochStore } from "@/stores/plan-map-directions-epoch-store";
/** `orderIndex` 기준 정렬(비변형) */
export function sortRoomScheduleItemsByOrder(
@@ -185,65 +174,24 @@ 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,
roomId: string,
scheduleId: number,
- { joinInFlight = false }: { joinInFlight?: boolean } = {},
): Promise {
const queryKey = scheduleItemsQueryKey(roomId.trim(), scheduleId);
// 목록이 아직 없으면 TanStack은 진행 중인 첫 조회를 취소하지 않고 합류만 한다
const state = queryClient.getQueryState(queryKey);
- if (!joinInFlight && state?.data === undefined && state?.fetchStatus === "fetching") {
+ if (state?.data === undefined && state?.fetchStatus === "fetching") {
await queryClient.cancelQueries({ queryKey, exact: true });
}
- await queryClient.invalidateQueries(
- { queryKey, exact: true },
- { throwOnError: true, cancelRefetch: !joinInFlight },
- );
+ await queryClient.invalidateQueries({ queryKey, exact: true }, { throwOnError: true });
}
/**
@@ -266,98 +214,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`를 갱신합니다. */
@@ -475,38 +348,22 @@ export async function fetchSchedulePlanPlacesFromCacheOrApi(
return fetchScheduleItemsAsPlanPlaces(rid, scheduleId);
}
-/** 순서 응답을 반영하고 변경된 구간의 경로 캐시만 무효화합니다. */
+/** 순서 응답을 반영합니다. 경로 캐시는 출발·도착 항목 키로 구분합니다. */
export async function syncPlanPlacesAfterReorderSuccess(
queryClient: QueryClient,
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,
scheduleId,
items,
);
-
- await invalidateScheduleItemRoutesAfterReorder(
- queryClient,
- rid,
- scheduleId,
- movedItemId,
- oldOrderedIds,
- items,
- );
}
/** PATCH 응답 한 건으로 로컬 PlanPlace 캐시만 갱신(같은 목록에 itemId가 있을 때). */
@@ -636,37 +493,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/scheduleItemRoutePersistedQuery.test.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts
index 65afd3ff..7171e8c4 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,
+ 2,
"DRIVING",
"",
{ segmentReady: true },
@@ -70,10 +71,34 @@ describe("persistedScheduleItemRouteQueryOptions", () => {
});
});
+describe("persistedScheduleItemRouteQueryOptions key", () => {
+ it("도착 항목이 key에 들어가 같은 출발 항목이라도 도착 항목마다 다른 캐시를 쓴다", () => {
+ const toB = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, 2, "DRIVING", "", {
+ segmentReady: true,
+ });
+ const toC = persistedScheduleItemRouteQueryOptions("room-1", 10, 1, 3, "DRIVING", "", {
+ segmentReady: true,
+ });
+
+ 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, null, "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, 2, "TRANSIT", "", {
+ segmentReady: true,
+ });
it("목록 요청이 방금 실패한 구간은 다시 보내지 않고 가진 값을 쓴다", async () => {
const queryClient = new QueryClient();
diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.ts
index 35164178..297902e8 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,11 +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,
+ /** 다음 장소의 `itemId` — 없으면 구간이 정해지지 않아 조회하지 않는다 */
+ destinationItemId: number | null,
travelMode: ScheduleTravelModeValue | null,
fingerprintTrimmed: string,
flags: PersistedScheduleRoutePersistFlags,
@@ -43,10 +45,13 @@ export function persistedScheduleItemRouteQueryOptions(
const itemOk =
typeof segmentSourceItemId === "number" &&
Number.isFinite(segmentSourceItemId);
+ const destOk =
+ typeof destinationItemId === "number" &&
+ Number.isFinite(destinationItemId);
const fp = fingerprintTrimmed.trim();
const keyEligible =
- flags.segmentReady && travelMode !== null && rid.length > 0 && sidOk && itemOk;
+ flags.segmentReady && travelMode !== null && rid.length > 0 && sidOk && itemOk && destOk;
const network =
typeof flags.networkEnabled === "boolean"
@@ -58,8 +63,8 @@ export function persistedScheduleItemRouteQueryOptions(
const keyRoom = rid.length > 0 ? rid : null;
const keySchedule = sidOk ? sid : null;
const keySegment = itemOk ? segmentSourceItemId : null;
- const keyMode =
- sidOk && itemOk && rid.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 &&
@@ -70,6 +75,7 @@ export function persistedScheduleItemRouteQueryOptions(
rid,
sid!,
segmentSourceItemId!,
+ destinationItemId!,
travelMode,
fp,
)
@@ -80,6 +86,7 @@ export function persistedScheduleItemRouteQueryOptions(
keyRoom,
keySchedule,
keySegment,
+ keyDestination,
keyMode,
),
...(sessionSeed !== undefined
@@ -93,6 +100,7 @@ export function persistedScheduleItemRouteQueryOptions(
roomId: rid,
scheduleId: sid!,
segmentSourceItemId: segmentSourceItemId!,
+ destinationItemId: destinationItemId!,
travelMode,
scheduleFingerprint: fp,
}),
diff --git a/src/lib/plan/scheduleSegmentRoute.test.ts b/src/lib/plan/scheduleSegmentRoute.test.ts
index 9fafad1a..ce240784 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 = 2;
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(destinationItemId = DESTINATION) {
return resolveScheduleSegmentRoute({
roomId: ROOM_ID,
scheduleId: SCHEDULE_ID,
segmentSourceItemId: ITEM_ID,
+ destinationItemId,
travelMode: "DRIVING",
});
}
@@ -70,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 () => {
@@ -83,10 +91,16 @@ describe("resolveScheduleSegmentRoute", () => {
await expect(resolve()).resolves.toBeNull();
});
- /**
- * 재정렬로 무효화된 구간은 같은 키라도 목적지가 달라져 있습니다.
- * 낡은 값을 그대로 돌려주면 지도에 이전 순서의 폴리라인이 남습니다.
- */
+ it("같은 출발 항목이라도 도착 항목이 다르면 그 도착 항목의 경로를 받고, 순서를 되돌리면 받아 둔 경로를 쓴다", async () => {
+ queryClient.setQueryData(ROUTE_KEY(2), ROUTE);
+ const toC = { ...ROUTE, distanceMeters: 999, encodedPolyline: "zzz" };
+ getScheduleItemRouteMock.mockResolvedValue(toC);
+
+ await expect(resolve(3)).resolves.toEqual(toC);
+ await expect(resolve(2)).resolves.toEqual(ROUTE);
+ expect(getScheduleItemRouteMock).toHaveBeenCalledTimes(1);
+ });
+
it("캐시가 무효화된 상태면 낡은 값을 쓰지 않고 다시 조회한다", async () => {
queryClient.setQueryData(ROUTE_KEY(), ROUTE);
await queryClient.invalidateQueries({
@@ -101,10 +115,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 6667d654..e943810a 100644
--- a/src/lib/plan/scheduleSegmentRoute.ts
+++ b/src/lib/plan/scheduleSegmentRoute.ts
@@ -13,24 +13,25 @@ export type ResolveScheduleSegmentRouteArgs = {
roomId: string;
scheduleId: number;
segmentSourceItemId: number;
+ /** 다음 장소의 `itemId` — 서버가 그 순간의 순서와 관계없이 이 항목까지 계산한다 */
+ destinationItemId: number;
travelMode: ScheduleTravelModeValue;
/** 일정 지문 — 비면 sessionStorage에 기록하지 않습니다. */
scheduleFingerprint?: string;
};
/**
- * 구간 `(room, schedule, 출발 항목, 이동수단)` 경로를 가져오는 fetch 본체입니다.
+ * 구간 `(room, schedule, 출발 항목, 도착 항목, 이동수단)` 경로를 가져오는 fetch 본체입니다.
* 거리·소요 시간과 지도 폴리라인이 같은 캐시 항목을 공유하도록 일정 리스트와 지도가 모두
* 이 함수를 queryFn으로 쓰는 하나의 쿼리(`scheduleItemRouteQueryKey`)를 거칩니다.
*
- * in-flight 합치기와 재정렬 중 뒤늦게 도착한 응답 폐기는 React Query가 담당합니다.
- * 여기서 직접 캐시에 쓰지 않습니다 — `setQueryData`는 무효화 플래그까지 지워서
- * 순서가 바뀐 뒤의 낡은 경로를 유효한 값으로 만들어 버립니다.
+ * in-flight 합치기와 캐시 쓰기는 이 함수를 queryFn으로 쓰는 쿼리(React Query)가 맡아, 여기서 직접 캐시에 쓰지 않습니다.
*/
export async function resolveScheduleSegmentRoute({
roomId,
scheduleId,
segmentSourceItemId,
+ destinationItemId,
travelMode,
scheduleFingerprint,
}: ResolveScheduleSegmentRouteArgs): Promise {
@@ -44,6 +45,7 @@ export async function resolveScheduleSegmentRoute({
rid,
scheduleId,
segmentSourceItemId,
+ destinationItemId,
travelMode,
);
const cached = getQueryClient()?.getQueryState(routeKey);
@@ -55,6 +57,7 @@ export async function resolveScheduleSegmentRoute({
rid,
scheduleId,
segmentSourceItemId,
+ destinationItemId,
travelMode,
);
@@ -64,6 +67,7 @@ export async function resolveScheduleSegmentRoute({
rid,
scheduleId,
segmentSourceItemId,
+ destinationItemId,
travelMode,
fp,
fresh,
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/query-keys.ts b/src/lib/query-keys.ts
index dee035b1..e63a7b29 100644
--- a/src/lib/query-keys.ts
+++ b/src/lib/query-keys.ts
@@ -51,11 +51,10 @@ export function planItinerarySegmentMapPathQueryKey(
rid: string,
scheduleId: number,
segmentSourceItemId: number,
+ destinationItemId: number,
originPlaceId: string,
destPlaceId: string,
travelModeCanon: string,
- roomDirectionsEpoch: number,
- segmentEpoch: number,
) {
return [
"plan-itinerary-map-segment-path",
@@ -63,11 +62,10 @@ export function planItinerarySegmentMapPathQueryKey(
rid,
scheduleId,
segmentSourceItemId,
+ destinationItemId,
originPlaceId.trim(),
destPlaceId.trim(),
travelModeCanon,
- roomDirectionsEpoch,
- segmentEpoch,
] as const;
}
@@ -120,13 +118,15 @@ export const scheduleItemsQueryKey = (
) => ["schedule-items", roomId, scheduleId, "v2-loc"] as const;
/**
- * 구간별 길찾기 API 결과 (`GET …/route?travelMode=…`).
- * 무효화: `["schedule-item-route", roomId]` prefix.
+ * 구간별 길찾기 API 결과 (`GET …/route?destinationItemId=…&travelMode=…`).
+ * 요청에 보내는 출발·도착 항목과 이동수단이 곧 key라, 순서가 바뀌면 다른 key가 되고
+ * 순서를 되돌리면 받아 둔 값을 그대로 쓴다. 일정이 바뀌어도 무효화하지 않는다.
*/
export const scheduleItemRouteQueryKey = (
roomId: string | null,
scheduleId: number | null,
segmentSourceItemId: number | null,
+ destinationItemId: number | null,
travelMode: ScheduleTravelModeValue | null,
) =>
[
@@ -134,6 +134,7 @@ export const scheduleItemRouteQueryKey = (
roomId,
scheduleId,
segmentSourceItemId,
+ destinationItemId,
travelMode,
] as const;
diff --git a/src/lib/stomp/bookmarks-dispatch.test.ts b/src/lib/stomp/bookmarks-dispatch.test.ts
new file mode 100644
index 00000000..746d9166
--- /dev/null
+++ b/src/lib/stomp/bookmarks-dispatch.test.ts
@@ -0,0 +1,75 @@
+import { QueryClient } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { sessionUserQueryKey } from "@/lib/query-keys";
+import type { RoomBookmarkChangedEvent } from "@/types/roomBookmarkStomp";
+
+const mocks = vi.hoisted(() => ({
+ invalidateRoomBookmarkQueries: vi.fn(),
+}));
+
+vi.mock("@/lib/bookmarks-invalidation", () => ({
+ invalidateRoomBookmarkQueries: mocks.invalidateRoomBookmarkQueries,
+}));
+vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" }));
+
+import { applyRoomBookmarkStompMessage } from "@/lib/stomp/bookmarks-dispatch";
+
+const ME = 7;
+
+/** 이 계정(ME)이 폴더를 추가한 이벤트 */
+function categoryCreated(originTabId: string | null | undefined): string {
+ const event: RoomBookmarkChangedEvent = {
+ roomId: "room-1",
+ actorUserId: ME,
+ type: "CATEGORY_CREATED",
+ bookmarkId: 0,
+ categoryId: 10,
+ originTabId,
+ };
+ return JSON.stringify(event);
+}
+
+describe("applyRoomBookmarkStompMessage — echo of this tab", () => {
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.invalidateRoomBookmarkQueries.mockResolvedValue(undefined);
+ queryClient = new QueryClient();
+ queryClient.setQueryData(sessionUserQueryKey, { id: ME });
+ });
+
+ afterEach(() => {
+ queryClient.clear();
+ });
+
+ it("skips a change sent from this tab", async () => {
+ await applyRoomBookmarkStompMessage(queryClient, categoryCreated("tab-a"));
+
+ expect(mocks.invalidateRoomBookmarkQueries).not.toHaveBeenCalled();
+ });
+
+ it("applies a change whose tab ID matches this tab but comes from another account", async () => {
+ queryClient.setQueryData(sessionUserQueryKey, { id: ME + 1 });
+
+ await applyRoomBookmarkStompMessage(queryClient, categoryCreated("tab-a"));
+
+ expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce();
+ });
+
+ it("applies a change sent from another tab of the same account", async () => {
+ await applyRoomBookmarkStompMessage(queryClient, categoryCreated("tab-b"));
+
+ expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce();
+ });
+
+ it.each([null, undefined])(
+ "applies a change of the same account without a tab ID (%s) — it may come from another tab",
+ async (originTabId) => {
+ await applyRoomBookmarkStompMessage(queryClient, categoryCreated(originTabId));
+
+ expect(mocks.invalidateRoomBookmarkQueries).toHaveBeenCalledOnce();
+ },
+ );
+});
diff --git a/src/lib/stomp/bookmarks-dispatch.tsx b/src/lib/stomp/bookmarks-dispatch.tsx
index 5fe9f2f9..d3510493 100644
--- a/src/lib/stomp/bookmarks-dispatch.tsx
+++ b/src/lib/stomp/bookmarks-dispatch.tsx
@@ -3,7 +3,7 @@
import type { QueryClient } from "@tanstack/react-query";
import { invalidateRoomBookmarkQueries } from "@/lib/bookmarks-invalidation";
-import { readSessionUserId } from "@/lib/session-user-cache";
+import { isOwnEcho } from "@/lib/stomp/own-echo";
import type { RoomBookmarkChangedEvent } from "@/types/roomBookmarkStomp";
/** bookmarks 토픽 STOMP — React Query 무효화만 (토스트·선 fetch 없음) */
@@ -15,13 +15,8 @@ export async function applyRoomBookmarkStompMessage(
const rid = String(event.roomId ?? "").trim();
if (!rid) return;
- const me = readSessionUserId(queryClient);
- const actorIsMe =
- typeof me === "number" &&
- Number.isFinite(me) &&
- me === event.actorUserId;
- /** 본인 mutation은 `onSuccess`에서 캐시 패치 */
- if (actorIsMe) {
+ /** 본인 탭의 변경은 그 탭의 mutation이 캐시를 맞춘다 */
+ if (isOwnEcho(queryClient, event)) {
return;
}
diff --git a/src/lib/stomp/own-echo.test.ts b/src/lib/stomp/own-echo.test.ts
new file mode 100644
index 00000000..ec90a607
--- /dev/null
+++ b/src/lib/stomp/own-echo.test.ts
@@ -0,0 +1,48 @@
+import { QueryClient } from "@tanstack/react-query";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { sessionUserQueryKey } from "@/lib/query-keys";
+
+const tab = vi.hoisted(() => ({ id: "tab-a" as string | null }));
+
+vi.mock("@/lib/tab-id", () => ({ getTabId: () => tab.id }));
+
+import { isOwnEcho } from "@/lib/stomp/own-echo";
+
+const ME = 7;
+const OTHER = 8;
+
+describe("isOwnEcho", () => {
+ const queryClient = new QueryClient();
+
+ afterEach(() => {
+ queryClient.clear();
+ tab.id = "tab-a";
+ });
+
+ it.each([
+ { name: "sent from this tab by me", originTabId: "tab-a", actorUserId: ME, expected: true },
+ { name: "sent from another tab of my account", originTabId: "tab-b", actorUserId: ME, expected: false },
+ // 탭 ID는 방 멤버 모두에게 보여 흉내 낼 수 있다 — 다른 계정이면 반향이 아니다
+ { name: "my tab ID forged by another account", originTabId: "tab-a", actorUserId: OTHER, expected: false },
+ // 탭 ID가 없으면 어느 탭 것인지 몰라 놓치지 않도록 받는다
+ { name: "no tab ID", originTabId: null, actorUserId: ME, expected: false },
+ { name: "tab ID field missing", originTabId: undefined, actorUserId: ME, expected: false },
+ { name: "empty tab ID", originTabId: "", actorUserId: ME, expected: false },
+ ])("$name → $expected", ({ originTabId, actorUserId, expected }) => {
+ queryClient.setQueryData(sessionUserQueryKey, { id: ME });
+
+ expect(isOwnEcho(queryClient, { actorUserId, originTabId })).toBe(expected);
+ });
+
+ it("does not match a tab-less event in a tab that could not make an ID", () => {
+ tab.id = null;
+ queryClient.setQueryData(sessionUserQueryKey, { id: ME });
+
+ expect(isOwnEcho(queryClient, { actorUserId: ME, originTabId: null })).toBe(false);
+ });
+
+ it("receives this tab's change while the session user is not loaded yet", () => {
+ expect(isOwnEcho(queryClient, { actorUserId: ME, originTabId: "tab-a" })).toBe(false);
+ });
+});
diff --git a/src/lib/stomp/own-echo.ts b/src/lib/stomp/own-echo.ts
new file mode 100644
index 00000000..0194f017
--- /dev/null
+++ b/src/lib/stomp/own-echo.ts
@@ -0,0 +1,21 @@
+import type { QueryClient } from "@tanstack/react-query";
+
+import { readSessionUserId } from "@/lib/session-user-cache";
+import { getTabId } from "@/lib/tab-id";
+
+type ChangeOrigin = {
+ actorUserId: number;
+ /** 변경 요청의 `X-Client-Tab-Id`. 헤더를 싣기 전 백엔드거나 탭 ID를 못 만든 탭의 변경이면 없다 */
+ originTabId?: string | null;
+};
+
+/**
+ * 이 탭이 보낸 변경의 반향인지 판정한다 — 반향이면 그 탭의 mutation이 이미 캐시를 맞춘다.
+ * 탭 ID가 없는 이벤트는 어느 탭의 변경인지 알 수 없어, 놓치지 않도록 다른 탭의 변경으로 받는다.
+ * 탭 ID는 방 멤버 모두에게 보이고 헤더로 흉내 낼 수 있어, 서버가 인증으로 정한 `actorUserId`도 나여야 반향으로 본다.
+ */
+export function isOwnEcho(queryClient: QueryClient, event: ChangeOrigin): boolean {
+ if (!event.originTabId || event.originTabId !== getTabId()) return false;
+ const me = readSessionUserId(queryClient);
+ return typeof me === "number" && me === event.actorUserId;
+}
diff --git a/src/lib/stomp/schedule-events.ts b/src/lib/stomp/schedule-events.ts
index b4cf8bbb..7b7e0b84 100644
--- a/src/lib/stomp/schedule-events.ts
+++ b/src/lib/stomp/schedule-events.ts
@@ -16,6 +16,8 @@ export type RoomScheduleChangedEvent = {
itemId: number | null;
affectedRouteItemIds: number[] | null;
scheduleIds: number[] | null;
+ /** 변경 요청을 보낸 탭 ID — 같으면 이 탭의 반향(`isOwnEcho`) */
+ originTabId?: string | null;
};
const KNOWN_TYPES = new Set([
diff --git a/src/lib/stomp/schedules-dispatch.order.test.ts b/src/lib/stomp/schedules-dispatch.order.test.ts
index 6656e6d8..9b7df890 100644
--- a/src/lib/stomp/schedules-dispatch.order.test.ts
+++ b/src/lib/stomp/schedules-dispatch.order.test.ts
@@ -3,7 +3,11 @@ 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 {
+ roomSchedulesQueryKey,
+ scheduleItemsQueryKey,
+ sessionUserQueryKey,
+} from "@/lib/query-keys";
import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events";
const fixtures = vi.hoisted(() => ({
@@ -36,27 +40,13 @@ 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() }),
- },
-}));
+vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" }));
import {
removeScheduleItemFromPlanPlacesCache,
@@ -251,6 +241,23 @@ describe("schedule day list refetch order", () => {
expect(fixtures.getScheduleItems).toHaveBeenCalledOnce();
});
+ it("shows a reorder made in another tab of the same account", async () => {
+ // remoteEvent의 actorUserId(2)가 이 계정 — 예전에는 계정이 같아 버렸다
+ client.setQueryData(sessionUserQueryKey, { id: 2 });
+ client.setQueryData(DAY_KEY, [100, 101, 102].map(fixtures.place));
+ watchDayList();
+ fixtures.getScheduleItems.mockResolvedValue(items(102, 100, 101));
+
+ await dispatchRoomScheduleEvent(client, {
+ ...remoteEvent("SCHEDULE_ITEMS_REORDERED", 102),
+ originTabId: "tab-b",
+ });
+ await settle();
+
+ expect(fixtures.getScheduleItems).toHaveBeenCalledOnce();
+ expect(dayItemIds()).toEqual([102, 100, 101]);
+ });
+
it("fetches from the server when the day list is invalidated", async () => {
client.setQueryData(DAY_KEY, [100, 102].map(fixtures.place));
watchDayList();
diff --git a/src/lib/stomp/schedules-dispatch.test.ts b/src/lib/stomp/schedules-dispatch.test.ts
index cbd60a4f..89bf39d4 100644
--- a/src/lib/stomp/schedules-dispatch.test.ts
+++ b/src/lib/stomp/schedules-dispatch.test.ts
@@ -1,20 +1,14 @@
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(),
+ syncRoomDetailFromServer: vi.fn(),
}));
vi.mock("@/lib/plan/scheduleItemPlaces", () => ({
@@ -22,32 +16,12 @@ 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(),
+ syncRoomDetailFromServer: mocks.syncRoomDetailFromServer,
}));
-vi.mock("@/stores/plan-map-directions-epoch-store", () => ({
- usePlanMapDirectionsEpochStore: {
- getState: () => ({
- bumpForDirections: vi.fn(),
- bumpSegments: vi.fn(),
- }),
- },
-}));
+vi.mock("@/lib/tab-id", () => ({ getTabId: () => "tab-a" }));
+const MY_TAB_ID = "tab-a";
import { dispatchRoomScheduleEvent } from "@/lib/stomp/schedules-dispatch";
@@ -68,15 +42,14 @@ function itemEvent(
describe("dispatchRoomScheduleEvent", () => {
let queryClient: QueryClient;
+ // 경로는 도착 항목까지 key에 있어 일정이 바뀌어도 무효화하지 않는다 — 순서가 바뀐 구간은 새 key로 받는다
+ const routeKey = scheduleItemRouteQueryKey("room-1", 10, 20, 21, "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);
});
@@ -85,85 +58,28 @@ describe("dispatchRoomScheduleEvent", () => {
queryClient.clear();
});
- it.each([false, true])("refetches ID-only time updates, including own-user tabs (%s)", async (ownUser) => {
+ it("refetches consecutive ID-only time updates once", async () => {
vi.useFakeTimers();
- if (ownUser) queryClient.setQueryData(sessionUserQueryKey, { id: 99 });
const event = itemEvent("SCHEDULE_ITEM_UPDATED", []);
await dispatchRoomScheduleEvent(queryClient, event);
await dispatchRoomScheduleEvent(queryClient, event);
await vi.runAllTimersAsync();
- // 본인 반향만 모였으면 본인 저장이 끝나며 다시 받는 조회에 합류한다
- 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],
- );
- });
-
- it("uses affectedRouteItemIds instead of calculating routes for a deleted item", async () => {
- mocks.collectSegmentSourcesForDelete.mockReturnValue({
- sources: [10],
- useFallback: false,
- });
- await dispatchRoomScheduleEvent(
- queryClient,
- itemEvent("SCHEDULE_ITEM_DELETED", [888]),
- );
-
- expect(mocks.collectSegmentSourcesForDelete).not.toHaveBeenCalled();
- expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith(
- queryClient,
- "room-1",
- 10,
- [888],
- );
+ expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10);
+ expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false);
});
- it("uses affectedRouteItemIds instead of calculating routes after reorder", async () => {
- vi.useFakeTimers();
- mocks.collectSegmentSourcesForReorder.mockReturnValue({
- sources: [10, 20],
- 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_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,73 +91,21 @@ describe("dispatchRoomScheduleEvent", () => {
"room-1",
10,
20,
- 20,
- [11, 21, 20],
- );
- });
-
- it("refreshes travel mode before invalidating affectedRouteItemIds", async () => {
- vi.useFakeTimers();
- const order: string[] = [];
- mocks.invalidateSchedulePlanPlaces.mockImplementation(async () => {
- order.push("refetch");
- });
- mocks.invalidateScheduleItemRouteForSources.mockImplementation(async () => {
- order.push("invalidate");
- });
-
- await dispatchRoomScheduleEvent(
- queryClient,
- itemEvent("SCHEDULE_ITEM_TRAVEL_MODE_UPDATED", [20]),
- );
- await vi.runAllTimersAsync();
-
- expect(order).toEqual(["refetch", "invalidate"]);
- expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith(
- queryClient,
- "room-1",
- 10,
- [20],
);
+ expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false);
});
- it("preserves a newer route bucket when an earlier travel-mode refresh fails", async () => {
+ it("refetches the day list after another member changes a travel mode, leaving route caches as they are", 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;
- });
- 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(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10);
+ expect(queryClient.getQueryState(routeKey)?.isInvalidated).toBe(false);
});
});
@@ -255,3 +119,86 @@ describe("expense synchronization on existing schedule events",()=>{
client.clear();
});
});
+
+describe("echo of this tab vs. other tabs of the same account", () => {
+ const SKIPPABLE_TYPES = [
+ "ROOM_SCHEDULES_RESYNCED",
+ "SCHEDULE_ITEM_MOVED",
+ "SCHEDULE_ITEM_DELETED",
+ "SCHEDULE_ITEM_CREATED",
+ "SCHEDULE_ITEMS_REORDERED",
+ "SCHEDULE_ITEM_TRAVEL_MODE_UPDATED",
+ ] as const;
+ let queryClient: QueryClient;
+
+ /** itemEvent의 actorUserId(99)가 이 계정이다 */
+ function ownAccountEvent(
+ type: RoomScheduleChangedEvent["type"],
+ originTabId: string | null,
+ ): RoomScheduleChangedEvent {
+ return { ...itemEvent(type, [20]), scheduleIds: [10, 11], originTabId };
+ }
+
+ /** 일차 목록·일정 전체·방 상세 중 어느 것이든 다시 받은 횟수 */
+ const refetchCount = () =>
+ mocks.invalidateSchedulePlanPlaces.mock.calls.length +
+ mocks.syncAfterCrossScheduleItemMove.mock.calls.length +
+ mocks.syncRoomDetailFromServer.mock.calls.length;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ mocks.invalidateSchedulePlanPlaces.mockResolvedValue(undefined);
+ mocks.syncAfterCrossScheduleItemMove.mockResolvedValue(undefined);
+ queryClient = new QueryClient();
+ queryClient.setQueryData(sessionUserQueryKey, { id: 99 });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ queryClient.clear();
+ });
+
+ it.each(SKIPPABLE_TYPES)("skips %s sent from this tab", async (type) => {
+ await dispatchRoomScheduleEvent(queryClient, ownAccountEvent(type, MY_TAB_ID));
+ await vi.runAllTimersAsync();
+
+ expect(refetchCount()).toBe(0);
+ });
+
+ it.each(SKIPPABLE_TYPES)("applies %s sent from another tab of the same account", async (type) => {
+ await dispatchRoomScheduleEvent(queryClient, ownAccountEvent(type, "tab-b"));
+ await vi.runAllTimersAsync();
+
+ expect(refetchCount()).toBe(1);
+ });
+
+ it.each(SKIPPABLE_TYPES)("applies %s of the same account without a tab ID — it may come from another tab", async (type) => {
+ await dispatchRoomScheduleEvent(queryClient, ownAccountEvent(type, null));
+ await vi.runAllTimersAsync();
+
+ expect(refetchCount()).toBe(1);
+ });
+
+ it("applies a change whose tab ID matches this tab but comes from another account", async () => {
+ queryClient.setQueryData(sessionUserQueryKey, { id: 100 });
+
+ await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_DELETED", MY_TAB_ID));
+
+ expect(refetchCount()).toBe(1);
+ });
+
+ it("skips a time update sent from this tab — its own settled refetch already has it", async () => {
+ await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_UPDATED", MY_TAB_ID));
+ await vi.runAllTimersAsync();
+
+ expect(mocks.invalidateSchedulePlanPlaces).not.toHaveBeenCalled();
+ });
+
+ it.each(["tab-b", null])("refetches a time update of the same account from another tab (%s)", async (originTabId) => {
+ await dispatchRoomScheduleEvent(queryClient, ownAccountEvent("SCHEDULE_ITEM_UPDATED", originTabId));
+ await vi.runAllTimersAsync();
+
+ expect(mocks.invalidateSchedulePlanPlaces).toHaveBeenCalledExactlyOnceWith(queryClient, "room-1", 10);
+ });
+});
diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts
index 6f1bf699..3a10e7ea 100644
--- a/src/lib/stomp/schedules-dispatch.ts
+++ b/src/lib/stomp/schedules-dispatch.ts
@@ -5,178 +5,33 @@ 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 { isOwnEcho } from "@/lib/stomp/own-echo";
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;
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,
- 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);
void (async () => {
try {
- 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);
- }
+ await invalidateSchedulePlanPlaces(queryClient, rid, sid);
} catch {
//
}
@@ -187,12 +42,13 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi(
/**
* Plan 화면 등이 쓰는 캐시만, 스키마의 `type`별로 갱신합니다.
- * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화 (원격 탭; 액터는 mutation `onSuccess`)
+ * — 이 탭이 보낸 변경의 반향(`isOwnEcho`)은 건너뛴다. 그 탭의 mutation이 캐시를 맞춘다.
+ * 같은 계정의 다른 탭과 탭 ID가 없는 변경은 다른 사람의 변경처럼 받는다
+ * — `ROOM_SCHEDULES_RESYNCED`: `room-schedules` 전체 재조회 + 방 상세 동기화
* — `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`·`SCHEDULE_ITEM_TRAVEL_MODE_UPDATED`: 연속 이벤트를 모아 그 일차를 한 번만 다시 받는다
*/
export async function dispatchRoomScheduleEvent(
queryClient: QueryClient,
@@ -207,20 +63,10 @@ export async function dispatchRoomScheduleEvent(
) {
await invalidateExpenses(queryClient, rid);
}
- const epochStore = usePlanMapDirectionsEpochStore.getState();
+ if (isOwnEcho(queryClient, event)) return;
switch (event.type) {
case "ROOM_SCHEDULES_RESYNCED": {
- const me = readSessionUserId(queryClient);
- const actorIsMe =
- typeof me === "number" &&
- Number.isFinite(me) &&
- me === event.actorUserId;
- /** 본인 mutation은 `onSuccess`에서 schedules·room-detail 캐시 갱신 */
- if (actorIsMe) {
- return;
- }
-
// 일정 전체 재조회는 room-schedules 조회 하나로 한다 — 진행 중인 옛 조회가 취소돼 새 일정을 덮지 않는다
await queryClient.invalidateQueries({ queryKey: roomSchedulesQueryKey(rid) });
await syncRoomDetailFromServer(queryClient, rid);
@@ -233,24 +79,12 @@ export async function dispatchRoomScheduleEvent(
const sourceScheduleId = scheduleIds[0]!;
const targetScheduleId = scheduleIds[1]!;
- const itemId = event.itemId;
-
- const me = readSessionUserId(queryClient);
- const actorIsMe =
- typeof me === "number" &&
- Number.isFinite(me) &&
- me === event.actorUserId;
- if (actorIsMe) {
- return;
- }
await syncAfterCrossScheduleItemMove(
queryClient,
rid,
sourceScheduleId,
targetScheduleId,
- itemId ?? undefined,
- event.affectedRouteItemIds,
);
return;
}
@@ -260,28 +94,7 @@ 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) &&
- me === event.actorUserId;
- /** 본인 mutation은 `applyScheduleItemDeletedOnClient`에서 캐시·route 처리 */
- 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;
}
@@ -290,26 +103,7 @@ 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) &&
- me === event.actorUserId;
- /** 본인 mutation은 append/reorder 직후 구간 단위 무효화 — STOMP 선도착 시 일정 전체 `/route` 폭주 방지 */
- 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;
}
@@ -318,25 +112,7 @@ 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) &&
- me === event.actorUserId;
- /** 본인 mutation은 `syncPlanPlacesAfterReorderSuccess` 직후 구간 무효화 */
- if (actorIsMe) {
- return;
- }
-
await invalidateSchedulePlanPlaces(queryClient, rid, sid);
- const sources = event.affectedRouteItemIds;
- enqueueDebouncedInvalidateScheduleRoutes(
- queryClient,
- rid,
- sid,
- sources ?? [],
- sources === null,
- );
return;
}
@@ -345,18 +121,7 @@ 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,
- actorIsMe,
- );
+ enqueueDebouncedHydratePlacesFromScheduleItemsApi(queryClient, rid, sid);
return;
}
@@ -365,24 +130,7 @@ 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;
- /** 본인 PATCH는 mutation `onSuccess`에서 `schedule-items` 캐시 갱신 */
- if (actorIsMe) {
- return;
- }
-
- /** 항목 재조회 후 서버가 지정한 출발 항목의 경로만 갱신합니다. */
- enqueueDebouncedHydratePlacesFromScheduleItemsApi(
- queryClient,
- rid,
- sid,
- false,
- event.affectedRouteItemIds,
- );
+ enqueueDebouncedHydratePlacesFromScheduleItemsApi(queryClient, rid, sid);
return;
}
}
diff --git a/src/lib/tab-id.test.ts b/src/lib/tab-id.test.ts
new file mode 100644
index 00000000..3724141c
--- /dev/null
+++ b/src/lib/tab-id.test.ts
@@ -0,0 +1,54 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+
+/** 탭 ID는 모듈 변수에 한 번 만들어 두므로 경우마다 모듈을 새로 읽는다 */
+async function loadGetTabId() {
+ vi.resetModules();
+ return (await import("@/lib/tab-id")).getTabId;
+}
+
+describe("getTabId", () => {
+ beforeEach(() => {
+ vi.stubGlobal("window", {});
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("returns null during server render", async () => {
+ vi.unstubAllGlobals();
+ const getTabId = await loadGetTabId();
+
+ expect(getTabId()).toBeNull();
+ });
+
+ it("creates the ID once with randomUUID and keeps it for the tab", async () => {
+ const randomUUID = vi.fn(() => "3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f");
+ vi.stubGlobal("crypto", { randomUUID, getRandomValues: vi.fn() });
+ const getTabId = await loadGetTabId();
+
+ expect(getTabId()).toBe("3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f");
+ expect(getTabId()).toBe("3f2b8c1e-7d4a-4e9b-9c2f-1a2b3c4d5e6f");
+ expect(randomUUID).toHaveBeenCalledOnce();
+ });
+
+ it("builds a v4 UUID from getRandomValues when randomUUID is missing (non-secure context)", async () => {
+ vi.stubGlobal("crypto", {
+ getRandomValues: (bytes: Uint8Array) => bytes.fill(0xff),
+ });
+ const getTabId = await loadGetTabId();
+
+ const id = getTabId();
+ expect(id).toMatch(UUID_V4);
+ expect(id).toBe("ffffffff-ffff-4fff-bfff-ffffffffffff");
+ });
+
+ it("returns null when the browser cannot make random values", async () => {
+ vi.stubGlobal("crypto", {});
+ const getTabId = await loadGetTabId();
+
+ expect(getTabId()).toBeNull();
+ });
+});
diff --git a/src/lib/tab-id.ts b/src/lib/tab-id.ts
new file mode 100644
index 00000000..95050886
--- /dev/null
+++ b/src/lib/tab-id.ts
@@ -0,0 +1,29 @@
+/**
+ * 이 탭이 보낸 변경 요청과 그 STOMP 반향(`originTabId`)을 잇는 ID.
+ *
+ * 모듈 변수에만 둔다. sessionStorage는 탭을 복제하면 값이 복사돼 두 탭이 같은 ID가 되고,
+ * localStorage는 모든 탭이 같은 값을 쓴다. 새로고침하면 새 ID가 된다.
+ */
+let tabId: string | null | undefined;
+
+/** 서버 렌더에서는 null. UUID를 만들 수 없는 브라우저도 null이고, 그때는 헤더를 싣지 않는다 */
+export function getTabId(): string | null {
+ if (tabId !== undefined) return tabId;
+ if (typeof window === "undefined") return null;
+ tabId = createUuidV4();
+ return tabId;
+}
+
+function createUuidV4(): string | null {
+ if (typeof crypto === "undefined") return null;
+ // randomUUID는 보안 컨텍스트(https·localhost)에서만 있다 — `http://`로 연 화면은 getRandomValues로 만든다
+ if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
+ if (typeof crypto.getRandomValues !== "function") return null;
+
+ const hex = Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) =>
+ byte.toString(16).padStart(2, "0"),
+ ).join("");
+ // RFC 9562 v4: 13번째 자리는 버전 4, 17번째 자리는 변형 8~b
+ const variant = ((Number.parseInt(hex.charAt(16), 16) & 0x3) | 0x8).toString(16);
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
+}
diff --git a/src/stores/plan-map-directions-epoch-store.ts b/src/stores/plan-map-directions-epoch-store.ts
deleted file mode 100644
index f38add8f..00000000
--- a/src/stores/plan-map-directions-epoch-store.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { create } from "zustand";
-
-type PlanMapSegmentEpochEntry = {
- scheduleId: number;
- segmentSourceItemId: number;
-};
-
-/** `epochBySegmentKey`: `{roomId}:{scheduleId}:{segmentSourceItemId}` */
-export function planMapSegmentEpochStoreKey(
- roomId: string,
- scheduleId: number,
- segmentSourceItemId: number,
-): string {
- return `${String(roomId ?? "").trim()}:${scheduleId}:${segmentSourceItemId}`;
-}
-
-/** STOMP 일정 브로드캐스트 중 지도 경로(polyline) 갱신 — 방 전체 에폭 + 구간별 에폭. */
-type EpochState = {
- epochByRoomId: Record;
- 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 };
- });
- },
-}));
diff --git a/src/types/roomBookmarkStomp.ts b/src/types/roomBookmarkStomp.ts
index 721e17d3..25ec194b 100644
--- a/src/types/roomBookmarkStomp.ts
+++ b/src/types/roomBookmarkStomp.ts
@@ -12,4 +12,6 @@ export type RoomBookmarkChangedEvent = {
categoryId: number;
roomId: string;
type: RoomBookmarkChangedType;
+ /** 변경 요청을 보낸 탭 ID — 같으면 이 탭의 반향(`isOwnEcho`) */
+ originTabId?: string | null;
};