Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
424e5999
Commit
424e5999
authored
Nov 24, 2025
by
Steven
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
refactor(web): optimize memo statistics fetching by using cached data from memo store
parent
72f93c53
Changes
3
Hide whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
96 additions
and
154 deletions
+96
-154
PagedMemoList.tsx
web/src/components/PagedMemoList/PagedMemoList.tsx
+57
-34
useFilteredMemoStats.ts
web/src/hooks/useFilteredMemoStats.ts
+35
-75
MainLayout.tsx
web/src/layouts/MainLayout.tsx
+4
-45
No files found.
web/src/components/PagedMemoList/PagedMemoList.tsx
View file @
424e5999
...
@@ -9,10 +9,11 @@ import useResponsiveWidth from "@/hooks/useResponsiveWidth";
...
@@ -9,10 +9,11 @@ import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import
{
Routes
}
from
"@/router"
;
import
{
Routes
}
from
"@/router"
;
import
{
memoStore
,
userStore
,
viewStore
}
from
"@/store"
;
import
{
memoStore
,
userStore
,
viewStore
}
from
"@/store"
;
import
{
State
}
from
"@/types/proto/api/v1/common"
;
import
{
State
}
from
"@/types/proto/api/v1/common"
;
import
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
type
{
Memo
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
Empty
from
"../Empty"
;
import
Empty
from
"../Empty"
;
import
MasonryView
,
{
MemoRenderContext
}
from
"../MasonryView"
;
import
type
{
MemoRenderContext
}
from
"../MasonryView"
;
import
MasonryView
from
"../MasonryView"
;
import
MemoEditor
from
"../MemoEditor"
;
import
MemoEditor
from
"../MemoEditor"
;
import
MemoFilters
from
"../MemoFilters"
;
import
MemoFilters
from
"../MemoFilters"
;
import
MemoSkeleton
from
"../MemoSkeleton"
;
import
MemoSkeleton
from
"../MemoSkeleton"
;
...
@@ -37,6 +38,8 @@ const PagedMemoList = observer((props: Props) => {
...
@@ -37,6 +38,8 @@ const PagedMemoList = observer((props: Props) => {
// Ref to manage auto-fetch timeout to prevent memory leaks
// Ref to manage auto-fetch timeout to prevent memory leaks
const
autoFetchTimeoutRef
=
useRef
<
number
|
null
>
(
null
);
const
autoFetchTimeoutRef
=
useRef
<
number
|
null
>
(
null
);
// Ref to track if initial fetch has been triggered to prevent duplicates
const
initialFetchTriggeredRef
=
useRef
(
false
);
// Apply custom sorting if provided, otherwise use store memos directly
// Apply custom sorting if provided, otherwise use store memos directly
const
sortedMemoList
=
props
.
listSort
?
props
.
listSort
(
memoStore
.
state
.
memos
)
:
memoStore
.
state
.
memos
;
const
sortedMemoList
=
props
.
listSort
?
props
.
listSort
(
memoStore
.
state
.
memos
)
:
memoStore
.
state
.
memos
;
...
@@ -45,36 +48,39 @@ const PagedMemoList = observer((props: Props) => {
...
@@ -45,36 +48,39 @@ const PagedMemoList = observer((props: Props) => {
const
showMemoEditor
=
Boolean
(
matchPath
(
Routes
.
ROOT
,
window
.
location
.
pathname
));
const
showMemoEditor
=
Boolean
(
matchPath
(
Routes
.
ROOT
,
window
.
location
.
pathname
));
// Fetch more memos with pagination support
// Fetch more memos with pagination support
const
fetchMoreMemos
=
async
(
pageToken
:
string
)
=>
{
const
fetchMoreMemos
=
useCallback
(
setIsRequesting
(
true
);
async
(
pageToken
:
string
)
=>
{
setIsRequesting
(
true
);
try
{
const
response
=
await
memoStore
.
fetchMemos
({
try
{
state
:
props
.
state
||
State
.
NORMAL
,
const
response
=
await
memoStore
.
fetchMemos
({
orderBy
:
props
.
orderBy
||
"display_time desc"
,
state
:
props
.
state
||
State
.
NORMAL
,
filter
:
props
.
filter
,
orderBy
:
props
.
orderBy
||
"display_time desc"
,
pageSize
:
props
.
pageSize
||
DEFAULT_LIST_MEMOS_PAGE_SIZE
,
filter
:
props
.
filter
,
pageToken
,
pageSize
:
props
.
pageSize
||
DEFAULT_LIST_MEMOS_PAGE_SIZE
,
});
pageToken
,
});
setNextPageToken
(
response
?.
nextPageToken
||
""
);
setNextPageToken
(
response
?.
nextPageToken
||
""
);
// Batch-fetch creators in parallel to avoid individual fetches in MemoView
// This significantly improves perceived performance by pre-populating the cache
// Batch-fetch creators in parallel to avoid individual fetches in MemoView
if
(
response
?.
memos
&&
props
.
showCreator
)
{
// This significantly improves perceived performance by pre-populating the cache
const
uniqueCreators
=
Array
.
from
(
new
Set
(
response
.
memos
.
map
((
memo
)
=>
memo
.
creator
)));
if
(
response
?.
memos
&&
props
.
showCreator
)
{
await
Promise
.
allSettled
(
uniqueCreators
.
map
((
creator
)
=>
userStore
.
getOrFetchUserByName
(
creator
)));
const
uniqueCreators
=
Array
.
from
(
new
Set
(
response
.
memos
.
map
((
memo
)
=>
memo
.
creator
)));
await
Promise
.
allSettled
(
uniqueCreators
.
map
((
creator
)
=>
userStore
.
getOrFetchUserByName
(
creator
)));
}
}
finally
{
setIsRequesting
(
false
);
}
}
}
finally
{
},
setIsRequesting
(
false
);
[
props
.
state
,
props
.
orderBy
,
props
.
filter
,
props
.
pageSize
,
props
.
showCreator
],
}
);
};
// Helper function to check if page has enough content to be scrollable
// Helper function to check if page has enough content to be scrollable
const
isPageScrollable
=
()
=>
{
const
isPageScrollable
=
useCallback
(
()
=>
{
const
documentHeight
=
Math
.
max
(
document
.
body
.
scrollHeight
,
document
.
documentElement
.
scrollHeight
);
const
documentHeight
=
Math
.
max
(
document
.
body
.
scrollHeight
,
document
.
documentElement
.
scrollHeight
);
return
documentHeight
>
window
.
innerHeight
+
100
;
// 100px buffer for safe measure
return
documentHeight
>
window
.
innerHeight
+
100
;
// 100px buffer for safe measure
};
}
,
[])
;
// Auto-fetch more content if page isn't scrollable and more data is available
// Auto-fetch more content if page isn't scrollable and more data is available
const
checkAndFetchIfNeeded
=
useCallback
(
async
()
=>
{
const
checkAndFetchIfNeeded
=
useCallback
(
async
()
=>
{
...
@@ -97,26 +103,43 @@ const PagedMemoList = observer((props: Props) => {
...
@@ -97,26 +103,43 @@ const PagedMemoList = observer((props: Props) => {
checkAndFetchIfNeeded
();
checkAndFetchIfNeeded
();
},
500
);
},
500
);
}
}
},
[
nextPageToken
,
isRequesting
,
sortedMemoList
.
length
]);
},
[
nextPageToken
,
isRequesting
,
sortedMemoList
.
length
,
isPageScrollable
,
fetchMoreMemos
]);
// Refresh the entire memo list from the beginning
// Refresh the entire memo list from the beginning
const
refreshList
=
async
()
=>
{
const
refreshList
=
useCallback
(
async
()
=>
{
memoStore
.
state
.
updateStateId
();
memoStore
.
state
.
updateStateId
();
setNextPageToken
(
""
);
setNextPageToken
(
""
);
await
fetchMoreMemos
(
""
);
await
fetchMoreMemos
(
""
);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[
fetchMoreMemos
]);
// Track previous props to detect changes
const
propsKey
=
`
${
props
.
state
}
-
${
props
.
orderBy
}
-
${
props
.
filter
}
-
${
props
.
pageSize
}
`
;
const
prevPropsKeyRef
=
useRef
<
string
>
();
// Initial load and reload when props change
// Initial load and reload when props change
useEffect
(()
=>
{
useEffect
(()
=>
{
refreshList
();
const
propsChanged
=
prevPropsKeyRef
.
current
!==
undefined
&&
prevPropsKeyRef
.
current
!==
propsKey
;
},
[
props
.
state
,
props
.
orderBy
,
props
.
filter
,
props
.
pageSize
]);
prevPropsKeyRef
.
current
=
propsKey
;
// Skip first render if we haven't marked it yet
if
(
!
initialFetchTriggeredRef
.
current
)
{
initialFetchTriggeredRef
.
current
=
true
;
refreshList
();
return
;
}
// For subsequent changes, refresh if props actually changed
if
(
propsChanged
)
{
refreshList
();
}
},
[
refreshList
,
propsKey
]);
// Auto-fetch more content when list changes and page isn't full
// Auto-fetch more content when list changes and page isn't full
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
isRequesting
&&
sortedMemoList
.
length
>
0
)
{
if
(
!
isRequesting
&&
sortedMemoList
.
length
>
0
)
{
checkAndFetchIfNeeded
();
checkAndFetchIfNeeded
();
}
}
},
[
sortedMemoList
.
length
,
isRequesting
,
nextPageToken
,
checkAndFetchIfNeeded
]);
},
[
sortedMemoList
.
length
,
isRequesting
,
checkAndFetchIfNeeded
]);
// Cleanup timeout on component unmount
// Cleanup timeout on component unmount
useEffect
(()
=>
{
useEffect
(()
=>
{
...
@@ -140,7 +163,7 @@ const PagedMemoList = observer((props: Props) => {
...
@@ -140,7 +163,7 @@ const PagedMemoList = observer((props: Props) => {
window
.
addEventListener
(
"scroll"
,
handleScroll
);
window
.
addEventListener
(
"scroll"
,
handleScroll
);
return
()
=>
window
.
removeEventListener
(
"scroll"
,
handleScroll
);
return
()
=>
window
.
removeEventListener
(
"scroll"
,
handleScroll
);
},
[
nextPageToken
,
isRequesting
]);
},
[
nextPageToken
,
isRequesting
,
fetchMoreMemos
]);
const
children
=
(
const
children
=
(
<
div
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"
>
...
...
web/src/hooks/useFilteredMemoStats.ts
View file @
424e5999
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
{
countBy
}
from
"lodash-es"
;
import
{
countBy
}
from
"lodash-es"
;
import
{
useEffect
,
useState
}
from
"react"
;
import
{
useEffect
,
useState
}
from
"react"
;
import
{
memoServiceClient
}
from
"@/grpcweb"
;
import
{
memoStore
}
from
"@/store"
;
import
{
memoStore
}
from
"@/store"
;
import
{
State
}
from
"@/types/proto/api/v1/common"
;
import
type
{
StatisticsData
}
from
"@/types/statistics"
;
import
type
{
StatisticsData
}
from
"@/types/statistics"
;
export
interface
FilteredMemoStats
{
export
interface
FilteredMemoStats
{
...
@@ -13,104 +11,66 @@ export interface FilteredMemoStats {
...
@@ -13,104 +11,66 @@ export interface FilteredMemoStats {
}
}
/**
/**
* Hook to
fetch and compute statistics and tags from memos matching a filter
.
* Hook to
compute statistics and tags from memos in the store cache
.
*
*
* This provides a unified approach for all pages (Home, Explore, Archived, Profile):
* This provides a unified approach for all pages (Home, Explore, Archived, Profile):
* - Uses
the same filter as PagedMemoList for consistency
* - Uses
memos already loaded in the store by PagedMemoList
* -
Fetches all memos matching the filter once
* -
Computes statistics and tags from those cached memos
* -
Computes statistics and tags from those memos
* -
Updates automatically when memos are created, updated, or deleted
* -
Stats/tags remain static and don't change when user applies additional filters
* -
No separate API call needed, reducing network overhead
*
*
* @param filter - CEL filter expression (same as used for memo list)
* @param state - Memo state (NORMAL for most pages, ARCHIVED for archived page)
* @param orderBy - Optional sort order (not used for stats, but ensures consistency)
* @returns Object with statistics data, tag counts, and loading state
* @returns Object with statistics data, tag counts, and loading state
*
*
* @example Home page
* Note: This hook now computes stats from the memo store cache rather than
* const { statistics, tags } = useFilteredMemoStats(
* making a separate API call. It relies on PagedMemoList to populate the store.
* `creator_id == ${currentUserId}`,
* State.NORMAL
* );
*
* @example Explore page
* const { statistics, tags } = useFilteredMemoStats(
* `visibility in ["PUBLIC", "PROTECTED"]`,
* State.NORMAL
* );
*
* @example Archived page
* const { statistics, tags } = useFilteredMemoStats(
* `creator_id == ${currentUserId}`,
* State.ARCHIVED
* );
*/
*/
export
const
useFilteredMemoStats
=
(
filter
?:
string
,
state
:
State
=
State
.
NORMAL
,
orderBy
?:
string
):
FilteredMemoStats
=>
{
export
const
useFilteredMemoStats
=
():
FilteredMemoStats
=>
{
const
[
data
,
setData
]
=
useState
<
FilteredMemoStats
>
({
const
[
data
,
setData
]
=
useState
<
FilteredMemoStats
>
({
statistics
:
{
statistics
:
{
activityStats
:
{},
activityStats
:
{},
},
},
tags
:
{},
tags
:
{},
loading
:
tru
e
,
loading
:
fals
e
,
});
});
// React to memo store changes (create, update, delete)
// React to memo store changes (create, update, delete)
const
memoStoreStateId
=
memoStore
.
state
.
stateId
;
const
memoStoreStateId
=
memoStore
.
state
.
stateId
;
useEffect
(()
=>
{
useEffect
(()
=>
{
const
fetchMemosAndComputeStats
=
async
()
=>
{
// Compute statistics and tags from memos already in the store
setData
((
prev
)
=>
({
...
prev
,
loading
:
true
}));
// This avoids making a separate API call and relies on PagedMemoList to populate the store
const
computeStatsFromCache
=
()
=>
{
try
{
const
displayTimeList
:
Date
[]
=
[];
// Fetch all memos matching the filter
const
tagCount
:
Record
<
string
,
number
>
=
{};
// Use large page size to ensure we get all memos for accurate stats
const
response
=
await
memoServiceClient
.
listMemos
({
state
,
filter
,
orderBy
,
pageSize
:
10000
,
// Large enough to get all memos
});
// Compute statistics and tags from fetched memos
// Use memos already loaded in the store
const
displayTimeList
:
Date
[]
=
[];
const
memos
=
memoStore
.
state
.
memos
;
const
tagCount
:
Record
<
string
,
number
>
=
{};
if
(
response
.
memos
)
{
for
(
const
memo
of
memos
)
{
for
(
const
memo
of
response
.
memos
)
{
// Add display time for calendar
// Add display time for calendar
if
(
memo
.
displayTime
)
{
if
(
memo
.
displayTime
)
{
displayTimeList
.
push
(
memo
.
displayTime
);
displayTimeList
.
push
(
memo
.
displayTime
);
}
}
// Count tags
// Count tags
if
(
memo
.
tags
&&
memo
.
tags
.
length
>
0
)
{
if
(
memo
.
tags
&&
memo
.
tags
.
length
>
0
)
{
for
(
const
tag
of
memo
.
tags
)
{
for
(
const
tag
of
memo
.
tags
)
{
tagCount
[
tag
]
=
(
tagCount
[
tag
]
||
0
)
+
1
;
tagCount
[
tag
]
=
(
tagCount
[
tag
]
||
0
)
+
1
;
}
}
}
}
}
}
}
// Compute activity calendar data
// Compute activity calendar data
const
activityStats
=
countBy
(
displayTimeList
.
map
((
date
)
=>
dayjs
(
date
).
format
(
"YYYY-MM-DD"
)));
const
activityStats
=
countBy
(
displayTimeList
.
map
((
date
)
=>
dayjs
(
date
).
format
(
"YYYY-MM-DD"
)));
setData
({
setData
({
statistics
:
{
activityStats
},
statistics
:
{
activityStats
},
tags
:
tagCount
,
tags
:
tagCount
,
loading
:
false
,
loading
:
false
,
});
});
}
catch
(
error
)
{
console
.
error
(
"Failed to fetch memos for statistics:"
,
error
);
setData
({
statistics
:
{
activityStats
:
{},
},
tags
:
{},
loading
:
false
,
});
}
};
};
fetchMemosAndComputeStats
();
computeStatsFromCache
();
},
[
filter
,
state
,
orderBy
,
memoStoreStateId
]);
},
[
memoStoreStateId
]);
return
data
;
return
data
;
};
};
web/src/layouts/MainLayout.tsx
View file @
424e5999
import
{
last
}
from
"lodash-es"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
useMemo
}
from
"react"
;
import
{
useMemo
}
from
"react"
;
import
{
matchPath
,
Outlet
,
useLocation
}
from
"react-router-dom"
;
import
{
matchPath
,
Outlet
,
useLocation
}
from
"react-router-dom"
;
import
{
MemoExplorer
,
MemoExplorerContext
,
MemoExplorerDrawer
}
from
"@/components/MemoExplorer"
;
import
type
{
MemoExplorerContext
}
from
"@/components/MemoExplorer"
;
import
{
MemoExplorer
,
MemoExplorerDrawer
}
from
"@/components/MemoExplorer"
;
import
MobileHeader
from
"@/components/MobileHeader"
;
import
MobileHeader
from
"@/components/MobileHeader"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
import
{
useFilteredMemoStats
}
from
"@/hooks/useFilteredMemoStats"
;
import
{
useFilteredMemoStats
}
from
"@/hooks/useFilteredMemoStats"
;
import
useResponsiveWidth
from
"@/hooks/useResponsiveWidth"
;
import
useResponsiveWidth
from
"@/hooks/useResponsiveWidth"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
Routes
}
from
"@/router"
;
import
{
Routes
}
from
"@/router"
;
import
{
userStore
}
from
"@/store"
;
import
{
extractUserIdFromName
}
from
"@/store/common"
;
import
{
State
}
from
"@/types/proto/api/v1/common"
;
import
{
Visibility
}
from
"@/types/proto/api/v1/memo_service"
;
const
MainLayout
=
observer
(()
=>
{
const
MainLayout
=
observer
(()
=>
{
const
{
md
,
lg
}
=
useResponsiveWidth
();
const
{
md
,
lg
}
=
useResponsiveWidth
();
const
location
=
useLocation
();
const
location
=
useLocation
();
const
currentUser
=
useCurrentUser
();
// Determine context based on current route
// Determine context based on current route
const
context
:
MemoExplorerContext
=
useMemo
(()
=>
{
const
context
:
MemoExplorerContext
=
useMemo
(()
=>
{
...
@@ -28,43 +22,8 @@ const MainLayout = observer(() => {
...
@@ -28,43 +22,8 @@ const MainLayout = observer(() => {
return
"home"
;
// fallback
return
"home"
;
// fallback
},
[
location
.
pathname
]);
},
[
location
.
pathname
]);
// Compute filter and state based on context
// Fetch stats from memo store cache (populated by PagedMemoList)
// This should match what each page uses for their memo list
const
{
statistics
,
tags
}
=
useFilteredMemoStats
();
const
{
filter
,
state
}
=
useMemo
(()
=>
{
if
(
location
.
pathname
===
Routes
.
ROOT
&&
currentUser
)
{
// Home: current user's normal memos
return
{
filter
:
`creator_id ==
${
extractUserIdFromName
(
currentUser
.
name
)}
`
,
state
:
State
.
NORMAL
,
};
}
else
if
(
location
.
pathname
===
Routes
.
EXPLORE
)
{
// Explore: visible memos (PUBLIC for visitors, PUBLIC+PROTECTED for logged-in)
const
visibilities
=
currentUser
?
[
Visibility
.
PUBLIC
,
Visibility
.
PROTECTED
]
:
[
Visibility
.
PUBLIC
];
const
visibilityValues
=
visibilities
.
map
((
v
)
=>
`"
${
v
}
"`
).
join
(
", "
);
return
{
filter
:
`visibility in [
${
visibilityValues
}
]`
,
state
:
State
.
NORMAL
,
};
}
else
if
(
matchPath
(
"/archived"
,
location
.
pathname
)
&&
currentUser
)
{
// Archived: current user's archived memos
return
{
filter
:
`creator_id ==
${
extractUserIdFromName
(
currentUser
.
name
)}
`
,
state
:
State
.
ARCHIVED
,
};
}
else
if
(
matchPath
(
"/u/:username"
,
location
.
pathname
))
{
// Profile: specific user's normal memos
const
username
=
last
(
location
.
pathname
.
split
(
"/"
));
const
user
=
userStore
.
getUserByName
(
`users/
${
username
}
`
);
return
{
filter
:
user
?
`creator_id ==
${
extractUserIdFromName
(
user
.
name
)}
`
:
undefined
,
state
:
State
.
NORMAL
,
};
}
return
{
filter
:
undefined
,
state
:
State
.
NORMAL
};
},
[
location
.
pathname
,
currentUser
]);
// Fetch stats using the same filter as the memo list
const
{
statistics
,
tags
}
=
useFilteredMemoStats
(
filter
,
state
);
return
(
return
(
<
section
className=
"@container w-full min-h-full flex flex-col justify-start items-center"
>
<
section
className=
"@container w-full min-h-full flex flex-col justify-start items-center"
>
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment