Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 7 additions & 32 deletions apps/web/app/onboarding/(questions)/age/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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는 다음 단계로 넘기지 않는다.
Expand Down
31 changes: 7 additions & 24 deletions apps/web/app/profile/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
42 changes: 42 additions & 0 deletions apps/web/lib/birth-date.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
33 changes: 33 additions & 0 deletions apps/web/lib/birth-date.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading