Skip to content
This repository was archived by the owner on Sep 15, 2025. It is now read-only.

Commit 472cf5e

Browse files
authored
feat: 통계 spec 구현 (#69)
* 사이드바 통계 섹션 추가 및 아이콘 변경 * 마이페이지 통계 섹션 제거 * 통계 타이틀 추가 * 드래그 영역 설정 * 각 영역만 일단 설정 * 통계 타이틀 영역 버튼 스타일링 * 총 집중시간 영역 구현 * 시간 표시를 위한 유틸 함수 추가 및 적용 * 집중 기록 ui 구현 * 목데이터로 바 차트 구현 * 랭킹 ui 목데이터로 추가 * 툴팁이 빈내용으로 보이는거 방지 * 마우스 올릴때 커서 변경되도록 * 통계 조회를 위한 api hook 추가 * 집중 기록 api 연동 * 집중 추세 api 연동 * 카테고리 랭킹 api 연동 * 통계는 항상 최신값 가져오도록 * dev-tool 위치 이동 * 유저 가입일부터 통계 조회되도록 * 시간 기록 start, end 시간 표시 * 서버 데이터에 맞춰 차트 구성 또한 차트 테스트를 위한 목데이터도 추가 * 통계 데이터 로딩표시 추가 * copilot 리뷰 반영 * browserslist 업데이트 * 엣지 케이스 처리 * 불변성 적용
1 parent 8b1331e commit 472cf5e

39 files changed

+1078
-109
lines changed

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"@lottiefiles/dotlottie-react": "^0.8.8",
6565
"@radix-ui/react-dialog": "^1.1.1",
6666
"@radix-ui/react-popover": "^1.1.6",
67-
"@radix-ui/react-slot": "^1.1.0",
67+
"@radix-ui/react-slot": "^1.2.2",
6868
"@radix-ui/react-toast": "^1.2.1",
6969
"@radix-ui/react-toggle": "^1.1.0",
7070
"@radix-ui/react-toggle-group": "^1.1.0",
@@ -76,14 +76,17 @@
7676
"@tanstack/react-query-persist-client": "^5.51.9",
7777
"class-variance-authority": "^0.7.0",
7878
"clsx": "^2.1.1",
79+
"date-fns": "^4.1.0",
7980
"electron-squirrel-startup": "^1.0.1",
8081
"embla-carousel-autoplay": "^8.2.0",
8182
"embla-carousel-react": "^8.1.6",
8283
"lucide-react": "^0.408.0",
8384
"node-machine-id": "^1.1.12",
8485
"react": "^18.3.1",
86+
"react-day-picker": "8.10.1",
8587
"react-dom": "^18.3.1",
8688
"react-router-dom": "^6.25.0",
89+
"recharts": "^2.15.3",
8790
"tailwind-merge": "^2.4.0",
8891
"tailwindcss-animate": "^1.0.7",
8992
"update-electron-app": "^3.0.0",

src/renderer/app/provider.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const Provider = ({ children }: PropsWithChildren) => {
3333
>
3434
{children}
3535
<Toaster />
36-
<ReactQueryDevtools buttonPosition="bottom-left" initialIsOpen={false} />
36+
<ReactQueryDevtools buttonPosition="bottom-right" initialIsOpen={false} />
3737
</PersistQueryClientProvider>
3838
);
3939
};

src/renderer/app/router.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Naming from '@/pages/naming';
88
import Onboarding from '@/pages/onboarding';
99
import Pomodoro from '@/pages/pomodoro';
1010
import Selection from '@/pages/selection';
11+
import StatsPage from '@/pages/stats';
1112
import { PATH } from '@/shared/constants';
1213

1314
export const Router = () => {
@@ -22,6 +23,7 @@ export const Router = () => {
2223
<Route path={PATH.MY_PAGE} element={<MyPage />} />
2324
<Route path={PATH.MY_CAT} element={<MyCat />} />
2425
<Route path={PATH.CATEGORY} element={<Category />} />
26+
<Route path={PATH.STATS} element={<StatsPage />} />
2527
<Route path="*" element={<Navigate to={PATH.HOME} replace />} />
2628
</Routes>
2729
</ReactRouter>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './types';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Stats } from './types';
2+
3+
import { createIsoDuration } from '@/shared/utils';
4+
5+
export const mockTrend: Stats['weaklyFocusTimeTrend'] = {
6+
startDate: '2025-06-19',
7+
endDate: '2025-06-25',
8+
dateToFocusTimeStatistics: [
9+
{
10+
date: '2025-06-19',
11+
totalFocusTime: createIsoDuration({ hours: 20, minutes: 59 }),
12+
},
13+
{
14+
date: '2025-06-20',
15+
totalFocusTime: 'PT30M',
16+
},
17+
{
18+
date: '2025-06-21',
19+
totalFocusTime: 'PT30M',
20+
},
21+
{
22+
date: '2025-06-22',
23+
totalFocusTime: 'PT30M',
24+
},
25+
{
26+
date: '2025-06-23',
27+
totalFocusTime: 'PT30M',
28+
},
29+
{
30+
date: '2025-06-24',
31+
totalFocusTime: 'PT30M',
32+
},
33+
{
34+
date: '2025-06-25',
35+
totalFocusTime: 'PT30M',
36+
},
37+
],
38+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Category } from '../category';
2+
3+
export type Stats = {
4+
data: string;
5+
totalFocusTime: string;
6+
focusTimes: Array<{
7+
no: number;
8+
category: Pick<Category, 'no' | 'title' | 'iconType'>;
9+
totalFocusTime: string;
10+
startedAt: string;
11+
doneAt: string;
12+
}>;
13+
weaklyFocusTimeTrend: {
14+
startDate: string;
15+
endDate: string;
16+
dateToFocusTimeStatistics: Array<{
17+
date: string;
18+
totalFocusTime: string;
19+
}>;
20+
};
21+
categoryRanking: {
22+
startDate: string;
23+
endDate: string;
24+
rankingItems: Array<{
25+
rank: number;
26+
category: Pick<Category, 'no' | 'title' | 'iconType'>;
27+
totalFocusTime: string;
28+
}>;
29+
};
30+
};

src/renderer/entities/user/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,7 @@ export type User = {
99

1010
// 선택되어있는 고양이 정보
1111
cat: Cat | null;
12+
13+
// 가입일
14+
createdAt: string;
1215
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
3+
import { Stats } from '@/entities/stats';
4+
import { QUERY_KEY } from '@/shared/constants';
5+
import { useAuthClient } from '@/shared/hooks';
6+
7+
/**
8+
* @param date ISO 8601 형식, KST 기준 (예: '2025-10-01')
9+
*/
10+
export const useStats = (date: string) => {
11+
const authClient = useAuthClient();
12+
return useQuery({
13+
queryKey: [...QUERY_KEY.STATS, date],
14+
queryFn: async () => {
15+
return await authClient?.get<Stats>(`/api/v1/statistics/${date}`);
16+
},
17+
enabled: !!authClient && !!date,
18+
staleTime: 0,
19+
});
20+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './ui/stats-title';
2+
export * from './ui/stats-total-time';
3+
export * from './ui/stats-time-log';
4+
export * from './ui/stats-chart';
5+
export * from './ui/stats-ranks';
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { ComponentProps, useMemo, useState } from 'react';
2+
3+
import {
4+
BarChart,
5+
Bar,
6+
XAxis,
7+
YAxis,
8+
Tooltip,
9+
ResponsiveContainer,
10+
CartesianGrid,
11+
Cell,
12+
} from 'recharts';
13+
import { msToTimeString } from 'shared/util';
14+
15+
import { Stats } from '@/entities/stats';
16+
import { cn, isoDurationToMs } from '@/shared/utils';
17+
18+
export type StatsChartProps = {
19+
dataFromServer: Stats['weaklyFocusTimeTrend'];
20+
};
21+
22+
type YAxisProps = ComponentProps<typeof YAxis>;
23+
24+
const oneMinute = 60 * 1000; // 1분을 ms로 변환
25+
const oneHour = 60 * oneMinute; // 1시간을 ms로 변환
26+
27+
const toFormattedDate = (str: string) => {
28+
const date = new Date(str);
29+
return `${date.getMonth() + 1}/${date.getDate()}`;
30+
};
31+
const toFixed = (num: number, digit = 2) => Number(num.toFixed(digit));
32+
const toMinutes = (ms: number) => toFixed(ms / oneMinute);
33+
const toHours = (ms: number) => toFixed(ms / oneHour);
34+
35+
const range = ({ start, step, count }: { start: number; step: number; count: number }) => {
36+
return Array.from({ length: count + 1 }, (_, i) => start + i * step);
37+
};
38+
const minmax = (min: number, value: number, max: number) => {
39+
return Math.min(Math.max(value, min), max);
40+
};
41+
42+
export const StatsChart = ({ dataFromServer }: StatsChartProps) => {
43+
const [activeIndex, setActiveIndex] = useState(6);
44+
const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | undefined>();
45+
46+
const dataWithMs = useMemo(() => {
47+
return dataFromServer.dateToFocusTimeStatistics.map((item) => ({
48+
date: item.date,
49+
time: isoDurationToMs(item.totalFocusTime),
50+
}));
51+
}, [dataFromServer]);
52+
53+
const totalFocusTime = useMemo(() => {
54+
const totalMs = dataWithMs.reduce((acc, item) => acc + item.time, 0);
55+
if (totalMs === 0) return '0분';
56+
return msToTimeString(totalMs);
57+
}, [dataWithMs]);
58+
59+
const { data, ticks, tickUnit } = useMemo<{
60+
ticks: YAxisProps['ticks'];
61+
tickUnit: 'minute' | 'hour';
62+
data: typeof dataWithMs;
63+
}>(() => {
64+
const maxTime = Math.max(...dataWithMs.map((item) => item.time));
65+
66+
if (maxTime === 0) {
67+
return {
68+
ticks: [0, 10],
69+
tickUnit: 'minute',
70+
data: dataWithMs.map((item) => ({
71+
date: toFormattedDate(item.date),
72+
time: toMinutes(item.time),
73+
})),
74+
};
75+
}
76+
if (maxTime < oneHour) {
77+
return {
78+
ticks: [0, 15, 30, 45, 60],
79+
tickUnit: 'minute',
80+
data: dataWithMs.map((item) => ({
81+
date: toFormattedDate(item.date),
82+
time: toMinutes(item.time),
83+
})),
84+
};
85+
}
86+
if (maxTime < oneHour * 5) {
87+
// min: [0, 1, 2]
88+
// max: [0, 1, 2, 3, 4, 5]
89+
const ticks = range({
90+
start: 0,
91+
step: 1,
92+
count: minmax(2, Math.ceil(maxTime / oneHour), 5),
93+
});
94+
return {
95+
ticks: ticks,
96+
tickUnit: 'hour',
97+
data: dataWithMs.map((item) => ({
98+
date: toFormattedDate(item.date),
99+
time: toHours(item.time),
100+
})),
101+
};
102+
}
103+
if (maxTime < oneHour * 8) {
104+
return {
105+
// [0, 2, 4, 6, 8]
106+
ticks: range({ start: 0, step: 2, count: 4 }),
107+
tickUnit: 'hour',
108+
data: dataWithMs.map((item) => ({
109+
date: toFormattedDate(item.date),
110+
time: toHours(item.time),
111+
})),
112+
};
113+
}
114+
if (maxTime < oneHour * 20) {
115+
// min: [0, 5, 10]
116+
// max: [0, 5, 10, 15, 20]
117+
const ticks = range({
118+
start: 0,
119+
step: 5,
120+
count: minmax(2, Math.ceil(maxTime / (5 * oneHour)), 5),
121+
});
122+
return {
123+
ticks: ticks,
124+
tickUnit: 'hour',
125+
data: dataWithMs.map((item) => ({
126+
date: toFormattedDate(item.date),
127+
time: toHours(item.time),
128+
})),
129+
};
130+
}
131+
return {
132+
// [0, 6, 12, 18, 24]
133+
ticks: range({ start: 0, step: 6, count: 4 }),
134+
tickUnit: 'hour',
135+
data: dataWithMs.map((item) => ({
136+
date: toFormattedDate(item.date),
137+
time: toHours(item.time),
138+
})),
139+
};
140+
}, [dataWithMs]);
141+
142+
return (
143+
<div className="rounded-[16px] bg-white p-4">
144+
<h3 className="header-4 mb-[50px] text-text-secondary">{totalFocusTime}</h3>
145+
<ResponsiveContainer width="100%" minHeight={240}>
146+
<BarChart
147+
data={data}
148+
onMouseMove={(state) => {
149+
if (state.isTooltipActive && state.activeTooltipIndex != null) {
150+
setActiveIndex(state.activeTooltipIndex);
151+
152+
const barRects = document.querySelectorAll('.recharts-bar-rectangle');
153+
const rect = barRects[state.activeTooltipIndex] as SVGGraphicsElement | undefined;
154+
if (rect) {
155+
const { x, y, width } = rect.getBBox();
156+
setTooltipPosition({ x: x + width / 2, y: y - 8 });
157+
}
158+
}
159+
}}
160+
>
161+
<CartesianGrid vertical={false} strokeDasharray="3 3" />
162+
<XAxis
163+
dataKey="date"
164+
tick={{
165+
fontSize: 11,
166+
fill: '#8F867E',
167+
}}
168+
axisLine={{ stroke: '#DFD8D2' }}
169+
/>
170+
<YAxis
171+
width={36}
172+
tick={{
173+
fontSize: 11,
174+
fill: '#8F867E',
175+
}}
176+
domain={[0, 'dataMax']}
177+
ticks={ticks}
178+
tickFormatter={(tick) => `${tick}${tickUnit === 'minute' ? 'm' : 'h'}`}
179+
axisLine={false}
180+
tickLine={false}
181+
orientation="right"
182+
/>
183+
<Tooltip
184+
cursor={false}
185+
wrapperStyle={{
186+
pointerEvents: 'none',
187+
top: tooltipPosition?.y,
188+
left: tooltipPosition?.x,
189+
transform: 'translate(-50%, -100%)',
190+
}}
191+
content={(data) => {
192+
const { active, payload } = data;
193+
if (!active || !payload || payload.length === 0) return null;
194+
const realPayload = payload[0].payload;
195+
if (realPayload.time === 0) return null;
196+
let content = '';
197+
if (tickUnit === 'minute') {
198+
content = `${realPayload.time}분`;
199+
} else {
200+
const hour = Math.floor(realPayload.time);
201+
const minute = Math.round((realPayload.time - hour) * 60);
202+
content = [hour > 0 ? `${hour}시간` : '', minute > 0 ? `${minute}분` : '']
203+
.filter(Boolean)
204+
.join('\n');
205+
}
206+
return (
207+
<div className="flex flex-col items-center">
208+
<div
209+
className={cn(
210+
'caption-sb whitespace-pre-wrap rounded-[8px] bg-icon-primary px-2 py-1 text-center text-white',
211+
)}
212+
>
213+
{content}
214+
</div>
215+
<div className="-mt-[7px] h-0 w-0 border-l-[7px] border-r-[7px] border-t-[14px] border-l-transparent border-r-transparent border-t-icon-primary" />
216+
</div>
217+
);
218+
}}
219+
/>
220+
<Bar dataKey="time" barSize={20} radius={[6, 6, 0, 0]} minPointSize={8}>
221+
{data.map((entry, index) => (
222+
<Cell
223+
key={`cell-${index}`}
224+
fill={entry.time === 0 ? '#DFD8D2' : activeIndex === index ? '#F47A0A' : '#8F867E'}
225+
/>
226+
))}
227+
</Bar>
228+
</BarChart>
229+
</ResponsiveContainer>
230+
</div>
231+
);
232+
};

0 commit comments

Comments
 (0)