refactor: user stats state

pull/4439/merge
Johnny 2 days ago
parent 81502d9092
commit 012405f7fd

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

@ -1,9 +1,11 @@
import { Dropdown, Menu, MenuButton, MenuItem, Switch } from "@mui/joy";
import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import toast from "react-hot-toast";
import useLocalStorage from "react-use/lib/useLocalStorage";
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 { useTranslate } from "@/utils/i18n";
import showRenameTagDialog from "../RenameTagDialog";
@ -14,12 +16,11 @@ interface Props {
readonly?: boolean;
}
const TagsSection = (props: Props) => {
const TagsSection = observer((props: Props) => {
const t = useTranslate();
const memoFilterStore = useMemoFilterStore();
const userStatsStore = useUserStatsStore();
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) => b[1] - a[1]);
@ -42,7 +43,6 @@ const TagsSection = (props: Props) => {
parent: "memos/-",
tag: tag,
});
userStatsStore.setStateId();
toast.success(t("message.deleted-successfully"));
}
};
@ -114,6 +114,6 @@ const TagsSection = (props: Props) => {
)}
</div>
);
};
});
export default TagsSection;

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

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

@ -1,8 +1,9 @@
import Fuse from "fuse.js";
import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react";
import getCaretCoordinates from "textarea-caret";
import OverflowTip from "@/components/kit/OverflowTip";
import { useUserStatsTags } from "@/store/v1";
import { userStore } from "@/store/v2";
import { cn } from "@/utils";
import { EditorRefActions } from ".";
@ -13,12 +14,12 @@ type Props = {
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 [selected, select] = useState(0);
const selectedRef = useRef(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) => b[1] - a[1])
.map(([tag]) => tag);
@ -120,6 +121,6 @@ const TagSuggestions = ({ editorRef, editorActions }: Props) => {
))}
</div>
);
};
});
export default TagSuggestions;

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

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

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

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

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

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

@ -1,7 +1,8 @@
import { uniqueId } from "lodash-es";
import { makeAutoObservable } from "mobx";
import { authServiceClient, inboxServiceClient, userServiceClient } from "@/grpcweb";
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";
class LocalState {
@ -10,6 +11,20 @@ class LocalState {
shortcuts: Shortcut[] = [];
inboxes: Inbox[] = [];
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() {
makeAutoObservable(this);
@ -40,13 +55,19 @@ const userStore = (() => {
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({
username,
});
state.setPartial({
userMapByName: {
...state.userMapByName,
...userMap,
[user.name]: user,
},
});
@ -138,10 +159,30 @@ const userStore = (() => {
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 {
state,
getOrFetchUserByName,
fetchUserByUsername,
getOrFetchUserByUsername,
getUserByName,
fetchUsers,
updateUser,
@ -150,6 +191,8 @@ const userStore = (() => {
fetchShortcuts,
fetchInboxes,
updateInbox,
fetchUserStats,
setStatsStateId,
};
})();

Loading…
Cancel
Save