diff --git a/web/src/hooks/useFilteredMemoStats.ts b/web/src/hooks/useFilteredMemoStats.ts index 99a553646..cf0abe990 100644 --- a/web/src/hooks/useFilteredMemoStats.ts +++ b/web/src/hooks/useFilteredMemoStats.ts @@ -1,7 +1,7 @@ import dayjs from "dayjs"; import { countBy } from "lodash-es"; import { useEffect, useState } from "react"; -import { memoStore } from "@/store"; +import { memoStore, userStore } from "@/store"; import type { StatisticsData } from "@/types/statistics"; export interface FilteredMemoStats { @@ -11,20 +11,51 @@ export interface FilteredMemoStats { } /** - * Hook to compute statistics and tags from memos in the store cache. - * - * This provides a unified approach for all pages (Home, Explore, Archived, Profile): - * - Uses memos already loaded in the store by PagedMemoList - * - Computes statistics and tags from those cached memos - * - Updates automatically when memos are created, updated, or deleted - * - No separate API call needed, reducing network overhead - * - * @returns Object with statistics data, tag counts, and loading state - * - * Note: This hook now computes stats from the memo store cache rather than - * making a separate API call. It relies on PagedMemoList to populate the store. + * Convert user name to user stats key. + * Backend returns UserStats with name "users/{id}/stats" but we pass "users/{id}" + * @param userName - User name in format "users/{id}" + * @returns Stats key in format "users/{id}/stats" */ -export const useFilteredMemoStats = (): FilteredMemoStats => { +const getUserStatsKey = (userName: string): string => { + return `${userName}/stats`; +}; + +export interface UseFilteredMemoStatsOptions { + /** + * User name to fetch stats for (e.g., "users/123") + * + * When provided: + * - Fetches backend user stats via GetUserStats API + * - Returns unfiltered tags and activity (all NORMAL memos for that user) + * - Tags remain stable even when memo filters are applied + * + * When undefined: + * - Computes stats from cached memos in the store + * - Reflects current filters (useful for Explore/Archived pages) + * + * IMPORTANT: Backend user stats only include NORMAL (non-archived) memos. + * Do NOT use for Archived page context. + */ + userName?: string; +} + +/** + * Hook to compute statistics and tags for the sidebar. + * + * Data sources by context: + * - **Home/Profile**: Uses backend UserStats API (unfiltered, normal memos only) + * - **Archived/Explore**: Computes from cached memos (filtered by page context) + * + * Benefits of using backend stats: + * - Tag list remains stable when memo filters are applied + * - Activity calendar shows full history, not just filtered results + * - Prevents "disappearing tags" issue when filtering by tag + * + * @param options - Configuration options + * @returns Object with statistics data, tag counts, and loading state + */ +export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => { + const { userName } = options; const [data, setData] = useState({ statistics: { activityStats: {}, @@ -34,33 +65,65 @@ export const useFilteredMemoStats = (): FilteredMemoStats => { }); // React to memo store changes (create, update, delete) const memoStoreStateId = memoStore.state.stateId; + // React to user stats changes (for tag counts) + const userStatsStateId = userStore.state.statsStateId; useEffect(() => { - // Compute statistics and tags from memos already in the store - // This avoids making a separate API call and relies on PagedMemoList to populate the store - const computeStatsFromCache = () => { - const displayTimeList: Date[] = []; - const tagCount: Record = {}; + const computeStats = async () => { + let activityStats: Record = {}; + let tagCount: Record = {}; + let useBackendStats = false; - // Use memos already loaded in the store - const memos = memoStore.state.memos; + // Try to use backend user stats if userName is provided + if (userName) { + // Check if stats are already cached, otherwise fetch them + const statsKey = getUserStatsKey(userName); + let userStats = userStore.state.userStatsByName[statsKey]; - for (const memo of memos) { - // Add display time for calendar - if (memo.displayTime) { - displayTimeList.push(memo.displayTime); + if (!userStats) { + try { + await userStore.fetchUserStats(userName); + userStats = userStore.state.userStatsByName[statsKey]; + } catch (error) { + console.error("Failed to fetch user stats:", error); + // Will fall back to computing from cache below + } } - // Count tags - if (memo.tags && memo.tags.length > 0) { - for (const tag of memo.tags) { - tagCount[tag] = (tagCount[tag] || 0) + 1; + if (userStats) { + // Use activity timestamps from user stats + if (userStats.memoDisplayTimestamps && userStats.memoDisplayTimestamps.length > 0) { + activityStats = countBy(userStats.memoDisplayTimestamps.map((date) => dayjs(date).format("YYYY-MM-DD"))); } + // Use tag counts from user stats + if (userStats.tagCount) { + tagCount = userStats.tagCount; + } + useBackendStats = true; } } - // Compute activity calendar data - const activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD"))); + // Fallback: compute from cached memos if backend stats not available + // Also used for Explore and Archived contexts + if (!useBackendStats) { + const displayTimeList: Date[] = []; + const memos = memoStore.state.memos; + + for (const memo of memos) { + // Collect display timestamps for activity calendar + if (memo.displayTime) { + displayTimeList.push(memo.displayTime); + } + // Count tags + if (memo.tags && memo.tags.length > 0) { + for (const tag of memo.tags) { + tagCount[tag] = (tagCount[tag] || 0) + 1; + } + } + } + + activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD"))); + } setData({ statistics: { activityStats }, @@ -69,8 +132,8 @@ export const useFilteredMemoStats = (): FilteredMemoStats => { }); }; - computeStatsFromCache(); - }, [memoStoreStateId]); + computeStats(); + }, [memoStoreStateId, userStatsStateId, userName]); return data; }; diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx index d8d8dc0c5..8878427a7 100644 --- a/web/src/layouts/MainLayout.tsx +++ b/web/src/layouts/MainLayout.tsx @@ -1,17 +1,21 @@ import { observer } from "mobx-react-lite"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { matchPath, Outlet, useLocation } from "react-router-dom"; import type { MemoExplorerContext } from "@/components/MemoExplorer"; import { MemoExplorer, MemoExplorerDrawer } from "@/components/MemoExplorer"; import MobileHeader from "@/components/MobileHeader"; +import useCurrentUser from "@/hooks/useCurrentUser"; import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats"; import useResponsiveWidth from "@/hooks/useResponsiveWidth"; import { cn } from "@/lib/utils"; import { Routes } from "@/router"; +import { userStore } from "@/store"; const MainLayout = observer(() => { const { md, lg } = useResponsiveWidth(); const location = useLocation(); + const currentUser = useCurrentUser(); + const [profileUserName, setProfileUserName] = useState(); // Determine context based on current route const context: MemoExplorerContext = useMemo(() => { @@ -22,8 +26,46 @@ const MainLayout = observer(() => { return "home"; // fallback }, [location.pathname]); + // Extract username from URL for profile context + useEffect(() => { + const match = matchPath("/u/:username", location.pathname); + if (match && context === "profile") { + const username = match.params.username; + if (username) { + // Fetch or get user to obtain user name (e.g., "users/123") + // Note: User stats will be fetched by useFilteredMemoStats + userStore + .getOrFetchUserByUsername(username) + .then((user) => { + setProfileUserName(user.name); + }) + .catch((error) => { + console.error("Failed to fetch profile user:", error); + setProfileUserName(undefined); + }); + } + } else { + setProfileUserName(undefined); + } + }, [location.pathname, context]); + + // Determine which user name to use for stats + // - home: current user (uses backend user stats for normal memos) + // - profile: viewed user (uses backend user stats for normal memos) + // - archived: undefined (compute from cached archived memos, since user stats only includes normal memos) + // - explore: undefined (compute from cached memos) + const statsUserName = useMemo(() => { + if (context === "home") { + return currentUser?.name; + } else if (context === "profile") { + return profileUserName; + } + return undefined; // archived and explore contexts compute from cache + }, [context, currentUser, profileUserName]); + // Fetch stats from memo store cache (populated by PagedMemoList) - const { statistics, tags } = useFilteredMemoStats(); + // For user-scoped contexts, use backend user stats for tags (unaffected by filters) + const { statistics, tags } = useFilteredMemoStats({ userName: statsUserName }); return (
diff --git a/web/src/store/user.ts b/web/src/store/user.ts index 94094b26b..b73f15c34 100644 --- a/web/src/store/user.ts +++ b/web/src/store/user.ts @@ -52,7 +52,8 @@ class LocalState { if (!this.currentUser) { return undefined; } - return this.userStatsByName[this.currentUser]; + // Backend returns stats with key "users/{id}/stats" + return this.userStatsByName[`${this.currentUser}/stats`]; } constructor() { @@ -267,13 +268,14 @@ const userStore = (() => { } } else { const userStats = await userServiceClient.getUserStats({ name: user }); - userStatsByName[user] = userStats; + userStatsByName[userStats.name] = userStats; // Use userStats.name as key for consistency } state.setPartial({ userStatsByName: { ...state.userStatsByName, ...userStatsByName, }, + statsStateId: uniqueId(), // Update state ID to trigger reactivity }); } catch (error) { throw StoreError.wrap("FETCH_USER_STATS_FAILED", error);