mirror of
				https://github.com/usememos/memos.git
				synced 2025-10-31 08:46:39 +08:00 
			
		
		
		
	chore: update statistics view
This commit is contained in:
		
							parent
							
								
									376b25a69c
								
							
						
					
					
						commit
						673026ffa1
					
				
					 11 changed files with 463 additions and 277 deletions
				
			
		|  | @ -1,117 +0,0 @@ | |||
| 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; | ||||
							
								
								
									
										170
									
								
								web/src/components/ActivityCalendar/ActivityCalendar.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								web/src/components/ActivityCalendar/ActivityCalendar.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,170 @@ | |||
| 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"; | ||||
							
								
								
									
										1
									
								
								web/src/components/ActivityCalendar/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								web/src/components/ActivityCalendar/index.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| export { ActivityCalendar as default } from "./ActivityCalendar"; | ||||
|  | @ -1,160 +0,0 @@ | |||
| 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; | ||||
							
								
								
									
										51
									
								
								web/src/components/StatisticsView/MonthNavigator.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								web/src/components/StatisticsView/MonthNavigator.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,51 @@ | |||
| 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> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										32
									
								
								web/src/components/StatisticsView/StatCard.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								web/src/components/StatisticsView/StatCard.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,32 @@ | |||
| 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; | ||||
| }; | ||||
							
								
								
									
										97
									
								
								web/src/components/StatisticsView/StatisticsView.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								web/src/components/StatisticsView/StatisticsView.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,97 @@ | |||
| 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; | ||||
							
								
								
									
										1
									
								
								web/src/components/StatisticsView/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								web/src/components/StatisticsView/index.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| export { default } from "./StatisticsView"; | ||||
|  | @ -20,6 +20,35 @@ | |||
|     overflow-wrap: anywhere; | ||||
|     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 { | ||||
|  |  | |||
							
								
								
									
										27
									
								
								web/src/hooks/useStatisticsData.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								web/src/hooks/useStatisticsData.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,27 @@ | |||
| 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]); | ||||
| }; | ||||
							
								
								
									
										55
									
								
								web/src/types/statistics.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								web/src/types/statistics.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,55 @@ | |||
| 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>; | ||||
| } | ||||
		Loading…
	
	Add table
		
		Reference in a new issue