diff --git a/web/src/components/ActivityCalendar.tsx b/web/src/components/ActivityCalendar.tsx deleted file mode 100644 index 7d77d66d9..000000000 --- a/web/src/components/ActivityCalendar.tsx +++ /dev/null @@ -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; - 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 ( -
- {weekDays.map((day, index) => ( -
- {day} -
- ))} - {days.map((item, index) => { - const date = dayjs(`${year}-${month + 1}-${item.day}`).format("YYYY-MM-DD"); - - if (!item.isCurrentMonth) { - return ( -
- {item.day} -
- ); - } - - 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 ( - -
count && onClick && onClick(date)} - > - {item.day} -
-
- ); - })} -
- ); -}; - -export default ActivityCalendar; diff --git a/web/src/components/ActivityCalendar/ActivityCalendar.tsx b/web/src/components/ActivityCalendar/ActivityCalendar.tsx new file mode 100644 index 000000000..bb0fb7339 --- /dev/null +++ b/web/src/components/ActivityCalendar/ActivityCalendar.tsx @@ -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 = ( +
0 && "cursor-pointer hover:scale-110", + )} + onClick={count > 0 ? onClick : undefined} + > + {dayInfo.day} +
+ ); + + if (!dayInfo.isCurrentMonth) { + return ( +
+ {dayInfo.day} +
+ ); + } + + return ( + + {cellContent} + + ); + }, +); + +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 ( +
+ {weekDays.map((day, index) => ( +
+ {day} +
+ ))} + {days.map((dayInfo, index) => { + if (!dayInfo.isCurrentMonth) { + return ( + + ); + } + + 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 ( + onClick?.(date)} + tooltipText={tooltipText} + /> + ); + })} +
+ ); +}); + +ActivityCalendar.displayName = "ActivityCalendar"; diff --git a/web/src/components/ActivityCalendar/index.ts b/web/src/components/ActivityCalendar/index.ts new file mode 100644 index 000000000..925dcc7bb --- /dev/null +++ b/web/src/components/ActivityCalendar/index.ts @@ -0,0 +1 @@ +export { ActivityCalendar as default } from "./ActivityCalendar"; diff --git a/web/src/components/StatisticsView.tsx b/web/src/components/StatisticsView.tsx deleted file mode 100644 index ddb839ea1..000000000 --- a/web/src/components/StatisticsView.tsx +++ /dev/null @@ -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.fromPartial({})); - const [activityStats, setActivityStats] = useState>({}); - 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 ( -
-
-
- { - if (date) { - setVisibleMonthString(dayjs(date).format("YYYY-MM")); - } - }} - dateFormat="MMMM yyyy" - showMonthYearPicker - showFullMonthYearPicker - customInput={ - - {dayjs(visibleMonthString).toDate().toLocaleString(i18n.language, { year: "numeric", month: "long" })} - - } - popperPlacement="bottom-start" - calendarClassName="!bg-white !border-gray-200 !font-normal !shadow-lg" - /> -
-
- setVisibleMonthString(dayjs(visibleMonthString).subtract(1, "month").format("YYYY-MM"))} - > - - - setVisibleMonthString(dayjs(visibleMonthString).add(1, "month").format("YYYY-MM"))} - > - - -
-
-
- -
-
- {matchPath(Routes.ROOT, location.pathname) && - currentUser && - userStore.state.currentUserStats && - userStore.state.currentUserStats.pinnedMemos.length > 0 && ( -
memoFilterStore.addFilter({ factor: "pinned", value: "" })} - > -
- - {t("common.pinned")} -
- {userStore.state.currentUserStats.pinnedMemos.length} -
- )} -
memoFilterStore.addFilter({ factor: "property.hasLink", value: "" })} - > -
- - {t("memo.links")} -
- {memoTypeStats.linkCount} -
-
memoFilterStore.addFilter({ factor: "property.hasTaskList", value: "" })} - > -
- {memoTypeStats.undoCount > 0 ? : } - {t("memo.to-do")} -
- {memoTypeStats.undoCount > 0 ? ( - -
- {memoTypeStats.todoCount - memoTypeStats.undoCount} - / - {memoTypeStats.todoCount} -
-
- ) : ( - {memoTypeStats.todoCount} - )} -
-
memoFilterStore.addFilter({ factor: "property.hasCode", value: "" })} - > -
- - {t("memo.code")} -
- {memoTypeStats.codeCount} -
-
-
- ); -}); - -export default StatisticsView; diff --git a/web/src/components/StatisticsView/MonthNavigator.tsx b/web/src/components/StatisticsView/MonthNavigator.tsx new file mode 100644 index 000000000..d47d7561e --- /dev/null +++ b/web/src/components/StatisticsView/MonthNavigator.tsx @@ -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 ( +
+
+ { + if (date) { + onMonthChange(dayjs(date).format("YYYY-MM")); + } + }} + dateFormat="MMMM yyyy" + showMonthYearPicker + showFullMonthYearPicker + customInput={ + + {currentMonth.toLocaleString(i18n.language, { year: "numeric", month: "long" })} + + } + popperPlacement="bottom-start" + calendarClassName="!bg-white !border-gray-200 !font-normal !shadow-lg" + /> +
+
+ + +
+
+ ); +}; diff --git a/web/src/components/StatisticsView/StatCard.tsx b/web/src/components/StatisticsView/StatCard.tsx new file mode 100644 index 000000000..07b907abd --- /dev/null +++ b/web/src/components/StatisticsView/StatCard.tsx @@ -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 = ( +
+
+ {icon} + {label} +
+ {count} +
+ ); + + if (tooltip) { + return ( + + {content} + + ); + } + + return content; +}; diff --git a/web/src/components/StatisticsView/StatisticsView.tsx b/web/src/components/StatisticsView/StatisticsView.tsx new file mode 100644 index 000000000..1106722c1 --- /dev/null +++ b/web/src/components/StatisticsView/StatisticsView.tsx @@ -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 ( +
+ + +
+ +
+ +
+ {isRootPath && hasPinnedMemos && ( + } + label={t("common.pinned")} + count={userStore.state.currentUserStats!.pinnedMemos.length} + onClick={() => handleFilterClick("pinned")} + /> + )} + + } + label={t("memo.links")} + count={memoTypeStats.linkCount} + onClick={() => handleFilterClick("property.hasLink")} + /> + + 0 ? : + } + label={t("memo.to-do")} + count={ + memoTypeStats.undoCount > 0 ? ( +
+ {memoTypeStats.todoCount - memoTypeStats.undoCount} + / + {memoTypeStats.todoCount} +
+ ) : ( + memoTypeStats.todoCount + ) + } + onClick={() => handleFilterClick("property.hasTaskList")} + tooltip={memoTypeStats.undoCount > 0 ? "Done / Total" : undefined} + /> + + } + label={t("memo.code")} + count={memoTypeStats.codeCount} + onClick={() => handleFilterClick("property.hasCode")} + /> +
+
+ ); +}); + +export default StatisticsView; diff --git a/web/src/components/StatisticsView/index.ts b/web/src/components/StatisticsView/index.ts new file mode 100644 index 000000000..50bad2529 --- /dev/null +++ b/web/src/components/StatisticsView/index.ts @@ -0,0 +1 @@ +export { default } from "./StatisticsView"; diff --git a/web/src/css/tailwind.css b/web/src/css/tailwind.css index 88e1c8e54..0ed2fff00 100644 --- a/web/src/css/tailwind.css +++ b/web/src/css/tailwind.css @@ -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 { diff --git a/web/src/hooks/useStatisticsData.ts b/web/src/hooks/useStatisticsData.ts new file mode 100644 index 000000000..698f971a8 --- /dev/null +++ b/web/src/hooks/useStatisticsData.ts @@ -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]); +}; diff --git a/web/src/types/statistics.ts b/web/src/types/statistics.ts new file mode 100644 index 000000000..87b08feec --- /dev/null +++ b/web/src/types/statistics.ts @@ -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; + 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; +}