Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
dc555ec
fix(schedule): 늦게 온 일차 장소 목록 응답이 최신 목록을 덮지 않도록 수정
KangShinGyu98 Oct 30, 2026
1704611
fix(schedule): 본인 일정 변경은 응답을 반영한 뒤 그 일차를 다시 받도록 수정
KangShinGyu98 Oct 30, 2026
0fd9f06
refactor(schedule): 저장 중 표시를 복사한 상태 대신 mutation 요청 값으로 계산
KangShinGyu98 Oct 30, 2026
105a667
test(schedule): 일차 장소 목록 조회 순서와 본인 변경 뒤 재조회 테스트
KangShinGyu98 Oct 30, 2026
4d440f6
docs(conventions): 데이터 조회·갱신 원칙을 서버 상태 기준으로 수정
KangShinGyu98 Oct 30, 2026
a47e6f6
fix(plan): dev 병합 충돌과 미리보기 복구 흐름 정리
minbros Nov 2, 2026
cb0af69
refactor(schedule): Sonar 분기와 순차 조회 경고 해소
minbros Nov 2, 2026
b7c9bdb
fix(route): 경로 캐시 key에 도착 장소를 넣어 순서를 되돌리면 받아 둔 경로를 쓰도록 수정
KangShinGyu98 Nov 3, 2026
c329707
refactor(route): 도착 장소가 key에 들어가 필요 없어진 경로 무효화 제거
KangShinGyu98 Nov 3, 2026
c76978d
refactor(map): 경로 무효화가 없어져 지도 선 key의 에폭 제거
KangShinGyu98 Nov 3, 2026
9272887
test(route): 진입 batch·일정 목록·지도가 같은 경로 캐시를 쓰는지 확인
KangShinGyu98 Nov 4, 2026
6bc914f
fix(route): 단건 경로 조회에 도착 항목을 보내고 경로 key의 도착 값을 항목 ID로 변경
KangShinGyu98 Nov 4, 2026
7957b20
fix(route): batch 경로를 응답의 도착 항목으로 저장해 기다리는 사이 순서가 바뀌어도 버리지 않도록 수정
KangShinGyu98 Nov 4, 2026
d92cea3
docs(route): 경로 key 설명 주석의 도착 장소를 도착 항목으로 정리
KangShinGyu98 Nov 4, 2026
525eff5
chore(sync): 최신 dev를 반영
KangShinGyu98 Nov 6, 2026
be924a8
chore(sync): 최신 #250 반영
KangShinGyu98 Nov 6, 2026
7135a75
feat(plan): 드래그 결과를 캐시에 먼저 그릴 목록 계산 함수 추가
KangShinGyu98 Nov 6, 2026
3463fd1
fix(plan): 장소 순서 변경·일차 간 이동을 응답 전에 캐시에 먼저 반영하고 실패하면 되돌림
KangShinGyu98 Nov 9, 2026
cf3a4b1
feat(plan): PC·모바일이 함께 쓰는 장소 카드 놓기 결정 추가
KangShinGyu98 Nov 8, 2026
8249086
fix(plan): PC 장소 드래그를 페이지 컨트롤러 하나로 모아 놓자마자 새 자리에 보이도록 수정
KangShinGyu98 Nov 8, 2026
37f2158
fix(route): 장소 이동을 저장하는 동안 그 일차의 구간 경로를 조회하지 않음
KangShinGyu98 Nov 9, 2026
e2da325
refactor(plan): 모바일 드래그를 캐시 낙관적 갱신과 공용 놓기 결정으로 전환
KangShinGyu98 Nov 8, 2026
124839d
fix(plan): 일차 순서 변경을 응답 전에 캐시에 먼저 반영하고 날짜 라벨도 새 위치대로 바꿈
KangShinGyu98 Nov 9, 2026
5a60138
refactor(plan): 컨트롤러로 옮긴 일차별 드래그 훅·스토어와 dataTransfer 읽기 삭제
KangShinGyu98 Nov 8, 2026
fd3350f
refactor(plan): 일차 순서 드래그의 잠금을 없애고 useScheduleResyncPending 삭제
KangShinGyu98 Nov 8, 2026
ba2222f
fix(plan): 장소·일차를 옮기지 못했을 때 브라우저 네트워크 오류 문구 대신 한국어 문구를 띄움
KangShinGyu98 Nov 9, 2026
e9881ff
fix(plan): 장소·일차 옮기기 실패 토스트가 다시 받기를 기다리지 않고 바로 뜨도록 수정
KangShinGyu98 Nov 9, 2026
1e3067b
chore(sync): 최신 dev를 반영
KangShinGyu98 Nov 9, 2026
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
3 changes: 2 additions & 1 deletion src/app/(main)/map/_components/useMobileRouteView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ vi.mock("@/hooks/use-room-id", () => ({ useCurrentRoomId: () => ({ roomId: "room
vi.mock("@/hooks/useRooms", () => ({
useRoomSchedules: () => ({ data: fixtures.schedules, isSuccess: true }),
useSchedulePlanPlaces: () => ({ data: fixtures.places, isSuccess: true, isFetching: false }),
useScheduleItemMoveSavingDays: () => [],
}));
vi.mock("@/hooks/usePrefetchScheduleRoutes", () => ({ usePrefetchScheduleRoutes: () => true }));
vi.mock("@/components/expenses/ExpenseProvider", () => ({
Expand Down Expand Up @@ -128,7 +129,7 @@ it.each([
document.body.append(host);
const root = createRoot(host);
function Route() { return useMobileRouteView({ active: true, day: 1, initialItemId: 101 }).overlay; }
const drag = { editing: false, dragDisabled: false, pending: null, dropTargetScheduleId: null,
const drag = { editing: false, dropTargetScheduleId: null,
registerList: vi.fn(), registerRow: vi.fn(), registerPlaces: vi.fn(), startDrag: vi.fn(), rowStyle: () => undefined };
try {
await act(async () => root.render(<QueryClientProvider client={queryClient}>
Expand Down
32 changes: 11 additions & 21 deletions src/app/(main)/plan/_components/itinerary/PlanDaySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { Switch } from "@/components/ui/switch";
import { MAIN_CARD_INNER_PADDING_X_CLASS } from "@/lib/layout-tokens";
import { usePlanItineraryExpandedStore } from "@/stores/plan-itinerary-expanded-store";
import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store";
import { usePlanItemCrossDayDragStore } from "@/stores/plan-item-cross-day-drag-store";
import { cn } from "@/lib/utils";
import { ChevronDown, ChevronUp, GripVertical } from "lucide-react";
import { useCallback, useId, useState } from "react";
Expand Down Expand Up @@ -54,11 +53,14 @@ export type PlanDaySectionProps = {
sectionRef?: Ref<HTMLElement>;
sectionStyle?: CSSProperties;
dragHandleProps?: PlanDaySectionDragHandleProps;
/** item cross-day D&D — 헤더·접힌 일차 drop (PlanItinerary list와 병행) */
crossDaySectionDropProps?: {
onDragOver: (e: DragEvent<Element>) => void;
onDragLeave: (e: DragEvent<Element>) => void;
onDrop: (e: DragEvent<Element>) => void;
/** 다른 일차 장소 놓기 — 헤더·접힌 일차 놓을 영역(목록은 PlanItinerary가 받는다)과 놓을 대상 강조 */
crossDayDrop?: {
props?: {
onDragOver: (e: DragEvent<Element>) => void;
onDragLeave: (e: DragEvent<Element>) => void;
onDrop: (e: DragEvent<Element>) => void;
};
isTarget: boolean;
};
};

Expand Down Expand Up @@ -88,7 +90,7 @@ export function PlanDaySection({
sectionRef,
sectionStyle,
dragHandleProps,
crossDaySectionDropProps,
crossDayDrop,
}: PlanDaySectionProps) {
const panelId = useId();

Expand Down Expand Up @@ -142,18 +144,6 @@ export function PlanDaySection({
const dragDisabled = dragHandleProps?.dragDisabled ?? true;
const isDragging = dragHandleProps?.isDragging ?? false;

const sourceScheduleId = usePlanItemCrossDayDragStore(
(s) => s.sourceScheduleId,
);
const hoverTargetScheduleId = usePlanItemCrossDayDragStore(
(s) => s.hoverTargetScheduleId,
);
const isCrossDayItemDropTarget =
trackedSid !== undefined &&
sourceScheduleId !== null &&
sourceScheduleId !== trackedSid &&
hoverTargetScheduleId === trackedSid;

const showScheduleMenu =
onRequestAddExpense != null || onRequestDeleteSchedule != null || onRequestInsertScheduleAfter != null;

Expand All @@ -163,11 +153,11 @@ export function PlanDaySection({
<section
ref={sectionRef}
style={sectionStyle}
{...crossDaySectionDropProps}
{...crossDayDrop?.props}
className={cn(
"rounded-2xl border border-gray-border bg-white",
isDragging && "border-primary/80 ring-4 ring-inset ring-primary/45",
isCrossDayItemDropTarget &&
crossDayDrop?.isTarget &&
"border-primary ring-3 ring-inset ring-primary/50",
)}
aria-label={subtitle ? `${title} ${subtitle}` : title}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";

import { PlanItemDragProvider, type PlanItemDragValue } from "@/hooks/usePlanItemDragController";
import type { PlanPlace } from "@/lib/plan/types";

import { PlanItinerary } from "./PlanItinerary";

const fixtures = vi.hoisted(() => ({
places: [] as PlanPlace[],
savingDays: [] as number[],
travel: [] as { segmentSourceItemId: number; routeQueryEnabled?: boolean }[],
}));
vi.mock("@/hooks/useRooms", () => ({
useSchedulePlanPlaces: () => ({ data: fixtures.places, isLoading: false, isFetching: false, isError: false }),
useCreateScheduleItem: () => ({ mutateAsync: vi.fn(), isPending: false }),
useScheduleItemMoveSavingDays: () => fixtures.savingDays,
}));
vi.mock("@/hooks/usePrefetchScheduleRoutes", () => ({ usePrefetchScheduleRoutes: () => true }));
vi.mock("../travel-time/PlanTravelTime", () => ({
PlanTravelTime: (props: { segmentSourceItemId: number; routeQueryEnabled?: boolean }) => {
fixtures.travel.push(props);
return null;
},
}));
vi.mock("./PlanPlaceCard", () => ({ PlanPlaceCard: () => null }));
vi.mock("./PlanAddPlaceControls", () => ({ PlanAddPlaceControls: () => null }));
vi.mock("./AddFromBookmarkModal", () => ({ AddFromBookmarkModal: () => null }));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });

const drag: PlanItemDragValue = {
getListProps: () => ({
dropZoneProps: { "data-plan-itinerary-drop-zone": "", onDragOver: () => {}, onDragLeave: () => {}, onDrop: () => {} },
isItemDragActive: false,
reservePaddingBottom: 0,
getRowProps: () => ({
ref: () => {},
style: undefined,
placeCardDragProps: { displayOrderIndex: 1, isDragging: false, dragDisabled: false, onDragStart: () => {}, onDragEnd: () => {} },
}),
}),
getSectionDrop: () => ({ props: { onDragOver: () => {}, onDragLeave: () => {}, onDrop: () => {} }, isTarget: false }),
};

const place = (itemId: number): PlanPlace => ({ id: `item-${itemId}`, itemId, googlePlaceId: `g${itemId}`, title: `장소 ${itemId}` });

it("does not query segment routes of a day while its place move is saving — the server still has the old order", async () => {
fixtures.places = [place(2), place(1)];
const host = document.createElement("div");
const root = createRoot(host);
const render = () =>
act(async () => {
root.render(
<PlanItemDragProvider value={drag}>
<PlanItinerary roomId="room-1" scheduleId={10} />
</PlanItemDragProvider>,
);
});
try {
// 순서 변경을 저장하는 중 — 캐시는 이미 새 순서(2 → 1)다
fixtures.savingDays = [10];
fixtures.travel = [];
await render();
expect(fixtures.travel.at(-1)).toMatchObject({ segmentSourceItemId: 2, routeQueryEnabled: false });

// 다른 일차를 저장하는 중이면 이 일차는 조회한다
fixtures.savingDays = [20];
await render();
expect(fixtures.travel.at(-1)).toMatchObject({ segmentSourceItemId: 2, routeQueryEnabled: true });

// 저장이 끝나면 조회한다
fixtures.savingDays = [];
await render();
expect(fixtures.travel.at(-1)).toMatchObject({ segmentSourceItemId: 2, routeQueryEnabled: true });
} finally {
await act(async () => root.unmount());
}
});
46 changes: 14 additions & 32 deletions src/app/(main)/plan/_components/itinerary/PlanItinerary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,24 @@
import { Spinner } from "@/components/ui/spinner";

import { useCallback, useEffect, useMemo, useState } from "react";
import { useReducedMotion } from "framer-motion";
import { PlanLoadingSkeleton } from "../PlanLoadingSkeleton";
import { toast } from "sonner";

import {
useCreateScheduleItem,
useScheduleItemMoveSavingDays,
useSchedulePlanPlaces,
} from "@/hooks/useRooms";
import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track";
import { bucketItemCount } from "@/lib/analytics/context";
import { usePrefetchScheduleRoutes } from "@/hooks/usePrefetchScheduleRoutes";
import { usePlanItineraryReorder } from "@/hooks/usePlanItineraryReorder";
import { PLAN_ITEM_ITINERARY_DROP_ZONE_ATTR } from "@/lib/plan/planItemReorder";
import { usePlanItemDrag } from "@/hooks/usePlanItemDragController";
import { useMapCenterStore } from "@/stores/map-center-store";
import { schedulePlacesFingerprint } from "@/lib/plan/planTravelLocalStorage";
import {
scheduleIdsToRouteColors,
sortedScheduleIdsForRouteColors,
} from "@/lib/plan/planRouteDayColors";
import { usePlanItemCrossDayDragStore } from "@/stores/plan-item-cross-day-drag-store";
import { usePlanScheduleRouteVisibilityStore } from "@/stores/plan-schedule-route-visibility-store";
import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -86,6 +84,10 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) {
routeQueryEnabled,
);

// 장소 이동을 저장하는 동안 캐시는 새 순서지만 서버는 아직 옛 순서다 — 지금 새 구간 경로를 받으면
// 옛 이웃까지의 경로가 새 구간 key에 남는다. 저장이 끝나면(다시 받은 목록으로) 조회한다
const isSavingPlaceMove = useScheduleItemMoveSavingDays(roomId).includes(scheduleId);

const existingItemIds = useMemo(
() =>
new Set(
Expand All @@ -105,20 +107,8 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) {
insertIndex?: number;
} | null>(null);

const prefersReducedMotion = useReducedMotion();

const {
getRowProps,
listContainerProps,
isDraggingActive,
listReservePaddingBottom,
} = usePlanItineraryReorder({
roomId,
scheduleId,
places,
interactionLocked:
isAdding || activeInsertIndex !== null,
});
const { dropZoneProps, isItemDragActive, reservePaddingBottom, getRowProps } =
usePlanItemDrag().getListProps(scheduleId, places);

useEffect(() => {
if (activeInsertIndex === null) return;
Expand Down Expand Up @@ -171,18 +161,13 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) {

const addControlsDisabled = isAdding;

const crossDayItemDragActive = usePlanItemCrossDayDragStore(
(s) => s.sourceScheduleId !== null,
);

return (
<div
{...listContainerProps}
{...{ [PLAN_ITEM_ITINERARY_DROP_ZONE_ATTR]: "" }}
{...dropZoneProps}
className={cn(
!isLoading &&
places.length === 0 &&
crossDayItemDragActive &&
isItemDragActive &&
"min-h-12",
)}
>
Expand All @@ -205,17 +190,13 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) {
<div
className="flex flex-col gap-0"
style={
listReservePaddingBottom > 0
? { paddingBottom: listReservePaddingBottom }
reservePaddingBottom > 0
? { paddingBottom: reservePaddingBottom }
: undefined
}
>
{places.map((place, index) => {
const motionEnabled = isDraggingActive && !prefersReducedMotion;
const { ref, style, placeCardDragProps } = getRowProps(
index,
motionEnabled,
);
const { ref, style, placeCardDragProps } = getRowProps(index);
const insertIndex = index + 1;
const segmentActive = activeInsertIndex === insertIndex;
const nextItemId = places[index + 1]?.itemId;
Expand Down Expand Up @@ -275,6 +256,7 @@ export function PlanItinerary({ roomId, scheduleId }: PlanItineraryProps) {
routeQueryEnabled={
placesData !== undefined &&
!isFetchingPlaces &&
!isSavingPlaceMove &&
routesBatchSettled &&
existingItemIds.has(place.itemId) &&
existingItemIds.has(nextItemId)
Expand Down
69 changes: 38 additions & 31 deletions src/app/(main)/plan/_components/itinerary/PlanPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import { MainPageHeader } from "@/components/layout/MainPageHeader";
import { ConfirmDialog } from "@/components/settings/ConfirmDialog";

import { usePlanScheduleDayReorder } from "@/hooks/usePlanScheduleDayReorder";
import {
PlanItemDragProvider,
usePlanItemDragController,
} from "@/hooks/usePlanItemDragController";
import { planCopy } from "@/lib/plan/planCopy";
import { useRoomDetail } from "@/hooks/useRoomDetail";
import {
Expand Down Expand Up @@ -93,9 +97,11 @@ export function PlanPageView() {
usePlanScheduleDayReorder({
roomId,
schedules: sortedSchedules,
interactionLocked: isCreatingSchedule || isDeletingSchedule,
});

// 장소 드래그는 여러 일차에 걸치므로 페이지에서 한 벌만 둔다(모바일은 MobilePlanView의 컨트롤러)
const planItemDrag = usePlanItemDragController(roomId);

const scheduleExpansionSyncKey = useMemo(
() => sortedSchedules.map((s) => s.scheduleId).join(","),
[sortedSchedules],
Expand Down Expand Up @@ -257,37 +263,38 @@ export function PlanPageView() {
) : null}

{planDays.length > 0 ? (
<div className="space-y-2.5" {...listContainerProps}>
{planDays.map((day, dayIndex) => {
const sectionProps = getSectionProps(dayIndex);
<PlanItemDragProvider value={planItemDrag}>
<div className="space-y-2.5" {...listContainerProps}>
{planDays.map((day, dayIndex) => {
const sectionProps = getSectionProps(dayIndex);

return (
<PlanScheduleDayBlock
key={day.id}
roomId={roomId}
scheduleId={sortedSchedules[dayIndex]!.scheduleId}
title={day.dayLabel}
subtitle={day.dateLabel}
dateHeading={day.shortDateLabel}
onRequestDeleteSchedule={
sortedSchedules[dayIndex]
? () => handleDeleteScheduleDay(dayIndex)
: undefined
}
onRequestInsertScheduleAfter={
sortedSchedules[dayIndex]
? () => handleInsertScheduleAfter(dayIndex)
: undefined
}
isScheduleMenuDisabled={
isCreatingSchedule || isDeletingSchedule || isMovePending
}
interactionLocked={isCreatingSchedule || isDeletingSchedule}
{...sectionProps}
/>
);
})}
</div>
return (
<PlanScheduleDayBlock
key={day.id}
roomId={roomId}
scheduleId={sortedSchedules[dayIndex]!.scheduleId}
title={day.dayLabel}
subtitle={day.dateLabel}
dateHeading={day.shortDateLabel}
onRequestDeleteSchedule={
sortedSchedules[dayIndex]
? () => handleDeleteScheduleDay(dayIndex)
: undefined
}
onRequestInsertScheduleAfter={
sortedSchedules[dayIndex]
? () => handleInsertScheduleAfter(dayIndex)
: undefined
}
isScheduleMenuDisabled={
isCreatingSchedule || isDeletingSchedule || isMovePending
}
{...sectionProps}
/>
);
})}
</div>
</PlanItemDragProvider>
) : null}
</div>
{deleteDayDialog}
Expand Down
Loading
Loading