mirror of https://github.com/usememos/memos
chore: add statistics view
parent
138b69e36e
commit
914c0620c4
@ -1,156 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { memoServiceClient } from "@/grpcweb";
|
|
||||||
import { DAILY_TIMESTAMP } from "@/helpers/consts";
|
|
||||||
import { getDateStampByDate, getDateString, getTimeStampByDate } from "@/helpers/datetime";
|
|
||||||
import * as utils from "@/helpers/utils";
|
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
|
||||||
import { useGlobalStore } from "@/store/module";
|
|
||||||
import { useMemoStore } from "@/store/v1";
|
|
||||||
import { useTranslate, Translations } from "@/utils/i18n";
|
|
||||||
import "@/less/usage-heat-map.less";
|
|
||||||
|
|
||||||
interface DailyUsageStat {
|
|
||||||
timestamp: number;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableConfig = {
|
|
||||||
width: 10,
|
|
||||||
height: 7,
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInitialCreationStats = (usedDaysAmount: number, beginDayTimestamp: number): DailyUsageStat[] => {
|
|
||||||
const initialUsageStat: DailyUsageStat[] = [];
|
|
||||||
for (let i = 1; i <= usedDaysAmount; i++) {
|
|
||||||
initialUsageStat.push({
|
|
||||||
timestamp: beginDayTimestamp + DAILY_TIMESTAMP * i,
|
|
||||||
count: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return initialUsageStat;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MemoCreationHeatMap = () => {
|
|
||||||
const t = useTranslate();
|
|
||||||
const navigateTo = useNavigateTo();
|
|
||||||
const user = useCurrentUser();
|
|
||||||
const memoStore = useMemoStore();
|
|
||||||
const todayTimeStamp = getDateStampByDate(Date.now());
|
|
||||||
const weekDay = new Date(todayTimeStamp).getDay();
|
|
||||||
const weekFromMonday = ["zh-Hans", "ko"].includes(useGlobalStore().state.locale);
|
|
||||||
const dayTips = weekFromMonday ? ["mon", "", "wed", "", "fri", "", "sun"] : ["sun", "", "tue", "", "thu", "", "sat"];
|
|
||||||
const todayDay = weekFromMonday ? (weekDay == 0 ? 7 : weekDay) : weekDay + 1;
|
|
||||||
const nullCell = new Array(7 - todayDay).fill(0);
|
|
||||||
const usedDaysAmount = (tableConfig.width - 1) * tableConfig.height + todayDay;
|
|
||||||
const beginDayTimestamp = todayTimeStamp - usedDaysAmount * DAILY_TIMESTAMP;
|
|
||||||
const [memoAmount, setMemoAmount] = useState(0);
|
|
||||||
const [creationStatus, setCreationStatus] = useState<DailyUsageStat[]>(getInitialCreationStats(usedDaysAmount, beginDayTimestamp));
|
|
||||||
const containerElRef = useRef<HTMLDivElement>(null);
|
|
||||||
const memos = Object.values(memoStore.getState().memoMapById);
|
|
||||||
const createdDays = Math.ceil((Date.now() - getTimeStampByDate(user.createTime)) / 1000 / 3600 / 24);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (memos.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const { memoCreationStats } = await memoServiceClient.getUserMemosStats({
|
|
||||||
name: user.name,
|
|
||||||
});
|
|
||||||
const tempStats = getInitialCreationStats(usedDaysAmount, beginDayTimestamp);
|
|
||||||
Object.entries(memoCreationStats).forEach(([k, v]) => {
|
|
||||||
const dayIndex = Math.floor((getDateStampByDate(k) - beginDayTimestamp) / DAILY_TIMESTAMP) - 1;
|
|
||||||
if (tempStats[dayIndex]) {
|
|
||||||
tempStats[dayIndex].count = v;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setCreationStatus(tempStats);
|
|
||||||
setMemoAmount(Object.values(memoCreationStats).reduce((acc, cur) => acc + cur, 0));
|
|
||||||
})();
|
|
||||||
}, [memos.length, user.name]);
|
|
||||||
|
|
||||||
const handleUsageStatItemMouseEnter = useCallback((event: React.MouseEvent, item: DailyUsageStat) => {
|
|
||||||
const tempDiv = document.createElement("div");
|
|
||||||
tempDiv.className = "usage-detail-container pop-up";
|
|
||||||
const bounding = utils.getElementBounding(event.target as HTMLElement);
|
|
||||||
tempDiv.style.left = bounding.left + "px";
|
|
||||||
tempDiv.style.top = bounding.top - 2 + "px";
|
|
||||||
const tMemoOnOpts = { amount: item.count, date: getDateString(item.timestamp as number) };
|
|
||||||
tempDiv.innerHTML = item.count === 1 ? t("heatmap.memo-on", tMemoOnOpts) : t("heatmap.memos-on", tMemoOnOpts);
|
|
||||||
document.body.appendChild(tempDiv);
|
|
||||||
|
|
||||||
if (tempDiv.offsetLeft - tempDiv.clientWidth / 2 < 0) {
|
|
||||||
tempDiv.style.left = bounding.left + tempDiv.clientWidth * 0.4 + "px";
|
|
||||||
tempDiv.className += " offset-left";
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleUsageStatItemMouseLeave = useCallback(() => {
|
|
||||||
document.body.querySelectorAll("div.usage-detail-container.pop-up").forEach((node) => node.remove());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleUsageStatItemClick = useCallback((item: DailyUsageStat) => {
|
|
||||||
navigateTo(`/timeline?timestamp=${item.timestamp}`);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// This interpolation is not being used because of the current styling,
|
|
||||||
// but it can improve translation quality by giving it a more meaningful context
|
|
||||||
const tMemoInOpts = { amount: memoAmount, period: "", date: "" };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="usage-heat-map-wrapper" ref={containerElRef}>
|
|
||||||
<div className="usage-heat-map">
|
|
||||||
{}
|
|
||||||
{creationStatus.map((v, i) => {
|
|
||||||
const count = v.count;
|
|
||||||
const colorLevel =
|
|
||||||
count <= 0
|
|
||||||
? ""
|
|
||||||
: count <= 1
|
|
||||||
? "stat-day-l1-bg"
|
|
||||||
: count <= 2
|
|
||||||
? "stat-day-l2-bg"
|
|
||||||
: count <= 4
|
|
||||||
? "stat-day-l3-bg"
|
|
||||||
: "stat-day-l4-bg";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="stat-wrapper"
|
|
||||||
key={i}
|
|
||||||
onMouseEnter={(e) => handleUsageStatItemMouseEnter(e, v)}
|
|
||||||
onMouseLeave={handleUsageStatItemMouseLeave}
|
|
||||||
onClick={() => handleUsageStatItemClick(v)}
|
|
||||||
>
|
|
||||||
<span className={`stat-container ${colorLevel} ${todayTimeStamp === v.timestamp ? "today" : ""}`}></span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{nullCell.map((_, i) => (
|
|
||||||
<div className="stat-wrapper" key={i}>
|
|
||||||
<span className="stat-container null"></span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="day-tip-text-container">
|
|
||||||
{dayTips.map((v, i) => (
|
|
||||||
<span className="tip-text" key={i}>
|
|
||||||
{v && t(("days." + v) as Translations)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="w-full pl-4 text-xs -mt-2 mb-3 text-gray-400 dark:text-zinc-400">
|
|
||||||
<span className="font-medium text-gray-500 dark:text-zinc-300 number">{memoAmount} </span>
|
|
||||||
{memoAmount === 1 ? t("heatmap.memo-in", tMemoInOpts) : t("heatmap.memos-in", tMemoInOpts)}{" "}
|
|
||||||
<span className="font-medium text-gray-500 dark:text-zinc-300">{createdDays} </span>
|
|
||||||
{createdDays === 1 ? t("heatmap.day", tMemoInOpts) : t("heatmap.days", tMemoInOpts)}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MemoCreationHeatMap;
|
|
@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { memoServiceClient } from "@/grpcweb";
|
||||||
|
import { useTagStore } from "@/store/module";
|
||||||
|
import { useMemoStore } from "@/store/v1";
|
||||||
|
import { User } from "@/types/proto/api/v2/user_service";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PersonalStatistics = (props: Props) => {
|
||||||
|
const { user } = props;
|
||||||
|
const tagStore = useTagStore();
|
||||||
|
const memoStore = useMemoStore();
|
||||||
|
const [memoAmount, setMemoAmount] = useState(0);
|
||||||
|
const [isRequesting, setIsRequesting] = useState(false);
|
||||||
|
const days = Math.ceil((Date.now() - user.createTime!.getTime()) / 86400000);
|
||||||
|
const memos = Object.values(memoStore.getState().memoMapById);
|
||||||
|
const tags = tagStore.state.tags.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (memos.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
setIsRequesting(true);
|
||||||
|
const { memoCreationStats } = await memoServiceClient.getUserMemosStats({
|
||||||
|
name: user.name,
|
||||||
|
});
|
||||||
|
setIsRequesting(false);
|
||||||
|
setMemoAmount(Object.values(memoCreationStats).reduce((acc, cur) => acc + cur, 0));
|
||||||
|
})();
|
||||||
|
}, [memos.length, user.name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full border mt-2 py-2 px-3 rounded-md space-y-0.5 bg-zinc-50 dark:bg-zinc-900 dark:border-zinc-800">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Statistics</p>
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div className="w-full flex justify-start items-center text-gray-500">
|
||||||
|
<Icon.CalendarDays className="w-4 h-auto mr-1" />
|
||||||
|
<span className="block text-base sm:text-sm">Days</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-500 font-mono">{days}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div className="w-full flex justify-start items-center text-gray-500">
|
||||||
|
<Icon.PencilLine className="w-4 h-auto mr-1" />
|
||||||
|
<span className="block text-base sm:text-sm">Memos</span>
|
||||||
|
</div>
|
||||||
|
{isRequesting ? (
|
||||||
|
<Icon.Loader className="animate-spin w-4 h-auto text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500 font-mono">{memoAmount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div className="w-full flex justify-start items-center text-gray-500">
|
||||||
|
<Icon.Hash className="w-4 h-auto mr-1" />
|
||||||
|
<span className="block text-base sm:text-sm">Tags</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-500 font-mono">{tags}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PersonalStatistics;
|
@ -1,73 +0,0 @@
|
|||||||
.usage-heat-map-wrapper {
|
|
||||||
@apply flex flex-row justify-start items-center flex-nowrap w-full h-32 pl-4 pb-3 shrink-0;
|
|
||||||
|
|
||||||
> .usage-heat-map {
|
|
||||||
@apply w-full h-full grid grid-rows-7 grid-cols-10 grid-flow-col;
|
|
||||||
|
|
||||||
> .stat-wrapper {
|
|
||||||
> .stat-container {
|
|
||||||
@apply block rounded bg-gray-200 dark:bg-zinc-700;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
|
|
||||||
&.stat-day-l1-bg {
|
|
||||||
@apply bg-green-400 dark:bg-green-800;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.stat-day-l2-bg {
|
|
||||||
@apply bg-green-500 dark:bg-green-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.stat-day-l3-bg {
|
|
||||||
@apply bg-green-600 dark:bg-green-600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.stat-day-l4-bg {
|
|
||||||
@apply bg-green-700 dark:bg-green-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.today {
|
|
||||||
@apply border border-black dark:border-gray-400;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.null {
|
|
||||||
@apply opacity-40;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
> .day-tip-text-container {
|
|
||||||
@apply w-8 h-full grid grid-rows-7;
|
|
||||||
|
|
||||||
> .tip-text {
|
|
||||||
@apply pl-1 w-full h-full text-left font-mono text-gray-400;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.usage-detail-container {
|
|
||||||
@apply fixed left-0 top-0 ml-2 -mt-9 p-2 z-100 -translate-x-1/2 select-none text-white text-xs rounded whitespace-nowrap;
|
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
|
||||||
|
|
||||||
> .date-text {
|
|
||||||
@apply text-gray-300;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.offset-left {
|
|
||||||
&::before {
|
|
||||||
left: calc(10% - 5px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
bottom: -4px;
|
|
||||||
left: calc(50% - 5px);
|
|
||||||
border-left: 4px solid transparent;
|
|
||||||
border-right: 4px solid transparent;
|
|
||||||
border-top: 4px solid rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue