From 20be644576d61fbfee10a3ad3f8fd11cba7147d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKangShinGyu=E2=80=9D?= <“rkdtlseb@naver.com”> Date: Mon, 5 Oct 2026 15:58:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(time-picker):=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=ED=83=80=EC=9E=84=ED=94=BC=EC=BB=A4=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 13 + package.json | 1 + .../_components/itinerary/TimeWheelPicker.tsx | 285 ++++++------------ .../mobile/MobilePlanDaySection.tsx | 4 + .../mobile/MobilePlanItinerary.tsx | 4 + .../_components/mobile/MobilePlanView.tsx | 3 +- .../_components/mobile/MobileTimeSheet.tsx | 139 ++++++--- src/app/globals.css | 2 + src/components/mobile/MobileBottomSheet.tsx | 2 +- src/lib/plan/mobilePlanFormat.test.ts | 13 +- src/lib/plan/mobilePlanFormat.ts | 7 + 11 files changed, 223 insertions(+), 250 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1d16790e..675fe989 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@amplitude/unified": "^1.1.28", + "@ncdai/react-wheel-picker": "^1.2.3", "@next/third-parties": "^16.2.1", "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.95.2", @@ -3211,6 +3212,18 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@ncdai/react-wheel-picker": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@ncdai/react-wheel-picker/-/react-wheel-picker-1.2.3.tgz", + "integrity": "sha512-TQjDfuDH9Ss7sgacSRPnakqWfkR8E1w6g7Pkt7bTYX35SfdHbGSzC8g/lXKFeMyNIikOWD9wC3uFPLy+Ui/R0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ncdai" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/@next/env": { "version": "16.2.1", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz", diff --git a/package.json b/package.json index c327e613..9432c968 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@amplitude/unified": "^1.1.28", + "@ncdai/react-wheel-picker": "^1.2.3", "@next/third-parties": "^16.2.1", "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.95.2", diff --git a/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx b/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx index 2b1d75ca..56ebcb59 100644 --- a/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx +++ b/src/app/(main)/plan/_components/itinerary/TimeWheelPicker.tsx @@ -1,216 +1,107 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef } from "react"; - -import { cn } from "@/lib/utils"; - -const ITEM_HEIGHT = 40; -const VISIBLE_ROWS = 5; -const CENTER_INDEX = Math.floor(VISIBLE_ROWS / 2); -const CONTAINER_HEIGHT = VISIBLE_ROWS * ITEM_HEIGHT; -const CENTER_PADDING = CENTER_INDEX * ITEM_HEIGHT; -const SCROLL_SETTLE_MS = 110; - -type WheelColumnProps = { - items: number[]; - value: number; - onChange: (next: number) => void; - ariaLabel: string; - disabled?: boolean; -}; +import { + WheelPicker, + WheelPickerWrapper, + type WheelPickerOption, +} from "@ncdai/react-wheel-picker"; + +type Period = "AM" | "PM"; + +/** Figma Time Picker Base — 행 44px, 5행(≈220px)이 보이도록 링 개수 16 */ +const OPTION_HEIGHT = 44; +const VISIBLE_COUNT = 16; + +const PERIOD_OPTIONS: WheelPickerOption[] = [ + { value: "AM", label: "오전" }, + { value: "PM", label: "오후" }, +]; +const HOUR_OPTIONS: WheelPickerOption[] = Array.from( + { length: 12 }, + (_, i) => ({ value: i, label: String(i) }), +); +const MINUTE_OPTIONS: WheelPickerOption[] = Array.from( + { length: 60 }, + (_, i) => ({ value: i, label: String(i).padStart(2, "0") }), +); + +function splitHm(hm: string): { period: Period; hour: number; minute: number } { + const [h, m] = hm.split(":").map(Number); + return { period: h < 12 ? "AM" : "PM", hour: h % 12, minute: m }; +} -function nearestIndex(items: number[], value: number): number { - let best = 0; - let bestDelta = Number.POSITIVE_INFINITY; - for (let i = 0; i < items.length; i += 1) { - const d = Math.abs(items[i]! - value); - if (d < bestDelta) { - best = i; - bestDelta = d; - } - } - return best; +function joinHm(period: Period, hour: number, minute: number): string { + const h = hour + (period === "PM" ? 12 : 0); + return `${String(h).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; } -function WheelColumn({ - items, +/** 12시간제 휠 — `value`/`onChange`는 24시간 HH:mm */ +export function TimeWheelPicker({ value, onChange, - ariaLabel, - disabled = false, -}: WheelColumnProps) { - const scrollRef = useRef(null); - const settleTimerRef = useRef(null); - const suppressScrollRef = useRef(false); - - const selectedIndex = nearestIndex(items, value); - - const scrollToIndex = useCallback((index: number, smooth: boolean) => { - const el = scrollRef.current; - if (!el) return; - const target = index * ITEM_HEIGHT; - if (Math.abs(el.scrollTop - target) < 0.5) return; - suppressScrollRef.current = true; - el.scrollTo({ top: target, behavior: smooth ? "smooth" : "auto" }); - window.setTimeout(() => { - suppressScrollRef.current = false; - }, smooth ? 260 : 40); - }, []); - - useEffect(() => { - scrollToIndex(selectedIndex, false); - }, [selectedIndex, scrollToIndex]); - - useEffect(() => { - return () => { - if (settleTimerRef.current !== null) { - window.clearTimeout(settleTimerRef.current); - } - }; - }, []); - - const commit = useCallback( - (index: number) => { - const clamped = Math.max(0, Math.min(items.length - 1, index)); - onChange(items[clamped]!); - }, - [items, onChange], - ); - - const handleScroll = () => { - if (suppressScrollRef.current) return; - const el = scrollRef.current; - if (!el) return; - if (settleTimerRef.current !== null) { - window.clearTimeout(settleTimerRef.current); - } - settleTimerRef.current = window.setTimeout(() => { - const idx = Math.round(el.scrollTop / ITEM_HEIGHT); - commit(idx); - }, SCROLL_SETTLE_MS); + active, + onInteract, +}: Readonly<{ + value: string; + onChange: (hm: string) => void; + /** 편집 중인 필드가 있을 때만 중앙 값을 진하게 표시 */ + active: boolean; + /** 휠을 건드리는 순간 — 편집 대상 필드가 없으면 부모가 지정 */ + onInteract: () => void; +}>) { + const { period, hour, minute } = splitHm(value); + const classNames = { + optionItem: "text-heading-s text-text-disabled", + highlightWrapper: "bg-fill text-heading-s", + highlightItem: active ? "text-text" : "text-text-disabled", }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (disabled) return; - if (e.key === "ArrowUp") { - e.preventDefault(); - commit(selectedIndex - 1); - } else if (e.key === "ArrowDown") { - e.preventDefault(); - commit(selectedIndex + 1); - } else if (e.key === "PageUp") { - e.preventDefault(); - commit(selectedIndex - 5); - } else if (e.key === "PageDown") { - e.preventDefault(); - commit(selectedIndex + 5); - } else if (e.key === "Home") { - e.preventDefault(); - commit(0); - } else if (e.key === "End") { - e.preventDefault(); - commit(items.length - 1); - } + const shared = { + classNames, + optionItemHeight: OPTION_HEIGHT, + visibleCount: VISIBLE_COUNT, }; return (
{ + if (e.key !== "Tab" && e.key !== "Escape") onInteract(); }} > -
- {items.map((v, i) => { - const isSelected = i === selectedIndex; - return ( - - ); - })} -
-
- ); -} - -export type TimeWheelValue = { hour: number; minute: number }; - -type TimeWheelPickerProps = { - value: TimeWheelValue; - onChange: (next: TimeWheelValue) => void; - disabled?: boolean; -}; - -export function TimeWheelPicker({ - value, - onChange, - disabled, -}: TimeWheelPickerProps) { - const hours = useMemo(() => Array.from({ length: 24 }, (_, i) => i), []); - const minutes = useMemo(() => Array.from({ length: 60 }, (_, i) => i), []); - - return ( -
- -
- onChange({ hour: h, minute: value.minute })} - ariaLabel="시" - disabled={disabled} - /> - - : - - onChange({ hour: value.hour, minute: m })} - ariaLabel="분" - disabled={disabled} - /> -
+ +
+ onChange(joinHm(next, hour, minute))} + /> +
+
+ onChange(joinHm(period, next, minute))} + /> +
+
+ onChange(joinHm(period, hour, next))} + /> +
+
); } diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx index 9566de6e..44466517 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanDaySection.tsx @@ -27,6 +27,8 @@ type MobilePlanDaySectionProps = Readonly<{ dayLabel: string; /** `09. 21 수` */ dateLabel: string; + /** `9월 21일` — 방문 시간 시트에 표시 */ + monthDayLabel: string; menuDisabled: boolean; /** 순서 편집 모드 — 모든 일차가 함께 들어가고 나온다 */ editing: boolean; @@ -43,6 +45,7 @@ export function MobilePlanDaySection({ scheduleId, dayLabel, dateLabel, + monthDayLabel, menuDisabled, editing, onStartEditing, @@ -104,6 +107,7 @@ export function MobilePlanDaySection({ onRequestAddPlace(scheduleId, places)} /> diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx index 4222fd56..6fd9f315 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanItinerary.tsx @@ -54,6 +54,8 @@ type SheetState = type MobilePlanItineraryProps = Readonly<{ roomId: string; scheduleId: number; + /** `9월 21일` — 방문 시간 시트에 표시 */ + monthDayLabel?: string; /** 전체 화면 검색으로 이 일차에 장소 추가 — 탭 제스처 안에서 호출해야 한다 */ onRequestAddPlace: (places: PlanPlace[]) => void; }>; @@ -62,6 +64,7 @@ type MobilePlanItineraryProps = Readonly<{ export function MobilePlanItinerary({ roomId, scheduleId, + monthDayLabel, onRequestAddPlace, }: MobilePlanItineraryProps) { const expenses = useExpenseContext(); @@ -256,6 +259,7 @@ export function MobilePlanItinerary({ scheduleId={scheduleId} itemId={sheetItemId} placeName={sheetPlace.title} + dateLabel={monthDayLabel} startTime={sheetPlace.startTime} endTime={sheetPlace.endTime} onClose={() => setSheet(null)} diff --git a/src/app/(main)/plan/_components/mobile/MobilePlanView.tsx b/src/app/(main)/plan/_components/mobile/MobilePlanView.tsx index f3023d91..39da4092 100644 --- a/src/app/(main)/plan/_components/mobile/MobilePlanView.tsx +++ b/src/app/(main)/plan/_components/mobile/MobilePlanView.tsx @@ -10,7 +10,7 @@ import type { RoomSchedule } from "@/lib/api/rooms/schedules"; import { bucketItemCount } from "@/lib/analytics/context"; import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track"; import type { MapSearchPlaceEntry } from "@/lib/map-search-history"; -import { formatMobileDayDate } from "@/lib/plan/mobilePlanFormat"; +import { formatMobileDayDate, formatMonthDayKo } from "@/lib/plan/mobilePlanFormat"; import { planCopy } from "@/lib/plan/planCopy"; import { dayNumberForInsertAfterDayIndex } from "@/lib/plan/scheduleMerge"; import type { PlanPlace } from "@/lib/plan/types"; @@ -154,6 +154,7 @@ export function MobilePlanView({ scheduleId={schedule.scheduleId} dayLabel={`${dayIndex + 1}일차`} dateLabel={formatMobileDayDate(schedule.date)} + monthDayLabel={formatMonthDayKo(schedule.date)} menuDisabled={menuDisabled || isCreatingDay} editing={editing} onStartEditing={() => setEditing(true)} diff --git a/src/app/(main)/plan/_components/mobile/MobileTimeSheet.tsx b/src/app/(main)/plan/_components/mobile/MobileTimeSheet.tsx index 43a17c99..5414f7f2 100644 --- a/src/app/(main)/plan/_components/mobile/MobileTimeSheet.tsx +++ b/src/app/(main)/plan/_components/mobile/MobileTimeSheet.tsx @@ -3,15 +3,18 @@ import { useState } from "react"; import { toast } from "sonner"; +import { LoadingIndicator } from "@/components/loading/LoadingIndicator"; import { MobileBottomSheet } from "@/components/mobile/MobileBottomSheet"; import { useUpdateScheduleItem } from "@/hooks/useRooms"; import { + addMinutesToHm, + computeDurationMinutesFromRange, normalizeStartTimeToHm, validateScheduleTimeDraft, } from "@/lib/plan/scheduleTime"; import { cn } from "@/lib/utils"; -import { TimeWheelPicker, type TimeWheelValue } from "../itinerary/TimeWheelPicker"; +import { TimeWheelPicker } from "../itinerary/TimeWheelPicker"; type Field = "start" | "end"; @@ -20,19 +23,20 @@ type MobileTimeSheetProps = Readonly<{ scheduleId: number; itemId: number; placeName: string; + /** `9월 21일` */ + dateLabel?: string; startTime: string | null | undefined; endTime: string | null | undefined; onClose: () => void; }>; -function toWheel(hm: string, fallback: TimeWheelValue): TimeWheelValue { - if (!hm) return fallback; - const [h, m] = hm.split(":").map(Number); - return { hour: h ?? fallback.hour, minute: m ?? fallback.minute }; -} +const DEFAULT_HM = "10:00"; -function toHm({ hour, minute }: TimeWheelValue): string { - return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; +/** "13:05" → "오후 1:05" — 휠과 같이 시를 0~11로 표기 */ +function formatHm12h(hm: string): string { + if (!hm) return ""; + const [h, m] = hm.split(":").map(Number); + return `${h < 12 ? "오전" : "오후"} ${h % 12}:${String(m).padStart(2, "0")}`; } /** 방문 시간 시트 — 시작·종료 칸을 누르면 휠 피커로 고른다 */ @@ -41,29 +45,35 @@ export function MobileTimeSheet({ scheduleId, itemId, placeName, + dateLabel, startTime, endTime, onClose, }: MobileTimeSheetProps) { const serverStart = normalizeStartTimeToHm(startTime ?? ""); const serverEnd = normalizeStartTimeToHm(endTime ?? ""); - const [start, setStart] = useState(serverStart); + const [start, setStart] = useState(serverStart || DEFAULT_HM); const [end, setEnd] = useState(serverEnd); - const [activeField, setActiveField] = useState(null); + const [activeField, setActiveField] = useState("start"); const { mutateAsync, isPending } = useUpdateScheduleItem(); const dirty = start !== serverStart || end !== serverEnd; + const duration = computeDurationMinutesFromRange(start, end); + const subtitle = [ + dateLabel, + duration != null && end < start ? "다음 날 종료(+1일)" : null, + duration != null ? `${Math.floor(duration / 60)}시간 ${duration % 60}분` : null, + ] + .filter(Boolean) + .join(" · "); const validation = validateScheduleTimeDraft(start, end); function openField(field: Field) { if (field === "end" && !start) return; setActiveField((current) => (current === field ? null : field)); // 비어 있는 칸을 처음 열면 휠 기본값으로 채운다 - if (field === "start" && !start) setStart("12:00"); - if (field === "end" && !end) { - const base = toWheel(start, { hour: 12, minute: 0 }); - setEnd(toHm({ hour: (base.hour + 1) % 24, minute: base.minute })); - } + if (field === "start" && !start) setStart(DEFAULT_HM); + if (field === "end" && !end) setEnd(addMinutesToHm(start, 60)); } async function handleSave() { @@ -90,61 +100,65 @@ export function MobileTimeSheet({ } const fields: { key: Field; label: string; value: string }[] = [ - { key: "start", label: "시작", value: start }, - { key: "end", label: "종료", value: end }, + { key: "start", label: "시작 시간", value: start }, + { key: "end", label: "종료 시간", value: end }, ]; return ( -
- {fields.map(({ key, label, value }) => ( -
- {label} +
+ {fields.map(({ key, label, value }) => { + const active = activeField === key; + const display = formatHm12h(value); + return ( -
- ))} + ); + })}
{activeField ? ( - (activeField === "start" ? setStart : setEnd)(toHm(next))} - disabled={isPending} - /> +
+ {}} + /> +
) : null} - -
-

- 종료가 시작보다 이르면 다음 날로 표시 -

- {start || end ? ( + {start || end ? ( +
- ) : null} +
+ ) : null} + +
+ +
); diff --git a/src/app/globals.css b/src/app/globals.css index 92f6a7de..c9a88420 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,8 @@ @import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"); @import "tailwindcss"; @import "tw-animate-css"; +/* 휠 피커 기본 스타일 — components 레이어에 둬서 Tailwind 유틸리티가 덮어쓸 수 있게 한다 */ +@import "@ncdai/react-wheel-picker/style.css" layer(components); @config "../../tailwind.config.js"; /* 제품 서체 Pretendard — Figma Typography/Foundation 기준 */ diff --git a/src/components/mobile/MobileBottomSheet.tsx b/src/components/mobile/MobileBottomSheet.tsx index c5711c9a..01b209c1 100644 --- a/src/components/mobile/MobileBottomSheet.tsx +++ b/src/components/mobile/MobileBottomSheet.tsx @@ -94,7 +94,7 @@ function MobileBottomSheetContent({
{subtitle ? ( -

{subtitle}

+

{subtitle}

) : null}
{children}
diff --git a/src/lib/plan/mobilePlanFormat.test.ts b/src/lib/plan/mobilePlanFormat.test.ts index cac81784..1ff51485 100644 --- a/src/lib/plan/mobilePlanFormat.test.ts +++ b/src/lib/plan/mobilePlanFormat.test.ts @@ -2,12 +2,23 @@ import { describe, expect, it } from "vitest"; import type { Expense } from "@/lib/api/rooms/expenses"; -import { formatMobileDayDate, summarizeExpensesForMobile } from "./mobilePlanFormat"; +import { formatMobileDayDate, formatMonthDayKo, summarizeExpensesForMobile } from "./mobilePlanFormat"; function expense(currency: string, totalAmount: string, id: number): Expense { return { id, currency, totalAmount } as Expense; } +describe("formatMonthDayKo", () => { + it("월·일을 앞자리 0 없이 표시한다", () => { + expect(formatMonthDayKo("2026-09-08")).toBe("9월 8일"); + expect(formatMonthDayKo("2026-12-25")).toBe("12월 25일"); + }); + + it("형식이 맞지 않으면 빈 문자열", () => { + expect(formatMonthDayKo("")).toBe(""); + }); +}); + describe("formatMobileDayDate", () => { it("월·일을 두 자리로 맞추고 요일을 붙인다", () => { expect(formatMobileDayDate("2026-09-21")).toBe("09. 21 월"); diff --git a/src/lib/plan/mobilePlanFormat.ts b/src/lib/plan/mobilePlanFormat.ts index 7831c9ad..58a994b5 100644 --- a/src/lib/plan/mobilePlanFormat.ts +++ b/src/lib/plan/mobilePlanFormat.ts @@ -14,6 +14,13 @@ export function formatMobileDayDate(ymd: string): string { return `${mm}. ${dd} ${WEEKDAYS_KO[d.getDay()]}`; } +/** `2026-09-28` → `9월 28일` */ +export function formatMonthDayKo(ymd: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return ""; + const d = parseLocalYmd(ymd); + return `${d.getMonth() + 1}월 ${d.getDate()}일`; +} + /** * 모바일 비용 요약 — 지출이 가장 많은 통화의 합계만 보여주고, 다른 통화 지출은 건수로 붙인다. * 예: `357 KRW`, `357 KRW 외 1건`. 지출이 없으면 null.