Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
673026ff
Commit
673026ff
authored
May 31, 2025
by
Johnny
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
chore: update statistics view
parent
376b25a6
Changes
11
Hide whitespace changes
Inline
Side-by-side
Showing
11 changed files
with
463 additions
and
277 deletions
+463
-277
ActivityCalendar.tsx
web/src/components/ActivityCalendar.tsx
+0
-117
ActivityCalendar.tsx
web/src/components/ActivityCalendar/ActivityCalendar.tsx
+170
-0
index.ts
web/src/components/ActivityCalendar/index.ts
+1
-0
StatisticsView.tsx
web/src/components/StatisticsView.tsx
+0
-160
MonthNavigator.tsx
web/src/components/StatisticsView/MonthNavigator.tsx
+51
-0
StatCard.tsx
web/src/components/StatisticsView/StatCard.tsx
+32
-0
StatisticsView.tsx
web/src/components/StatisticsView/StatisticsView.tsx
+97
-0
index.ts
web/src/components/StatisticsView/index.ts
+1
-0
tailwind.css
web/src/css/tailwind.css
+29
-0
useStatisticsData.ts
web/src/hooks/useStatisticsData.ts
+27
-0
statistics.ts
web/src/types/statistics.ts
+55
-0
No files found.
web/src/components/ActivityCalendar.tsx
deleted
100644 → 0
View file @
376b25a6
import
{
Tooltip
}
from
"@mui/joy"
;
import
dayjs
from
"dayjs"
;
import
{
workspaceStore
}
from
"@/store/v2"
;
import
{
cn
}
from
"@/utils"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
interface
Props
{
month
:
string
;
// Format: 2021-1
selectedDate
:
string
;
data
:
Record
<
string
,
number
>
;
onClick
?:
(
date
:
string
)
=>
void
;
}
const
getCellAdditionalStyles
=
(
count
:
number
,
maxCount
:
number
)
=>
{
if
(
count
===
0
)
{
return
""
;
}
const
ratio
=
count
/
maxCount
;
if
(
ratio
>
0.75
)
{
return
"bg-primary-darker/90 text-gray-100 dark:bg-primary-lighter/80"
;
}
else
if
(
ratio
>
0.5
)
{
return
"bg-primary-darker/70 text-gray-100 dark:bg-primary-lighter/60"
;
}
else
if
(
ratio
>
0.25
)
{
return
"bg-primary/70 text-gray-100 dark:bg-primary-lighter/40"
;
}
else
{
return
"bg-primary/50 text-gray-100 dark:bg-primary-lighter/20"
;
}
};
const
ActivityCalendar
=
(
props
:
Props
)
=>
{
const
t
=
useTranslate
();
const
{
month
:
monthStr
,
data
,
onClick
}
=
props
;
const
weekStartDayOffset
=
workspaceStore
.
state
.
generalSetting
.
weekStartDayOffset
;
const
year
=
dayjs
(
monthStr
).
toDate
().
getFullYear
();
const
month
=
dayjs
(
monthStr
).
toDate
().
getMonth
();
const
dayInMonth
=
new
Date
(
year
,
month
+
1
,
0
).
getDate
();
const
firstDay
=
(((
new
Date
(
year
,
month
,
1
).
getDay
()
-
weekStartDayOffset
)
%
7
)
+
7
)
%
7
;
const
lastDay
=
new
Date
(
year
,
month
,
dayInMonth
).
getDay
()
-
weekStartDayOffset
;
const
prevMonthDays
=
new
Date
(
year
,
month
,
0
).
getDate
();
const
WEEK_DAYS
=
[
t
(
"days.sun"
),
t
(
"days.mon"
),
t
(
"days.tue"
),
t
(
"days.wed"
),
t
(
"days.thu"
),
t
(
"days.fri"
),
t
(
"days.sat"
)];
const
weekDays
=
WEEK_DAYS
.
slice
(
weekStartDayOffset
).
concat
(
WEEK_DAYS
.
slice
(
0
,
weekStartDayOffset
));
const
maxCount
=
Math
.
max
(...
Object
.
values
(
data
));
const
days
=
[];
// Fill in previous month's days.
for
(
let
i
=
firstDay
-
1
;
i
>=
0
;
i
--
)
{
days
.
push
({
day
:
prevMonthDays
-
i
,
isCurrentMonth
:
false
});
}
// Fill in current month's days.
for
(
let
i
=
1
;
i
<=
dayInMonth
;
i
++
)
{
days
.
push
({
day
:
i
,
isCurrentMonth
:
true
});
}
// Fill in next month's days.
for
(
let
i
=
1
;
i
<
7
-
lastDay
;
i
++
)
{
days
.
push
({
day
:
i
,
isCurrentMonth
:
false
});
}
return
(
<
div
className=
{
cn
(
"w-full h-auto shrink-0 grid grid-cols-7 grid-flow-row gap-1"
)
}
>
{
weekDays
.
map
((
day
,
index
)
=>
(
<
div
key=
{
index
}
className=
{
cn
(
"w-6 h-5 text-xs flex justify-center items-center cursor-default opacity-60"
)
}
>
{
day
}
</
div
>
))
}
{
days
.
map
((
item
,
index
)
=>
{
const
date
=
dayjs
(
`${year}-${month + 1}-${item.day}`
).
format
(
"YYYY-MM-DD"
);
if
(
!
item
.
isCurrentMonth
)
{
return
(
<
div
key=
{
`${date}-${index}`
}
className=
{
cn
(
"w-6 h-6 text-xs lg:text-[13px] flex justify-center items-center cursor-default"
,
"opacity-60 text-gray-400"
)
}
>
{
item
.
day
}
</
div
>
);
}
const
count
=
item
.
isCurrentMonth
?
data
[
date
]
||
0
:
0
;
const
isToday
=
dayjs
().
format
(
"YYYY-MM-DD"
)
===
date
;
const
tooltipText
=
count
===
0
?
date
:
t
(
"memo.count-memos-in-date"
,
{
count
:
count
,
memos
:
count
===
1
?
t
(
"common.memo"
)
:
t
(
"common.memos"
),
date
:
date
,
}).
toLowerCase
();
const
isSelected
=
dayjs
(
props
.
selectedDate
).
format
(
"YYYY-MM-DD"
)
===
date
;
return
(
<
Tooltip
className=
"shrink-0"
key=
{
`${date}-${index}`
}
title=
{
tooltipText
}
placement=
"top"
arrow
>
<
div
className=
{
cn
(
"w-6 h-6 text-xs lg:text-[13px] flex justify-center items-center cursor-default"
,
"rounded-lg border-2 text-gray-400"
,
item
.
isCurrentMonth
&&
getCellAdditionalStyles
(
count
,
maxCount
),
item
.
isCurrentMonth
&&
isToday
&&
"border-zinc-400"
,
item
.
isCurrentMonth
&&
isSelected
&&
"font-medium border-zinc-400"
,
item
.
isCurrentMonth
&&
!
isToday
&&
!
isSelected
&&
"border-transparent"
,
)
}
onClick=
{
()
=>
count
&&
onClick
&&
onClick
(
date
)
}
>
{
item
.
day
}
</
div
>
</
Tooltip
>
);
})
}
</
div
>
);
};
export
default
ActivityCalendar
;
web/src/components/ActivityCalendar/ActivityCalendar.tsx
0 → 100644
View file @
673026ff
import
{
Tooltip
}
from
"@mui/joy"
;
import
dayjs
from
"dayjs"
;
import
{
memo
,
useMemo
}
from
"react"
;
import
{
workspaceStore
}
from
"@/store/v2"
;
import
type
{
ActivityCalendarProps
,
CalendarDay
}
from
"@/types/statistics"
;
import
{
cn
}
from
"@/utils"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
const
getCellOpacity
=
(
ratio
:
number
):
string
=>
{
if
(
ratio
===
0
)
return
""
;
if
(
ratio
>
0.75
)
return
"bg-primary-darker/90 text-gray-100 dark:bg-primary-lighter/80"
;
if
(
ratio
>
0.5
)
return
"bg-primary-darker/70 text-gray-100 dark:bg-primary-lighter/60"
;
if
(
ratio
>
0.25
)
return
"bg-primary/70 text-gray-100 dark:bg-primary-lighter/40"
;
return
"bg-primary/50 text-gray-100 dark:bg-primary-lighter/20"
;
};
const
CalendarCell
=
memo
(
({
dayInfo
,
count
,
maxCount
,
isToday
,
isSelected
,
onClick
,
tooltipText
,
}:
{
dayInfo
:
CalendarDay
;
count
:
number
;
maxCount
:
number
;
isToday
:
boolean
;
isSelected
:
boolean
;
onClick
?:
()
=>
void
;
tooltipText
:
string
;
})
=>
{
const
cellContent
=
(
<
div
className=
{
cn
(
"w-6 h-6 text-xs lg:text-[13px] flex justify-center items-center cursor-default"
,
"rounded-lg border-2 text-gray-400 transition-all duration-200"
,
dayInfo
.
isCurrentMonth
&&
getCellOpacity
(
count
/
maxCount
),
dayInfo
.
isCurrentMonth
&&
isToday
&&
"border-zinc-400"
,
dayInfo
.
isCurrentMonth
&&
isSelected
&&
"font-medium border-zinc-400"
,
dayInfo
.
isCurrentMonth
&&
!
isToday
&&
!
isSelected
&&
"border-transparent"
,
count
>
0
&&
"cursor-pointer hover:scale-110"
,
)
}
onClick=
{
count
>
0
?
onClick
:
undefined
}
>
{
dayInfo
.
day
}
</
div
>
);
if
(
!
dayInfo
.
isCurrentMonth
)
{
return
(
<
div
className=
{
cn
(
"w-6 h-6 text-xs lg:text-[13px] flex justify-center items-center cursor-default opacity-60 text-gray-400"
)
}
>
{
dayInfo
.
day
}
</
div
>
);
}
return
(
<
Tooltip
className=
"shrink-0"
title=
{
tooltipText
}
placement=
"top"
arrow
>
{
cellContent
}
</
Tooltip
>
);
},
);
CalendarCell
.
displayName
=
"CalendarCell"
;
export
const
ActivityCalendar
=
memo
((
props
:
ActivityCalendarProps
)
=>
{
const
t
=
useTranslate
();
const
{
month
:
monthStr
,
data
,
onClick
}
=
props
;
const
weekStartDayOffset
=
workspaceStore
.
state
.
generalSetting
.
weekStartDayOffset
;
const
{
days
,
weekDays
,
maxCount
}
=
useMemo
(()
=>
{
const
yearValue
=
dayjs
(
monthStr
).
toDate
().
getFullYear
();
const
monthValue
=
dayjs
(
monthStr
).
toDate
().
getMonth
();
const
dayInMonth
=
new
Date
(
yearValue
,
monthValue
+
1
,
0
).
getDate
();
const
firstDay
=
(((
new
Date
(
yearValue
,
monthValue
,
1
).
getDay
()
-
weekStartDayOffset
)
%
7
)
+
7
)
%
7
;
const
lastDay
=
new
Date
(
yearValue
,
monthValue
,
dayInMonth
).
getDay
()
-
weekStartDayOffset
;
const
prevMonthDays
=
new
Date
(
yearValue
,
monthValue
,
0
).
getDate
();
const
WEEK_DAYS
=
[
t
(
"days.sun"
),
t
(
"days.mon"
),
t
(
"days.tue"
),
t
(
"days.wed"
),
t
(
"days.thu"
),
t
(
"days.fri"
),
t
(
"days.sat"
)];
const
weekDaysOrdered
=
WEEK_DAYS
.
slice
(
weekStartDayOffset
).
concat
(
WEEK_DAYS
.
slice
(
0
,
weekStartDayOffset
));
const
daysArray
:
CalendarDay
[]
=
[];
// Previous month's days
for
(
let
i
=
firstDay
-
1
;
i
>=
0
;
i
--
)
{
daysArray
.
push
({
day
:
prevMonthDays
-
i
,
isCurrentMonth
:
false
});
}
// Current month's days
for
(
let
i
=
1
;
i
<=
dayInMonth
;
i
++
)
{
const
date
=
dayjs
(
`
${
yearValue
}
-
${
monthValue
+
1
}
-
${
i
}
`
).
format
(
"YYYY-MM-DD"
);
daysArray
.
push
({
day
:
i
,
isCurrentMonth
:
true
,
date
});
}
// Next month's days
for
(
let
i
=
1
;
i
<
7
-
lastDay
;
i
++
)
{
daysArray
.
push
({
day
:
i
,
isCurrentMonth
:
false
});
}
const
maxCountValue
=
Math
.
max
(...
Object
.
values
(
data
),
1
);
return
{
year
:
yearValue
,
month
:
monthValue
,
days
:
daysArray
,
weekDays
:
weekDaysOrdered
,
maxCount
:
maxCountValue
,
};
},
[
monthStr
,
data
,
weekStartDayOffset
,
t
]);
const
today
=
useMemo
(()
=>
dayjs
().
format
(
"YYYY-MM-DD"
),
[]);
const
selectedDateFormatted
=
useMemo
(()
=>
dayjs
(
props
.
selectedDate
).
format
(
"YYYY-MM-DD"
),
[
props
.
selectedDate
]);
return
(
<
div
className=
{
cn
(
"w-full h-auto shrink-0 grid grid-cols-7 grid-flow-row gap-1"
)
}
>
{
weekDays
.
map
((
day
,
index
)
=>
(
<
div
key=
{
index
}
className=
{
cn
(
"w-6 h-5 text-xs flex justify-center items-center cursor-default opacity-60"
)
}
>
{
day
}
</
div
>
))
}
{
days
.
map
((
dayInfo
,
index
)
=>
{
if
(
!
dayInfo
.
isCurrentMonth
)
{
return
(
<
CalendarCell
key=
{
`prev-next-${index}`
}
dayInfo=
{
dayInfo
}
count=
{
0
}
maxCount=
{
maxCount
}
isToday=
{
false
}
isSelected=
{
false
}
tooltipText=
""
/>
);
}
const
date
=
dayInfo
.
date
!
;
const
count
=
data
[
date
]
||
0
;
const
isToday
=
today
===
date
;
const
isSelected
=
selectedDateFormatted
===
date
;
const
tooltipText
=
count
===
0
?
date
:
t
(
"memo.count-memos-in-date"
,
{
count
:
count
,
memos
:
count
===
1
?
t
(
"common.memo"
)
:
t
(
"common.memos"
),
date
:
date
,
}).
toLowerCase
();
return
(
<
CalendarCell
key=
{
date
}
dayInfo=
{
dayInfo
}
count=
{
count
}
maxCount=
{
maxCount
}
isToday=
{
isToday
}
isSelected=
{
isSelected
}
onClick=
{
()
=>
onClick
?.(
date
)
}
tooltipText=
{
tooltipText
}
/>
);
})
}
</
div
>
);
});
ActivityCalendar
.
displayName
=
"ActivityCalendar"
;
web/src/components/ActivityCalendar/index.ts
0 → 100644
View file @
673026ff
export
{
ActivityCalendar
as
default
}
from
"./ActivityCalendar"
;
web/src/components/StatisticsView.tsx
deleted
100644 → 0
View file @
376b25a6
import
{
Tooltip
}
from
"@mui/joy"
;
import
dayjs
from
"dayjs"
;
import
{
countBy
}
from
"lodash-es"
;
import
{
CheckCircleIcon
,
ChevronRightIcon
,
ChevronLeftIcon
,
Code2Icon
,
LinkIcon
,
ListTodoIcon
,
BookmarkIcon
}
from
"lucide-react"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
useState
}
from
"react"
;
import
DatePicker
from
"react-datepicker"
;
import
{
matchPath
,
useLocation
}
from
"react-router-dom"
;
import
useAsyncEffect
from
"@/hooks/useAsyncEffect"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
import
i18n
from
"@/i18n"
;
import
{
Routes
}
from
"@/router"
;
import
{
userStore
}
from
"@/store/v2"
;
import
memoFilterStore
from
"@/store/v2/memoFilter"
;
import
{
UserStats_MemoTypeStats
}
from
"@/types/proto/api/v1/user_service"
;
import
{
cn
}
from
"@/utils"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
ActivityCalendar
from
"./ActivityCalendar"
;
import
"react-datepicker/dist/react-datepicker.css"
;
const
StatisticsView
=
observer
(()
=>
{
const
t
=
useTranslate
();
const
location
=
useLocation
();
const
currentUser
=
useCurrentUser
();
const
[
memoTypeStats
,
setMemoTypeStats
]
=
useState
<
UserStats_MemoTypeStats
>
(
UserStats_MemoTypeStats
.
fromPartial
({}));
const
[
activityStats
,
setActivityStats
]
=
useState
<
Record
<
string
,
number
>>
({});
const
[
selectedDate
]
=
useState
(
new
Date
());
const
[
visibleMonthString
,
setVisibleMonthString
]
=
useState
(
dayjs
(
selectedDate
.
toDateString
()).
format
(
"YYYY-MM"
));
useAsyncEffect
(
async
()
=>
{
const
memoTypeStats
=
UserStats_MemoTypeStats
.
fromPartial
({});
const
displayTimeList
:
Date
[]
=
[];
for
(
const
stats
of
Object
.
values
(
userStore
.
state
.
userStatsByName
))
{
displayTimeList
.
push
(...
stats
.
memoDisplayTimestamps
);
if
(
stats
.
memoTypeStats
)
{
memoTypeStats
.
codeCount
+=
stats
.
memoTypeStats
.
codeCount
;
memoTypeStats
.
linkCount
+=
stats
.
memoTypeStats
.
linkCount
;
memoTypeStats
.
todoCount
+=
stats
.
memoTypeStats
.
todoCount
;
memoTypeStats
.
undoCount
+=
stats
.
memoTypeStats
.
undoCount
;
}
}
setMemoTypeStats
(
memoTypeStats
);
setActivityStats
(
countBy
(
displayTimeList
.
map
((
date
)
=>
dayjs
(
date
).
format
(
"YYYY-MM-DD"
))));
},
[
userStore
.
state
.
userStatsByName
]);
const
onCalendarClick
=
(
date
:
string
)
=>
{
memoFilterStore
.
removeFilter
((
f
)
=>
f
.
factor
===
"displayTime"
);
memoFilterStore
.
addFilter
({
factor
:
"displayTime"
,
value
:
date
});
};
const
currentMonth
=
dayjs
(
visibleMonthString
).
toDate
();
return
(
<
div
className=
"group w-full mt-3 space-y-1 text-gray-500 dark:text-gray-400"
>
<
div
className=
"w-full mb-1 flex flex-row justify-between items-center gap-1"
>
<
div
className=
"relative text-sm font-medium inline-flex flex-row items-center w-auto dark:text-gray-400"
>
<
DatePicker
selected=
{
currentMonth
}
onChange=
{
(
date
)
=>
{
if
(
date
)
{
setVisibleMonthString
(
dayjs
(
date
).
format
(
"YYYY-MM"
));
}
}
}
dateFormat=
"MMMM yyyy"
showMonthYearPicker
showFullMonthYearPicker
customInput=
{
<
span
className=
"cursor-pointer text-base hover:text-gray-600 dark:hover:text-gray-300"
>
{
dayjs
(
visibleMonthString
).
toDate
().
toLocaleString
(
i18n
.
language
,
{
year
:
"numeric"
,
month
:
"long"
})
}
</
span
>
}
popperPlacement=
"bottom-start"
calendarClassName=
"!bg-white !border-gray-200 !font-normal !shadow-lg"
/>
</
div
>
<
div
className=
"flex justify-end items-center shrink-0 gap-1"
>
<
span
className=
"cursor-pointer hover:opacity-80"
onClick=
{
()
=>
setVisibleMonthString
(
dayjs
(
visibleMonthString
).
subtract
(
1
,
"month"
).
format
(
"YYYY-MM"
))
}
>
<
ChevronLeftIcon
className=
"w-5 h-auto shrink-0 opacity-40"
/>
</
span
>
<
span
className=
"cursor-pointer hover:opacity-80"
onClick=
{
()
=>
setVisibleMonthString
(
dayjs
(
visibleMonthString
).
add
(
1
,
"month"
).
format
(
"YYYY-MM"
))
}
>
<
ChevronRightIcon
className=
"w-5 h-auto shrink-0 opacity-40"
/>
</
span
>
</
div
>
</
div
>
<
div
className=
"w-full"
>
<
ActivityCalendar
month=
{
visibleMonthString
}
selectedDate=
{
selectedDate
.
toDateString
()
}
data=
{
activityStats
}
onClick=
{
onCalendarClick
}
/>
</
div
>
<
div
className=
"pt-1 w-full flex flex-row justify-start items-center gap-1 flex-wrap"
>
{
matchPath
(
Routes
.
ROOT
,
location
.
pathname
)
&&
currentUser
&&
userStore
.
state
.
currentUserStats
&&
userStore
.
state
.
currentUserStats
.
pinnedMemos
.
length
>
0
&&
(
<
div
className=
{
cn
(
"w-auto border dark:border-zinc-800 pl-1.5 pr-2 py-0.5 rounded-md flex justify-between items-center"
)
}
onClick=
{
()
=>
memoFilterStore
.
addFilter
({
factor
:
"pinned"
,
value
:
""
})
}
>
<
div
className=
"w-auto flex justify-start items-center mr-1"
>
<
BookmarkIcon
className=
"w-4 h-auto mr-1"
/>
<
span
className=
"block text-sm"
>
{
t
(
"common.pinned"
)
}
</
span
>
</
div
>
<
span
className=
"text-sm truncate"
>
{
userStore
.
state
.
currentUserStats
.
pinnedMemos
.
length
}
</
span
>
</
div
>
)
}
<
div
className=
{
cn
(
"w-auto border dark:border-zinc-800 pl-1.5 pr-2 py-0.5 rounded-md flex justify-between items-center"
)
}
onClick=
{
()
=>
memoFilterStore
.
addFilter
({
factor
:
"property.hasLink"
,
value
:
""
})
}
>
<
div
className=
"w-auto flex justify-start items-center mr-1"
>
<
LinkIcon
className=
"w-4 h-auto mr-1"
/>
<
span
className=
"block text-sm"
>
{
t
(
"memo.links"
)
}
</
span
>
</
div
>
<
span
className=
"text-sm truncate"
>
{
memoTypeStats
.
linkCount
}
</
span
>
</
div
>
<
div
className=
{
cn
(
"w-auto border dark:border-zinc-800 pl-1.5 pr-2 py-0.5 rounded-md flex justify-between items-center"
)
}
onClick=
{
()
=>
memoFilterStore
.
addFilter
({
factor
:
"property.hasTaskList"
,
value
:
""
})
}
>
<
div
className=
"w-auto flex justify-start items-center mr-1"
>
{
memoTypeStats
.
undoCount
>
0
?
<
ListTodoIcon
className=
"w-4 h-auto mr-1"
/>
:
<
CheckCircleIcon
className=
"w-4 h-auto mr-1"
/>
}
<
span
className=
"block text-sm"
>
{
t
(
"memo.to-do"
)
}
</
span
>
</
div
>
{
memoTypeStats
.
undoCount
>
0
?
(
<
Tooltip
title=
{
"Done / Total"
}
placement=
"top"
arrow
>
<
div
className=
"text-sm flex flex-row items-start justify-center"
>
<
span
className=
"truncate"
>
{
memoTypeStats
.
todoCount
-
memoTypeStats
.
undoCount
}
</
span
>
<
span
className=
"font-mono opacity-50"
>
/
</
span
>
<
span
className=
"truncate"
>
{
memoTypeStats
.
todoCount
}
</
span
>
</
div
>
</
Tooltip
>
)
:
(
<
span
className=
"text-sm truncate"
>
{
memoTypeStats
.
todoCount
}
</
span
>
)
}
</
div
>
<
div
className=
{
cn
(
"w-auto border dark:border-zinc-800 pl-1.5 pr-2 py-0.5 rounded-md flex justify-between items-center"
)
}
onClick=
{
()
=>
memoFilterStore
.
addFilter
({
factor
:
"property.hasCode"
,
value
:
""
})
}
>
<
div
className=
"w-auto flex justify-start items-center mr-1"
>
<
Code2Icon
className=
"w-4 h-auto mr-1"
/>
<
span
className=
"block text-sm"
>
{
t
(
"memo.code"
)
}
</
span
>
</
div
>
<
span
className=
"text-sm truncate"
>
{
memoTypeStats
.
codeCount
}
</
span
>
</
div
>
</
div
>
</
div
>
);
});
export
default
StatisticsView
;
web/src/components/StatisticsView/MonthNavigator.tsx
0 → 100644
View file @
673026ff
import
dayjs
from
"dayjs"
;
import
{
ChevronLeftIcon
,
ChevronRightIcon
}
from
"lucide-react"
;
import
DatePicker
from
"react-datepicker"
;
import
i18n
from
"@/i18n"
;
import
type
{
MonthNavigatorProps
}
from
"@/types/statistics"
;
import
"react-datepicker/dist/react-datepicker.css"
;
export
const
MonthNavigator
=
({
visibleMonth
,
onMonthChange
}:
MonthNavigatorProps
)
=>
{
const
currentMonth
=
dayjs
(
visibleMonth
).
toDate
();
const
handlePrevMonth
=
()
=>
{
onMonthChange
(
dayjs
(
visibleMonth
).
subtract
(
1
,
"month"
).
format
(
"YYYY-MM"
));
};
const
handleNextMonth
=
()
=>
{
onMonthChange
(
dayjs
(
visibleMonth
).
add
(
1
,
"month"
).
format
(
"YYYY-MM"
));
};
return
(
<
div
className=
"w-full mb-1 flex flex-row justify-between items-center gap-1"
>
<
div
className=
"relative text-sm font-medium inline-flex flex-row items-center w-auto dark:text-gray-400"
>
<
DatePicker
selected=
{
currentMonth
}
onChange=
{
(
date
)
=>
{
if
(
date
)
{
onMonthChange
(
dayjs
(
date
).
format
(
"YYYY-MM"
));
}
}
}
dateFormat=
"MMMM yyyy"
showMonthYearPicker
showFullMonthYearPicker
customInput=
{
<
span
className=
"cursor-pointer text-base hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{
currentMonth
.
toLocaleString
(
i18n
.
language
,
{
year
:
"numeric"
,
month
:
"long"
})
}
</
span
>
}
popperPlacement=
"bottom-start"
calendarClassName=
"!bg-white !border-gray-200 !font-normal !shadow-lg"
/>
</
div
>
<
div
className=
"flex justify-end items-center shrink-0 gap-1"
>
<
button
className=
"p-1 cursor-pointer hover:opacity-80 transition-opacity"
onClick=
{
handlePrevMonth
}
aria
-
label=
"Previous month"
>
<
ChevronLeftIcon
className=
"w-5 h-auto shrink-0 opacity-40"
/>
</
button
>
<
button
className=
"p-1 cursor-pointer hover:opacity-80 transition-opacity"
onClick=
{
handleNextMonth
}
aria
-
label=
"Next month"
>
<
ChevronRightIcon
className=
"w-5 h-auto shrink-0 opacity-40"
/>
</
button
>
</
div
>
</
div
>
);
};
web/src/components/StatisticsView/StatCard.tsx
0 → 100644
View file @
673026ff
import
{
Tooltip
}
from
"@mui/joy"
;
import
type
{
StatCardProps
}
from
"@/types/statistics"
;
import
{
cn
}
from
"@/utils"
;
export
const
StatCard
=
({
icon
,
label
,
count
,
onClick
,
tooltip
,
className
}:
StatCardProps
)
=>
{
const
content
=
(
<
div
className=
{
cn
(
"w-auto border dark:border-zinc-800 pl-1.5 pr-2 py-0.5 rounded-md flex justify-between items-center"
,
"cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
,
className
,
)
}
onClick=
{
onClick
}
>
<
div
className=
"w-auto flex justify-start items-center mr-1"
>
{
icon
}
<
span
className=
"block text-sm"
>
{
label
}
</
span
>
</
div
>
<
span
className=
"text-sm truncate"
>
{
count
}
</
span
>
</
div
>
);
if
(
tooltip
)
{
return
(
<
Tooltip
title=
{
tooltip
}
placement=
"top"
arrow
>
{
content
}
</
Tooltip
>
);
}
return
content
;
};
web/src/components/StatisticsView/StatisticsView.tsx
0 → 100644
View file @
673026ff
import
dayjs
from
"dayjs"
;
import
{
CheckCircleIcon
,
Code2Icon
,
LinkIcon
,
ListTodoIcon
,
BookmarkIcon
}
from
"lucide-react"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
useState
,
useCallback
}
from
"react"
;
import
{
matchPath
,
useLocation
}
from
"react-router-dom"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
import
{
useStatisticsData
}
from
"@/hooks/useStatisticsData"
;
import
{
Routes
}
from
"@/router"
;
import
{
userStore
}
from
"@/store/v2"
;
import
memoFilterStore
,
{
FilterFactor
}
from
"@/store/v2/memoFilter"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
ActivityCalendar
from
"../ActivityCalendar"
;
import
{
MonthNavigator
}
from
"./MonthNavigator"
;
import
{
StatCard
}
from
"./StatCard"
;
const
StatisticsView
=
observer
(()
=>
{
const
t
=
useTranslate
();
const
location
=
useLocation
();
const
currentUser
=
useCurrentUser
();
const
{
memoTypeStats
,
activityStats
}
=
useStatisticsData
();
const
[
selectedDate
]
=
useState
(
new
Date
());
const
[
visibleMonthString
,
setVisibleMonthString
]
=
useState
(
dayjs
().
format
(
"YYYY-MM"
));
const
handleCalendarClick
=
useCallback
((
date
:
string
)
=>
{
memoFilterStore
.
removeFilter
((
f
)
=>
f
.
factor
===
"displayTime"
);
memoFilterStore
.
addFilter
({
factor
:
"displayTime"
,
value
:
date
});
},
[]);
const
handleFilterClick
=
useCallback
((
factor
:
FilterFactor
,
value
:
string
=
""
)
=>
{
memoFilterStore
.
addFilter
({
factor
,
value
});
},
[]);
const
isRootPath
=
matchPath
(
Routes
.
ROOT
,
location
.
pathname
);
const
hasPinnedMemos
=
currentUser
&&
(
userStore
.
state
.
currentUserStats
?.
pinnedMemos
||
[]).
length
>
0
;
return
(
<
div
className=
"group w-full mt-3 space-y-1 text-gray-500 dark:text-gray-400 animate-fade-in"
>
<
MonthNavigator
visibleMonth=
{
visibleMonthString
}
onMonthChange=
{
setVisibleMonthString
}
/>
<
div
className=
"w-full animate-scale-in"
>
<
ActivityCalendar
month=
{
visibleMonthString
}
selectedDate=
{
selectedDate
.
toDateString
()
}
data=
{
activityStats
}
onClick=
{
handleCalendarClick
}
/>
</
div
>
<
div
className=
"pt-1 w-full flex flex-row justify-start items-center gap-1 flex-wrap"
>
{
isRootPath
&&
hasPinnedMemos
&&
(
<
StatCard
icon=
{
<
BookmarkIcon
className=
"w-4 h-auto mr-1"
/>
}
label=
{
t
(
"common.pinned"
)
}
count=
{
userStore
.
state
.
currentUserStats
!
.
pinnedMemos
.
length
}
onClick=
{
()
=>
handleFilterClick
(
"pinned"
)
}
/>
)
}
<
StatCard
icon=
{
<
LinkIcon
className=
"w-4 h-auto mr-1"
/>
}
label=
{
t
(
"memo.links"
)
}
count=
{
memoTypeStats
.
linkCount
}
onClick=
{
()
=>
handleFilterClick
(
"property.hasLink"
)
}
/>
<
StatCard
icon=
{
memoTypeStats
.
undoCount
>
0
?
<
ListTodoIcon
className=
"w-4 h-auto mr-1"
/>
:
<
CheckCircleIcon
className=
"w-4 h-auto mr-1"
/>
}
label=
{
t
(
"memo.to-do"
)
}
count=
{
memoTypeStats
.
undoCount
>
0
?
(
<
div
className=
"text-sm flex flex-row items-start justify-center"
>
<
span
className=
"truncate"
>
{
memoTypeStats
.
todoCount
-
memoTypeStats
.
undoCount
}
</
span
>
<
span
className=
"font-mono opacity-50"
>
/
</
span
>
<
span
className=
"truncate"
>
{
memoTypeStats
.
todoCount
}
</
span
>
</
div
>
)
:
(
memoTypeStats
.
todoCount
)
}
onClick=
{
()
=>
handleFilterClick
(
"property.hasTaskList"
)
}
tooltip=
{
memoTypeStats
.
undoCount
>
0
?
"Done / Total"
:
undefined
}
/>
<
StatCard
icon=
{
<
Code2Icon
className=
"w-4 h-auto mr-1"
/>
}
label=
{
t
(
"memo.code"
)
}
count=
{
memoTypeStats
.
codeCount
}
onClick=
{
()
=>
handleFilterClick
(
"property.hasCode"
)
}
/>
</
div
>
</
div
>
);
});
export
default
StatisticsView
;
web/src/components/StatisticsView/index.ts
0 → 100644
View file @
673026ff
export
{
default
}
from
"./StatisticsView"
;
web/src/css/tailwind.css
View file @
673026ff
...
@@ -20,6 +20,35 @@
...
@@ -20,6 +20,35 @@
overflow-wrap
:
anywhere
;
overflow-wrap
:
anywhere
;
word-break
:
normal
;
word-break
:
normal
;
}
}
/* Animation utilities for smooth transitions */
.animate-fade-in
{
animation
:
fadeIn
0.3s
ease-in-out
;
}
.animate-scale-in
{
animation
:
scaleIn
0.2s
ease-out
;
}
@keyframes
fadeIn
{
from
{
opacity
:
0
;
}
to
{
opacity
:
1
;
}
}
@keyframes
scaleIn
{
from
{
transform
:
scale
(
0.95
);
opacity
:
0
;
}
to
{
transform
:
scale
(
1
);
opacity
:
1
;
}
}
}
}
html
.dark
{
html
.dark
{
...
...
web/src/hooks/useStatisticsData.ts
0 → 100644
View file @
673026ff
import
dayjs
from
"dayjs"
;
import
{
countBy
}
from
"lodash-es"
;
import
{
useMemo
}
from
"react"
;
import
{
userStore
}
from
"@/store/v2"
;
import
{
UserStats_MemoTypeStats
}
from
"@/types/proto/api/v1/user_service"
;
import
type
{
StatisticsData
}
from
"@/types/statistics"
;
export
const
useStatisticsData
=
():
StatisticsData
=>
{
return
useMemo
(()
=>
{
const
memoTypeStats
=
UserStats_MemoTypeStats
.
fromPartial
({});
const
displayTimeList
:
Date
[]
=
[];
for
(
const
stats
of
Object
.
values
(
userStore
.
state
.
userStatsByName
))
{
displayTimeList
.
push
(...
stats
.
memoDisplayTimestamps
);
if
(
stats
.
memoTypeStats
)
{
memoTypeStats
.
codeCount
+=
stats
.
memoTypeStats
.
codeCount
;
memoTypeStats
.
linkCount
+=
stats
.
memoTypeStats
.
linkCount
;
memoTypeStats
.
todoCount
+=
stats
.
memoTypeStats
.
todoCount
;
memoTypeStats
.
undoCount
+=
stats
.
memoTypeStats
.
undoCount
;
}
}
const
activityStats
=
countBy
(
displayTimeList
.
map
((
date
)
=>
dayjs
(
date
).
format
(
"YYYY-MM-DD"
)));
return
{
memoTypeStats
,
activityStats
};
},
[
userStore
.
state
.
userStatsByName
]);
};
web/src/types/statistics.ts
0 → 100644
View file @
673026ff
import
{
UserStats_MemoTypeStats
}
from
"@/types/proto/api/v1/user_service"
;
export
interface
ActivityData
{
date
:
string
;
count
:
number
;
}
export
interface
CalendarDay
{
day
:
number
;
isCurrentMonth
:
boolean
;
date
?:
string
;
}
export
interface
StatCardData
{
id
:
string
;
icon
:
React
.
ComponentType
<
{
className
?:
string
}
>
;
label
:
string
;
count
:
number
;
filter
:
{
factor
:
string
;
value
?:
string
;
};
tooltip
?:
string
;
visible
?:
boolean
;
}
export
interface
StatisticsViewProps
{
className
?:
string
;
}
export
interface
MonthNavigatorProps
{
visibleMonth
:
string
;
onMonthChange
:
(
month
:
string
)
=>
void
;
}
export
interface
ActivityCalendarProps
{
month
:
string
;
selectedDate
:
string
;
data
:
Record
<
string
,
number
>
;
onClick
?:
(
date
:
string
)
=>
void
;
}
export
interface
StatCardProps
{
icon
:
React
.
ReactNode
;
label
:
string
;
count
:
number
|
React
.
ReactNode
;
onClick
:
()
=>
void
;
tooltip
?:
string
;
className
?:
string
;
}
export
interface
StatisticsData
{
memoTypeStats
:
UserStats_MemoTypeStats
;
activityStats
:
Record
<
string
,
number
>
;
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment