diff --git a/.gitignore b/.gitignore index f027c55..c89c003 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ SERVICE_CONTEXT.md # misc .DS_Store +.idea/ *.pem # debug diff --git a/docs/superpowers/plans/2026-07-16-schedule-item-insert-delta.md b/docs/superpowers/plans/2026-07-16-schedule-item-insert-delta.md new file mode 100644 index 0000000..f02a70b --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-schedule-item-insert-delta.md @@ -0,0 +1,75 @@ +# Schedule Item Insert Delta Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 일정 항목을 서버에서 지정 위치에 직접 삽입하고, 생성 항목과 변경된 기존 항목의 델타를 HTTP 응답으로 반환해 요청 당사자의 캐시를 정확히 동기화한다. + +**Architecture:** 생성 요청에 0-based `orderIndex`를 선택 필드로 추가하며 생략 시 맨 뒤에 삽입한다. 백엔드는 일정 단위 쓰기 잠금 아래 기존 항목을 이동하고 실제 직전 항목 기준으로 이동수단을 추천한 뒤 `createdItem`, `updatedItems`, `affectedRouteItemIds`를 반환한다. 프론트는 별도 reorder 요청 없이 응답 델타를 기존 캐시에 병합하고, 병합이 불가능한 경우에만 전체 항목을 재조회한다. + +**Tech Stack:** Java 21, Spring Boot 4, JPA, JUnit 5, Mockito, TypeScript, TanStack Query, Vitest + +## Global Constraints + +- `orderIndex`는 기존 reorder API와 동일한 0-based 인덱스다. +- `orderIndex` 생략은 기존 호환 동작인 맨 뒤 추가다. +- `updatedItems`는 순서 또는 `travelMode`가 실제로 변경된 기존 항목만 포함한다. +- 요청 당사자는 HTTP 응답을 정본으로 반영하고 자신의 STOMP 이벤트를 계속 무시한다. +- 사용자 소유의 기존 프론트 변경(`package*.json`, `src/lib/maps.ts`, `src/lib/maps-routes.test.ts`)은 건드리지 않는다. + +--- + +### Task 1: Backend insert-and-delta contract + +**Files:** +- Create: `/Users/minbros/projects/uttae-backend/src/main/java/com/howaboutus/backend/schedules/service/dto/ScheduleItemCreateResult.java` +- Create: `/Users/minbros/projects/uttae-backend/src/main/java/com/howaboutus/backend/schedules/controller/dto/CreateScheduleItemResponse.java` +- Modify: `/Users/minbros/projects/uttae-backend/src/main/java/com/howaboutus/backend/schedules/controller/dto/CreateScheduleItemRequest.java` +- Modify: `/Users/minbros/projects/uttae-backend/src/main/java/com/howaboutus/backend/schedules/service/dto/ScheduleItemCreateCommand.java` +- Modify: `/Users/minbros/projects/uttae-backend/src/main/java/com/howaboutus/backend/schedules/service/ScheduleItemService.java` +- Modify: `/Users/minbros/projects/uttae-backend/src/main/java/com/howaboutus/backend/schedules/controller/ScheduleItemController.java` +- Test: `/Users/minbros/projects/uttae-backend/src/test/java/com/howaboutus/backend/schedules/service/ScheduleItemServiceTest.java` +- Test: `/Users/minbros/projects/uttae-backend/src/test/java/com/howaboutus/backend/schedules/controller/ScheduleItemControllerTest.java` + +**Interfaces:** +- Consumes: `ScheduleItemCreateCommand(String googlePlaceId, LocalTime startTime, Integer durationMinutes, Integer orderIndex)` +- Produces: `ScheduleItemCreateResult(ScheduleItemResult createdItem, List updatedItems, List affectedRouteItemIds)` and the same JSON shape through `CreateScheduleItemResponse`. + +- [ ] **Step 1: Write failing service tests** for middle insertion, append fallback, invalid index, shifted item deltas, recommended predecessor delta, and emitted affected route IDs. +- [ ] **Step 2: Run `./gradlew test --tests '*ScheduleItemServiceTest'`** and confirm failure because the new command/result contract and insertion behavior do not exist. +- [ ] **Step 3: Write failing controller tests** asserting `orderIndex` validation/forwarding and the nested `createdItem`/`updatedItems`/`affectedRouteItemIds` response. +- [ ] **Step 4: Run `./gradlew test --tests '*ScheduleItemControllerTest'`** and confirm the old flat response fails. +- [ ] **Step 5: Implement the minimal backend contract and transaction logic.** Load ordered items after taking the existing schedule write lock, validate `0..size`, shift `targetIndex..end`, create at the target, recommend from `targetIndex - 1`, collect changed existing items, and publish `SCHEDULE_ITEM_CREATED` after constructing the delta result. +- [ ] **Step 6: Run both focused backend test classes** and confirm they pass. + +### Task 2: Frontend delta request and cache merge + +**Files:** +- Modify: `/Users/minbros/projects/uttae-frontend/src/lib/api/rooms/schedule-items.ts` +- Modify: `/Users/minbros/projects/uttae-frontend/src/lib/plan/scheduleItemPlaces.ts` +- Test: `/Users/minbros/projects/uttae-frontend/src/lib/plan/scheduleItemPlaces.test.ts` + +**Interfaces:** +- Consumes: `CreateScheduleItemResponse { createdItem, updatedItems, affectedRouteItemIds }`. +- Produces: one POST carrying `orderIndex`, no follow-up reorder request, and an exact cache array with updated predecessor fields plus the created item at its returned index. + +- [ ] **Step 1: Write failing Vitest cases** that merge a middle-insert delta, immediately apply a predecessor `WALKING` recommendation, and reject a delta whose updated item is absent from the cache. +- [ ] **Step 2: Run `npm test -- src/lib/plan/scheduleItemPlaces.test.ts`** and confirm failure because the delta merge helper does not exist. +- [ ] **Step 3: Implement frontend request/response types and pure delta merge.** Insert `createdItem` at its returned index, apply every `updatedItems` payload to matching cached places, and return `null` on divergence. +- [ ] **Step 4: Replace create-then-reorder with one create request.** On successful merge set the cache; on divergence fetch `GET items`; invalidate the route segments touching the created item from the merged order and preserve self-STOMP ignore behavior. +- [ ] **Step 5: Run the focused Vitest file** and confirm it passes. + +### Task 3: API documentation and verification + +**Files:** +- Modify: `/Users/minbros/projects/uttae-backend/docs/ai/features.md` +- Modify annotations in the request/response DTOs and controller from Task 1. + +**Interfaces:** +- Consumes: implemented runtime behavior. +- Produces: OpenAPI showing optional 0-based `orderIndex` and the delta response schema; feature docs describing insertion-position recommendation. + +- [ ] **Step 1: Update feature and OpenAPI descriptions** only after runtime behavior is green. +- [ ] **Step 2: Run backend focused tests, `./gradlew compileJava`, and `./gradlew checkstyleMain checkstyleTest`.** +- [ ] **Step 3: Run frontend focused tests, `npm test`, `npm run lint`, and `npx tsc --noEmit`.** +- [ ] **Step 4: Restart or use the running backend as appropriate and inspect `/v3/api-docs`** for `orderIndex`, `createdItem`, `updatedItems`, and `affectedRouteItemIds`. +- [ ] **Step 5: Review both git diffs and confirm unrelated dirty frontend files were not modified.** diff --git a/package-lock.json b/package-lock.json index 855bd7e..7f7ffa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/google.maps": "^3.65.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -2169,9 +2170,9 @@ } }, "node_modules/@types/google.maps": { - "version": "3.58.1", - "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", - "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==", + "version": "3.65.2", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.65.2.tgz", + "integrity": "sha512-e52bmOhGCQSNabFpL48iQlwJybq6rfns8NUVJ20MR7CdPlHQ2RmSCnPbJfrUYJfogrE4OiHQTZ4LXpop+eer1w==", "license": "MIT" }, "node_modules/@types/hast": { diff --git a/package.json b/package.json index 3b255ef..aa46605 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/google.maps": "^3.65.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx new file mode 100644 index 0000000..08af896 --- /dev/null +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx @@ -0,0 +1,32 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { PlanDaySection } from "./PlanDaySection"; + +describe("PlanDaySection", () => { + it("펼침 토글 아이콘을 제목 왼쪽에 배치한다", () => { + const html = renderToStaticMarkup( + createElement( + PlanDaySection, + { + title: "1일차", + subtitle: "7월 16일 (목)", + }, + createElement("div", null, "일정"), + ), + ); + + const triggerMarkup = html.match( + /]*aria-expanded="true"[^>]*>([\s\S]*?)<\/button>/, + )?.[1]; + + expect(triggerMarkup).toBeDefined(); + expect(triggerMarkup).toContain('class="shrink-0 text-dark-gray"'); + expect(triggerMarkup).not.toContain("ml-auto"); + expect(triggerMarkup!.indexOf("lucide-chevron-up")).toBeLessThan( + triggerMarkup!.indexOf("7월 16일 (목)"), + ); + expect(html).not.toContain("absolute left-1/2"); + }); +}); diff --git a/src/components/map/PlanItineraryMapRoutes.tsx b/src/components/map/PlanItineraryMapRoutes.tsx index 6df1d4f..d12066a 100644 --- a/src/components/map/PlanItineraryMapRoutes.tsx +++ b/src/components/map/PlanItineraryMapRoutes.tsx @@ -41,7 +41,7 @@ import { PlanItineraryStopMapPin } from "./PlanItineraryStopMapPin"; /** * 일차별 "지도에 경로 표시" 토글(`usePlanScheduleRouteVisibilityStore`) ON인 * 일차만 지도에 경로(polyline)와 정류장 마커를 그립니다. - * 폴리라인 travelMode는 항상 DRIVING(`planItineraryMapSegments`). + * 폴리라인 travelMode는 서버에 저장된 구간별 공유 이동수단을 따릅니다. * STOMP 에폭은 {@link usePlanMapDirectionsEpochStore} 참고. */ export function PlanItineraryMapRoutes() { @@ -190,7 +190,7 @@ export function PlanItineraryMapRoutes() { polylines.push( { +): Promise { return requestJson( apiUrl(`/rooms/${roomId}/schedules/${scheduleId}/items`), { method: "POST", ...jsonBody(body) }, diff --git a/src/lib/maps-routes.test.ts b/src/lib/maps-routes.test.ts new file mode 100644 index 0000000..59b0a93 --- /dev/null +++ b/src/lib/maps-routes.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { fetchPlanSegmentPathLatLng } from "./maps"; + +describe("fetchPlanSegmentPathLatLng", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("Place ID로 Route를 계산하고 overview path 좌표를 반환한다", async () => { + const placeIds: string[] = []; + class Place { + constructor({ id }: { id: string }) { + placeIds.push(id); + } + } + + const computeRoutes = vi.fn().mockResolvedValue({ + routes: [ + { + path: [ + { lat: 37.5665, lng: 126.978, altitude: 0 }, + { lat: 37.5446, lng: 127.0557, altitude: 0 }, + ], + }, + ], + fallbackInfo: null, + geocodingResults: null, + }); + const importLibrary = vi.fn(async (name: string) => { + if (name === "places") return { Place }; + if (name === "routes") return { Route: { computeRoutes } }; + throw new Error(`unexpected library: ${name}`); + }); + + vi.stubGlobal("google", { + maps: { + importLibrary, + TravelMode: { + WALKING: "WALKING", + DRIVING: "DRIVING", + BICYCLING: "BICYCLING", + TRANSIT: "TRANSIT", + }, + }, + }); + + await expect( + fetchPlanSegmentPathLatLng( + " ChIJ-origin ", + " ChIJ-destination ", + "DRIVING", + ), + ).resolves.toEqual([ + { lat: 37.5665, lng: 126.978 }, + { lat: 37.5446, lng: 127.0557 }, + ]); + expect(placeIds).toEqual(["ChIJ-origin", "ChIJ-destination"]); + expect(computeRoutes).toHaveBeenCalledWith({ + origin: expect.any(Place), + destination: expect.any(Place), + travelMode: "DRIVING", + fields: ["path"], + polylineQuality: "OVERVIEW", + }); + }); + + it("이동수단이 누락되면 일정 기본값 DRIVING으로 경로를 계산한다", async () => { + class Place {} + const computeRoutes = vi.fn().mockResolvedValue({ + routes: [{ path: [] }], + fallbackInfo: null, + geocodingResults: null, + }); + const importLibrary = vi.fn(async (name: string) => { + if (name === "places") return { Place }; + if (name === "routes") return { Route: { computeRoutes } }; + throw new Error(`unexpected library: ${name}`); + }); + + vi.stubGlobal("google", { + maps: { + importLibrary, + TravelMode: { + WALKING: "WALKING", + DRIVING: "DRIVING", + BICYCLING: "BICYCLING", + TRANSIT: "TRANSIT", + }, + }, + }); + + await fetchPlanSegmentPathLatLng("origin", "destination", undefined); + + expect(computeRoutes).toHaveBeenCalledWith( + expect.objectContaining({ travelMode: "DRIVING" }), + ); + }); + + it("경로 계산에 실패하면 빈 경로를 반환한다", async () => { + const importLibrary = vi.fn(async (name: string) => { + if (name === "places") { + return { Place: class Place {} }; + } + if (name === "routes") { + return { + Route: { + computeRoutes: vi.fn().mockRejectedValue(new Error("routes error")), + }, + }; + } + throw new Error(`unexpected library: ${name}`); + }); + + vi.stubGlobal("google", { + maps: { + importLibrary, + TravelMode: { + WALKING: "WALKING", + DRIVING: "DRIVING", + BICYCLING: "BICYCLING", + TRANSIT: "TRANSIT", + }, + }, + }); + + await expect( + fetchPlanSegmentPathLatLng("origin", "destination", "WALKING"), + ).resolves.toEqual([]); + }); +}); diff --git a/src/lib/maps.ts b/src/lib/maps.ts index 59e2839..094d4f4 100644 --- a/src/lib/maps.ts +++ b/src/lib/maps.ts @@ -2,7 +2,10 @@ import { clampPlacesSearchRadiusMeters } from "@/lib/places/placesSearchRadius"; import type { PlanItinerarySegmentDescriptor } from "@/lib/plan/planItineraryMapSegments"; -import { canonicalScheduleTravelMode } from "@/lib/plan/scheduleTravelMode"; +import { + SCHEDULE_TRAVEL_MODE_DEFAULT, + canonicalScheduleTravelMode, +} from "@/lib/plan/scheduleTravelMode"; const STORAGE_KEY = "hau:destination-center-v1"; @@ -236,7 +239,8 @@ export function shouldSuggestPlacesSearchRecenter(params: { function toGmTravelMode( modeRaw: string | undefined, ): google.maps.TravelMode { - const c = canonicalScheduleTravelMode(modeRaw) ?? "WALKING"; + const c = + canonicalScheduleTravelMode(modeRaw) ?? SCHEDULE_TRAVEL_MODE_DEFAULT; const T = google.maps.TravelMode; switch (c) { case "WALKING": @@ -248,12 +252,12 @@ function toGmTravelMode( case "TRANSIT": return T.TRANSIT; default: - return T.WALKING; + return T.DRIVING; } } /** - * Directions `overview_path` 가 출발/도착과 반대일 때 뒤집습니다. + * Routes `path`가 출발/도착과 반대일 때 뒤집습니다. * 폴리라인·화살표가 일정 순서(작은 번호 → 큰 번호) 방향을 가리키게 합니다. */ export function orientPathSmallerStopToLarger( @@ -278,48 +282,35 @@ export function orientPathSmallerStopToLarger( } /** - * 일정 두 장소(Place ID) 구간 경로 좌표 — Maps JS DirectionsService. + * 일정 두 장소(Place ID) 구간 경로 좌표 — Maps JS Route.computeRoutes. */ export async function fetchPlanSegmentPathLatLng( originPlaceId: string, destPlaceId: string, segmentTravelMode: string | undefined, ): Promise { - await google.maps.importLibrary("routes"); - const svc = new google.maps.DirectionsService(); - const o = originPlaceId.trim(); const d = destPlaceId.trim(); if (!o.length || !d.length) return []; - const travelMode = toGmTravelMode(segmentTravelMode); - - return await new Promise((resolve) => { - svc.route( - { - origin: { placeId: o }, - destination: { placeId: d }, - travelMode, - }, - (result, status) => { - if ( - status !== google.maps.DirectionsStatus.OK || - !result?.routes[0] - ) { - resolve([]); - return; - } - const pts = result.routes[0]?.overview_path; - if (!pts?.length) { - resolve([]); - return; - } - resolve( - pts.map((p) => ({ lat: p.lat(), lng: p.lng() })), - ); - }, - ); - }); + try { + const [{ Place }, { Route }] = await Promise.all([ + google.maps.importLibrary("places"), + google.maps.importLibrary("routes"), + ]); + const { routes } = await Route.computeRoutes({ + origin: new Place({ id: o }), + destination: new Place({ id: d }), + travelMode: toGmTravelMode(segmentTravelMode), + fields: ["path"], + polylineQuality: "OVERVIEW", + }); + const path = routes?.[0]?.path; + if (!path?.length) return []; + return path.map((point) => ({ lat: point.lat, lng: point.lng })); + } catch { + return []; + } } /** diff --git a/src/lib/plan/planItineraryMapSegments.test.ts b/src/lib/plan/planItineraryMapSegments.test.ts new file mode 100644 index 0000000..d339442 --- /dev/null +++ b/src/lib/plan/planItineraryMapSegments.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "vitest"; + +import { flattenPlanItinerarySegmentsFromPlaces } from "./planItineraryMapSegments"; + +function flattenTravelMode(travelMode?: string) { + const [segment] = flattenPlanItinerarySegmentsFromPlaces( + [7], + [ + [ + { + id: "a", + itemId: 10, + title: "출발", + googlePlaceId: "origin", + ...(travelMode === undefined ? {} : { travelMode }), + }, + { id: "b", itemId: 11, title: "도착", googlePlaceId: "destination" }, + ], + ], + ); + + return segment?.travelModeCanon; +} + +test("구간별 선택 이동수단을 지도 경로 descriptor에 반영한다", () => { + expect(flattenTravelMode("WALKING")).toBe("WALKING"); +}); + +test("소문자 이동수단을 표준값으로 정규화한다", () => { + expect(flattenTravelMode("walking")).toBe("WALKING"); +}); + +test("이동수단이 누락되면 일정 기본값을 사용한다", () => { + expect(flattenTravelMode()).toBe("DRIVING"); +}); diff --git a/src/lib/plan/planItineraryMapSegments.ts b/src/lib/plan/planItineraryMapSegments.ts index 2c861c7..a37aad6 100644 --- a/src/lib/plan/planItineraryMapSegments.ts +++ b/src/lib/plan/planItineraryMapSegments.ts @@ -1,7 +1,11 @@ /// import type { PlanPlace } from "@/lib/plan/types"; -import { SCHEDULE_TRAVEL_MODE_DEFAULT } from "@/lib/plan/scheduleTravelMode"; +import { + SCHEDULE_TRAVEL_MODE_DEFAULT, + canonicalScheduleTravelMode, + type ScheduleTravelModeValue, +} from "@/lib/plan/scheduleTravelMode"; /** 펼친 일차 일정 순서에서 인접 두 장소로 만든 Directions 구간 단위 */ export type PlanItinerarySegmentDescriptor = { @@ -9,7 +13,7 @@ export type PlanItinerarySegmentDescriptor = { segmentSourceItemId: number; originPlaceId: string; destPlaceId: string; - travelModeCanon: string; + travelModeCanon: ScheduleTravelModeValue; /** 순서상 앞(작은 번호) 장소 — 경로 방향 보정 */ originLocation?: google.maps.LatLngLiteral; /** 순서상 뒤(큰 번호) 장소 — 경로 방향 보정 */ @@ -40,8 +44,9 @@ export function flattenPlanItinerarySegmentsFromPlaces( segmentSourceItemId: a.itemId, originPlaceId: o, destPlaceId: d, - /** 지도 폴리라인은 공유 이동수단과 무관하게 자동차 고정 */ - travelModeCanon: SCHEDULE_TRAVEL_MODE_DEFAULT, + travelModeCanon: + canonicalScheduleTravelMode(a.travelMode) ?? + SCHEDULE_TRAVEL_MODE_DEFAULT, originLocation: a.location, destLocation: b.location, }); @@ -50,4 +55,3 @@ export function flattenPlanItinerarySegmentsFromPlaces( return segments; } - diff --git a/src/lib/plan/scheduleItemPlaces.create.test.ts b/src/lib/plan/scheduleItemPlaces.create.test.ts new file mode 100644 index 0000000..928e1d0 --- /dev/null +++ b/src/lib/plan/scheduleItemPlaces.create.test.ts @@ -0,0 +1,212 @@ +import { QueryClient } from "@tanstack/react-query"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + CreateScheduleItemResponse, + RoomScheduleItem, +} from "@/lib/api/rooms/schedule-items"; +import { scheduleItemsQueryKey } from "@/lib/query-keys"; + +const mocks = vi.hoisted(() => ({ + bumpForDirections: vi.fn(), + bumpPlanMapRouteSegments: vi.fn(), + createScheduleItem: vi.fn(), + fetchPlacePreview: vi.fn(), + getScheduleItems: vi.fn(), + invalidateScheduleItemRouteForSources: vi.fn(), + invalidateScheduleItemRouteForWholeSchedule: vi.fn(), + invalidateScheduleItemRoutesAfterDelete: vi.fn(), + invalidateScheduleItemRoutesAfterItemCreated: vi.fn(), + readOrderedItemIdsFromScheduleItemsCache: vi.fn(), +})); + +vi.mock("@/lib/api/rooms/schedule-items", () => ({ + createScheduleItem: mocks.createScheduleItem, + getScheduleItems: mocks.getScheduleItems, + updateScheduleItem: vi.fn(), +})); + +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(), +})); + +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 syncAfterCrossScheduleItemMove: typeof import("@/lib/plan/scheduleItemPlaces").syncAfterCrossScheduleItemMove; + +beforeAll(async () => { + process.env.NEXT_PUBLIC_API_BASE_URL = "http://localhost:8080"; + process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = "test-client"; + process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI = "http://localhost/callback"; + process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY = "test-maps-key"; + + ({ createScheduleItemAtPlanIndex, syncAfterCrossScheduleItemMove } = + await import("@/lib/plan/scheduleItemPlaces")); +}); + +const scheduleItem = (itemId: number, orderIndex: number): RoomScheduleItem => ({ + itemId, + scheduleId: 10, + googlePlaceId: `place-${itemId}`, + startTime: null, + durationMinutes: null, + orderIndex, + travelMode: "DRIVING", + createdAt: "2026-07-16T00:00:00Z", +}); + +describe("createScheduleItemAtPlanIndex", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.fetchPlacePreview.mockResolvedValue({ + name: "Created place", + formattedAddress: "Address", + }); + mocks.invalidateScheduleItemRouteForSources.mockResolvedValue(undefined); + mocks.invalidateScheduleItemRoutesAfterItemCreated.mockResolvedValue( + undefined, + ); + }); + + it("invalidates the route IDs returned by the create response", async () => { + const queryClient = new QueryClient(); + const response: CreateScheduleItemResponse = { + createdItem: scheduleItem(3, 1), + updatedItems: [scheduleItem(2, 2)], + affectedRouteItemIds: [777], + }; + mocks.createScheduleItem.mockResolvedValue(response); + const places = [ + { id: "item-1", itemId: 1, googlePlaceId: "place-1", title: "One" }, + { id: "item-2", itemId: 2, googlePlaceId: "place-2", title: "Two" }, + ]; + queryClient.setQueryData(scheduleItemsQueryKey("room-1", 10), places); + + await createScheduleItemAtPlanIndex(queryClient, { + roomId: "room-1", + scheduleId: 10, + places, + insertIndex: 1, + googlePlaceId: "place-3", + }); + + expect( + mocks.invalidateScheduleItemRoutesAfterItemCreated, + ).not.toHaveBeenCalled(); + expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( + queryClient, + "room-1", + 10, + [777], + ); + expect(mocks.bumpPlanMapRouteSegments).toHaveBeenCalledWith( + "room-1", + 10, + [777], + ); + }); + + it("returns the created item when post-create route synchronization fails", async () => { + const queryClient = new QueryClient(); + const response: CreateScheduleItemResponse = { + createdItem: scheduleItem(3, 1), + updatedItems: [scheduleItem(2, 2)], + affectedRouteItemIds: [777], + }; + mocks.createScheduleItem.mockResolvedValue(response); + mocks.invalidateScheduleItemRouteForSources.mockRejectedValueOnce( + new Error("route sync failed"), + ); + const places = [ + { id: "item-1", itemId: 1, googlePlaceId: "place-1", title: "One" }, + { id: "item-2", itemId: 2, googlePlaceId: "place-2", title: "Two" }, + ]; + const key = scheduleItemsQueryKey("room-1", 10); + queryClient.setQueryData(key, places); + + await expect( + createScheduleItemAtPlanIndex(queryClient, { + roomId: "room-1", + scheduleId: 10, + places, + insertIndex: 1, + googlePlaceId: "place-3", + }), + ).resolves.toEqual(response.createdItem); + + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); + 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(); + }); +}); diff --git a/src/lib/plan/scheduleItemPlaces.test.ts b/src/lib/plan/scheduleItemPlaces.test.ts index 4b18573..7e6ea88 100644 --- a/src/lib/plan/scheduleItemPlaces.test.ts +++ b/src/lib/plan/scheduleItemPlaces.test.ts @@ -1,9 +1,13 @@ import { beforeAll, describe, expect, it } from "vitest"; -import type { RoomScheduleItem } from "@/lib/api/rooms/schedule-items"; +import type { + CreateScheduleItemResponse, + RoomScheduleItem, +} from "@/lib/api/rooms/schedule-items"; import type { PlanPlace } from "@/lib/plan/types"; let applyRoomScheduleItemToPlanPlaces: typeof import("@/lib/plan/scheduleItemPlaces").applyRoomScheduleItemToPlanPlaces; +let mergeCreatedScheduleItemDelta: typeof import("@/lib/plan/scheduleItemPlaces").mergeCreatedScheduleItemDelta; beforeAll(async () => { process.env.NEXT_PUBLIC_API_BASE_URL = "http://localhost:8080"; @@ -11,9 +15,81 @@ beforeAll(async () => { process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI = "http://localhost/callback"; process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY = "test-maps-key"; - ({ applyRoomScheduleItemToPlanPlaces } = await import( - "@/lib/plan/scheduleItemPlaces" - )); + ({ applyRoomScheduleItemToPlanPlaces, mergeCreatedScheduleItemDelta } = + await import("@/lib/plan/scheduleItemPlaces")); +}); + +const scheduleItem = ( + itemId: number, + orderIndex: number, + travelMode = "DRIVING", +): RoomScheduleItem => ({ + itemId, + scheduleId: 10, + googlePlaceId: `place-${itemId}`, + startTime: null, + durationMinutes: null, + orderIndex, + travelMode, + createdAt: "2026-07-16T00:00:00Z", +}); + +const planPlace = (itemId: number, travelMode = "DRIVING"): PlanPlace => ({ + id: `item-${itemId}`, + itemId, + googlePlaceId: `place-${itemId}`, + title: `Place ${itemId}`, + travelMode, +}); + +describe("mergeCreatedScheduleItemDelta", () => { + it("inserts the created item at the server-returned middle index", () => { + const response: CreateScheduleItemResponse = { + createdItem: scheduleItem(3, 1), + updatedItems: [scheduleItem(2, 2)], + affectedRouteItemIds: [1], + }; + + const next = mergeCreatedScheduleItemDelta( + [planPlace(1), planPlace(2)], + planPlace(3), + response, + ); + + expect(next?.map((place) => place.itemId)).toEqual([1, 3, 2]); + }); + + it("applies the recommended travel mode from updatedItems", () => { + const response: CreateScheduleItemResponse = { + createdItem: scheduleItem(3, 1), + updatedItems: [scheduleItem(1, 0, "WALKING"), scheduleItem(2, 2)], + affectedRouteItemIds: [1], + }; + + const next = mergeCreatedScheduleItemDelta( + [planPlace(1), planPlace(2)], + planPlace(3), + response, + ); + + expect(next?.[0]?.travelMode).toBe("WALKING"); + }); + + it("returns null when an updated item is missing from the current cache", () => { + const response: CreateScheduleItemResponse = { + createdItem: scheduleItem(3, 1), + updatedItems: [scheduleItem(99, 2)], + affectedRouteItemIds: [1], + }; + + const next = mergeCreatedScheduleItemDelta( + [planPlace(1), planPlace(2)], + planPlace(3), + response, + ); + + expect(next).toBeNull(); + }); }); describe("applyRoomScheduleItemToPlanPlaces", () => { diff --git a/src/lib/plan/scheduleItemPlaces.ts b/src/lib/plan/scheduleItemPlaces.ts index 32588e1..c44b108 100644 --- a/src/lib/plan/scheduleItemPlaces.ts +++ b/src/lib/plan/scheduleItemPlaces.ts @@ -6,8 +6,8 @@ import { awaitRoomSchedulesHydrated } from "@/lib/rooms"; import { createScheduleItem, getScheduleItems, - reorderScheduleItem, updateScheduleItem, + type CreateScheduleItemResponse, type RoomScheduleItem, } from "@/lib/api/rooms/schedule-items"; import { fetchPlacePreview } from "@/lib/places/place-queries"; @@ -18,6 +18,8 @@ import { } from "@/lib/plan/schedule-bulk-hydration"; import { getQueryClient } from "@/lib/query-client"; import { + bumpPlanMapRouteSegments, + invalidateScheduleItemRouteForSources, invalidateScheduleItemRouteForWholeSchedule, invalidateScheduleItemRoutesAfterDelete, invalidateScheduleItemRoutesAfterItemCreated, @@ -271,6 +273,7 @@ export async function syncAfterCrossScheduleItemMove( sourceScheduleId: number, targetScheduleId: number, movedItemId?: number, + affectedRouteItemIds?: number[] | null, ): Promise { const rid = roomId.trim(); if (!rid.length) return; @@ -299,6 +302,45 @@ export async function syncAfterCrossScheduleItemMove( ); } + 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, @@ -624,7 +666,37 @@ function resolveInsertTargetIndex( return Math.max(0, Math.min(insertIndex, listLength - 1)); } -/** 1) 일차 항목 생성 2) `insertIndex` 슬롯으로 reorder(필요 시). */ +/** HTTP 생성 델타를 현재 일정 캐시에 병합합니다. 캐시가 어긋나면 null을 반환합니다. */ +export function mergeCreatedScheduleItemDelta( + prev: PlanPlace[] | undefined, + createdPlace: PlanPlace, + response: CreateScheduleItemResponse, +): PlanPlace[] | null { + if (!prev || prev.some((place) => place.itemId === response.createdItem.itemId)) { + return null; + } + + let next = [...prev]; + for (const updated of response.updatedItems) { + const patched = applyRoomScheduleItemToPlanPlaces(next, updated); + if (!patched) return null; + next = patched; + } + + const targetIndex = response.createdItem.orderIndex; + if ( + !Number.isInteger(targetIndex) || + targetIndex < 0 || + targetIndex > next.length + ) { + return null; + } + + next.splice(targetIndex, 0, createdPlace); + return next; +} + +/** 서버가 지정 위치 삽입과 이동수단 추천을 함께 처리하고 델타를 반환합니다. */ export async function createScheduleItemAtPlanIndex( queryClient: QueryClient, args: { @@ -640,79 +712,61 @@ export async function createScheduleItemAtPlanIndex( throw new Error("createScheduleItemAtPlanIndex: empty roomId"); } - const created = await createScheduleItem(rid, args.scheduleId, { - googlePlaceId: args.googlePlaceId, - }); - const listLengthAfterCreate = args.places.length + 1; const targetIndex = resolveInsertTargetIndex( args.insertIndex, listLengthAfterCreate, ); - const fromIndex = - typeof created.orderIndex === "number" && - Number.isFinite(created.orderIndex) - ? created.orderIndex - : args.places.length; - - if (fromIndex === targetIndex) { - await appendCreatedScheduleItemToPlanPlaces( + const response = await createScheduleItem(rid, args.scheduleId, { + googlePlaceId: args.googlePlaceId, + orderIndex: targetIndex, + }); + + const key = scheduleItemsQueryKey(rid, args.scheduleId); + try { + const prev = queryClient.getQueryData(key); + const createdPlace = await planPlaceFromScheduleItem(response.createdItem); + const merged = mergeCreatedScheduleItemDelta(prev, createdPlace, response); + if (merged) { + queryClient.setQueryData(key, merged); + } else { + const fresh = await getScheduleItems(rid, args.scheduleId); + await mergeOrRefetchSchedulePlanPlacesFromItems( + queryClient, + rid, + args.scheduleId, + fresh, + ); + } + + await invalidateScheduleItemRouteForSources( queryClient, rid, args.scheduleId, - created, + response.affectedRouteItemIds, ); - return created; - } - - const items = await reorderScheduleItem( - rid, - args.scheduleId, - created.itemId, - { - newOrderIndex: newOrderIndexAfterMove( - fromIndex, - targetIndex, - listLengthAfterCreate, + bumpPlanMapRouteSegments( + rid, + args.scheduleId, + response.affectedRouteItemIds, + ); + } catch { + await Promise.allSettled([ + queryClient.invalidateQueries({ + queryKey: key, + exact: true, + refetchType: "active", + }), + invalidateScheduleItemRouteForWholeSchedule( + queryClient, + rid, + args.scheduleId, ), - }, - ); - await syncPlanPlacesAfterReorderSuccess( - queryClient, - rid, - args.scheduleId, - items, - created.itemId, - ); - - return created; -} + ]); + usePlanMapDirectionsEpochStore.getState().bumpForDirections(rid); + } -/** POST 응답으로 `schedule-items` 캐시에 새 항목을 반영합니다(GET items 생략). */ -export async function appendCreatedScheduleItemToPlanPlaces( - queryClient: QueryClient, - roomId: string, - scheduleId: number, - created: RoomScheduleItem, -): Promise { - const rid = roomId.trim(); - if (!rid.length) return; - const key = scheduleItemsQueryKey(rid, scheduleId); - const prev = queryClient.getQueryData(key) ?? []; - if (prev.some((p) => p.itemId === created.itemId)) return; - - const newPlace = await planPlaceFromScheduleItem(created); - const without = prev.filter((p) => p.itemId !== created.itemId); - const idx = Math.max(0, Math.min(created.orderIndex, without.length)); - const next = [...without]; - next.splice(idx, 0, newPlace); - queryClient.setQueryData(key, next); - await invalidateScheduleItemRoutesAfterItemCreated( - queryClient, - rid, - scheduleId, - created.itemId, - ); + return response.createdItem; } /** 드래그를 `toIndex` 자리에 놓았을 때 PATCH에 넣을 `newOrderIndex`(0-based) */ diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts new file mode 100644 index 0000000..b05a43f --- /dev/null +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.test.ts @@ -0,0 +1,70 @@ +import { QueryClient } from "@tanstack/react-query"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/api/rooms/schedule-items", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getScheduleItemRoute: vi.fn(), + }; +}); + +let persistedScheduleItemRouteQueryOptions: typeof import("@/lib/plan/scheduleItemRoutePersistedQuery").persistedScheduleItemRouteQueryOptions; +let registerQueryClient: typeof import("@/lib/query-client").registerQueryClient; +let getScheduleItemRouteMock: ReturnType; + +beforeAll(async () => { + process.env.NEXT_PUBLIC_API_BASE_URL = "http://localhost:8080"; + process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = "test-client"; + process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI = "http://localhost/callback"; + process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY = "test-maps-key"; + + ({ persistedScheduleItemRouteQueryOptions } = await import( + "@/lib/plan/scheduleItemRoutePersistedQuery" + )); + ({ registerQueryClient } = await import("@/lib/query-client")); + ({ getScheduleItemRoute: getScheduleItemRouteMock } = vi.mocked( + await import("@/lib/api/rooms/schedule-items"), + )); +}); + +beforeEach(() => { + getScheduleItemRouteMock.mockReset(); +}); + +describe("persistedScheduleItemRouteQueryOptions", () => { + it("무효화된 경로 캐시가 있어도 API에서 최신 이동 정보를 조회한다", async () => { + const queryClient = new QueryClient(); + registerQueryClient(queryClient); + const options = persistedScheduleItemRouteQueryOptions( + "room-1", + 10, + 1, + "DRIVING", + "", + { segmentReady: true }, + ); + const staleRoute = { + travelMode: "DRIVING", + distanceMeters: 1200, + durationSeconds: 300, + }; + const freshRoute = { + travelMode: "DRIVING", + distanceMeters: 2400, + durationSeconds: 600, + }; + queryClient.setQueryData(options.queryKey, staleRoute); + await queryClient.invalidateQueries({ + queryKey: options.queryKey, + refetchType: "none", + }); + getScheduleItemRouteMock.mockResolvedValue(freshRoute); + + const result = await queryClient.fetchQuery(options); + + expect(getScheduleItemRouteMock).toHaveBeenCalledOnce(); + expect(result).toEqual(freshRoute); + }); +}); diff --git a/src/lib/plan/scheduleItemRoutePersistedQuery.ts b/src/lib/plan/scheduleItemRoutePersistedQuery.ts index e8b9cee..3aea47f 100644 --- a/src/lib/plan/scheduleItemRoutePersistedQuery.ts +++ b/src/lib/plan/scheduleItemRoutePersistedQuery.ts @@ -107,9 +107,9 @@ export function persistedScheduleItemRouteQueryOptions( } if (qc) { - const cached = qc.getQueryData(routeKey); - if (cached !== undefined) { - return cached; + const cached = qc.getQueryState(routeKey); + if (cached?.data !== undefined && !cached.isInvalidated) { + return cached.data; } } diff --git a/src/lib/stomp/schedules-dispatch.test.ts b/src/lib/stomp/schedules-dispatch.test.ts new file mode 100644 index 0000000..2803b8f --- /dev/null +++ b/src/lib/stomp/schedules-dispatch.test.ts @@ -0,0 +1,241 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RoomScheduleChangedEvent } from "@/lib/stomp/schedule-events"; + +const mocks = vi.hoisted(() => ({ + collectSegmentSourcesForCreate: vi.fn(), + collectSegmentSourcesForDelete: vi.fn(), + collectSegmentSourcesForReorder: vi.fn(), + getScheduleItems: vi.fn(), + invalidateScheduleItemRouteForSources: vi.fn(), + invalidateScheduleItemRouteForWholeSchedule: vi.fn(), + mergeOrRefetchSchedulePlanPlacesFromItems: vi.fn(), + readOrderedItemIdsFromScheduleItemsCache: vi.fn(), + refetchSchedulePlanPlacesIntoCache: vi.fn(), + removeRouteQueriesForDeletedItemSource: vi.fn(), + syncAfterCrossScheduleItemMove: vi.fn(), +})); + +vi.mock("@/lib/api/rooms/schedule-items", () => ({ + getScheduleItems: mocks.getScheduleItems, +})); + +vi.mock("@/lib/plan/scheduleItemPlaces", () => ({ + mergeOrRefetchSchedulePlanPlacesFromItems: + mocks.mergeOrRefetchSchedulePlanPlacesFromItems, + refetchSchedulePlanPlacesIntoCache: mocks.refetchSchedulePlanPlacesIntoCache, + 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", () => ({ + hydrateRoomSchedulesFromServer: vi.fn(), + syncRoomDetailFromServer: vi.fn(), +})); + +vi.mock("@/stores/plan-map-directions-epoch-store", () => ({ + usePlanMapDirectionsEpochStore: { + getState: () => ({ + bumpForDirections: vi.fn(), + bumpSegments: vi.fn(), + }), + }, +})); + +import { dispatchRoomScheduleEvent } from "@/lib/stomp/schedules-dispatch"; + +function itemEvent( + type: RoomScheduleChangedEvent["type"], + affectedRouteItemIds: number[], +): RoomScheduleChangedEvent { + return { + roomId: "room-1", + actorUserId: 99, + type, + scheduleId: 10, + itemId: 20, + affectedRouteItemIds, + scheduleIds: null, + }; +} + +describe("dispatchRoomScheduleEvent", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient(); + vi.clearAllMocks(); + mocks.getScheduleItems.mockResolvedValue([]); + mocks.invalidateScheduleItemRouteForSources.mockResolvedValue(undefined); + mocks.invalidateScheduleItemRouteForWholeSchedule.mockResolvedValue( + undefined, + ); + mocks.mergeOrRefetchSchedulePlanPlacesFromItems.mockResolvedValue(undefined); + mocks.refetchSchedulePlanPlacesIntoCache.mockResolvedValue(undefined); + mocks.syncAfterCrossScheduleItemMove.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + queryClient.clear(); + }); + + 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], + ); + }); + + it("uses affectedRouteItemIds instead of calculating routes after reorder", async () => { + vi.useFakeTimers(); + mocks.collectSegmentSourcesForReorder.mockReturnValue({ + sources: [10, 20], + useFallback: false, + }); + + await dispatchRoomScheduleEvent( + queryClient, + itemEvent("SCHEDULE_ITEMS_REORDERED", [999]), + ); + await vi.runAllTimersAsync(); + + expect(mocks.collectSegmentSourcesForReorder).not.toHaveBeenCalled(); + expect(mocks.invalidateScheduleItemRouteForSources).toHaveBeenCalledWith( + queryClient, + "room-1", + 10, + [999], + ); + }); + + it("passes affectedRouteItemIds through a cross-schedule move", async () => { + const event = itemEvent("SCHEDULE_ITEM_MOVED", [11, 21, 20]); + event.scheduleId = 20; + event.scheduleIds = [10, 20]; + + await dispatchRoomScheduleEvent(queryClient, event); + + expect(mocks.syncAfterCrossScheduleItemMove).toHaveBeenCalledWith( + queryClient, + "room-1", + 10, + 20, + 20, + [11, 21, 20], + ); + }); + + it("refreshes travel mode before invalidating affectedRouteItemIds", async () => { + vi.useFakeTimers(); + const order: string[] = []; + mocks.getScheduleItems.mockImplementation(async () => { + order.push("refetch"); + return []; + }); + 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], + ); + }); + + it("preserves a newer route bucket when an earlier travel-mode refresh fails", async () => { + vi.useFakeTimers(); + let rejectFirst!: (reason?: unknown) => void; + let resolveSecond!: (value: never[]) => void; + const first = new Promise((_, reject) => { + rejectFirst = reject; + }); + const second = new Promise((resolve) => { + resolveSecond = resolve; + }); + mocks.getScheduleItems + .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], + ); + }); +}); diff --git a/src/lib/stomp/schedules-dispatch.ts b/src/lib/stomp/schedules-dispatch.ts index c56797b..5050e51 100644 --- a/src/lib/stomp/schedules-dispatch.ts +++ b/src/lib/stomp/schedules-dispatch.ts @@ -7,12 +7,8 @@ import { } from "@/lib/plan/scheduleItemPlaces"; import { getScheduleItems } from "@/lib/api/rooms/schedule-items"; import { - collectSegmentSourcesForCreate, - collectSegmentSourcesForDelete, - collectSegmentSourcesForReorder, invalidateScheduleItemRouteForSources, invalidateScheduleItemRouteForWholeSchedule, - readOrderedItemIdsFromScheduleItemsCache, removeRouteQueriesForDeletedItemSource, } from "@/lib/plan/scheduleStompRouteScope"; import { @@ -129,20 +125,38 @@ const syncSchedulePlacesFromItemsDebouncers = new Map< string, ReturnType >(); +const syncSchedulePlacesRouteInvalidationPending = new Map< + string, + DebouncedRouteInvBucket +>(); const DEBOUNCE_ITEMS_GET_MS = 90; function enqueueDebouncedHydratePlacesFromScheduleItemsApi( queryClient: QueryClient, rid: string, sid: number, + affectedRouteItemIds?: number[] | null, ): void { const mapKey = `${rid}:${sid}`; + 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); void (async () => { try { const rows = await getScheduleItems(rid, sid); @@ -152,6 +166,25 @@ function enqueueDebouncedHydratePlacesFromScheduleItemsApi( sid, rows, ); + if (routeBucket?.scope === "whole") { + await invalidateScheduleItemRouteForWholeSchedule( + queryClient, + rid, + sid, + ); + usePlanMapDirectionsEpochStore + .getState() + .bumpForDirections(rid); + } else if (routeBucket && routeBucket.ids.size > 0) { + const sources = [...routeBucket.ids]; + await invalidateScheduleItemRouteForSources( + queryClient, + rid, + sid, + sources, + ); + bumpMapForRouteSources(rid, sid, sources); + } } catch { // } @@ -217,6 +250,7 @@ export async function dispatchRoomScheduleEvent( sourceScheduleId, targetScheduleId, itemId ?? undefined, + event.affectedRouteItemIds, ); return; } @@ -236,11 +270,6 @@ export async function dispatchRoomScheduleEvent( return; } - const oldIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - sid, - ); removeRouteQueriesForDeletedItemSource(queryClient, rid, sid, itemId); const items = await getScheduleItems(rid, sid); @@ -251,11 +280,8 @@ export async function dispatchRoomScheduleEvent( items, ); - const { sources, useFallback } = collectSegmentSourcesForDelete( - oldIds, - itemId, - ); - if (useFallback) { + const sources = event.affectedRouteItemIds; + if (sources === null) { await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); epochStore.bumpForDirections(rid); } else { @@ -282,16 +308,8 @@ export async function dispatchRoomScheduleEvent( await refetchScheduleItemsPlaces(queryClient, rid, sid); - const newIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - sid, - ); - const { sources, useFallback } = collectSegmentSourcesForCreate( - newIds, - itemId, - ); - if (useFallback) { + const sources = event.affectedRouteItemIds; + if (sources === null) { await invalidateScheduleItemRouteForWholeSchedule(queryClient, rid, sid); epochStore.bumpForDirections(rid); } else { @@ -316,12 +334,6 @@ export async function dispatchRoomScheduleEvent( return; } - const oldIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - sid, - ); - const items = await getScheduleItems(rid, sid); await mergeOrRefetchSchedulePlanPlacesFromItems( queryClient, @@ -329,22 +341,13 @@ export async function dispatchRoomScheduleEvent( sid, items, ); - const newIds = readOrderedItemIdsFromScheduleItemsCache( - queryClient, - rid, - sid, - ); - const { sources, useFallback } = collectSegmentSourcesForReorder( - oldIds, - newIds, - itemId, - ); + const sources = event.affectedRouteItemIds; enqueueDebouncedInvalidateScheduleRoutes( queryClient, rid, sid, - useFallback ? [] : sources, - useFallback, + sources ?? [], + sources === null, ); return; } @@ -383,11 +386,13 @@ export async function dispatchRoomScheduleEvent( return; } - /** - * 항목 재조회로 `place.travelMode`가 갱신되면 구간 카드가 새 수단의 route - * 쿼리를 켠다 — 경로 캐시는 수단별 키로 분리돼 있어 무효화 불필요. - */ - enqueueDebouncedHydratePlacesFromScheduleItemsApi(queryClient, rid, sid); + /** 항목 재조회 후 서버가 지정한 출발 항목의 경로만 갱신합니다. */ + enqueueDebouncedHydratePlacesFromScheduleItemsApi( + queryClient, + rid, + sid, + event.affectedRouteItemIds, + ); return; } }