From ef54d3b3369f2e6eb6ca761147306039304b5c85 Mon Sep 17 00:00:00 2001 From: minbros Date: Wed, 15 Jul 2026 23:01:08 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(plan):=20=EC=9D=B4=EB=8F=99=EC=88=98?= =?UTF-8?q?=EB=8B=A8=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구간별 선택 이동수단을 지도 폴리라인 경로 조회에 연결 - 지도 경로 상태 및 단위 테스트 추가 --- src/components/map/PlanItineraryMapRoutes.tsx | 4 ++-- src/lib/plan/planItineraryMapSegments.test.ts | 23 +++++++++++++++++++ src/lib/plan/planItineraryMapSegments.ts | 14 +++++++---- 3 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 src/lib/plan/planItineraryMapSegments.test.ts 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( { + const [segment] = flattenPlanItinerarySegmentsFromPlaces( + [7], + [ + [ + { + id: "a", + itemId: 10, + title: "출발", + googlePlaceId: "origin", + travelMode: "WALKING", + }, + { id: "b", itemId: 11, title: "도착", googlePlaceId: "destination" }, + ], + ], + ); + + expect(segment?.travelModeCanon).toBe("WALKING"); +}); 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; } - From 808b77911a3f45312e01fe4fc7e57145096a1836 Mon Sep 17 00:00:00 2001 From: minbros Date: Thu, 16 Jul 2026 15:52:45 +0900 Subject: [PATCH 2/8] =?UTF-8?q?chore(git):=20IntelliJ=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From a8b8021d1330ec3ae9e9e0e1c7eb7f17bf786a9c Mon Sep 17 00:00:00 2001 From: minbros Date: Thu, 16 Jul 2026 16:14:00 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix(plan):=20=EC=9D=BC=EC=A0=95=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=ED=9B=84=20=EC=9D=B4=EB=8F=99=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 무효화된 경로 캐시 재사용 방지 - 삭제 후 재추가 회귀 테스트 추가 --- .../scheduleItemRoutePersistedQuery.test.ts | 70 +++++++++++++++++++ .../plan/scheduleItemRoutePersistedQuery.ts | 6 +- 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 src/lib/plan/scheduleItemRoutePersistedQuery.test.ts 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; } } From d72ae0a3329b27aaa96b8c19b45ae49c621657fd Mon Sep 17 00:00:00 2001 From: minbros Date: Thu, 16 Jul 2026 17:24:52 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(schedule):=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=83=9D=EC=84=B1=20=EB=8D=B8=ED=83=80=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 생성 요청에 orderIndex를 전달하고 후속 재정렬 제거 - 생성·변경 항목 델타를 일정 캐시에 병합 --- .../2026-07-16-schedule-item-insert-delta.md | 75 ++++++++++++ src/lib/api/rooms/index.ts | 1 + src/lib/api/rooms/schedule-items.ts | 11 +- src/lib/plan/scheduleItemPlaces.test.ts | 84 +++++++++++++- src/lib/plan/scheduleItemPlaces.ts | 109 ++++++++---------- 5 files changed, 216 insertions(+), 64 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-16-schedule-item-insert-delta.md 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/src/lib/api/rooms/index.ts b/src/lib/api/rooms/index.ts index 64cb802..5c9eca9 100644 --- a/src/lib/api/rooms/index.ts +++ b/src/lib/api/rooms/index.ts @@ -59,6 +59,7 @@ export type { } from "./schedules"; export type { + CreateScheduleItemResponse, MoveScheduleItemToScheduleRequest, RoomScheduleItem, RoomScheduleItemCreateRequest, diff --git a/src/lib/api/rooms/schedule-items.ts b/src/lib/api/rooms/schedule-items.ts index 68b72e5..32d626f 100644 --- a/src/lib/api/rooms/schedule-items.ts +++ b/src/lib/api/rooms/schedule-items.ts @@ -30,6 +30,15 @@ export type RoomScheduleItemCreateRequest = { /** 로컬 시:분, 예: `"09:45"`. 미지정 시 서버는 저장하지 않음(미설정 상태) */ startTime?: string; durationMinutes?: number; + /** 0-based 삽입 위치. 생략하면 맨 뒤에 추가 */ + orderIndex?: number; +}; + +export type CreateScheduleItemResponse = { + createdItem: RoomScheduleItem; + /** 삽입으로 순서가 밀리거나 추천 이동수단이 변경된 기존 항목 */ + updatedItems: RoomScheduleItem[]; + affectedRouteItemIds: number[]; }; export type RoomScheduleItemUpdateRequest = { @@ -181,7 +190,7 @@ export async function createScheduleItem( roomId: string, scheduleId: number, body: RoomScheduleItemCreateRequest, -): Promise { +): Promise { return requestJson( apiUrl(`/rooms/${roomId}/schedules/${scheduleId}/items`), { method: "POST", ...jsonBody(body) }, 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..1b8c821 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"; @@ -624,7 +624,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 +670,40 @@ 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); + 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, - created, + fresh, ); - return created; } - const items = await reorderScheduleItem( - rid, - args.scheduleId, - created.itemId, - { - newOrderIndex: newOrderIndexAfterMove( - fromIndex, - targetIndex, - listLengthAfterCreate, - ), - }, - ); - await syncPlanPlacesAfterReorderSuccess( + await invalidateScheduleItemRoutesAfterItemCreated( queryClient, rid, args.scheduleId, - items, - created.itemId, + response.createdItem.itemId, ); - return created; -} - -/** 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) */ From c8d1cbbc931c0369593024cb17e41f62138bbc56 Mon Sep 17 00:00:00 2001 From: minbros Date: Thu, 16 Jul 2026 17:34:58 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat(map):=20Routes=20API=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=9D=BC=EC=A0=95=20=EA=B2=BD=EB=A1=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DirectionsService를 Route.computeRoutes로 교체 - Google Maps 타입 갱신 및 경로 조회 테스트 추가 --- package-lock.json | 7 +-- package.json | 1 + src/lib/maps-routes.test.ts | 99 +++++++++++++++++++++++++++++++++++++ src/lib/maps.ts | 53 ++++++++------------ 4 files changed, 124 insertions(+), 36 deletions(-) create mode 100644 src/lib/maps-routes.test.ts 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/lib/maps-routes.test.ts b/src/lib/maps-routes.test.ts new file mode 100644 index 0000000..3489de9 --- /dev/null +++ b/src/lib/maps-routes.test.ts @@ -0,0 +1,99 @@ +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("경로 계산에 실패하면 빈 경로를 반환한다", 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 4c263fc..657a013 100644 --- a/src/lib/maps.ts +++ b/src/lib/maps.ts @@ -234,7 +234,7 @@ function toGmTravelMode( } /** - * Directions `overview_path` 가 출발/도착과 반대일 때 뒤집습니다. + * Routes `path`가 출발/도착과 반대일 때 뒤집습니다. * 폴리라인·화살표가 일정 순서(작은 번호 → 큰 번호) 방향을 가리키게 합니다. */ export function orientPathSmallerStopToLarger( @@ -259,48 +259,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 []; + } } /** From d2f45ac3999b6f89d31db47867eed37ab3bddc03 Mon Sep 17 00:00:00 2001 From: minbros Date: Thu, 16 Jul 2026 17:53:23 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix(plan):=20=EC=9D=BC=EC=B0=A8=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EB=B2=84=ED=8A=BC=20=EA=B2=B9=EC=B9=A8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 펼침 아이콘을 헤더 오른쪽으로 정렬 - 레이아웃 회귀 테스트 추가 --- .../itinerary/PlanDaySection.test.tsx | 28 +++++++++++++++++++ .../_components/itinerary/PlanDaySection.tsx | 19 ++++++------- 2 files changed, 36 insertions(+), 11 deletions(-) create mode 100644 src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx 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..1672787 --- /dev/null +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx @@ -0,0 +1,28 @@ +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).toContain("ml-auto"); + expect(triggerMarkup).toContain("shrink-0"); + expect(html).not.toContain("absolute left-1/2"); + }); +}); diff --git a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx index 3f51a5d..7d32a1f 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx @@ -204,7 +204,7 @@ export function PlanDaySection({ onClick={toggle} className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-lg text-left transition-colors hover:bg-bubble-gray/60" > -
+

{subtitle || title} @@ -216,17 +216,14 @@ export function PlanDaySection({ ) : null}

+ + {expanded ? ( + + ) : ( + + )} + - - {expanded ? ( - - ) : ( - - )} - {trackedSid !== undefined ? (