Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ SERVICE_CONTEXT.md

# misc
.DS_Store
.idea/
*.pem

# debug
Expand Down
75 changes: 75 additions & 0 deletions docs/superpowers/plans/2026-07-16-schedule-item-insert-delta.md
Original file line number Diff line number Diff line change
@@ -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<ScheduleItemResult> updatedItems, List<Long> 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.**
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/google.maps": "^3.65.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
Expand Down
32 changes: 32 additions & 0 deletions src/app/(main)/plan/_components/itinerary/PlanDaySection.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
/<button[^>]*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");
});
});
4 changes: 2 additions & 2 deletions src/components/map/PlanItineraryMapRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { PlanItineraryStopMapPin } from "./PlanItineraryStopMapPin";
/**
* 일차별 "지도에 경로 표시" 토글(`usePlanScheduleRouteVisibilityStore`) ON인
* 일차만 지도에 경로(polyline)와 정류장 마커를 그립니다.
* 폴리라인 travelMode는 항상 DRIVING(`planItineraryMapSegments`).
* 폴리라인 travelMode는 서버에 저장된 구간별 공유 이동수단을 따릅니다.
* STOMP 에폭은 {@link usePlanMapDirectionsEpochStore} 참고.
*/
export function PlanItineraryMapRoutes() {
Expand Down Expand Up @@ -190,7 +190,7 @@ export function PlanItineraryMapRoutes() {

polylines.push(
<Polyline
key={`${seg.scheduleId}-${seg.segmentSourceItemId}-${directionsEpoch}-${segEpoch}`}
key={`${seg.scheduleId}-${seg.segmentSourceItemId}-${seg.travelModeCanon}-${directionsEpoch}-${segEpoch}`}
zIndex={40 + segIdx}
strokeColor={routeColor}
strokeOpacity={0.8}
Expand Down
1 change: 1 addition & 0 deletions src/lib/api/rooms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type {
} from "./schedules";

export type {
CreateScheduleItemResponse,
MoveScheduleItemToScheduleRequest,
RoomScheduleItem,
RoomScheduleItemCreateRequest,
Expand Down
11 changes: 10 additions & 1 deletion src/lib/api/rooms/schedule-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -181,7 +190,7 @@ export async function createScheduleItem(
roomId: string,
scheduleId: number,
body: RoomScheduleItemCreateRequest,
): Promise<RoomScheduleItem> {
): Promise<CreateScheduleItemResponse> {
return requestJson(
apiUrl(`/rooms/${roomId}/schedules/${scheduleId}/items`),
{ method: "POST", ...jsonBody(body) },
Expand Down
131 changes: 131 additions & 0 deletions src/lib/maps-routes.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading