chore: add statistics view

This commit is contained in:
Steven 2024-01-03 08:22:32 +08:00
parent 138b69e36e
commit 914c0620c4
10 changed files with 98 additions and 260 deletions

View File

@ -1,14 +1,15 @@
import MemoCreationHeatMap from "./MemoCreationHeatMap";
import useCurrentUser from "@/hooks/useCurrentUser";
import PersonalStatistics from "./PersonalStatistics";
import SearchBar from "./SearchBar";
import TagList from "./TagList";
const HomeSidebar = () => {
const currentUser = useCurrentUser();
return (
<aside className="relative w-full pr-2 h-full max-h-screen overflow-auto hide-scrollbar flex flex-col justify-start items-start py-4 sm:pt-6">
<div className="px-4 pr-8 mb-4 w-full">
<SearchBar />
</div>
<MemoCreationHeatMap />
<aside className="relative w-full px-4 h-full max-h-screen overflow-auto hide-scrollbar flex flex-col justify-start items-start py-4 sm:pt-6">
<SearchBar />
<PersonalStatistics user={currentUser} />
<TagList />
</aside>
);

View File

@ -26,7 +26,7 @@ const HomeSidebarDrawer = () => {
<Icon.Search className="w-5 h-auto dark:text-gray-200" />
</IconButton>
<Drawer anchor="right" size="sm" open={open} onClose={toggleDrawer(false)}>
<div className="w-full px-4">
<div className="w-full px-1">
<HomeSidebar />
</div>
</Drawer>

View File

@ -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;

View File

@ -31,6 +31,7 @@ const MemoResourceListView = ({ resourceList = [] }: { resourceList: Resource[]
const MediaCard = ({ resource, thumbnail }: { resource: Resource; thumbnail?: boolean }) => {
const type = getResourceType(resource);
const url = getResourceUrl(resource);
if (type === "image/*") {
return (
<img
@ -40,9 +41,7 @@ const MemoResourceListView = ({ resourceList = [] }: { resourceList: Resource[]
decoding="async"
/>
);
}
if (type === "video/*") {
} else if (type === "video/*") {
return (
<video
className="cursor-pointer w-full h-full object-contain bg-zinc-100 dark:bg-zinc-800"
@ -52,9 +51,9 @@ const MemoResourceListView = ({ resourceList = [] }: { resourceList: Resource[]
controls
/>
);
} else {
return <></>;
}
return <></>;
};
const MediaList = ({ resources = [] }: { resources: Resource[] }) => {

View File

@ -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;

View File

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Input } from "@mui/joy";
import { useEffect, useState } from "react";
import useDebounce from "react-use/lib/useDebounce";
import { useFilterStore } from "@/store/module";
import { useTranslate } from "@/utils/i18n";
@ -8,7 +9,6 @@ const SearchBar = () => {
const t = useTranslate();
const filterStore = useFilterStore();
const [queryText, setQueryText] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const text = filterStore.getState().text;
@ -28,13 +28,12 @@ const SearchBar = () => {
};
return (
<div className="w-full h-9 flex flex-row justify-start items-center py-2 px-3 rounded-md bg-gray-200 dark:bg-zinc-700">
<Icon.Search className="w-4 h-auto opacity-30 dark:text-gray-200" />
<input
className="flex ml-2 w-24 grow text-sm outline-none bg-transparent dark:text-gray-200"
type="text"
<div className="w-full h-9 flex flex-row justify-start items-center">
<Input
className="w-full !shadow-none !border-gray-200 dark:!border-zinc-800"
size="md"
startDecorator={<Icon.Search className="w-4 h-auto opacity-30" />}
placeholder={t("memo.search-placeholder")}
ref={inputRef}
value={queryText}
onChange={handleTextQueryInput}
/>

View File

@ -70,8 +70,8 @@ const TagList = () => {
}, [tagsText]);
return (
<div className="flex flex-col justify-start items-start w-full mt-2 h-auto shrink-0 flex-nowrap hide-scrollbar">
<div className="flex flex-row justify-start items-center w-full px-4">
<div className="flex flex-col justify-start items-start w-full mt-3 px-1 h-auto shrink-0 flex-nowrap hide-scrollbar">
<div className="flex flex-row justify-start items-center w-full">
<span className="text-sm leading-6 font-mono text-gray-400">{t("common.tags")}</span>
<button
onClick={() => showCreateTagDialog()}
@ -80,7 +80,7 @@ const TagList = () => {
<Icon.Plus className="w-4 h-4 text-gray-400" />
</button>
</div>
<div className="flex flex-col justify-start items-start relative w-full h-auto flex-nowrap mt-2 mb-2">
<div className="flex flex-col justify-start items-start relative w-full h-auto flex-nowrap">
{tags.map((t, idx) => (
<TagItemContainer key={t.text + "-" + idx} tag={t} tagQuery={filter.tag} />
))}
@ -117,15 +117,15 @@ const TagItemContainer: React.FC<TagItemContainerProps> = (props: TagItemContain
return (
<>
<div
className="relative group flex flex-row justify-between items-center w-full h-10 py-0 px-4 mt-px first:mt-1 rounded-lg text-base cursor-pointer select-none shrink-0 hover:opacity-60"
className="relative group flex flex-row justify-between items-center w-full h-8 py-0 mt-px first:mt-1 rounded-lg text-base sm:text-sm cursor-pointer select-none shrink-0 hover:opacity-80"
onClick={handleTagClick}
>
<div
className={`flex flex-row justify-start items-center truncate shrink leading-5 mr-1 text-black dark:text-gray-200 ${
className={`flex flex-row justify-start items-center truncate shrink leading-5 mr-1 text-gray-600 dark:text-gray-400 ${
isActive && "text-green-600"
}`}
>
<span className="block w-4 shrink-0">#</span>
<Icon.Hash className="w-4 h-auto shrink-0 opacity-60 mr-1" />
<span className="truncate">{tag.key}</span>
</div>
<div className="flex flex-row justify-end items-center">
@ -141,7 +141,7 @@ const TagItemContainer: React.FC<TagItemContainerProps> = (props: TagItemContain
</div>
{hasSubTags ? (
<div
className={`w-[calc(100%-1rem)] flex flex-col justify-start items-start h-auto ml-4 pl-1 border-l-2 border-l-gray-200 dark:border-l-gray-400 ${
className={`w-[calc(100%-0.5rem)] flex flex-col justify-start items-start h-auto ml-2 pl-2 border-l-2 border-l-gray-200 dark:border-l-gray-400 ${
!showSubTags && "!hidden"
}`}
>

View File

@ -1,4 +1,3 @@
import Icon from "@/components/Icon";
import MemoContent from "@/components/MemoContent";
import MemoResourceListView from "@/components/MemoResourceListView";
import { getTimeString } from "@/helpers/datetime";
@ -18,8 +17,6 @@ const TimelineMemo = (props: Props) => {
<div className="relative w-full flex flex-col justify-start items-start">
<div className="w-full flex flex-row justify-start items-center mt-0.5 mb-1 text-sm font-mono text-gray-500 dark:text-gray-400">
<span className="opacity-80">{getTimeString(memo.displayTime)}</span>
<Icon.Dot className="w-5 h-auto opacity-60" />
<span className="opacity-60">#{memo.id}</span>
</div>
<MemoContent nodes={memo.nodes} />
<MemoResourceListView resourceList={memo.resources} />

View File

@ -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);
}
}

View File

@ -13,6 +13,7 @@ import useCurrentUser from "@/hooks/useCurrentUser";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { useFilterStore } from "@/store/module";
import { useMemoList, useMemoStore } from "@/store/v1";
import { RowStatus } from "@/types/proto/api/v2/common";
import { useTranslate } from "@/utils/i18n";
const Home = () => {
@ -26,6 +27,7 @@ const Home = () => {
const [isComplete, setIsComplete] = useState(false);
const { tag: tagQuery, text: textQuery } = filterStore.state;
const sortedMemos = memoList.value
.filter((memo) => memo.rowStatus === RowStatus.ACTIVE)
.sort((a, b) => getTimeStampByDate(b.displayTime) - getTimeStampByDate(a.displayTime))
.sort((a, b) => Number(b.pinned) - Number(a.pinned));
@ -62,7 +64,7 @@ const Home = () => {
<MobileHeader>{!md && <HomeSidebarDrawer />}</MobileHeader>
<div className="w-full px-4 sm:px-6 md:pr-2">
<MemoEditor className="mb-2" cacheKey="home-memo-editor" />
<div className="flex flex-col justify-start items-start w-full max-w-full overflow-y-scroll pb-28 hide-scrollbar">
<div className="flex flex-col justify-start items-start w-full max-w-full pb-28">
<MemoFilter />
{sortedMemos.map((memo) => (
<MemoView key={`${memo.id}-${memo.updateTime}`} memo={memo} showVisibility showPinnedStyle showParent />