) => {
- 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/MobileMemoSheet.tsx b/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx
index 5c9ffb2c..d6bf4add 100644
--- a/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx
+++ b/src/app/(main)/plan/_components/mobile/MobileMemoSheet.tsx
@@ -62,16 +62,18 @@ export function MobileMemoSheet({
>
예약 정보나 함께 기억할 내용을 남겨보세요.
@@ -82,7 +84,7 @@ export function MobileMemoSheet({
type="button"
onClick={() => void saveMemo("")}
disabled={isPending}
- className="min-h-12 flex-1 rounded-xl border border-border text-label-m-regular text-status-negative disabled:opacity-40"
+ className="min-h-12 flex-1 rounded-[12px] border border-border text-label-m-regular text-status-negative disabled:opacity-40"
>
삭제
@@ -91,7 +93,7 @@ export function MobileMemoSheet({
type="button"
onClick={() => void saveMemo(draft.trim())}
disabled={isPending || !dirty}
- className="min-h-12 flex-1 rounded-xl bg-primary text-label-m-regular text-fill-elevate disabled:opacity-40"
+ className="min-h-12 flex-1 rounded-[12px] bg-primary text-label-m-regular text-fill-elevate disabled:opacity-40"
>
{isPending ? "저장 중…" : "저장"}
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/agreements/PolicyDocumentView.tsx b/src/components/agreements/PolicyDocumentView.tsx
index 143ae38f..ccfdcd47 100644
--- a/src/components/agreements/PolicyDocumentView.tsx
+++ b/src/components/agreements/PolicyDocumentView.tsx
@@ -7,6 +7,7 @@ import { findAgreementByType } from "@/lib/agreements/paths";
import type { AgreementType } from "@/lib/agreements/types";
import { AgreementMarkdownContent } from "./AgreementMarkdownContent";
+import { PolicyHomeLink } from "./PolicyHomeLink";
type Props = {
agreementType: AgreementType;
@@ -19,6 +20,7 @@ export function PolicyDocumentView({ agreementType }: Props) {
if (isPending) {
return (
);
@@ -27,6 +29,7 @@ export function PolicyDocumentView({ agreementType }: Props) {
if (isError) {
return (
+
문서를 불러오지 못했습니다
@@ -49,6 +52,7 @@ export function PolicyDocumentView({ agreementType }: Props) {
if (!agreement) {
return (
+
현재 게시된 문서를 찾을 수 없습니다.
@@ -58,6 +62,7 @@ export function PolicyDocumentView({ agreementType }: Props) {
return (
+
{agreement.title}
버전 {agreement.version}
diff --git a/src/components/agreements/PolicyHomeLink.tsx b/src/components/agreements/PolicyHomeLink.tsx
new file mode 100644
index 00000000..f1f4da3e
--- /dev/null
+++ b/src/components/agreements/PolicyHomeLink.tsx
@@ -0,0 +1,15 @@
+import Link from "next/link";
+import { ArrowLeft } from "lucide-react";
+
+// "/"는 세션이 있으면 /home으로, 없으면 랜딩으로 proxy에서 분기된다
+export function PolicyHomeLink() {
+ return (
+
+
+ 홈으로 돌아가기
+
+ );
+}
diff --git a/src/components/agreements/StaticPolicyDocumentView.tsx b/src/components/agreements/StaticPolicyDocumentView.tsx
index a2d62b0d..9b714e5f 100644
--- a/src/components/agreements/StaticPolicyDocumentView.tsx
+++ b/src/components/agreements/StaticPolicyDocumentView.tsx
@@ -1,4 +1,5 @@
import { AgreementMarkdownContent } from "@/components/agreements/AgreementMarkdownContent";
+import { PolicyHomeLink } from "@/components/agreements/PolicyHomeLink";
type StaticPolicyDocumentViewProps = {
title: string;
@@ -13,6 +14,7 @@ export function StaticPolicyDocumentView({
}: StaticPolicyDocumentViewProps) {
return (
+
{title}
{effectiveDate ? (
diff --git a/src/components/chat/messages/ChatMemberAvatarRing.tsx b/src/components/chat/messages/ChatMemberAvatarRing.tsx
index 75de9447..3cd76a01 100644
--- a/src/components/chat/messages/ChatMemberAvatarRing.tsx
+++ b/src/components/chat/messages/ChatMemberAvatarRing.tsx
@@ -37,6 +37,8 @@ export function ChatMemberAvatarRing({
,
@@ -106,12 +116,18 @@ export function ChatMessageList({
const [isAtBottom, setIsAtBottom] = useState(true);
/** 하단으로 부드럽게 이동 중 상태 정리용 */
const [smoothJumpToBottom, setSmoothJumpToBottom] = useState(false);
+ /** 위로 스크롤한 동안 도착한 다른 멤버의 최신 메시지 */
+ const [newMessagePreview, setNewMessagePreview] =
+ useState(null);
+ /** 스크롤바 폭(px) — 하단 버튼이 스크롤바에 가리지 않게 그만큼 오른쪽을 비운다 */
+ const [scrollbarWidth, setScrollbarWidth] = useState(0);
const scrollToBottomSmooth = useCallback(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, []);
const handleJumpClick = useCallback(() => {
+ setNewMessagePreview(null);
restoringReadBoundaryRef.current = false;
followTailRef.current = true;
if (hasMoreNewer && onJumpToLatest) {
@@ -150,7 +166,10 @@ export function ChatMessageList({
const near = dist < CHAT_NEAR_BOTTOM_PX;
followTailRef.current = near;
setIsAtBottomIfChanged(setIsAtBottom, near);
- if (near) onAtBottom?.();
+ if (near) {
+ setNewMessagePreview(null);
+ onAtBottom?.();
+ }
}, [onAtBottom]);
useLayoutEffect(() => {
@@ -225,6 +244,22 @@ export function ChatMessageList({
const nearAfter = distAfter < CHAT_NEAR_BOTTOM_PX;
setIsAtBottomIfChanged(setIsAtBottom, nearAfter);
+ const newest = messages[count - 1];
+ if (
+ appended &&
+ !nearAfter &&
+ newest &&
+ isPreviewMessage(newest)
+ ) {
+ // 스크롤 보정이 끝난 다음 프레임에 표시 — 그 사이 하단에 닿았으면 띄우지 않는다
+ requestAnimationFrame(() => {
+ const r = scrollRootRef.current;
+ if (!r) return;
+ const d = r.scrollHeight - r.scrollTop - r.clientHeight;
+ if (d >= CHAT_NEAR_BOTTOM_PX) setNewMessagePreview(newest);
+ });
+ }
+
if (scrollToAnchor && lastScrollAnchorKeyRef.current !== scrollToAnchor.key) {
lastScrollAnchorKeyRef.current = scrollToAnchor.key;
const targetId = scrollToAnchor.anchorId;
@@ -281,7 +316,13 @@ export function ChatMessageList({
setIsAtBottomIfChanged(setIsAtBottom, d < CHAT_NEAR_BOTTOM_PX);
});
}
- }, [messages, scrollToAnchor, groups, readMarkerMessageId, readDividerPlacement]);
+ }, [
+ messages,
+ scrollToAnchor,
+ groups,
+ readMarkerMessageId,
+ readDividerPlacement,
+ ]);
useEffect(() => {
const root = scrollRootRef.current;
@@ -370,6 +411,9 @@ export function ChatMessageList({
const lastClientHeightRef = { current: root.clientHeight };
const ro = new ResizeObserver(() => {
+ const sbw = root.offsetWidth - root.clientWidth;
+ setScrollbarWidth((prev) => (prev === sbw ? prev : sbw));
+
const ch = root.clientHeight;
if (ch === lastClientHeightRef.current) return;
lastClientHeightRef.current = ch;
@@ -493,8 +537,20 @@ export function ChatMessageList({
- {!isAtBottom ? (
-
+ {!isAtBottom && newMessagePreview ? (
+
+ ) : null}
+ {!isAtBottom && !newMessagePreview ? (
+
) : null}
);
diff --git a/src/components/chat/messages/JumpToBottomButton.tsx b/src/components/chat/messages/JumpToBottomButton.tsx
index ebade42b..5f525e5c 100644
--- a/src/components/chat/messages/JumpToBottomButton.tsx
+++ b/src/components/chat/messages/JumpToBottomButton.tsx
@@ -6,12 +6,18 @@ import { cn } from "@/lib/utils";
export function JumpToBottomButton({
onClick,
isMinimized = false,
+ scrollbarWidth = 0,
}: {
onClick: () => void;
isMinimized?: boolean;
+ /** 메시지 목록 스크롤바 폭 — 버튼을 스크롤바 바깥(왼쪽)에 둔다 */
+ scrollbarWidth?: number;
}) {
return (
-
+
,
document.body,
diff --git a/src/hooks/useChatMessages.ts b/src/hooks/useChatMessages.ts
index 6f40afa5..976e3089 100644
--- a/src/hooks/useChatMessages.ts
+++ b/src/hooks/useChatMessages.ts
@@ -339,6 +339,7 @@ export function useChatMessages(
bridged.slice,
lastReadMessageId,
bridged.placementBeforeSlice,
+ hasMoreNewerByRoomRef.current.get(targetRoomId) === true,
);
applySessionReadBoundary(rid, lastReadMessageId, placement);
@@ -462,6 +463,7 @@ export function useChatMessages(
slice,
out.lastReadMessageId,
placementBeforeSlice,
+ hasMoreNewer,
)
: EMPTY_READ_DIVIDER_PLACEMENT;
applySessionReadBoundary(ridTrim, out.lastReadMessageId, placement);
diff --git a/src/lib/chat/readBoundary.test.ts b/src/lib/chat/readBoundary.test.ts
new file mode 100644
index 00000000..5191cd1f
--- /dev/null
+++ b/src/lib/chat/readBoundary.test.ts
@@ -0,0 +1,41 @@
+import { expect, it, vi } from "vitest";
+import type { ServerChatMessage } from "@/types/chat";
+import {
+ EMPTY_READ_DIVIDER_PLACEMENT,
+ resolveReadDividerPlacement,
+} from "./readBoundary";
+
+vi.mock("@/lib/api/rooms", () => ({ getRoomMessages: vi.fn() }));
+
+function msg(id: string, minute: number): ServerChatMessage {
+ return {
+ id,
+ roomId: "room",
+ senderId: 1,
+ messageType: "CHAT",
+ content: id,
+ createdAt: `2026-10-01T00:${String(minute).padStart(2, "0")}:00Z`,
+ };
+}
+
+const slice = [msg("a", 0), msg("b", 1)];
+
+it("does not draw the read divider when nothing comes after the last read message", () => {
+ expect(resolveReadDividerPlacement(slice, "b", slice)).toEqual(
+ EMPTY_READ_DIVIDER_PLACEMENT,
+ );
+});
+
+it("draws the read divider before unread messages", () => {
+ expect(resolveReadDividerPlacement(slice, "a", slice)).toEqual({
+ afterMessageId: "a",
+ beforeFirst: false,
+ });
+});
+
+it("keeps the divider at the loaded tail when newer pages remain", () => {
+ expect(resolveReadDividerPlacement(slice, "b", slice, true)).toEqual({
+ afterMessageId: "b",
+ beforeFirst: false,
+ });
+});
diff --git a/src/lib/chat/readBoundary.ts b/src/lib/chat/readBoundary.ts
index 215c87cf..333f6ac7 100644
--- a/src/lib/chat/readBoundary.ts
+++ b/src/lib/chat/readBoundary.ts
@@ -31,16 +31,22 @@ export function resolveReadDividerPlacement(
slice: readonly ServerChatMessage[],
lastReadMessageId: string | null,
beforeSlice: readonly ServerChatMessage[],
+ hasMoreNewer = false,
): ReadDividerPlacement {
if (!lastReadMessageId) {
return EMPTY_READ_DIVIDER_PLACEMENT;
}
+ // 경계 뒤에 새 메시지가 없으면 목록 맨 아래에 구분선만 남으므로 그리지 않는다
+ const placeAfter = (messageId: string): ReadDividerPlacement =>
+ !hasMoreNewer && newestServerMessageByCreatedAt([...slice])?.id === messageId
+ ? EMPTY_READ_DIVIDER_PLACEMENT
+ : { afterMessageId: messageId, beforeFirst: false };
if (slice.some((m) => m.id === lastReadMessageId)) {
- return { afterMessageId: lastReadMessageId, beforeFirst: false };
+ return placeAfter(lastReadMessageId);
}
const beforeNewest = newestServerMessageByCreatedAt([...beforeSlice]);
if (beforeNewest) {
- return { afterMessageId: beforeNewest.id, beforeFirst: false };
+ return placeAfter(beforeNewest.id);
}
if (slice.length > 0) {
return { afterMessageId: null, beforeFirst: true };
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.
diff --git a/src/providers/full-app-provider-stack.tsx b/src/providers/full-app-provider-stack.tsx
index efc3f002..0bd92d0e 100644
--- a/src/providers/full-app-provider-stack.tsx
+++ b/src/providers/full-app-provider-stack.tsx
@@ -53,7 +53,10 @@ export function FullAppProviderStack({ children }: { children: ReactNode }) {
-
+ {/* 개발 중 캐시 확인이 필요할 때만 .env.local에 NEXT_PUBLIC_RQ_DEVTOOLS=1 */}
+ {process.env.NEXT_PUBLIC_RQ_DEVTOOLS === "1" ? (
+
+ ) : null}
);
}