From afdb6ecea5bdf1610b862179335b5fa10bd381fc Mon Sep 17 00:00:00 2001 From: minbros Date: Wed, 28 Oct 2026 01:39:16 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(plan):=20=EC=9D=BC=EC=A0=95=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=9E=90=EB=8F=99=20=EC=9E=85=EB=A0=A5=EA=B3=BC=20?= =?UTF-8?q?=EC=9E=90=EC=A0=84=EA=B1=B0=20=EB=8C=80=EC=B2=B4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 순서 변경 시 설정된 시간과 미설정 값을 보존 - 명시적 시간 선택만 저장하고 미지원 이동수단 자동 변환 제거 --- .../itinerary/PlanItemTimeForm.test.tsx | 12 ++ .../itinerary/TimeWheelPicker.test.tsx | 22 ++++ .../_components/itinerary/TimeWheelPicker.tsx | 39 ++++-- .../mobile/MobileTimeSheet.test.tsx | 84 +++++++++++++ .../_components/mobile/MobileTimeSheet.tsx | 31 ++--- .../travel-time/PlanTravelTime.test.tsx | 45 +++++++ .../travel-time/PlanTravelTime.tsx | 18 ++- .../travel-time/TravelDirectionsCard.test.tsx | 4 - .../travel-time/TravelDirectionsCard.tsx | 36 +----- .../travel-time/TravelModeGlyph.tsx | 2 +- src/hooks/useRooms.ts | 30 +---- src/lib/api/rooms/schedule-items.ts | 2 +- src/lib/google-maps-url.test.ts | 2 +- src/lib/maps.ts | 4 +- src/lib/plan/planItineraryMapSegments.test.ts | 4 +- src/lib/plan/planItineraryMapSegments.ts | 7 +- src/lib/plan/schedule-bulk-hydration.test.ts | 4 +- src/lib/plan/schedule-bulk-hydration.ts | 11 +- .../plan/scheduleItemPlaces.create.test.ts | 42 ++++++- src/lib/plan/scheduleItemPlaces.test.ts | 16 --- src/lib/plan/scheduleItemPlaces.ts | 111 +----------------- .../plan/scheduleItemRoutePersistedQuery.ts | 6 +- src/lib/plan/scheduleTime.ts | 19 --- src/lib/plan/scheduleTravelMode.test.ts | 9 +- src/lib/plan/scheduleTravelMode.ts | 25 +--- 25 files changed, 289 insertions(+), 296 deletions(-) create mode 100644 src/app/(main)/plan/_components/itinerary/TimeWheelPicker.test.tsx create mode 100644 src/app/(main)/plan/_components/mobile/MobileTimeSheet.test.tsx create mode 100644 src/app/(main)/plan/_components/travel-time/PlanTravelTime.test.tsx diff --git a/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.test.tsx b/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.test.tsx index b845c5d5..26d7f2e6 100644 --- a/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.test.tsx +++ b/src/app/(main)/plan/_components/itinerary/PlanItemTimeForm.test.tsx @@ -229,3 +229,15 @@ describe("web time editing", () => { expect(mocks.error).toHaveBeenCalledWith("synthetic failure"); }); }); + + +it("opening and focusing unset desktop fields never enables saving or sends a PATCH", async () => { + props = { ...props, startTime: "", endTime: null }; + await render(); + await act(async () => { input(0).focus(); input(1).focus(); }); + expect(fields.map((_, i) => selected(i))).toEqual(["", "", "", ""]); + const apply = [...container.querySelectorAll("button")].find(b => b.textContent === "적용")!; + expect(apply.disabled).toBe(true); + await click("적용"); + expect(mocks.mutateAsync).not.toHaveBeenCalled(); +}); diff --git a/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.test.tsx b/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.test.tsx new file mode 100644 index 00000000..8614019c --- /dev/null +++ b/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.test.tsx @@ -0,0 +1,22 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; +import { TimeWheelPicker } from "./TimeWheelPicker"; + +it("the real wheel widget keeps empty parts on mount and focus without emitting a time", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + const change = vi.fn(); + try { + await act(async () => root.render( {}} />)); + const wheels = host.querySelectorAll("[data-rwp]"); + expect(wheels).toHaveLength(3); + await act(async () => { wheels[0].focus(); wheels[1].focus(); wheels[2].focus(); }); + expect(change).not.toHaveBeenCalled(); + for (const selected of host.querySelectorAll("[data-rwp-highlight-list]")) expect(selected.textContent).toContain("--"); + } finally { + await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals(); + } +}); diff --git a/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx b/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx index 56ebcb59..27c4b750 100644 --- a/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx +++ b/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx @@ -1,5 +1,7 @@ "use client"; +import { useState } from "react"; + import { WheelPicker, WheelPickerWrapper, @@ -7,25 +9,28 @@ import { } from "@ncdai/react-wheel-picker"; type Period = "AM" | "PM"; +type TimeParts = { period: Period | ""; hour: number | ""; minute: number | "" }; /** Figma Time Picker Base — 행 44px, 5행(≈220px)이 보이도록 링 개수 16 */ const OPTION_HEIGHT = 44; const VISIBLE_COUNT = 16; -const PERIOD_OPTIONS: WheelPickerOption[] = [ +const PERIOD_OPTIONS: WheelPickerOption[] = [ + { value: "", label: "--" }, { value: "AM", label: "오전" }, { value: "PM", label: "오후" }, ]; -const HOUR_OPTIONS: WheelPickerOption[] = Array.from( +const HOUR_OPTIONS: WheelPickerOption[] = [{ value: "", label: "--" }, ...Array.from( { length: 12 }, (_, i) => ({ value: i, label: String(i) }), -); -const MINUTE_OPTIONS: WheelPickerOption[] = Array.from( +)]; +const MINUTE_OPTIONS: WheelPickerOption[] = [{ value: "", label: "--" }, ...Array.from( { length: 60 }, (_, i) => ({ value: i, label: String(i).padStart(2, "0") }), -); +)]; -function splitHm(hm: string): { period: Period; hour: number; minute: number } { +function splitHm(hm: string | null): TimeParts { + if (!hm) return { period: "", hour: "", minute: "" }; const [h, m] = hm.split(":").map(Number); return { period: h < 12 ? "AM" : "PM", hour: h % 12, minute: m }; } @@ -42,14 +47,24 @@ export function TimeWheelPicker({ active, onInteract, }: Readonly<{ - value: string; - onChange: (hm: string) => void; + /** 빈 문자열은 미설정, null은 아직 완성하지 않은 입력입니다. */ + value: string | null; + onChange: (hm: string | null) => void; /** 편집 중인 필드가 있을 때만 중앙 값을 진하게 표시 */ active: boolean; /** 휠을 건드리는 순간 — 편집 대상 필드가 없으면 부모가 지정 */ onInteract: () => void; }>) { - const { period, hour, minute } = splitHm(value); + const [partial, setPartial] = useState(() => splitHm(value)); + const parts = value ? splitHm(value) : partial; + const { period, hour, minute } = parts; + function changePart(patch: Partial) { + const next = { ...parts, ...patch }; + setPartial(next); + onChange(next.period !== "" && next.hour !== "" && next.minute !== "" + ? joinHm(next.period, next.hour, next.minute) + : next.period === "" && next.hour === "" && next.minute === "" ? "" : null); + } const classNames = { optionItem: "text-heading-s text-text-disabled", highlightWrapper: "bg-fill text-heading-s", @@ -80,7 +95,7 @@ export function TimeWheelPicker({ {...shared} options={PERIOD_OPTIONS} value={period} - onValueChange={(next) => onChange(joinHm(next, hour, minute))} + onValueChange={(next) => changePart({ period: next })} />
@@ -89,7 +104,7 @@ export function TimeWheelPicker({ infinite options={HOUR_OPTIONS} value={hour} - onValueChange={(next) => onChange(joinHm(period, next, minute))} + onValueChange={(next) => changePart({ hour: next })} />
@@ -98,7 +113,7 @@ export function TimeWheelPicker({ infinite options={MINUTE_OPTIONS} value={minute} - onValueChange={(next) => onChange(joinHm(period, hour, next))} + onValueChange={(next) => changePart({ minute: next })} />
diff --git a/src/app/(main)/plan/_components/mobile/MobileTimeSheet.test.tsx b/src/app/(main)/plan/_components/mobile/MobileTimeSheet.test.tsx new file mode 100644 index 00000000..99dbdd1f --- /dev/null +++ b/src/app/(main)/plan/_components/mobile/MobileTimeSheet.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +import { act, type ComponentProps, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const save = vi.hoisted(() => vi.fn()); +vi.mock("@/hooks/useRooms", () => ({ useUpdateScheduleItem: () => ({ mutateAsync: save, isPending: false }) })); +// Only replace the external scrolling widget: the sheet, draft logic and time picker remain real. +vi.mock("@ncdai/react-wheel-picker", () => ({ + WheelPickerWrapper: ({ children }: { children: ReactNode }) =>
{children}
, + WheelPicker: ({ options, value, onValueChange }: { + options: Array<{ value: string | number; label: string }>; value: string | number; + onValueChange: (value: string | number) => void; + }) => , +})); +import { MobileTimeSheet } from "./MobileTimeSheet"; +let root: Root; +let container: HTMLDivElement; +let props: ComponentProps; +const button = (label: string) => [...document.querySelectorAll("button")].find(b => b.textContent === label || b.getAttribute("aria-label")?.startsWith(label))!; +async function click(label: string) { await act(async () => button(label).click()); } +async function render() { await act(async () => root.render()); } +async function choose(group: string, value: string) { + const el = document.querySelector(`[aria-label="${group}"] select`)!; + await act(async () => { el.value = value; el.dispatchEvent(new Event("change", { bubbles: true })); }); +} +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + save.mockReset().mockResolvedValue({}); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + props = { roomId: "room", scheduleId: 10, itemId: 1, placeName: "Place", startTime: null, endTime: null, onClose: vi.fn() }; +}); +afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.unstubAllGlobals(); }); +it("opening an unset time leaves both unset and cannot save", async () => { + await render(); + expect(button("시작 시간").getAttribute("aria-label")).toContain("미설정"); + expect(button("종료 시간").getAttribute("aria-label")).toContain("미설정"); + expect(button("확인").disabled).toBe(true); + await click("확인"); expect(save).not.toHaveBeenCalled(); +}); +it("opening an unset end does not invent a duration", async () => { + props = { ...props, startTime: "14:30" }; await render(); await click("종료 시간"); + expect(button("종료 시간").getAttribute("aria-label")).toContain("미설정"); + expect(button("확인").disabled).toBe(true); expect(save).not.toHaveBeenCalled(); +}); +it("requires explicit period, hour and minute before saving start only", async () => { + await render(); + await choose("시", "0"); expect(button("확인").disabled).toBe(true); + await choose("분", "0"); expect(button("확인").disabled).toBe(true); + await choose("오전/오후", "AM"); await click("확인"); + expect(save).toHaveBeenCalledExactlyOnceWith({ roomId: "room", scheduleId: 10, itemId: 1, body: { startTime: "00:00" } }); +}); +it("explicitly sets an overnight end without rewriting start", async () => { + props = { ...props, startTime: "23:15" }; await render(); await click("종료 시간"); + await choose("오전/오후", "AM"); await choose("시", "1"); await choose("분", "5"); await click("확인"); + expect(save.mock.calls[0][0].body).toEqual({ endTime: "01:05" }); +}); +it("changes an existing time without altering its unset end", async () => { + props = { ...props, startTime: "09:07" }; await render(); await choose("분", "8"); await click("확인"); + expect(save.mock.calls[0][0].body).toEqual({ startTime: "09:08" }); +}); +it.each(["취소", "닫기"])("%s never saves edits or deletions", async label => { + props = { ...props, startTime: "09:07", endTime: "10:13" }; await render(); await click("시간 지우기"); await click(label); + expect(save).not.toHaveBeenCalled(); expect(props.onClose).toHaveBeenCalledOnce(); +}); +it("deletes both times only on confirmation", async () => { + props = { ...props, startTime: "09:07", endTime: "10:13" }; await render(); await click("시간 지우기"); + expect(save).not.toHaveBeenCalled(); await click("확인"); + expect(save.mock.calls[0][0].body).toEqual({ startTime: null, endTime: null }); +}); +it("cannot save a start change while the end has only been partially chosen", async () => { + props = { ...props, startTime: "09:07" }; await render(); + await choose("분", "8"); await click("종료 시간"); await choose("오전/오후", "AM"); + expect(button("확인").disabled).toBe(true); + await click("확인"); expect(save).not.toHaveBeenCalled(); +}); +it("a partially cleared existing end is not silently saved as deletion", async () => { + props = { ...props, startTime: "09:07", endTime: "10:13" }; await render(); + await click("종료 시간"); await choose("분", ""); + expect(button("확인").disabled).toBe(true); + await click("확인"); expect(save).not.toHaveBeenCalled(); +}); diff --git a/src/app/(main)/plan/_components/mobile/MobileTimeSheet.tsx b/src/app/(main)/plan/_components/mobile/MobileTimeSheet.tsx index 5414f7f2..1f2e6c70 100644 --- a/src/app/(main)/plan/_components/mobile/MobileTimeSheet.tsx +++ b/src/app/(main)/plan/_components/mobile/MobileTimeSheet.tsx @@ -7,10 +7,10 @@ import { LoadingIndicator } from "@/components/loading/LoadingIndicator"; import { MobileBottomSheet } from "@/components/mobile/MobileBottomSheet"; import { useUpdateScheduleItem } from "@/hooks/useRooms"; import { - addMinutesToHm, computeDurationMinutesFromRange, normalizeStartTimeToHm, validateScheduleTimeDraft, + type ScheduleTimeDraftValidation, } from "@/lib/plan/scheduleTime"; import { cn } from "@/lib/utils"; @@ -30,10 +30,9 @@ type MobileTimeSheetProps = Readonly<{ onClose: () => void; }>; -const DEFAULT_HM = "10:00"; - /** "13:05" → "오후 1:05" — 휠과 같이 시를 0~11로 표기 */ -function formatHm12h(hm: string): string { +function formatHm12h(hm: string | null): string { + if (hm === null) return "선택 중"; if (!hm) return ""; const [h, m] = hm.split(":").map(Number); return `${h < 12 ? "오전" : "오후"} ${h % 12}:${String(m).padStart(2, "0")}`; @@ -52,28 +51,29 @@ export function MobileTimeSheet({ }: MobileTimeSheetProps) { const serverStart = normalizeStartTimeToHm(startTime ?? ""); const serverEnd = normalizeStartTimeToHm(endTime ?? ""); - const [start, setStart] = useState(serverStart || DEFAULT_HM); - const [end, setEnd] = useState(serverEnd); + const [draftStart, setStart] = useState(); + const [draftEnd, setEnd] = useState(); + const start = draftStart === undefined ? serverStart : draftStart; + const end = draftEnd === undefined ? serverEnd : draftEnd; const [activeField, setActiveField] = useState("start"); const { mutateAsync, isPending } = useUpdateScheduleItem(); const dirty = start !== serverStart || end !== serverEnd; - const duration = computeDurationMinutesFromRange(start, end); + const duration = computeDurationMinutesFromRange(start ?? "", end ?? ""); const subtitle = [ dateLabel, - duration != null && end < start ? "다음 날 종료(+1일)" : null, + duration != null && end !== null && start !== null && end < start ? "다음 날 종료(+1일)" : null, duration != null ? `${Math.floor(duration / 60)}시간 ${duration % 60}분` : null, ] .filter(Boolean) .join(" · "); - const validation = validateScheduleTimeDraft(start, end); + const validation: ScheduleTimeDraftValidation = start === null || end === null + ? { valid: false, message: "오전/오후, 시, 분을 모두 선택해 주세요." } + : validateScheduleTimeDraft(start, end); function openField(field: Field) { if (field === "end" && !start) return; setActiveField((current) => (current === field ? null : field)); - // 비어 있는 칸을 처음 열면 휠 기본값으로 채운다 - if (field === "start" && !start) setStart(DEFAULT_HM); - if (field === "end" && !end) setEnd(addMinutesToHm(start, 60)); } async function handleSave() { @@ -99,7 +99,7 @@ export function MobileTimeSheet({ } } - const fields: { key: Field; label: string; value: string }[] = [ + const fields: { key: Field; label: string; value: string | null }[] = [ { key: "start", label: "시작 시간", value: start }, { key: "end", label: "종료 시간", value: end }, ]; @@ -149,7 +149,8 @@ export function MobileTimeSheet({ {activeField ? (
{}} @@ -157,7 +158,7 @@ export function MobileTimeSheet({
) : null} - {start || end ? ( + {start !== "" || end !== "" ? (