Chart.js와 date-fns로 일주일 단위의 독서 통계 차트 만들기

정우희
29 min readNov 25, 2023

--

현재 진행중인 프로젝트 checkit에서 일주일 단위로 매일 몇 장의 독서를 하는 지 데이터를 통계내는 작업을 해야했다.

내가 들어오기 전 기존에 진행 중이였던 라이브러리가 chart.js였기에 그대로 이어나가서 작업을 진행하기로 결정했다.

Chart.js

8개의 차트 유형(막대 그래프, 선도표, 에어리어 차트, 원그래프, 버블 차트, 레이더 차트, 폴라, 산점도)을 지원하는

데이터 시각화를 위한 오픈 소스 자바스크립트 라이브러리

date-fns

자바스크립트의 날짜와 시간 처리 라이브러리

장점:

다양한 날짜 계산 기능 제공

Native Date 객체를 사용함

함수형으로 사용하기 용이함

폴더구조는 다음과 같다.

├── src
│ ├── components
│ │ ├── statistics
│ │ │ ├── BarChat.tsx
│ │ │ └── LineChart.tsx
│ ├── pages
│ │ └── Statistics.tsx

내가 완성해야되는 부분은 다음과 같았다.

  1. 처음 렌더링 될 때 이번 주의 데이터가 먼저 뜨게 하기
  2. 이전 주, 다음 주 버튼을 눌렀을 때 해당 주에 맞는 데이터 불러오기

첫 번째, 이번 주의 날짜가 먼저 뜨게 하는 건 그렇게 어렵지 않았다.

 // 시작 날짜와 끝 날짜의 상태 설정
const [startDate, setStartDate] = useState(startOfWeek(new Date(), { weekStartsOn: 1 })); // 현재 주의 월요일
const [endDate, setEndDate] = useState(endOfWeek(new Date(), { weekStartsOn: 1 })); // 현재 주의 일요일

useEffect(() => {
fetchData(); // 페이지 로드 시 데이터를 가져옴
}, []); // 빈 배열을 사용하여 페이지 로드 시 한 번만 실행되도록 설정

const fetchData = async () => {
try {
const accessToken = localStorage.getItem('accessToken');

const response = await baseInstance.get('/readingvolumes', {
params: {
startDate: format(startDate, 'yyyy-MM-dd'), // 시작 날짜를 ISO 형식으로 변환하여 API 요청
endDate: format(endDate, 'yyyy-MM-dd'), // 끝 날짜를 ISO 형식으로 변환하여 API 요청
},
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
  1. 초기 데이터 및 상태를 설정하고, 여기서 date-fns 라이브러리의 format 함수를 사용하여 startDateendDate를 현재 주의 월요일과 일요일로 초기화한다.
  2. 페이지가 로드 될 때 데이터를 가져오는 fetchData 함수를 useEffect를 사용하여 페이지 로드 시에 한 번 호출한다.

문제는 두 번째 task 였다.

아무리 로직을 바꿔서 이전 주, 다음 주 버튼을 눌러도 처음 렌더링 될 때 호출된 “이번 주의 데이터” 만이 그래프로 나왔다.

key prop을 사용하여 startDate와 endDate가 변경될 때마다 BarChart 컴포넌트, LineChart 컴포넌트를 강제로 리렌더링하는 것으로 이전주, 다음주 버튼을 누를때마다 차트가 리렌더링 되게끔 로직을 수정했다.

하지만 이럼에도 불구하고 여전히 요청한 날짜와 다르게 이번 주의 데이터만 그래프로 반환되는 것을 확인하고 뭔가 이상함을 느껴 console.log를 자세히 찍어봤다.

확인해보니 데이터 요청에는 문제가 없었던 것을 확인하였고 백엔드 측에서 현재 날짜를 기준으로 일주일의 데이터만 보내는 문제를 확인할 수 있었다.

이에 대해 백엔드 측에서 “API 요청시 원하는 날짜를 파라미터로 요청하면 요청한 날짜로부터 앞으로 1주일 데이터를 반환” 하도록 코드를 수정해주었다.

나 역시도 이에 맞춰, endDate 파라미터 값으로 이전 주, 다음 주 버튼을 누를때마다 그 주의 독서 데이터를 가져오는 것으로 코드를 수정하였고

가져온 데이터를 날짜 순으로 정렬하며

다음과 같이 원하는 결과를 얻게 되었다.

(+ 추가적으로 데이터 값이 존재하지 않는 주에는 “책을 읽지 않으셨네요!” 라는 문구가 뜨게끔 로직 추가)

이후 “차트에 데이터가 없는 날짜도 그래프에 보이게 해달라” 는 팀원의 요청을 받았다.

// startDate부터 endDate까지의 모든 날짜를 포함하는 labels 배열 생성
const dateRange = eachDayOfInterval({ start: startDate, end: endDate })
const labels = dateRange.map((day) => format(day, 'yyyy.MM.dd'))

// 모든 날짜에 대한 기본 데이터 값 설정 (여기서는 0으로 설정)
const defaultData = new Array(labels.length).fill(0)

// API에서 반환된 데이터가 있는 날짜를 찾아 해당 값으로 업데이트
sortedData.forEach((entry: { date: number[]; page: number }) => {
const entryDateStr = format(
new Date(entry.date[0], entry.date[1] - 1, entry.date[2]),
'yyyy.MM.dd',
)
const index = labels.indexOf(entryDateStr)
if (index !== -1) {
defaultData[index] = entry.page
}
})

다음과 같이 date-fns의 `eachDayOfInterval` 함수를 사용해서 시작일-종료일 (이번 주) 사이에 날짜들을 조회하여 배열을 생성 후,

구한 모든 날짜에 기본 데이터 값으로 0을 주고

API 호출을 통해서 반한된 데이터 값이 존재하는 날짜만 해당 데이터 값으로 상태를 업데이트하는 방식의 로직으로 변경했다.

최종 통계 페이지 결과물

전체 코드

Statistics.tsx

import React, { useState } from 'react'
import '../scss/MyShelf.scss'
import Toolbar from '../components/statistics/Toolbar'
import BarChart from '../components/statistics/BarChart'
import LineChart from '../components/statistics/LineChart'
import MyHeader from '../components/Header/MyHeader'
import { addWeeks, endOfWeek, startOfWeek, subWeeks } from 'date-fns'

export default function Statistics() {
const [activeTab, setActiveTab] = useState('bar')
const [startDate, setStartDate] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }))
const [endDate, setEndDate] = useState(endOfWeek(new Date(), { weekStartsOn: 1 }))

const handleTabToggle = (tab: React.SetStateAction<string>) => {
setActiveTab(tab)
}

const handlePrevWeek = () => {
const prevStartDate = subWeeks(startDate, 1)
const prevEndDate = subWeeks(endDate, 1)
console.log('이전 주로 변경:', prevStartDate, prevEndDate)
setStartDate(prevStartDate)
setEndDate(prevEndDate)
}

const handleNextWeek = () => {
const nextStartDate = addWeeks(startDate, 1)
const nextEndDate = addWeeks(endDate, 1)
console.log('다음 주로 변경:', nextStartDate, nextEndDate)
setStartDate(nextStartDate)
setEndDate(nextEndDate)
}

return (
<div className="flex flex-col h-screen">
<div>
<MyHeader />
</div>
<div className="mt-32">
<Toolbar onTabToggle={handleTabToggle} />
</div>
<div className="flex-grow relative">
{activeTab === 'bar' ? (
<div className="h-screen w-fit">
<BarChart
key={`${startDate}-${endDate}`}
startDate={startDate}
endDate={endDate}
handlePrevWeek={handlePrevWeek}
handleNextWeek={handleNextWeek}
/>
</div>
) : (
<div className="h-screen w-fit">
<LineChart
key={`${startDate}-${endDate}`}
startDate={startDate}
endDate={endDate}
handlePrevWeek={handlePrevWeek}
handleNextWeek={handleNextWeek}
/>
</div>
)}
</div>
</div>
)
}

BarChart.tsx

import React, { useState, useEffect } from 'react'
import { Bar } from 'react-chartjs-2'
import {
Chart,
CategoryScale,
LinearScale,
PointElement,
BarElement,
Tooltip,
Title,
Legend,
ChartConfiguration,
} from 'chart.js'
import 'chartjs-adapter-date-fns' // 날짜 형식을 지원하기 위한 Chart.js 어댑터
import { eachDayOfInterval, format } from 'date-fns' // 날짜와 시간 처리를 위한 라이브러리
import { IoMdArrowBack, IoMdArrowForward } from 'react-icons/io'
import { baseInstance } from '../../api/config'

Chart.register(CategoryScale, LinearScale, PointElement, BarElement, Tooltip, Title, Legend)

interface BarChartProps {
startDate: Date
endDate: Date
handlePrevWeek: () => void
handleNextWeek: () => void
}

const BarChart: React.FC<BarChartProps> = ({
startDate,
endDate,
handlePrevWeek: propsHandlePrevWeek,
handleNextWeek: propsHandleNextWeek,
}) => {
console.log('BarChart 렌더링 -', startDate, endDate)

// 차트 데이터와 날짜 상태를 초기화합니다.
const [data, setData] = useState<any>({
labels: [],
datasets: [
{
label: '독서 통계',
data: [],
backgroundColor: 'rgba(191, 198, 106, 1)',
borderColor: 'rgba(191, 198, 106, 1)',
borderWidth: 1,
},
],
})

const [isEmptyData, setIsEmptyData] = useState<boolean>(false)

useEffect(() => {
fetchData(endDate)
}, [endDate])

const fetchData = async (endDate: Date) => {
try {
const formattedEndDate = format(endDate, 'yyyy-MM-dd')
const accessToken = localStorage.getItem('accessToken')

// API로 데이터를 요청하고 응답을 받아옵니다.
const response = await baseInstance.get(`/readingvolumes/${formattedEndDate}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})

if (response.status === 200) {
const responseData = response.data

const sortedData = responseData.data.sort(
(a: { date: number[] }, b: { date: number[] }) => {
const dateA = new Date(a.date[0], a.date[1] - 1, a.date[2])
const dateB = new Date(b.date[0], b.date[1] - 1, b.date[2])
return dateA.getTime() - dateB.getTime()
},
)

// startDate부터 endDate까지의 모든 날짜를 포함하는 labels 배열 생성
const dateRange = eachDayOfInterval({ start: startDate, end: endDate })
const labels = dateRange.map((day) => format(day, 'yyyy.MM.dd'))

// 모든 날짜에 대한 기본 데이터 값 설정 (여기서는 0으로 설정)
const defaultData = new Array(labels.length).fill(0)

// API에서 반환된 데이터가 있는 날짜를 찾아 해당 값으로 업데이트
sortedData.forEach((entry: { date: number[]; page: number }) => {
const entryDateStr = format(
new Date(entry.date[0], entry.date[1] - 1, entry.date[2]),
'yyyy.MM.dd',
)
const index = labels.indexOf(entryDateStr)
if (index !== -1) {
defaultData[index] = entry.page
}
})

const hasData = defaultData.some((value) => value > 0)
setIsEmptyData(!hasData)

// 새로운 차트 데이터로 상태를 업데이트합니다.
const newChartData = {
labels,
datasets: [
{
label: '독서 통계',
data: defaultData,
backgroundColor: 'rgba(191, 198, 106, 1)',
borderColor: 'rgba(191, 198, 106, 1)',
borderWidth: 1,
},
],
}

setData(newChartData)

// 로그 출력
console.log(
'API 요청 날짜:',
format(startDate, 'yyyy-MM-dd'),
format(endDate, 'yyyy-MM-dd'),
)
console.log('API 응답 데이터:', responseData.data)
console.log('가공된 차트 데이터:', newChartData)
} else {
console.error('API 요청 실패:', response.status, response.statusText)
setIsEmptyData(true) // API 요청 실패 시 데이터가 없는 것으로 간주
}
} catch (error) {
console.error('API 요청 중 오류 발생:', error)
setIsEmptyData(true) // 예외 발생 시 데이터가 없는 것으로 간주
}
}

const movePrevWeek = () => {
propsHandlePrevWeek() // props로 전달받은 함수 호출
}

const moveNextWeek = () => {
propsHandleNextWeek() // props로 전달받은 함수 호출
}

const options: ChartConfiguration<'bar'> = {
type: 'bar',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
enabled: true,
mode: 'nearest',
intersect: false,
callbacks: {
label: (context: any) => `${context.parsed.y} 장`,
},
},
legend: {
display: true,
position: 'top',
},
},
scales: {
x: {
display: true,
grid: {
display: false,
},
ticks: {
font: {
family: 'bmfont',
size: 20,
weight: 'bold',
},
},
},
y: {
display: true,
grid: {
display: true,
},
ticks: {
stepSize: 5,
font: {
family: 'bmfont',
size: 25,
weight: 'bold',
},
},
},
},
},
}

return (
<div
className="absolute inset-0 flex flex-col items-center justify-center bg-white mb-20 h-3/4 "
style={{ fontFamily: 'bmfont' }}>
<div className="w-10/12 sm:w-8/12 lg:w-9/12 h-3/4">
<div className="flex z-10 items-center justify-between mb-4 border border-green-700 rounded-full p-2 absolute top-1 left-1/2 transform -translate-x-1/2">
<button
className="flex items-center justify-center w-8 h-8 text-gray-500 rounded-full hover:bg-gray-200"
onClick={movePrevWeek}>
<IoMdArrowBack className="w-5 h-5" />
</button>
<span className="text-lg font-semibold whitespace-nowrap">
{format(startDate, 'yyyy.MM.dd')} ~ {format(endDate, 'yyyy.MM.dd')}
</span>
<button
className="flex items-center justify-center w-8 h-8 text-gray-500 rounded-full hover.bg-gray-200"
onClick={moveNextWeek}>
<IoMdArrowForward className="w-5 h-5" />
</button>
</div>
<Bar {...options} />
{isEmptyData && (
<div className="absolute inset-0 flex items-center justify-center bg-white opacity-70">
<span className="text-xl font-bold">책을 읽지 않으셨네요!</span>
</div>
)}
</div>
</div>
)
}

export default BarChart

LineChart.tsx

import React, { useEffect, useState } from 'react'
import { Line } from 'react-chartjs-2'
import {
Chart,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Title,
Legend,
ChartConfiguration,
} from 'chart.js'
import 'chartjs-adapter-date-fns'
import { eachDayOfInterval, format } from 'date-fns'
import { IoMdArrowBack, IoMdArrowForward } from 'react-icons/io'
import { baseInstance } from '../../api/config'

Chart.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Title, Legend)

interface LineChartProps {
startDate: Date
endDate: Date
handlePrevWeek: () => void
handleNextWeek: () => void
}

const LineChart: React.FC<LineChartProps> = ({
startDate,
endDate,
handlePrevWeek: propsHandlePrevWeek,
handleNextWeek: propsHandleNextWeek,
}) => {
console.log('LineChart 렌더링 -', startDate, endDate)

const [isEmptyData, setIsEmptyData] = useState<boolean>(false)

const [data, setData] = useState<any>({
labels: [],
datasets: [
{
label: '독서 통계',
data: [],
fill: false,
backgroundColor: 'rgba(191, 198, 106, 1)',
borderColor: 'rgba(191, 198, 106, 1)',
pointHoverBackgroundColor: 'rgba(191, 198, 106, 1)',
pointHoverBorderColor: 'rgba(191, 198, 106, 1)',
pointBackgroundColor: 'rgba(191, 198, 106, 1)',
borderWidth: 5,
pointRadius: 6,
},
],
})

useEffect(() => {
fetchData(endDate)
}, [endDate])

const fetchData = async (endDate: Date) => {
try {
const formattedEndDate = format(endDate, 'yyyy-MM-dd')
const accessToken = localStorage.getItem('accessToken')

// API로 데이터를 요청하고 응답을 받아옵니다.
const response = await baseInstance.get(`/readingvolumes/${formattedEndDate}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})

if (response.status === 200) {
const responseData = response.data

const sortedData = responseData.data.sort(
(a: { date: number[] }, b: { date: number[] }) => {
const dateA = new Date(a.date[0], a.date[1] - 1, a.date[2])
const dateB = new Date(b.date[0], b.date[1] - 1, b.date[2])
return dateA.getTime() - dateB.getTime()
},
)

// startDate부터 endDate까지의 모든 날짜를 포함하는 labels 배열 생성
const dateRange = eachDayOfInterval({ start: startDate, end: endDate })
const labels = dateRange.map((day) => format(day, 'yyyy.MM.dd'))

// 모든 날짜에 대한 기본 데이터 값 설정 (여기서는 0으로 설정)
const defaultData = new Array(labels.length).fill(0)

// API에서 반환된 데이터가 있는 날짜를 찾아 해당 값으로 업데이트
sortedData.forEach((entry: { date: number[]; page: number }) => {
const entryDateStr = format(
new Date(entry.date[0], entry.date[1] - 1, entry.date[2]),
'yyyy.MM.dd',
)
const index = labels.indexOf(entryDateStr)
if (index !== -1) {
defaultData[index] = entry.page
}
})

const hasData = defaultData.some((value) => value > 0)
setIsEmptyData(!hasData)

// 새로운 차트 데이터로 상태를 업데이트합니다.
const newChartData = {
labels,
datasets: [
{
label: '독서 통계',
data: defaultData,
fill: false,
backgroundColor: 'rgba(191, 198, 106, 1)',
borderColor: 'rgba(191, 198, 106, 1)',
pointHoverBackgroundColor: 'rgba(191, 198, 106, 1)',
pointHoverBorderColor: 'rgba(191, 198, 106, 1)',
pointBackgroundColor: 'rgba(191, 198, 106, 1)',
borderWidth: 5,
pointRadius: 6,
},
],
}

setData(newChartData)

// 로그 출력
console.log(
'API 요청 날짜:',
format(startDate, 'yyyy-MM-dd'),
format(endDate, 'yyyy-MM-dd'),
)
console.log('API 응답 데이터:', responseData.data)
console.log('가공된 차트 데이터:', newChartData)
} else {
console.error('API 요청 실패:', response.status, response.statusText)
setIsEmptyData(true) // API 요청 실패 시 데이터가 없는 것으로 간주
}
} catch (error) {
console.error('API 요청 중 오류 발생:', error)
setIsEmptyData(true) // 예외 발생 시 데이터가 없는 것으로 간주
}
}

const movePrevWeek = () => {
propsHandlePrevWeek()
}

const moveNextWeek = () => {
propsHandleNextWeek()
}

const options: ChartConfiguration<'line'> = {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
enabled: true,
mode: 'nearest',
intersect: false,
callbacks: {
label: (context) => `${context.parsed.y} 장`,
},
},
legend: {
display: true,
position: 'top',
},
},
scales: {
x: {
display: true,
grid: {
display: false,
},
ticks: {
font: {
family: 'bmfont',
size: 20,
weight: 'bold',
},
},
},
y: {
display: true,
grid: {
display: true,
},
ticks: {
stepSize: 5,
font: {
family: 'bmfont',
size: 25,
weight: 'bold',
},
},
},
},
},
}

return (
<div
className="absolute inset-0 flex flex-col items-center justify-center bg-white mb-20 h-3/4 "
style={{ fontFamily: 'bmfont' }}>
<div className="w-10/12 sm:w-8/12 lg:w-9/12 h-3/4">
<div className="flex z-10 items-center justify-between mb-4 border border-green-700 rounded-full p-2 absolute top-1 left-1/2 transform -translate-x-1/2">
<button
className="flex items-center justify-center w-8 h-8 text-gray-500 rounded-full hover-bg-gray-200"
onClick={movePrevWeek}>
<IoMdArrowBack className="w-5 h-5" />
</button>
<span className="text-lg font-semibold whitespace-nowrap">
{format(startDate, 'yyyy.MM.dd')} ~ {format(endDate, 'yyyy.MM.dd')}
</span>
<button
className="flex items-center justify-center w-8 h-8 text-gray-500 rounded-full hover-bg-gray-200"
onClick={moveNextWeek}>
<IoMdArrowForward className="w-5 h-5" />
</button>
</div>
<Line {...options} />
{isEmptyData && (
<div className="absolute inset-0 flex items-center justify-center bg-white opacity-70">
<span className="text-xl font-bold">책을 읽지 않으셨네요!</span>
</div>
)}
</div>
</div>
)
}

export default LineChart

--

--