diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx b/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx new file mode 100644 index 00000000..3ed0ffff --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx @@ -0,0 +1,120 @@ +import { + StatisticsClockEmptyIcon, + StatisticsClockFilledIcon, + StatisticsClockLightIcon, + StatisticsClockOutlineIcon, +} from "@repo/timo-design-system/icons"; +import { cn } from "@repo/timo-design-system/utils"; + +import type { StatisticsCalendarResponse } from "@/app/[locale]/(main)/statistics/_types/statistics"; + +import { + formatStatisticsCalendarDate, + formatStatisticsMonth, +} from "@/app/[locale]/(main)/statistics/_utils/format-statistics-date"; +import { + formatDateKey, + getCalendarDates, + getFirstDayOffset, +} from "@/app/[locale]/(main)/statistics/_utils/statistics-calendar"; + +type CalendarIconStatus = "disabled" | "empty" | "outline" | "light" | "filled"; + +const DisabledClockIcon = () => ( +
+); + +const STATUS_ICON = { + disabled: DisabledClockIcon, + empty: StatisticsClockEmptyIcon, + outline: StatisticsClockOutlineIcon, + light: StatisticsClockLightIcon, + filled: StatisticsClockFilledIcon, +}; + +const getIconStatus = (completionRate: number | null): CalendarIconStatus => { + if (completionRate === null) return "disabled"; + if (completionRate === 0) return "empty"; + if (completionRate < 50) return "outline"; + if (completionRate < 100) return "light"; + return "filled"; +}; + +const WEEKDAYS = [ + { label: "M", ariaLabel: "Monday" }, + { label: "T", ariaLabel: "Tuesday" }, + { label: "W", ariaLabel: "Wednesday" }, + { label: "T", ariaLabel: "Thursday" }, + { label: "F", ariaLabel: "Friday" }, + { label: "S", ariaLabel: "Saturday" }, + { label: "S", ariaLabel: "Sunday" }, +]; + +interface StatisticsCalendarProps { + currentMonth: Date; + calendarData: StatisticsCalendarResponse; +} + +export const StatisticsCalendar = ({ + currentMonth, + calendarData, +}: StatisticsCalendarProps) => { + const today = new Date(calendarData.today); + const calendarDates = getCalendarDates(currentMonth); + const firstDayOffset = getFirstDayOffset(currentMonth); + const completionRateByDate = new Map( + calendarData.days.map(({ date, completionRate }) => [date, completionRate]), + ); + const todayLabel = formatStatisticsCalendarDate(today); + + return ( +
+
+
+

+ {formatStatisticsMonth(currentMonth)} +

+ +

+ {todayLabel} +

+
+ +
+ {WEEKDAYS.map(({ label, ariaLabel }, index) => ( +
= 5 ? "text-timo-red" : "text-timo-gray-900", + )} + > + {label} +
+ ))} + + {Array.from({ length: firstDayOffset }, (_, index) => ( +
+ ))} + + {calendarDates.map((calendarDate) => { + const dateKey = formatDateKey(calendarDate.date); + const completionRate = completionRateByDate.get(dateKey) ?? null; + const status = getIconStatus(completionRate); + const Icon = STATUS_ICON[status]; + + return ( +
+ + + {calendarDate.day} + +
+ ); + })} +
+
+
+ ); +}; diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsContainer.tsx b/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsContainer.tsx new file mode 100644 index 00000000..e926fcfc --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsContainer.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useState } from "react"; + +import { StatisticsCalendar } from "@/app/[locale]/(main)/statistics/_components/StatisticsCalendar"; +import { StatisticsHeaderContainer } from "@/app/[locale]/(main)/statistics/_containers/StatisticsHeaderContainer"; +import { MOCK_STATISTICS_CALENDAR } from "@/app/[locale]/(main)/statistics/_mocks/statistics-calendar"; + +export const StatisticsContainer = () => { + const [currentMonth, setCurrentMonth] = useState(() => new Date()); + + return ( + <> + + + + ); +}; diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsHeaderContainer.tsx b/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsHeaderContainer.tsx index 8f978ff2..dd64590c 100644 --- a/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsHeaderContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsHeaderContainer.tsx @@ -1,10 +1,15 @@ "use client"; -import { useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; import { Header } from "@/components/layout/header/Header"; import { useNavigationSidebar } from "@/components/layout/sidebar/navigation/NavigationSidebarContext"; +interface StatisticsHeaderContainerProps { + currentMonth: Date; + onChangeMonth: Dispatch>; +} + const MONTH_LABEL_FORMATTER = new Intl.DateTimeFormat("en-US", { month: "long", }); @@ -12,12 +17,14 @@ const MONTH_LABEL_FORMATTER = new Intl.DateTimeFormat("en-US", { const addMonths = (date: Date, amount: number) => new Date(date.getFullYear(), date.getMonth() + amount, 1); -export const StatisticsHeaderContainer = () => { - const [currentMonth, setCurrentMonth] = useState(() => new Date()); +export const StatisticsHeaderContainer = ({ + currentMonth, + onChangeMonth, +}: StatisticsHeaderContainerProps) => { const { isOpen, toggle } = useNavigationSidebar(); - const handlePrev = () => setCurrentMonth((prev) => addMonths(prev, -1)); - const handleNext = () => setCurrentMonth((prev) => addMonths(prev, 1)); + const handlePrev = () => onChangeMonth((prev) => addMonths(prev, -1)); + const handleNext = () => onChangeMonth((prev) => addMonths(prev, 1)); return (
{ + const [currentMonth, setCurrentMonth] = useState(() => new Date()); + + return ( + <> + + + + ); +}; diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_mocks/statistics-calendar.ts b/apps/timo-web/app/[locale]/(main)/statistics/_mocks/statistics-calendar.ts new file mode 100644 index 00000000..9bc7768a --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/statistics/_mocks/statistics-calendar.ts @@ -0,0 +1,13 @@ +import type { StatisticsCalendarResponse } from "@/app/[locale]/(main)/statistics/_types/statistics"; + +export const MOCK_STATISTICS_CALENDAR: StatisticsCalendarResponse = { + yearMonth: "2026-07", + today: "2026-07-10", + days: [ + { date: "2026-07-01", completionRate: 0 }, + { date: "2026-07-02", completionRate: 25 }, + { date: "2026-07-03", completionRate: 75 }, + { date: "2026-07-04", completionRate: 100 }, + { date: "2026-07-05", completionRate: null }, + ], +}; diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_types/statistics.ts b/apps/timo-web/app/[locale]/(main)/statistics/_types/statistics.ts index febf963e..5a1e7f35 100644 --- a/apps/timo-web/app/[locale]/(main)/statistics/_types/statistics.ts +++ b/apps/timo-web/app/[locale]/(main)/statistics/_types/statistics.ts @@ -22,6 +22,17 @@ export const statisticsDayDetailSchema = z.object({ todos: z.array(statisticsTodoRecordSchema), }); +export const statisticsCalendarResponseSchema = z.object({ + yearMonth: z.string(), + today: z.string(), + days: z.array( + z.object({ + date: z.string(), + completionRate: z.number().nullable(), + }), + ), +}); + export type StatisticsMonthSummary = z.infer< typeof statisticsMonthSummarySchema >; @@ -29,3 +40,7 @@ export type StatisticsMonthSummary = z.infer< export type StatisticsDayDetail = z.infer; export type StatisticsTodoRecord = z.infer; + +export type StatisticsCalendarResponse = z.infer< + typeof statisticsCalendarResponseSchema +>; diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_utils/format-statistics-date.ts b/apps/timo-web/app/[locale]/(main)/statistics/_utils/format-statistics-date.ts new file mode 100644 index 00000000..4985b47b --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/statistics/_utils/format-statistics-date.ts @@ -0,0 +1,34 @@ +/** + * 통계 캘린더 헤더에 표시할 월 이름을 반환합니다. + * + * @param date - 월 이름을 가져올 날짜 + * @param locale - 날짜 표기에 사용할 locale 값 + * @returns locale에 맞게 변환된 월 이름 + * + * @example + * formatStatisticsMonth(new Date(2026, 5, 1)); // "6월" + */ +export const formatStatisticsMonth = (date: Date, locale = "ko") => { + return new Intl.DateTimeFormat(locale, { + month: "long", + }).format(date); +}; + +/** + * 통계 캘린더 설명 영역에 표시할 날짜 문구를 반환합니다. + * + * @param date - 표시할 날짜 + * @param locale - 날짜 표기에 사용할 locale 값 + * @returns 년, 월, 일, 요일이 포함된 날짜 문구 + * + * @example + * formatStatisticsCalendarDate(new Date(2026, 5, 28)); // "2026년 6월 28일 일요일" + */ +export const formatStatisticsCalendarDate = (date: Date, locale = "ko") => { + return new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "long", + day: "numeric", + weekday: "long", + }).format(date); +}; diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_utils/statistics-calendar.ts b/apps/timo-web/app/[locale]/(main)/statistics/_utils/statistics-calendar.ts new file mode 100644 index 00000000..ffe14dbb --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/statistics/_utils/statistics-calendar.ts @@ -0,0 +1,61 @@ +export interface CalendarDate { + date: Date; + day: number; +} + +const getLastDayOfMonth = (date: Date) => { + return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); +}; + +/** + * 주어진 월에 포함된 날짜 목록을 반환합니다. + * 이전/다음 달 날짜는 포함하지 않고 현재 월 날짜만 생성합니다. + * + * @param month - 날짜 목록을 생성할 기준 월 + * @returns 해당 월의 날짜와 일자를 담은 배열 + */ +export const getCalendarDates = (month: Date): CalendarDate[] => { + const year = month.getFullYear(); + const monthIndex = month.getMonth(); + const lastDay = getLastDayOfMonth(month); + + return Array.from({ length: lastDay }, (_, index) => { + const day = index + 1; + + return { + date: new Date(year, monthIndex, day), + day, + }; + }); +}; + +/** + * 월요일 시작 캘린더에서 1일 앞에 필요한 빈 칸 개수를 반환합니다. + * JS Date.getDay()는 일요일을 0으로 반환하므로 월요일 기준 인덱스로 변환합니다. + * + * @param month - 첫 주 offset을 계산할 기준 월 + * @returns 월요일 시작 캘린더에서 1일 앞에 필요한 빈 칸 개수 + */ +export const getFirstDayOffset = (month: Date) => { + const firstDate = new Date(month.getFullYear(), month.getMonth(), 1); + + return (firstDate.getDay() + 6) % 7; +}; + +/** + * Date 객체를 API 응답 날짜 형식인 yyyy-MM-dd 문자열로 변환합니다. + * 날짜별 completionRate를 매칭할 때 사용합니다. + * + * @param date - 변환할 날짜 + * @returns yyyy-MM-dd 형식의 날짜 문자열 + * + * @example + * formatDateKey(new Date(2026, 5, 28)); // "2026-06-28" + */ +export const formatDateKey = (date: Date) => { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + + return `${year}-${month}-${day}`; +}; diff --git a/apps/timo-web/app/[locale]/(main)/statistics/page.tsx b/apps/timo-web/app/[locale]/(main)/statistics/page.tsx index a602413c..2ed50a4a 100644 --- a/apps/timo-web/app/[locale]/(main)/statistics/page.tsx +++ b/apps/timo-web/app/[locale]/(main)/statistics/page.tsx @@ -1,5 +1,5 @@ -import { StatisticsHeaderContainer } from "@/app/[locale]/(main)/statistics/_containers/StatisticsHeaderContainer"; +import { StatisticsContainer } from "@/app/[locale]/(main)/statistics/_containers/StatisticsContainer"; export default function StatisticsPage() { - return ; + return ; } diff --git a/packages/timo-design-system/src/icons/source/statistics-clock-empty.svg b/packages/timo-design-system/src/icons/source/statistics-clock-empty.svg new file mode 100644 index 00000000..ac08c2ac --- /dev/null +++ b/packages/timo-design-system/src/icons/source/statistics-clock-empty.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/timo-design-system/src/icons/source/statistics-clock-filled.svg b/packages/timo-design-system/src/icons/source/statistics-clock-filled.svg new file mode 100644 index 00000000..5875877c --- /dev/null +++ b/packages/timo-design-system/src/icons/source/statistics-clock-filled.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/timo-design-system/src/icons/source/statistics-clock-light.svg b/packages/timo-design-system/src/icons/source/statistics-clock-light.svg new file mode 100644 index 00000000..bb9ebaf8 --- /dev/null +++ b/packages/timo-design-system/src/icons/source/statistics-clock-light.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/timo-design-system/src/icons/source/statistics-clock-outline.svg b/packages/timo-design-system/src/icons/source/statistics-clock-outline.svg new file mode 100644 index 00000000..6c74fc26 --- /dev/null +++ b/packages/timo-design-system/src/icons/source/statistics-clock-outline.svg @@ -0,0 +1,7 @@ + + + + + + +