feat: list user stats

pull/4298/head
johnnyjoy 2 months ago
parent cde058c72a
commit ee96465be0

@ -192,8 +192,9 @@ message UserStats {
message MemoTypeStats {
int32 link_count = 1;
int32 task_count = 2;
int32 code_count = 3;
int32 code_count = 2;
int32 todo_count = 3;
int32 undo_count = 4;
}
}

@ -1286,8 +1286,9 @@ func (x *DeleteUserAccessTokenRequest) GetAccessToken() string {
type UserStats_MemoTypeStats struct {
state protoimpl.MessageState `protogen:"open.v1"`
LinkCount int32 `protobuf:"varint,1,opt,name=link_count,json=linkCount,proto3" json:"link_count,omitempty"`
TaskCount int32 `protobuf:"varint,2,opt,name=task_count,json=taskCount,proto3" json:"task_count,omitempty"`
CodeCount int32 `protobuf:"varint,3,opt,name=code_count,json=codeCount,proto3" json:"code_count,omitempty"`
CodeCount int32 `protobuf:"varint,2,opt,name=code_count,json=codeCount,proto3" json:"code_count,omitempty"`
TodoCount int32 `protobuf:"varint,3,opt,name=todo_count,json=todoCount,proto3" json:"todo_count,omitempty"`
UndoCount int32 `protobuf:"varint,4,opt,name=undo_count,json=undoCount,proto3" json:"undo_count,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1329,16 +1330,23 @@ func (x *UserStats_MemoTypeStats) GetLinkCount() int32 {
return 0
}
func (x *UserStats_MemoTypeStats) GetTaskCount() int32 {
func (x *UserStats_MemoTypeStats) GetCodeCount() int32 {
if x != nil {
return x.TaskCount
return x.CodeCount
}
return 0
}
func (x *UserStats_MemoTypeStats) GetCodeCount() int32 {
func (x *UserStats_MemoTypeStats) GetTodoCount() int32 {
if x != nil {
return x.CodeCount
return x.TodoCount
}
return 0
}
func (x *UserStats_MemoTypeStats) GetUndoCount() int32 {
if x != nil {
return x.UndoCount
}
return 0
}
@ -1428,7 +1436,7 @@ var file_api_v1_user_service_proto_rawDesc = []byte{
0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x73, 0x6b, 0x22, 0x27, 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65,
0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a,
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d,
0x65, 0x22, 0xb1, 0x03, 0x0a, 0x09, 0x55, 0x73, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12,
0x65, 0x22, 0xd1, 0x03, 0x0a, 0x09, 0x55, 0x73, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12,
0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
0x61, 0x6d, 0x65, 0x12, 0x52, 0x0a, 0x17, 0x6d, 0x65, 0x6d, 0x6f, 0x5f, 0x64, 0x69, 0x73, 0x70,
0x6c, 0x61, 0x79, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x18, 0x02,
@ -1448,13 +1456,15 @@ var file_api_v1_user_service_proto_rawDesc = []byte{
0x67, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b,
0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a,
0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x76, 0x61,
0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x6c, 0x0a, 0x0d, 0x4d, 0x65, 0x6d, 0x6f, 0x54,
0x79, 0x70, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, 0x69, 0x6e, 0x6b,
0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x6c, 0x69,
0x6e, 0x6b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x61, 0x73, 0x6b, 0x5f,
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x61, 0x73,
0x6b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x64, 0x65, 0x5f, 0x63,
0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x63, 0x6f, 0x64, 0x65,
0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x8b, 0x01, 0x0a, 0x0d, 0x4d, 0x65, 0x6d, 0x6f,
0x54, 0x79, 0x70, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, 0x69, 0x6e,
0x6b, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x6c,
0x69, 0x6e, 0x6b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x64, 0x65,
0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x63, 0x6f,
0x64, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x64, 0x6f, 0x5f,
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x6f, 0x64,
0x6f, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x6e, 0x64, 0x6f, 0x5f, 0x63,
0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x75, 0x6e, 0x64, 0x6f,
0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x31, 0x0a, 0x17, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x6c, 0x6c,
0x55, 0x73, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x12, 0x16, 0x0a, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,

@ -1868,10 +1868,13 @@ definitions:
linkCount:
type: integer
format: int32
taskCount:
codeCount:
type: integer
format: int32
codeCount:
todoCount:
type: integer
format: int32
undoCount:
type: integer
format: int32
WorkspaceStorageSettingS3Config:

@ -13,7 +13,7 @@ var authenticationAllowlistMethods = map[string]bool{
"/memos.api.v1.AuthService/SignUp": true,
"/memos.api.v1.UserService/GetUser": true,
"/memos.api.v1.UserService/GetUserAvatarBinary": true,
"/memos.api.v1.UserService/ListUserStats": true,
"/memos.api.v1.UserService/ListAllUserStats": true,
"/memos.api.v1.UserService/SearchUsers": true,
"/memos.api.v1.MemoService/GetMemo": true,
"/memos.api.v1.MemoService/GetMemoByUid": true,

@ -275,100 +275,6 @@ func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserR
return &emptypb.Empty{}, nil
}
func (s *APIV1Service) ListAllUserStats(ctx context.Context, request *v1pb.ListAllUserStatsRequest) (*v1pb.ListAllUserStatsResponse, error) {
users, err := s.Store.ListUsers(ctx, &store.FindUser{})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
}
userStatsList := []*v1pb.UserStats{}
for _, user := range users {
userStats, err := s.GetUserStats(ctx, &v1pb.GetUserStatsRequest{
Name: fmt.Sprintf("%s%d", UserNamePrefix, user.ID),
Filter: request.Filter,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user stats: %v", err)
}
userStatsList = append(userStatsList, userStats)
}
return &v1pb.ListAllUserStatsResponse{
UserStats: userStatsList,
}, nil
}
func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserStatsRequest) (*v1pb.UserStats, error) {
userID, err := ExtractUserIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
}
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
currentUser, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
// For unauthenticated users, only public memos are visible.
visibilities := []store.Visibility{store.Public}
if currentUser != nil {
// For authenticated users, protected memos are also visible.
visibilities = append(visibilities, store.Protected)
if currentUser.ID == user.ID {
// For the current user, show all memos including private ones.
visibilities = []store.Visibility{store.Public, store.Protected, store.Private}
}
}
workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx)
if err != nil {
return nil, errors.Wrap(err, "failed to get workspace memo related setting")
}
userStats := &v1pb.UserStats{
Name: fmt.Sprintf("%s%d", UserNamePrefix, user.ID),
MemoDisplayTimestamps: []*timestamppb.Timestamp{},
MemoTypeStats: &v1pb.UserStats_MemoTypeStats{},
TagCount: map[string]int32{},
}
memoFind := &store.FindMemo{
// Exclude comments by default.
ExcludeComments: true,
ExcludeContent: true,
}
if err := s.buildMemoFindWithFilter(ctx, memoFind, request.Filter); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to build find memos with filter: %v", err)
}
// Override the creator ID and visibility list.
memoFind.CreatorID = &user.ID
memoFind.VisibilityList = visibilities
memos, err := s.Store.ListMemos(ctx, memoFind)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memos: %v", err)
}
for _, memo := range memos {
displayTs := memo.CreatedTs
if workspaceMemoRelatedSetting.DisplayWithUpdateTime {
displayTs = memo.UpdatedTs
}
userStats.MemoDisplayTimestamps = append(userStats.MemoDisplayTimestamps, timestamppb.New(time.Unix(displayTs, 0)))
// Handle duplicated tags.
for _, tag := range memo.Payload.Tags {
userStats.TagCount[tag]++
}
if memo.Payload.Property.GetHasLink() {
userStats.MemoTypeStats.LinkCount++
}
if memo.Payload.Property.GetHasTaskList() {
userStats.MemoTypeStats.TaskCount++
}
if memo.Payload.Property.GetHasCode() {
userStats.MemoTypeStats.CodeCount++
}
}
return userStats, nil
}
func getDefaultUserSetting(workspaceMemoRelatedSetting *storepb.WorkspaceMemoRelatedSetting) *v1pb.UserSetting {
defaultVisibility := "PRIVATE"
if workspaceMemoRelatedSetting.DefaultVisibility != "" {

@ -0,0 +1,124 @@
package v1
import (
"context"
"fmt"
"slices"
"time"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/store"
)
func (s *APIV1Service) ListAllUserStats(ctx context.Context, request *v1pb.ListAllUserStatsRequest) (*v1pb.ListAllUserStatsResponse, error) {
users, err := s.Store.ListUsers(ctx, &store.FindUser{})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
}
userStatsList := []*v1pb.UserStats{}
for _, user := range users {
userStats, err := s.GetUserStats(ctx, &v1pb.GetUserStatsRequest{
Name: fmt.Sprintf("%s%d", UserNamePrefix, user.ID),
Filter: request.Filter,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user stats: %v", err)
}
userStatsList = append(userStatsList, userStats)
}
return &v1pb.ListAllUserStatsResponse{
UserStats: userStatsList,
}, nil
}
func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserStatsRequest) (*v1pb.UserStats, error) {
userID, err := ExtractUserIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
}
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx)
if err != nil {
return nil, errors.Wrap(err, "failed to get workspace memo related setting")
}
userStats := &v1pb.UserStats{
Name: fmt.Sprintf("%s%d", UserNamePrefix, user.ID),
MemoDisplayTimestamps: []*timestamppb.Timestamp{},
MemoTypeStats: &v1pb.UserStats_MemoTypeStats{},
TagCount: map[string]int32{},
}
memoFind := &store.FindMemo{
// Exclude comments by default.
ExcludeComments: true,
ExcludeContent: true,
}
if err := s.buildMemoFindWithFilter(ctx, memoFind, request.Filter); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to build find memos with filter: %v", err)
}
currentUser, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if len(memoFind.VisibilityList) == 0 {
visibilities := []store.Visibility{store.Public}
if currentUser != nil {
visibilities = append(visibilities, store.Protected)
if currentUser.ID == user.ID {
visibilities = append(visibilities, store.Private)
}
}
memoFind.VisibilityList = visibilities
} else {
if slices.Contains(memoFind.VisibilityList, store.Private) {
if currentUser == nil || currentUser.ID != user.ID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
}
if slices.Contains(memoFind.VisibilityList, store.Protected) {
if currentUser == nil {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
}
}
// Override the creator ID.
memoFind.CreatorID = &user.ID
memos, err := s.Store.ListMemos(ctx, memoFind)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memos: %v", err)
}
for _, memo := range memos {
displayTs := memo.CreatedTs
if workspaceMemoRelatedSetting.DisplayWithUpdateTime {
displayTs = memo.UpdatedTs
}
userStats.MemoDisplayTimestamps = append(userStats.MemoDisplayTimestamps, timestamppb.New(time.Unix(displayTs, 0)))
// Handle duplicated tags.
for _, tag := range memo.Payload.Tags {
userStats.TagCount[tag]++
}
if memo.Payload.Property.GetHasLink() {
userStats.MemoTypeStats.LinkCount++
}
if memo.Payload.Property.GetHasCode() {
userStats.MemoTypeStats.CodeCount++
}
if memo.Payload.Property.GetHasTaskList() {
userStats.MemoTypeStats.TodoCount++
}
if memo.Payload.Property.GetHasIncompleteTasks() {
userStats.MemoTypeStats.UndoCount++
}
}
return userStats, nil
}

@ -1,26 +1,26 @@
import clsx from "clsx";
import { useLocation } from "react-router-dom";
import useDebounce from "react-use/lib/useDebounce";
import SearchBar from "@/components/SearchBar";
import { useMemoList, useMemoMetadataStore } from "@/store/v1";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useUserStatsStore } from "@/store/v1";
import TagsSection from "../HomeSidebar/TagsSection";
import StatisticsView from "../StatisticsView";
interface Props {
className?: string;
}
const ExploreSidebar = (props: Props) => {
const location = useLocation();
const memoList = useMemoList();
const memoMetadataStore = useMemoMetadataStore();
const currentUser = useCurrentUser();
const userStatsStore = useUserStatsStore();
useDebounce(
async () => {
if (memoList.size() === 0) return;
await memoMetadataStore.fetchMemoMetadata({ location });
const filters = [`state == "NORMAL"`, `visibilities == [${currentUser ? "'PUBLIC', 'PROTECTED'" : "'PUBLIC'"}]`];
userStatsStore.listUserStats(undefined, filters.join(" && "));
},
300,
[memoList.size(), location.pathname],
[],
);
return (
@ -31,6 +31,7 @@ const ExploreSidebar = (props: Props) => {
)}
>
<SearchBar />
<StatisticsView />
<TagsSection readonly={true} />
</aside>
);

@ -1,10 +1,9 @@
import clsx from "clsx";
import { useLocation } from "react-router-dom";
import useDebounce from "react-use/lib/useDebounce";
import SearchBar from "@/components/SearchBar";
import UserStatisticsView from "@/components/UserStatisticsView";
import StatisticsView from "@/components/StatisticsView";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useMemoList, useMemoMetadataStore } from "@/store/v1";
import { useMemoList, useUserStatsStore } from "@/store/v1";
import TagsSection from "./TagsSection";
interface Props {
@ -12,17 +11,17 @@ interface Props {
}
const HomeSidebar = (props: Props) => {
const location = useLocation();
const user = useCurrentUser();
const currentUser = useCurrentUser();
const memoList = useMemoList();
const memoMetadataStore = useMemoMetadataStore();
const userStatsStore = useUserStatsStore();
useDebounce(
async () => {
await memoMetadataStore.fetchMemoMetadata({ user, location });
const filters = [`state == "NORMAL"`];
await userStatsStore.listUserStats(currentUser.name, filters.join(" && "));
},
300,
[memoList.size(), user, location.pathname],
[memoList.size(), currentUser],
);
return (
@ -33,7 +32,7 @@ const HomeSidebar = (props: Props) => {
)}
>
<SearchBar />
<UserStatisticsView />
<StatisticsView />
<TagsSection />
</aside>
);

@ -2,11 +2,10 @@ import { Dropdown, Menu, MenuButton, MenuItem, Switch } from "@mui/joy";
import clsx from "clsx";
import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "lucide-react";
import toast from "react-hot-toast";
import { useLocation } from "react-router-dom";
import useLocalStorage from "react-use/lib/useLocalStorage";
import { memoServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useMemoFilterStore, useMemoMetadataStore, useMemoTagList } from "@/store/v1";
import { useMemoFilterStore, useUserStatsStore, useUserStatsTags } from "@/store/v1";
import { useTranslate } from "@/utils/i18n";
import showRenameTagDialog from "../RenameTagDialog";
import TagTree from "../TagTree";
@ -18,12 +17,11 @@ interface Props {
const TagsSection = (props: Props) => {
const t = useTranslate();
const location = useLocation();
const user = useCurrentUser();
const currentUser = useCurrentUser();
const memoFilterStore = useMemoFilterStore();
const memoMetadataStore = useMemoMetadataStore();
const userStatsStore = useUserStatsStore();
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
const tags = Object.entries(useMemoTagList())
const tags = Object.entries(useUserStatsTags())
.sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]);
@ -46,7 +44,7 @@ const TagsSection = (props: Props) => {
parent: "memos/-",
tag: tag,
});
await memoMetadataStore.fetchMemoMetadata({ user, location });
await userStatsStore.listUserStats(currentUser.name);
toast.success(t("message.deleted-successfully"));
}
};

@ -4,7 +4,7 @@ import { HashIcon } from "lucide-react";
import { useRef, useState } from "react";
import useClickAway from "react-use/lib/useClickAway";
import OverflowTip from "@/components/kit/OverflowTip";
import { useMemoTagList } from "@/store/v1";
import { useUserStatsTags } from "@/store/v1";
import { useTranslate } from "@/utils/i18n";
import { EditorRefActions } from "../Editor";
@ -17,7 +17,7 @@ const TagSelector = (props: Props) => {
const { editorRef } = props;
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const tags = Object.entries(useMemoTagList())
const tags = Object.entries(useUserStatsTags())
.sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1])
.map(([tag]) => tag);

@ -3,7 +3,7 @@ import Fuse from "fuse.js";
import { useEffect, useRef, useState } from "react";
import getCaretCoordinates from "textarea-caret";
import OverflowTip from "@/components/kit/OverflowTip";
import { useMemoTagList } from "@/store/v1";
import { useUserStatsTags } from "@/store/v1";
import { EditorRefActions } from ".";
type Props = {
@ -18,7 +18,7 @@ const TagSuggestions = ({ editorRef, editorActions }: Props) => {
const [selected, select] = useState(0);
const selectedRef = useRef(selected);
selectedRef.current = selected;
const tags = Object.entries(useMemoTagList())
const tags = Object.entries(useUserStatsTags())
.sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1])
.map(([tag]) => tag);

@ -1,10 +1,9 @@
import { Button } from "@usememos/mui";
import { ArrowDownIcon, ArrowUpIcon, LoaderIcon, SlashIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useState } from "react";
import PullToRefresh from "react-simple-pull-to-refresh";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { Routes } from "@/router";
import { useMemoList, useMemoStore } from "@/store/v1";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
@ -27,15 +26,10 @@ const PagedMemoList = (props: Props) => {
const { md } = useResponsiveWidth();
const memoStore = useMemoStore();
const memoList = useMemoList();
const containerRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<State>({
isRequesting: true, // Initial request
nextPageToken: "",
});
const shouldShowBackToTop = useMemo(
() => [Routes.ROOT, Routes.EXPLORE, Routes.ARCHIVED].includes(location.pathname as Routes) || location.pathname.startsWith("/u/"),
[location.pathname],
);
const sortedMemoList = props.listSort ? props.listSort(memoList.value) : memoList.value;
const fetchMoreMemos = async (nextPageToken: string) => {
@ -62,7 +56,7 @@ const PagedMemoList = (props: Props) => {
}, [props.filter, props.pageSize]);
const children = (
<div ref={containerRef} className="flex flex-col justify-start items-start w-full max-w-full">
<div className="flex flex-col justify-start items-start w-full max-w-full">
{sortedMemoList.map((memo) => props.renderer(memo))}
{state.isRequesting && (
<div className="w-full flex flex-row justify-center items-center my-4">
@ -84,10 +78,10 @@ const PagedMemoList = (props: Props) => {
{t("memo.load-more")}
<ArrowDownIcon className="ml-1 w-4 h-auto" />
</Button>
{shouldShowBackToTop && <SlashIcon className="mx-1 w-4 h-auto opacity-40" />}
<SlashIcon className="mx-1 w-4 h-auto opacity-40" />
</>
)}
{shouldShowBackToTop && <BackToTop />}
<BackToTop />
</div>
)}
</>

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

@ -7,25 +7,18 @@ import { useState } from "react";
import useAsyncEffect from "@/hooks/useAsyncEffect";
import useCurrentUser from "@/hooks/useCurrentUser";
import i18n from "@/i18n";
import { useMemoFilterStore, useMemoMetadataStore } from "@/store/v1";
import { useMemoFilterStore, useUserStatsStore } from "@/store/v1";
import { UserStats_MemoTypeStats } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n";
import ActivityCalendar from "./ActivityCalendar";
interface UserMemoStats {
link: number;
taskList: number;
code: number;
incompleteTasks: number;
}
const UserStatisticsView = () => {
const StatisticsView = () => {
const t = useTranslate();
const currentUser = useCurrentUser();
const memoFilterStore = useMemoFilterStore();
const memoMetadataStore = useMemoMetadataStore();
const metadataList = Object.values(memoMetadataStore.getState().dataMapByName);
const userStatsStore = useUserStatsStore();
const [memoAmount, setMemoAmount] = useState(0);
const [memoStats, setMemoStats] = useState<UserMemoStats>({ link: 0, taskList: 0, code: 0, incompleteTasks: 0 });
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"));
@ -35,26 +28,21 @@ const UserStatisticsView = () => {
const singularOrPluralDay = (days > 0 ? t("common.days") : t("common.day")).toLowerCase();
useAsyncEffect(async () => {
const memoStats: UserMemoStats = { link: 0, taskList: 0, code: 0, incompleteTasks: 0 };
metadataList.forEach((memo) => {
const { property } = memo;
if (property?.hasLink) {
memoStats.link += 1;
}
if (property?.hasTaskList) {
memoStats.taskList += 1;
}
if (property?.hasCode) {
memoStats.code += 1;
}
if (property?.hasIncompleteTasks) {
memoStats.incompleteTasks += 1;
const memoTypeStats = UserStats_MemoTypeStats.fromPartial({});
const displayTimeList: Date[] = [];
for (const stats of Object.values(userStatsStore.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;
}
});
setMemoStats(memoStats);
setMemoAmount(metadataList.length);
setActivityStats(countBy(metadataList.map((memo) => dayjs(memo.displayTime).format("YYYY-MM-DD"))));
}, [memoMetadataStore.stateId]);
}
setMemoTypeStats(memoTypeStats);
setMemoAmount(displayTimeList.length);
setActivityStats(countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD"))));
}, [userStatsStore.stateId]);
const onCalendarClick = (date: string) => {
memoFilterStore.removeFilter((f) => f.factor === "displayTime");
@ -110,26 +98,26 @@ const UserStatisticsView = () => {
<LinkIcon className="w-4 h-auto mr-1" />
<span className="block text-sm">{t("memo.links")}</span>
</div>
<span className="text-sm truncate">{memoStats.link}</span>
<span className="text-sm truncate">{memoTypeStats.linkCount}</span>
</div>
<div
className={clsx("w-auto border dark:border-zinc-800 pl-1 pr-1.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">
{memoStats.incompleteTasks > 0 ? <ListTodoIcon className="w-4 h-auto mr-1" /> : <CheckCircleIcon className="w-4 h-auto 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>
{memoStats.incompleteTasks > 0 ? (
{memoTypeStats.undoCount > 0 ? (
<Tooltip title={"Done / Total"} placement="top" arrow>
<div className="text-sm flex flex-row items-start justify-center">
<span className="truncate">{memoStats.taskList - memoStats.incompleteTasks}</span>
<span className="truncate">{memoTypeStats.todoCount - memoTypeStats.undoCount}</span>
<span className="font-mono opacity-50">/</span>
<span className="truncate">{memoStats.taskList}</span>
<span className="truncate">{memoTypeStats.todoCount}</span>
</div>
</Tooltip>
) : (
<span className="text-sm truncate">{memoStats.taskList}</span>
<span className="text-sm truncate">{memoTypeStats.todoCount}</span>
)}
</div>
<div
@ -140,11 +128,11 @@ const UserStatisticsView = () => {
<Code2Icon className="w-4 h-auto mr-1" />
<span className="block text-sm">{t("memo.code")}</span>
</div>
<span className="text-sm truncate">{memoStats.code}</span>
<span className="text-sm truncate">{memoTypeStats.codeCount}</span>
</div>
</div>
</div>
);
};
export default UserStatisticsView;
export default StatisticsView;

@ -5,4 +5,4 @@ export * from "./resourceName";
export * from "./resource";
export * from "./workspaceSetting";
export * from "./memoFilter";
export * from "./memoMetadata";
export * from "./userStats";

@ -1,76 +0,0 @@
import { uniqueId } from "lodash-es";
import { Location } from "react-router-dom";
import { create } from "zustand";
import { combine } from "zustand/middleware";
import { memoServiceClient } from "@/grpcweb";
import { Routes } from "@/router";
import { Memo, MemoView } from "@/types/proto/api/v1/memo_service";
import { User } from "@/types/proto/api/v1/user_service";
// Set the maximum number of memos to fetch.
const DEFAULT_MEMO_PAGE_SIZE = 1000000;
interface State {
// stateId is used to identify the store instance state.
// It should be update when any state change.
stateId: string;
dataMapByName: Record<string, Memo>;
}
const getDefaultState = (): State => ({
stateId: uniqueId(),
dataMapByName: {},
});
export const useMemoMetadataStore = create(
combine(getDefaultState(), (set, get) => ({
setState: (state: State) => set(state),
getState: () => get(),
fetchMemoMetadata: async (params: { user?: User; location?: Location<any> }) => {
const filters = [`state == "NORMAL"`];
if (params.user) {
if (params.location?.pathname === Routes.EXPLORE) {
filters.push(`visibilities == ["PUBLIC", "PROTECTED"]`);
}
filters.push(`creator == "${params.user.name}"`);
} else {
filters.push(`visibilities == ["PUBLIC"]`);
}
const { memos, nextPageToken } = await memoServiceClient.listMemos({
filter: filters.join(" && "),
view: MemoView.MEMO_VIEW_METADATA_ONLY,
pageSize: DEFAULT_MEMO_PAGE_SIZE,
});
const memoMap = memos.reduce<Record<string, Memo>>(
(acc, memo) => ({
...acc,
[memo.name]: memo,
}),
{},
);
set({ stateId: uniqueId(), dataMapByName: memoMap });
return { memos, nextPageToken };
},
})),
);
export const useMemoTagList = () => {
const memoStore = useMemoMetadataStore();
const memos = Object.values(memoStore.getState().dataMapByName);
const tagAmounts: Record<string, number> = {};
memos.forEach((memo) => {
const tagSet = new Set<string>();
for (const tag of memo.tags) {
const parts = tag.split("/");
let currentTag = "";
for (const part of parts) {
currentTag = currentTag ? `${currentTag}/${part}` : part;
tagSet.add(currentTag);
}
}
Array.from(tagSet).forEach((tag) => {
tagAmounts[tag] = tagAmounts[tag] ? tagAmounts[tag] + 1 : 1;
});
});
return tagAmounts;
};

@ -0,0 +1,48 @@
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, filter?: string) => {
const userStatsByName: Record<string, UserStats> = {};
if (!user) {
const { userStats } = await userServiceClient.listAllUserStats({ filter });
for (const stats of userStats) {
userStatsByName[stats.name] = stats;
}
} else {
const userStats = await userServiceClient.getUserStats({ name: user, filter });
userStatsByName[user] = userStats;
}
set({ stateId: uniqueId(), userStatsByName });
},
})),
);
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;
};
Loading…
Cancel
Save