-
Notifications
You must be signed in to change notification settings - Fork 0
[FEAT] 통계 페이지 캘린더 구현 #133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[FEAT] 통계 페이지 캘린더 구현 #133
Changes from all commits
6aee5bd
95a0be2
da32fc8
3a7009a
59c7870
a34e16d
474d576
b8ede7c
0ad681f
833f0f4
769ddad
999c27c
4e357c3
b0d8fcf
5f85d9c
8289b96
6f28e7a
8abdc43
cfd8520
b86be66
a5e9c1c
f217bd9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = () => ( | ||
| <div className="bg-timo-gray-300 size-16.5 rounded-full" /> | ||
| ); | ||
|
|
||
| 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 ( | ||
| <section className="w-full px-14.75 pt-10 pb-13"> | ||
| <div className="w-199.5"> | ||
| <div className="mb-17.25"> | ||
| <h1 className="typo-headline-b-30 text-timo-gray-900"> | ||
| {formatStatisticsMonth(currentMonth)} | ||
| </h1> | ||
|
|
||
| <p className="typo-headline-m-14 text-timo-black mt-2"> | ||
| {todayLabel} | ||
| </p> | ||
| </div> | ||
|
|
||
| <div className="grid grid-cols-7 gap-x-14 gap-y-5"> | ||
| {WEEKDAYS.map(({ label, ariaLabel }, index) => ( | ||
| <div | ||
| key={ariaLabel} | ||
| aria-label={ariaLabel} | ||
| className={cn( | ||
| "typo-body-r-12 flex h-6 items-center justify-center", | ||
| index >= 5 ? "text-timo-red" : "text-timo-gray-900", | ||
| )} | ||
| > | ||
| {label} | ||
| </div> | ||
| ))} | ||
|
|
||
| {Array.from({ length: firstDayOffset }, (_, index) => ( | ||
| <div key={`empty-${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 ( | ||
| <div key={dateKey} className="flex flex-col items-center gap-2.5"> | ||
| <Icon /> | ||
| <span className="typo-body-sb-11 text-timo-gray-700"> | ||
| {calendarDate.day} | ||
| </span> | ||
| </div> | ||
| ); | ||
| })} | ||
| </div> | ||
| </div> | ||
| </section> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <> | ||
| <StatisticsHeaderContainer | ||
| currentMonth={currentMonth} | ||
| onChangeMonth={setCurrentMonth} | ||
| /> | ||
| <StatisticsCalendar | ||
| currentMonth={currentMonth} | ||
| calendarData={MOCK_STATISTICS_CALENDAR} | ||
| /> | ||
| </> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <> | ||
| <StatisticsHeaderContainer | ||
| currentMonth={currentMonth} | ||
| onChangeMonth={setCurrentMonth} | ||
| /> | ||
| <StatisticsCalendar | ||
| currentMonth={currentMonth} | ||
| calendarData={MOCK_STATISTICS_CALENDAR} | ||
| /> | ||
| </> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import type { StatisticsCalendarResponse } from "@/app/[locale]/(main)/statistics/_types/statistics"; | ||
|
yumin-kim2 marked this conversation as resolved.
|
||
|
|
||
| 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 }, | ||
| ], | ||
| }; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -22,10 +22,25 @@ 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(), | ||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+25
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Zod 4의 현재 ♻️ 제안하는 스키마 개선 export const statisticsCalendarResponseSchema = z.object({
- yearMonth: z.string(),
- today: z.string(),
+ yearMonth: z.string().regex(/^\d{4}-\d{2}$/),
+ today: z.iso.date(),
days: z.array(
z.object({
- date: z.string(),
+ date: z.iso.date(),
completionRate: z.number().nullable(),
}),
),
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export type StatisticsMonthSummary = z.infer< | ||||||||||||||||||||||||||||||||||||||||||
| typeof statisticsMonthSummarySchema | ||||||||||||||||||||||||||||||||||||||||||
| >; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export type StatisticsDayDetail = z.infer<typeof statisticsDayDetailSchema>; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export type StatisticsTodoRecord = z.infer<typeof statisticsTodoRecordSchema>; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export type StatisticsCalendarResponse = z.infer< | ||||||||||||||||||||||||||||||||||||||||||
| typeof statisticsCalendarResponseSchema | ||||||||||||||||||||||||||||||||||||||||||
| >; | ||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| export interface CalendarDate { | ||
|
yumin-kim2 marked this conversation as resolved.
|
||
| 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}`; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <StatisticsHeaderContainer />; | ||
| return <StatisticsContainer />; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
new Date(calendarData.today)UTC 파싱 이슈가 여전히 남아 있습니다.이전 리뷰에서 지적된 내용이 아직 반영되지 않았습니다.
calendarData.today가"YYYY-MM-DD"형식의 문자열이므로, ECMAScript 스펙상 UTC 자정으로 파싱됩니다.formatStatisticsCalendarDate가 local timezone 기준으로 포맷하므로, UTC-5 등의 timezone에서는 날짜가 하루 앞당겨져 표시됩니다.참고: ECMAScript Date Time String Format specification
🛡️ 제안하는 안전한 날짜 파싱
🤖 Prompt for AI Agents