Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8,749 changes: 8,749 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@
"@hookform/resolvers": "^2.9.11",
"@reduxjs/toolkit": "^1.8.3",
"@tanstack/react-query": "^4.2.3",
"@tanstack/react-virtual": "^3.13.19",
"@types/papaparse": "^5.5.2",
"axios": "^0.27.2",
"clsx": "^2.1.0",
"daisyui": "^2.31.0",
"dayjs": "^1.11.10",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"html-to-image": "^1.11.13",
"i18next": "^23.10.1",
"jwt-decode": "^3.1.2",
"papaparse": "^5.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.34.2",
Expand Down
6 changes: 5 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import { useTranslation } from 'react-i18next'
import clsx from 'clsx'
import { Authentication, Layout, Loading, Notification } from '@/components'
import { Home, Note, Archive, Trash, NotFound, Login, Signup, Icons } from '@/pages'
import { Home, Note, Archive, Trash, Statistics, NotFound, Login, Signup, Icons } from '@/pages'

export default function App(): JSX.Element {
const { i18n } = useTranslation()
Expand Down Expand Up @@ -49,6 +49,10 @@ export default function App(): JSX.Element {
path="trash"
element={<Trash />}
/>
<Route
path="statistics"
element={<Statistics />}
/>
</Route>
<Route
path="/icons"
Expand Down
9 changes: 9 additions & 0 deletions src/components/Sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ export default function Sidebar(): JSX.Element {
onClickSidebarItem(ActiveSidebarItem.Trash)
}}
/>
<SidebarItem
shouldExpand={sidebarMode === 'expand' || (shouldExpand && sidebarMode === 'collapse')}
icon={<Icon.Statistics className="fill-black dark:fill-white" />}
title={t('layout:SIDEBAR.TITLE.STATISTICS')}
active={activeSidebarItem === ActiveSidebarItem.Statistics}
onClick={() => {
onClickSidebarItem(ActiveSidebarItem.Statistics)
}}
/>
</div>
)
}
129 changes: 129 additions & 0 deletions src/components/Statistics/BarChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { useTranslation } from 'react-i18next'
import { PriorityStats, PRIORITY_LABELS } from '@/utils'

interface BarChartProps {
data: PriorityStats[]
selectedPriority: 'high' | 'medium' | 'low' | null
onPrioritySelect: (priority: 'high' | 'medium' | 'low' | null) => void
className?: string
}

const PRIORITY_COLORS = {
high: '#ef4444',
medium: '#f59e0b',
low: '#10b981'
}

export default function BarChart({
data,
selectedPriority,
onPrioritySelect,
className
}: BarChartProps) {
const { t, i18n } = useTranslation(['statistics'])

const option = useMemo(() => {
const isDark = document.documentElement.classList.contains('dark')

const priorityLabels = data.map((item) =>
t(`statistics:PRIORITY.${item.priority.toUpperCase()}`)
)
const counts = data.map((item) => item.count)
const colors = data.map((item) => PRIORITY_COLORS[item.priority])

return {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: (params: any) => {
const param = params[0]
return `
<div style="padding: 8px;">
<div style="font-weight: bold; margin-bottom: 4px;">${param.name}</div>
<div>${t('statistics:CHART.TASK_COUNT')}: ${param.value}</div>
<div style="margin-top: 4px; font-size: 12px; color: #666;">${t('statistics:CHART.CLICK_TO_FILTER')}</div>
</div>
`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: priorityLabels,
axisLabel: {
color: isDark ? '#9ca3af' : '#6b7280'
},
axisLine: {
lineStyle: {
color: isDark ? '#4b5563' : '#e5e7eb'
}
}
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: {
color: isDark ? '#9ca3af' : '#6b7280'
},
splitLine: {
lineStyle: {
color: isDark ? '#374151' : '#f3f4f6'
}
}
},
series: [
{
name: t('statistics:CHART.TASK_COUNT'),
type: 'bar',
barWidth: '50%',
data: data.map((item, index) => ({
value: item.count,
itemStyle: {
color: PRIORITY_COLORS[item.priority],
borderRadius: [8, 8, 0, 0],
opacity: selectedPriority === null || selectedPriority === item.priority ? 1 : 0.3
}
})),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.3)'
}
}
}
]
}
}, [data, selectedPriority, t, i18n.language])

const onEvents = {
click: (params: any) => {
if (params.componentType === 'series') {
const clickedPriority = data[params.dataIndex]?.priority
if (clickedPriority) {
onPrioritySelect(selectedPriority === clickedPriority ? null : clickedPriority)
}
}
}
}

return (
<div className={className}>
<ReactECharts
option={option}
style={{ height: '300px', width: '100%' }}
onEvents={onEvents}
opts={{ renderer: 'canvas' }}
/>
</div>
)
}
155 changes: 155 additions & 0 deletions src/components/Statistics/LineChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import { DailyCompletedStats } from '@/utils'

interface LineChartProps {
data: DailyCompletedStats[]
selectedDate: string | null
onDateSelect: (date: string | null) => void
highlightTag?: string | null
className?: string
}

export default function LineChart({
data,
selectedDate,
onDateSelect,
highlightTag,
className
}: LineChartProps) {
const { t, i18n } = useTranslation(['statistics'])

const option = useMemo(() => {
const dates = data.map((d) => d.date)
const counts = data.map((d) => d.count)

const isDark = document.documentElement.classList.contains('dark')

return {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const param = params[0]
const dateData = data.find((d) => d.date === param.axisValue)
return `
<div style="padding: 8px;">
<div style="font-weight: bold; margin-bottom: 4px;">${param.axisValue}</div>
<div>${t('statistics:CHART.COMPLETED_TASKS')}: ${param.value}</div>
${dateData?.tasks.length ? `<div style="margin-top: 4px; font-size: 12px; color: #666;">${t('statistics:CHART.CLICK_TO_VIEW')}</div>` : ''}
</div>
`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '10%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLabel: {
formatter: (value: string) => dayjs(value).format('MM/DD'),
color: isDark ? '#9ca3af' : '#6b7280'
},
axisLine: {
lineStyle: {
color: isDark ? '#4b5563' : '#e5e7eb'
}
}
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: {
color: isDark ? '#9ca3af' : '#6b7280'
},
splitLine: {
lineStyle: {
color: isDark ? '#374151' : '#f3f4f6'
}
}
},
series: [
{
name: t('statistics:CHART.COMPLETED_TASKS'),
type: 'line',
smooth: true,
data: counts,
lineStyle: {
width: 3,
color: highlightTag ? '#f59e0b' : '#10b981'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: highlightTag ? 'rgba(245, 158, 11, 0.3)' : 'rgba(16, 185, 129, 0.3)'
},
{
offset: 1,
color: highlightTag ? 'rgba(245, 158, 11, 0.05)' : 'rgba(16, 185, 129, 0.05)'
}
]
}
},
itemStyle: {
color: highlightTag ? '#f59e0b' : '#10b981'
},
emphasis: {
itemStyle: {
borderWidth: 3,
borderColor: highlightTag ? '#f59e0b' : '#10b981'
}
},
markPoint:
selectedDate && dates.includes(selectedDate)
? {
data: [
{
coord: [selectedDate, counts[dates.indexOf(selectedDate)]],
itemStyle: { color: '#ef4444' }
}
],
symbol: 'circle',
symbolSize: 12
}
: undefined
}
]
}
}, [data, selectedDate, highlightTag, t, i18n.language])

const onEvents = {
click: (params: any) => {
if (params.componentType === 'series') {
const clickedDate = data[params.dataIndex]?.date
if (clickedDate) {
onDateSelect(selectedDate === clickedDate ? null : clickedDate)
}
}
}
}

return (
<div className={className}>
<ReactECharts
option={option}
style={{ height: '300px', width: '100%' }}
onEvents={onEvents}
opts={{ renderer: 'canvas' }}
/>
</div>
)
}
Loading