diff --git a/apps/web/app/onboarding/(questions)/age/page.tsx b/apps/web/app/onboarding/(questions)/age/page.tsx index 89b7f28..17adca6 100644 --- a/apps/web/app/onboarding/(questions)/age/page.tsx +++ b/apps/web/app/onboarding/(questions)/age/page.tsx @@ -6,37 +6,12 @@ import { useRouter } from "next/navigation"; import { type FormEvent, useEffect, useRef } from "react"; import { Controller, useFormContext, useWatch } from "react-hook-form"; import { useSaveOnboardingProfile } from "@/app/onboarding/_hooks/use-save-onboarding-profile"; -import { onlyDigits } from "@/lib/number"; - -function formatBirthDateInput(value: string) { - const digits = onlyDigits(value).slice(0, 8); - return [digits.slice(0, 4), digits.slice(4, 6), digits.slice(6, 8)].filter(Boolean).join("."); -} - -function toBirthDate(value: string) { - return /^\d{4}\.\d{2}\.\d{2}$/.test(value) ? value.replaceAll(".", "-") : value; -} - -function isRealBirthDate(value: string) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; - - const parts = value.split("-"); - const year = Number(parts[0]); - const month = Number(parts[1]); - const day = Number(parts[2]); - const date = new Date(Date.UTC(year, month - 1, day)); - return ( - date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day - ); -} - -/** 서버는 생년월일을 과거 날짜로만 받는다(오늘도 거부) — 보내기 전에 같은 기준으로 거른다. */ -function isPastBirthDate(value: string) { - const today = new Date(); - const todayUtc = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()); - const parts = value.split("-"); - return Date.UTC(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])) < todayUtc; -} +import { + formatBirthDateInput, + isPastBirthDate, + isRealBirthDate, + toIsoBirthDate, +} from "@/lib/birth-date"; export default function AgeOnboardingPage() { const router = useRouter(); @@ -107,7 +82,7 @@ export default function AgeOnboardingPage() { placeholder="YYYY.MM.DD" value={field.value.replaceAll("-", ".")} onChange={(event) => - field.onChange(toBirthDate(formatBirthDateInput(event.target.value))) + field.onChange(toIsoBirthDate(formatBirthDateInput(event.target.value))) } onKeyDown={(event) => { // 한글 등 IME 조합을 확정하는 Enter는 다음 단계로 넘기지 않는다. diff --git a/apps/web/app/profile/edit/page.tsx b/apps/web/app/profile/edit/page.tsx index b36318f..9d9958b 100644 --- a/apps/web/app/profile/edit/page.tsx +++ b/apps/web/app/profile/edit/page.tsx @@ -7,6 +7,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronLeft } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { type FormEvent, useEffect, useState } from "react"; +import { + formatBirthDateInput, + isPastBirthDate, + isRealBirthDate, + toIsoBirthDate, +} from "@/lib/birth-date"; import { SAVE_FAILED_TEXT } from "@/lib/messages"; import { onlyDigits } from "@/lib/number"; import { updateGoalOptions } from "@/lib/queries/goal"; @@ -33,29 +39,6 @@ const PERIOD_OPTIONS = [ { label: "3년 미만", months: 36 }, ] as const; -function formatBirthDateInput(value: string) { - const digits = onlyDigits(value).slice(0, 8); - return [digits.slice(0, 4), digits.slice(4, 6), digits.slice(6, 8)].filter(Boolean).join("."); -} - -function toIsoBirthDate(value: string) { - return /^\d{4}\.\d{2}\.\d{2}$/.test(value) ? value.replaceAll(".", "-") : value; -} - -function isValidPastDate(value: string) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; - - const [year, month, day] = value.split("-").map(Number); - if (year === undefined || month === undefined || day === undefined) return false; - - const date = new Date(Date.UTC(year, month - 1, day)); - const isRealDate = - date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; - const today = new Date(); - const todayUtc = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()); - return isRealDate && date.getTime() < todayUtc; -} - function selectedPeriodOption(periodMonths: number) { if (periodMonths <= 12) return 12; if (periodMonths <= 24) return 24; @@ -160,7 +143,7 @@ export default function ProfileEditPage() { const monthlySavingManwon = Number(draft.monthlySavingManwon); const netWorthManwon = Number(draft.netWorthManwon); - if (!isValidPastDate(birthDate)) { + if (!isRealBirthDate(birthDate) || !isPastBirthDate(birthDate)) { setSubmitError("올바른 생년월일을 입력해 주세요."); return; } diff --git a/apps/web/lib/birth-date.test.ts b/apps/web/lib/birth-date.test.ts new file mode 100644 index 0000000..2dd7b17 --- /dev/null +++ b/apps/web/lib/birth-date.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { + formatBirthDateInput, + isPastBirthDate, + isRealBirthDate, + toIsoBirthDate, +} from "./birth-date"; + +describe("birth date utilities", () => { + it("부분 입력을 점 구분 표기로 정규화한다", () => { + expect(formatBirthDateInput("1998")).toBe("1998"); + expect(formatBirthDateInput("199803")).toBe("1998.03"); + expect(formatBirthDateInput("1998.03.01abc")).toBe("1998.03.01"); + }); + + it("완성된 점 구분 표기만 ISO 날짜로 바꾼다", () => { + expect(toIsoBirthDate("2002.10.24")).toBe("2002-10-24"); + expect(toIsoBirthDate("2002.10")).toBe("2002.10"); + expect(toIsoBirthDate("2002-10-24")).toBe("2002-10-24"); + }); + + it("실제로 존재하는 날짜인지 검증한다", () => { + expect(isRealBirthDate("2000-02-29")).toBe(true); + expect(isRealBirthDate("1900-02-29")).toBe(false); + expect(isRealBirthDate("2002-10")).toBe(false); + }); + + it("오늘 이전의 실재하는 날짜만 과거 날짜로 인정한다", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 8, 11, 12)); + + try { + expect(isPastBirthDate("2026-09-10")).toBe(true); + expect(isPastBirthDate("2026-09-11")).toBe(false); + expect(isPastBirthDate("2026-09-12")).toBe(false); + expect(isPastBirthDate("1900-02-29")).toBe(false); + expect(isPastBirthDate("2002-10")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/web/lib/birth-date.ts b/apps/web/lib/birth-date.ts new file mode 100644 index 0000000..eb483b3 --- /dev/null +++ b/apps/web/lib/birth-date.ts @@ -0,0 +1,33 @@ +import { onlyDigits } from "./number"; + +export function formatBirthDateInput(value: string) { + const digits = onlyDigits(value).slice(0, 8); + return [digits.slice(0, 4), digits.slice(4, 6), digits.slice(6, 8)].filter(Boolean).join("."); +} + +export function toIsoBirthDate(value: string) { + return /^\d{4}\.\d{2}\.\d{2}$/.test(value) ? value.replaceAll(".", "-") : value; +} + +export function isRealBirthDate(value: string) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + + const parts = value.split("-"); + const year = Number(parts[0]); + const month = Number(parts[1]); + const day = Number(parts[2]); + const date = new Date(Date.UTC(year, month - 1, day)); + return ( + date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day + ); +} + +/** 서버는 생년월일을 과거 날짜로만 받는다(오늘도 거부) — 보내기 전에 같은 기준으로 거른다. */ +export function isPastBirthDate(value: string) { + if (!isRealBirthDate(value)) return false; + + const parts = value.split("-"); + const today = new Date(); + const todayUtc = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()); + return Date.UTC(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])) < todayUtc; +}