mirror of https://github.com/usememos/memos
chore: update statistics view
parent
376b25a69c
commit
673026ffa1
@ -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;
|
|
@ -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";
|
@ -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;
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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;
|
||||||
|
};
|
@ -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;
|
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./StatisticsView";
|
@ -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]);
|
||||||
|
};
|
@ -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…
Reference in New Issue