Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6aee5bd
feat(ui): 통계 캘린더 시계 상태 아이콘 추가 (#129)
yumin-kim2 Jul 9, 2026
95a0be2
feat(web): 통계 월 상태를 페이지에서 관리하도록 변경 (#129)
yumin-kim2 Jul 9, 2026
da32fc8
feat(web): 통계 캘린더 응답 타입 추가 (#129)
yumin-kim2 Jul 9, 2026
3a7009a
feat(web): 통계 날짜 포맷 유틸 추가 (#129)
yumin-kim2 Jul 9, 2026
59c7870
refactor(web): 통계 날짜 포맷 유틸 정리 (#129)
yumin-kim2 Jul 10, 2026
a34e16d
feat(web): 통계 캘린더 날짜 계산 유틸 추가 (#129)
yumin-kim2 Jul 10, 2026
474d576
chore(web): 통계 캘린더 목 데이터 추가 (#129)
yumin-kim2 Jul 10, 2026
b8ede7c
feat(web): 통계 캘린더 컴포넌트 추가 (#129)
yumin-kim2 Jul 10, 2026
0ad681f
docs(web): 통계 캘린더 유틸 JSDoc 추가 (#129)
yumin-kim2 Jul 10, 2026
833f0f4
refactor(web): 통계 캘린더 데이터 주입 구조로 변경 (#129)
yumin-kim2 Jul 10, 2026
769ddad
refactor(web): 통계 캘린더 비활성 아이콘 조건 정리 (#129)
yumin-kim2 Jul 10, 2026
999c27c
refactor(web): 통계 페이지 상태를 컨테이너로 이동 (#129)
yumin-kim2 Jul 10, 2026
4e357c3
docs(web): 통계 캘린더 유틸 JSDoc 태그 및 설명 추가 (#129)
yumin-kim2 Jul 10, 2026
b0d8fcf
refactor(web): 통계 캘린더 비활성 아이콘을 CSS로 구현 (#129)
yumin-kim2 Jul 10, 2026
5f85d9c
refactor(web): 통계 캘린더 selected 아이콘 에셋 제거 (#129)
yumin-kim2 Jul 10, 2026
8289b96
refactor(web): 통계 캘린더 dateKey를 렌더링 key로 사용 (#129)
yumin-kim2 Jul 10, 2026
6f28e7a
refactor(web): 통계 캘린더 요일 접근성 라벨 추가 (#129)
yumin-kim2 Jul 10, 2026
8abdc43
refactor(web): 통계 캘린더 내부 유틸 export 제거 (#129)
yumin-kim2 Jul 10, 2026
cfd8520
refactor(web): 컨테이너로 네이밍 수정 (#129)
yumin-kim2 Jul 10, 2026
b86be66
refactor(web): 통계 캘린더 파일명을 kebab-case로 정리 (#129)
yumin-kim2 Jul 10, 2026
a5e9c1c
refactor(web): 파일명 수정 (#129)
yumin-kim2 Jul 10, 2026
f217bd9
refactor(web): 통계 캘린더 파일명과 zod 정의 (#129)
yumin-kim2 Jul 10, 2026
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
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);

Copy link
Copy Markdown

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

🛡️ 제안하는 안전한 날짜 파싱
- const today = new Date(calendarData.today);
+ const [year, month, day] = calendarData.today.split("-").map(Number);
+ const today = new Date(year, month - 1, day);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/timo-web/app/`[locale]/(main)/statistics/_components/StatisticsCalendar.tsx
at line 51, StatisticsCalendar의 `calendarData.today`를 `new Date("YYYY-MM-DD")`로
직접 파싱하지 말고, 연·월·일을 분리해 local timezone 기준으로 Date를 생성하도록 수정하세요. 이후
`formatStatisticsCalendarDate`와 비교·포맷 로직이 동일한 local 날짜를 사용하도록 `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
@@ -1,23 +1,30 @@
"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<SetStateAction<Date>>;
}

const MONTH_LABEL_FORMATTER = new Intl.DateTimeFormat("en-US", {
month: "long",
});

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 (
<Header
Expand Down
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";
Comment thread
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 },
],
};
15 changes: 15 additions & 0 deletions apps/timo-web/app/[locale]/(main)/statistics/_types/statistics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Zod 4의 z.iso.date()로 날짜 문자열 검증을 강화하는 것을 제안합니다.

현재 z.string()은 모든 문자열을 허용하므로 날짜 형식 오류를 런타임에서 잡을 수 없습니다. Zod 4에서新增된 z.iso.date()를 사용하면 YYYY-MM-DD 형식 검증이 보장됩니다.

참고: Zod 4 String Enhancements

♻️ 제안하는 스키마 개선
 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const statisticsCalendarResponseSchema = z.object({
yearMonth: z.string(),
today: z.string(),
days: z.array(
z.object({
date: z.string(),
completionRate: z.number().nullable(),
}),
),
});
export const statisticsCalendarResponseSchema = z.object({
yearMonth: z.string().regex(/^\d{4}-\d{2}$/),
today: z.iso.date(),
days: z.array(
z.object({
date: z.iso.date(),
completionRate: z.number().nullable(),
}),
),
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/timo-web/app/`[locale]/(main)/statistics/_types/statistics.ts around
lines 3 - 12, statisticsCalendarResponseSchema의 날짜 필드 검증을 강화하세요. yearMonth와
today는 현재 형식에 맞는 ISO 날짜 스키마를 사용하고, days 내부 객체의 date 필드도 z.string() 대신 Zod 4의
z.iso.date()로 검증하도록 변경하세요.


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 {
Comment thread
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}`;
};
4 changes: 2 additions & 2 deletions apps/timo-web/app/[locale]/(main)/statistics/page.tsx
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 />;
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading