refactor: user stats state

This commit is contained in:
Johnny
2025-02-26 22:58:22 +08:00
parent 81502d9092
commit 012405f7fd
12 changed files with 83 additions and 93 deletions

View File

@@ -1,11 +1,12 @@
import { last } from "lodash-es"; import { last } from "lodash-es";
import { Globe2Icon, HomeIcon } from "lucide-react"; import { Globe2Icon, HomeIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { matchPath, NavLink, useLocation } from "react-router-dom"; import { matchPath, NavLink, useLocation } from "react-router-dom";
import useDebounce from "react-use/lib/useDebounce"; import useDebounce from "react-use/lib/useDebounce";
import SearchBar from "@/components/SearchBar"; import SearchBar from "@/components/SearchBar";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { Routes } from "@/router"; import { Routes } from "@/router";
import { useMemoList, useUserStatsStore } from "@/store/v1"; import { useMemoList } from "@/store/v1";
import { userStore } from "@/store/v2"; import { userStore } from "@/store/v2";
import { cn } from "@/utils"; import { cn } from "@/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
@@ -25,12 +26,11 @@ interface Props {
className?: string; className?: string;
} }
const HomeSidebar = (props: Props) => { const HomeSidebar = observer((props: Props) => {
const t = useTranslate(); const t = useTranslate();
const location = useLocation(); const location = useLocation();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const memoList = useMemoList(); const memoList = useMemoList();
const userStatsStore = useUserStatsStore();
const homeNavLink: NavLinkItem = { const homeNavLink: NavLinkItem = {
id: "header-home", id: "header-home",
@@ -55,13 +55,13 @@ const HomeSidebar = (props: Props) => {
} }
if (matchPath("/u/:username", location.pathname) !== null) { if (matchPath("/u/:username", location.pathname) !== null) {
const username = last(location.pathname.split("/")); const username = last(location.pathname.split("/"));
const user = await userStore.fetchUserByUsername(username || ""); const user = await userStore.getOrFetchUserByUsername(username || "");
parent = user.name; parent = user.name;
} }
await userStatsStore.listUserStats(parent); await userStore.fetchUserStats(parent);
}, },
300, 300,
[memoList.size(), userStatsStore.stateId, location.pathname], [memoList.size(), userStore.state.statsStateId, location.pathname],
); );
return ( return (
@@ -93,6 +93,6 @@ const HomeSidebar = (props: Props) => {
</div> </div>
</aside> </aside>
); );
}; });
export default HomeSidebar; export default HomeSidebar;

View File

@@ -1,9 +1,11 @@
import { Dropdown, Menu, MenuButton, MenuItem, Switch } from "@mui/joy"; import { Dropdown, Menu, MenuButton, MenuItem, Switch } from "@mui/joy";
import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "lucide-react"; import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import useLocalStorage from "react-use/lib/useLocalStorage"; import useLocalStorage from "react-use/lib/useLocalStorage";
import { memoServiceClient } from "@/grpcweb"; import { memoServiceClient } from "@/grpcweb";
import { useMemoFilterStore, useUserStatsStore, useUserStatsTags } from "@/store/v1"; import { useMemoFilterStore } from "@/store/v1";
import { userStore } from "@/store/v2";
import { cn } from "@/utils"; import { cn } from "@/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import showRenameTagDialog from "../RenameTagDialog"; import showRenameTagDialog from "../RenameTagDialog";
@@ -14,12 +16,11 @@ interface Props {
readonly?: boolean; readonly?: boolean;
} }
const TagsSection = (props: Props) => { const TagsSection = observer((props: Props) => {
const t = useTranslate(); const t = useTranslate();
const memoFilterStore = useMemoFilterStore(); const memoFilterStore = useMemoFilterStore();
const userStatsStore = useUserStatsStore();
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false); const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
const tags = Object.entries(useUserStatsTags()) const tags = Object.entries(userStore.state.tagCount)
.sort((a, b) => a[0].localeCompare(b[0])) .sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]); .sort((a, b) => b[1] - a[1]);
@@ -42,7 +43,6 @@ const TagsSection = (props: Props) => {
parent: "memos/-", parent: "memos/-",
tag: tag, tag: tag,
}); });
userStatsStore.setStateId();
toast.success(t("message.deleted-successfully")); toast.success(t("message.deleted-successfully"));
} }
}; };
@@ -114,6 +114,6 @@ const TagsSection = (props: Props) => {
)} )}
</div> </div>
); );
}; });
export default TagsSection; export default TagsSection;

View File

@@ -15,7 +15,8 @@ import toast from "react-hot-toast";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { markdownServiceClient } from "@/grpcweb"; import { markdownServiceClient } from "@/grpcweb";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { useMemoStore, useUserStatsStore } from "@/store/v1"; import { useMemoStore } from "@/store/v1";
import { userStore } from "@/store/v2";
import { State } from "@/types/proto/api/v1/common"; import { State } from "@/types/proto/api/v1/common";
import { NodeType } from "@/types/proto/api/v1/markdown_service"; import { NodeType } from "@/types/proto/api/v1/markdown_service";
import { Memo } from "@/types/proto/api/v1/memo_service"; import { Memo } from "@/types/proto/api/v1/memo_service";
@@ -48,14 +49,13 @@ const MemoActionMenu = (props: Props) => {
const location = useLocation(); const location = useLocation();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const memoStore = useMemoStore(); const memoStore = useMemoStore();
const userStatsStore = useUserStatsStore();
const isArchived = memo.state === State.ARCHIVED; const isArchived = memo.state === State.ARCHIVED;
const hasCompletedTaskList = checkHasCompletedTaskList(memo); const hasCompletedTaskList = checkHasCompletedTaskList(memo);
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`); const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
const memoUpdatedCallback = () => { const memoUpdatedCallback = () => {
// Refresh user stats. // Refresh user stats.
userStatsStore.setStateId(); userStore.setStatsStateId();
}; };
const handleTogglePinMemoBtnClick = async () => { const handleTogglePinMemoBtnClick = async () => {

View File

@@ -1,10 +1,11 @@
import { Dropdown, Menu, MenuButton } from "@mui/joy"; import { Dropdown, Menu, MenuButton } from "@mui/joy";
import { Button } from "@usememos/mui"; import { Button } from "@usememos/mui";
import { HashIcon } from "lucide-react"; import { HashIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import useClickAway from "react-use/lib/useClickAway"; import useClickAway from "react-use/lib/useClickAway";
import OverflowTip from "@/components/kit/OverflowTip"; import OverflowTip from "@/components/kit/OverflowTip";
import { useUserStatsTags } from "@/store/v1"; import { userStore } from "@/store/v2";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { EditorRefActions } from "../Editor"; import { EditorRefActions } from "../Editor";
@@ -12,12 +13,12 @@ interface Props {
editorRef: React.RefObject<EditorRefActions>; editorRef: React.RefObject<EditorRefActions>;
} }
const TagSelector = (props: Props) => { const TagSelector = observer((props: Props) => {
const t = useTranslate(); const t = useTranslate();
const { editorRef } = props; const { editorRef } = props;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const tags = Object.entries(useUserStatsTags()) const tags = Object.entries(userStore.state.tagCount)
.sort((a, b) => a[0].localeCompare(b[0])) .sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.map(([tag]) => tag); .map(([tag]) => tag);
@@ -71,6 +72,6 @@ const TagSelector = (props: Props) => {
</Menu> </Menu>
</Dropdown> </Dropdown>
); );
}; });
export default TagSelector; export default TagSelector;

View File

@@ -1,8 +1,9 @@
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import getCaretCoordinates from "textarea-caret"; import getCaretCoordinates from "textarea-caret";
import OverflowTip from "@/components/kit/OverflowTip"; import OverflowTip from "@/components/kit/OverflowTip";
import { useUserStatsTags } from "@/store/v1"; import { userStore } from "@/store/v2";
import { cn } from "@/utils"; import { cn } from "@/utils";
import { EditorRefActions } from "."; import { EditorRefActions } from ".";
@@ -13,12 +14,12 @@ type Props = {
type Position = { left: number; top: number; height: number }; type Position = { left: number; top: number; height: number };
const TagSuggestions = ({ editorRef, editorActions }: Props) => { const TagSuggestions = observer(({ editorRef, editorActions }: Props) => {
const [position, setPosition] = useState<Position | null>(null); const [position, setPosition] = useState<Position | null>(null);
const [selected, select] = useState(0); const [selected, select] = useState(0);
const selectedRef = useRef(selected); const selectedRef = useRef(selected);
selectedRef.current = selected; selectedRef.current = selected;
const tags = Object.entries(useUserStatsTags()) const tags = Object.entries(userStore.state.tagCount)
.sort((a, b) => a[0].localeCompare(b[0])) .sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.map(([tag]) => tag); .map(([tag]) => tag);
@@ -120,6 +121,6 @@ const TagSuggestions = ({ editorRef, editorActions }: Props) => {
))} ))}
</div> </div>
); );
}; });
export default TagSuggestions; export default TagSuggestions;

View File

@@ -5,7 +5,7 @@ import { Link, useLocation } from "react-router-dom";
import useAsyncEffect from "@/hooks/useAsyncEffect"; import useAsyncEffect from "@/hooks/useAsyncEffect";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { useMemoStore, useUserStatsStore } from "@/store/v1"; import { useMemoStore } from "@/store/v1";
import { userStore, workspaceStore } from "@/store/v2"; import { userStore, workspaceStore } from "@/store/v2";
import { State } from "@/types/proto/api/v1/common"; import { State } from "@/types/proto/api/v1/common";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_relation_service"; import { MemoRelation_Type } from "@/types/proto/api/v1/memo_relation_service";
@@ -45,7 +45,6 @@ const MemoView: React.FC<Props> = (props: Props) => {
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const user = useCurrentUser(); const user = useCurrentUser();
const memoStore = useMemoStore(); const memoStore = useMemoStore();
const userStatsStore = useUserStatsStore();
const [showEditor, setShowEditor] = useState<boolean>(false); const [showEditor, setShowEditor] = useState<boolean>(false);
const [creator, setCreator] = useState(userStore.getUserByName(memo.creator)); const [creator, setCreator] = useState(userStore.getUserByName(memo.creator));
const [showNSFWContent, setShowNSFWContent] = useState(props.showNsfwContent); const [showNSFWContent, setShowNSFWContent] = useState(props.showNsfwContent);
@@ -102,7 +101,7 @@ const MemoView: React.FC<Props> = (props: Props) => {
const onEditorConfirm = () => { const onEditorConfirm = () => {
setShowEditor(false); setShowEditor(false);
userStatsStore.setStateId(); userStore.setStatsStateId();
}; };
const onPinIconClick = async () => { const onPinIconClick = async () => {

View File

@@ -5,7 +5,6 @@ import React, { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { memoServiceClient } from "@/grpcweb"; import { memoServiceClient } from "@/grpcweb";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { useUserStatsStore } from "@/store/v1";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { generateDialog } from "./Dialog"; import { generateDialog } from "./Dialog";
@@ -16,7 +15,6 @@ interface Props extends DialogProps {
const RenameTagDialog: React.FC<Props> = (props: Props) => { const RenameTagDialog: React.FC<Props> = (props: Props) => {
const { tag, destroy } = props; const { tag, destroy } = props;
const t = useTranslate(); const t = useTranslate();
const userStatsStore = useUserStatsStore();
const [newName, setNewName] = useState(tag); const [newName, setNewName] = useState(tag);
const requestState = useLoading(false); const requestState = useLoading(false);
@@ -41,7 +39,6 @@ const RenameTagDialog: React.FC<Props> = (props: Props) => {
newTag: newName, newTag: newName,
}); });
toast.success("Rename tag successfully"); toast.success("Rename tag successfully");
userStatsStore.setStateId();
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.details); toast.error(error.details);

View File

@@ -2,21 +2,22 @@ import { Tooltip } from "@mui/joy";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { countBy } from "lodash-es"; import { countBy } from "lodash-es";
import { CheckCircleIcon, ChevronRightIcon, ChevronLeftIcon, Code2Icon, LinkIcon, ListTodoIcon } from "lucide-react"; import { CheckCircleIcon, ChevronRightIcon, ChevronLeftIcon, Code2Icon, LinkIcon, ListTodoIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react"; import { useState } from "react";
import DatePicker from "react-datepicker"; import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css"; import "react-datepicker/dist/react-datepicker.css";
import useAsyncEffect from "@/hooks/useAsyncEffect"; import useAsyncEffect from "@/hooks/useAsyncEffect";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { useMemoFilterStore, useUserStatsStore } from "@/store/v1"; import { useMemoFilterStore } from "@/store/v1";
import { userStore } from "@/store/v2";
import { UserStats_MemoTypeStats } from "@/types/proto/api/v1/user_service"; import { UserStats_MemoTypeStats } from "@/types/proto/api/v1/user_service";
import { cn } from "@/utils"; import { cn } from "@/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import ActivityCalendar from "./ActivityCalendar"; import ActivityCalendar from "./ActivityCalendar";
const StatisticsView = () => { const StatisticsView = observer(() => {
const t = useTranslate(); const t = useTranslate();
const memoFilterStore = useMemoFilterStore(); const memoFilterStore = useMemoFilterStore();
const userStatsStore = useUserStatsStore();
const [memoTypeStats, setMemoTypeStats] = useState<UserStats_MemoTypeStats>(UserStats_MemoTypeStats.fromPartial({})); const [memoTypeStats, setMemoTypeStats] = useState<UserStats_MemoTypeStats>(UserStats_MemoTypeStats.fromPartial({}));
const [activityStats, setActivityStats] = useState<Record<string, number>>({}); const [activityStats, setActivityStats] = useState<Record<string, number>>({});
const [selectedDate] = useState(new Date()); const [selectedDate] = useState(new Date());
@@ -25,7 +26,7 @@ const StatisticsView = () => {
useAsyncEffect(async () => { useAsyncEffect(async () => {
const memoTypeStats = UserStats_MemoTypeStats.fromPartial({}); const memoTypeStats = UserStats_MemoTypeStats.fromPartial({});
const displayTimeList: Date[] = []; const displayTimeList: Date[] = [];
for (const stats of Object.values(userStatsStore.userStatsByName)) { for (const stats of Object.values(userStore.state.userStatsByName)) {
displayTimeList.push(...stats.memoDisplayTimestamps); displayTimeList.push(...stats.memoDisplayTimestamps);
if (stats.memoTypeStats) { if (stats.memoTypeStats) {
memoTypeStats.codeCount += stats.memoTypeStats.codeCount; memoTypeStats.codeCount += stats.memoTypeStats.codeCount;
@@ -36,7 +37,7 @@ const StatisticsView = () => {
} }
setMemoTypeStats(memoTypeStats); setMemoTypeStats(memoTypeStats);
setActivityStats(countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD")))); setActivityStats(countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD"))));
}, [userStatsStore.userStatsByName, userStatsStore.stateId]); }, [userStore.state.userStatsByName]);
const onCalendarClick = (date: string) => { const onCalendarClick = (date: string) => {
memoFilterStore.removeFilter((f) => f.factor === "displayTime"); memoFilterStore.removeFilter((f) => f.factor === "displayTime");
@@ -135,6 +136,6 @@ const StatisticsView = () => {
</div> </div>
</div> </div>
); );
}; });
export default StatisticsView; export default StatisticsView;

View File

@@ -31,7 +31,7 @@ const UserProfile = () => {
} }
userStore userStore
.fetchUserByUsername(username) .getOrFetchUserByUsername(username)
.then((user) => { .then((user) => {
setUser(user); setUser(user);
loadingState.setFinish(); loadingState.setFinish();

View File

@@ -2,4 +2,3 @@ export * from "./memo";
export * from "./resourceName"; export * from "./resourceName";
export * from "./resource"; export * from "./resource";
export * from "./memoFilter"; export * from "./memoFilter";
export * from "./userStats";

View File

@@ -1,51 +0,0 @@
import { uniqueId } from "lodash-es";
import { create } from "zustand";
import { combine } from "zustand/middleware";
import { userServiceClient } from "@/grpcweb";
import { UserStats } from "@/types/proto/api/v1/user_service";
interface State {
// stateId is used to identify the store instance state.
// It should be update when any state change.
stateId: string;
userStatsByName: Record<string, UserStats>;
}
const getDefaultState = (): State => ({
stateId: uniqueId(),
userStatsByName: {},
});
export const useUserStatsStore = create(
combine(getDefaultState(), (set, get) => ({
setState: (state: State) => set(state),
getState: () => get(),
listUserStats: async (user?: string) => {
const userStatsByName: Record<string, UserStats> = {};
if (!user) {
const { userStats } = await userServiceClient.listAllUserStats({});
for (const stats of userStats) {
userStatsByName[stats.name] = stats;
}
} else {
const userStats = await userServiceClient.getUserStats({ name: user });
userStatsByName[user] = userStats;
}
set({ ...get(), userStatsByName });
},
setStateId: (id = uniqueId()) => {
set({ ...get(), stateId: id });
},
})),
);
export const useUserStatsTags = () => {
const userStatsStore = useUserStatsStore();
const tagAmounts: Record<string, number> = {};
for (const userStats of Object.values(userStatsStore.getState().userStatsByName)) {
for (const tag of Object.keys(userStats.tagCount)) {
tagAmounts[tag] = (tagAmounts[tag] || 0) + userStats.tagCount[tag];
}
}
return tagAmounts;
};

View File

@@ -1,7 +1,8 @@
import { uniqueId } from "lodash-es";
import { makeAutoObservable } from "mobx"; import { makeAutoObservable } from "mobx";
import { authServiceClient, inboxServiceClient, userServiceClient } from "@/grpcweb"; import { authServiceClient, inboxServiceClient, userServiceClient } from "@/grpcweb";
import { Inbox } from "@/types/proto/api/v1/inbox_service"; import { Inbox } from "@/types/proto/api/v1/inbox_service";
import { Shortcut, User, UserSetting } from "@/types/proto/api/v1/user_service"; import { Shortcut, User, UserSetting, UserStats } from "@/types/proto/api/v1/user_service";
import workspaceStore from "./workspace"; import workspaceStore from "./workspace";
class LocalState { class LocalState {
@@ -10,6 +11,20 @@ class LocalState {
shortcuts: Shortcut[] = []; shortcuts: Shortcut[] = [];
inboxes: Inbox[] = []; inboxes: Inbox[] = [];
userMapByName: Record<string, User> = {}; userMapByName: Record<string, User> = {};
userStatsByName: Record<string, UserStats> = {};
// The state id of user stats map.
statsStateId = uniqueId();
get tagCount() {
const tagCount: Record<string, number> = {};
for (const stats of Object.values(this.userStatsByName)) {
for (const tag of Object.keys(stats.tagCount)) {
tagCount[tag] = (tagCount[tag] || 0) + stats.tagCount[tag];
}
}
return tagCount;
}
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
@@ -40,13 +55,19 @@ const userStore = (() => {
return user; return user;
}; };
const fetchUserByUsername = async (username: string) => { const getOrFetchUserByUsername = async (username: string) => {
const userMap = state.userMapByName;
for (const name in userMap) {
if (userMap[name].username === username) {
return userMap[name];
}
}
const user = await userServiceClient.getUserByUsername({ const user = await userServiceClient.getUserByUsername({
username, username,
}); });
state.setPartial({ state.setPartial({
userMapByName: { userMapByName: {
...state.userMapByName, ...userMap,
[user.name]: user, [user.name]: user,
}, },
}); });
@@ -138,10 +159,30 @@ const userStore = (() => {
return updatedInbox; return updatedInbox;
}; };
const fetchUserStats = async (user?: string) => {
const userStatsByName: Record<string, UserStats> = {};
if (!user) {
const { userStats } = await userServiceClient.listAllUserStats({});
for (const stats of userStats) {
userStatsByName[stats.name] = stats;
}
} else {
const userStats = await userServiceClient.getUserStats({ name: user });
userStatsByName[user] = userStats;
}
state.setPartial({
userStatsByName,
});
};
const setStatsStateId = (id = uniqueId()) => {
state.statsStateId = id;
};
return { return {
state, state,
getOrFetchUserByName, getOrFetchUserByName,
fetchUserByUsername, getOrFetchUserByUsername,
getUserByName, getUserByName,
fetchUsers, fetchUsers,
updateUser, updateUser,
@@ -150,6 +191,8 @@ const userStore = (() => {
fetchShortcuts, fetchShortcuts,
fetchInboxes, fetchInboxes,
updateInbox, updateInbox,
fetchUserStats,
setStatsStateId,
}; };
})(); })();