c.categoryId === selectedCategoryId,
+ )?.colorCode;
+
+ const handleOpenOnMap = useCallback(
+ (googlePlaceId: string, preview: PlacePreview) => {
+ if (busy) return;
+ onClose();
+ openPlaceOnMap(
+ {
+ name: preview.name,
+ category: "",
+ rating: null,
+ address: preview.formattedAddress,
+ googlePlaceId: googlePlaceId.trim(),
+ location: preview.location,
+ fromBookmark: true,
+ bookmarkCategoryColor: selectedCategoryColor,
+ },
+ { itinerarySource: "bookmark" },
+ );
+ },
+ [busy, onClose, openPlaceOnMap, selectedCategoryColor],
+ );
+
return (
void handleAddPlace(googlePlaceId)}
+ onThumbnailClick={() =>
+ handleOpenOnMap(googlePlaceId, preview)
+ }
/>
);
})
diff --git a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
index f42a211e..9bfab2ea 100644
--- a/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
+++ b/src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
@@ -21,10 +21,7 @@ import {
import { usePlanPlaceCardPhoto } from "@/hooks/usePlanPlaceCardPhoto";
import { bucketItemCount } from "@/lib/analytics/context";
import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track";
-import {
- buildGoogleMapsPlaceUrl,
- normalizeGooglePlaceResourceId,
-} from "@/lib/maps";
+import { normalizeGooglePlaceResourceId } from "@/lib/maps";
import { PLAN_PLACE_CARD_TW } from "@/lib/layout-tokens";
import { handlePlacePhotoImageError } from "@/lib/places/place-photo-refresh";
import {
@@ -218,12 +215,7 @@ export function PlanPlaceCard({
const rawGooglePlaceId =
typeof place.googlePlaceId === "string" ? place.googlePlaceId.trim() : "";
- const googleMapsPlaceUrl = rawGooglePlaceId.length
- ? buildGoogleMapsPlaceUrl({
- placeId: normalizeGooglePlaceResourceId(rawGooglePlaceId),
- query: place.title,
- })
- : null;
+ const hasGooglePlace = rawGooglePlaceId.length > 0;
const memoText = typeof place.memo === "string" ? place.memo.trim() : "";
const hasMemo = memoText.length > 0;
@@ -334,7 +326,7 @@ export function PlanPlaceCard({
/>
) : null;
- if (!googleMapsPlaceUrl) {
+ if (!hasGooglePlace) {
return (
);
})()}
From ee7dedbf407e5592f591941d6a3e048ae10bddbf Mon Sep 17 00:00:00 2001
From: KangShingyu <103213494+KangShinGyu98@users.noreply.github.com>
Date: Tue, 6 Oct 2026 10:36:00 +0900
Subject: [PATCH 8/8] =?UTF-8?q?feat(time-picker):=EB=AA=A8=EB=B0=94?=
=?UTF-8?q?=EC=9D=BC=20=ED=83=80=EC=9E=84=ED=94=BC=EC=BB=A4=20=EB=B3=80?=
=?UTF-8?q?=EA=B2=BD=20(#217)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: “KangShinGyu” <“rkdtlseb@naver.com”>
---
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.