fix(web): resolve tag sidebar filtering and reactivity issues

This fixes multiple issues with the tag sidebar and activity calendar:

1. Tag disappearing bug: When filtering by a tag, the sidebar now shows all tags instead of only the selected tag
2. Activity calendar filtering: Calendar now shows full activity history instead of filtered results
3. Auto-update on memo changes: Sidebar tags and calendar now update automatically when creating/editing memos without requiring manual page refresh

Technical changes:
- Modified useFilteredMemoStats to fetch unfiltered UserStats from backend API for Home/Profile pages
- Fixed key mismatch bug in userStore where stats were stored with inconsistent keys
- Added statsStateId update in fetchUserStats to trigger reactivity
- Updated MainLayout to pass appropriate userName based on page context
- Archived/Explore pages continue to compute from cached memos (correct behavior)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steven 2025-11-25 22:17:01 +08:00
parent 8ec4c9ab63
commit eb541d04cc
3 changed files with 144 additions and 37 deletions

View File

@ -1,7 +1,7 @@
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 { memoStore } from "@/store"; import { memoStore, userStore } from "@/store";
import type { StatisticsData } from "@/types/statistics"; import type { StatisticsData } from "@/types/statistics";
export interface FilteredMemoStats { export interface FilteredMemoStats {
@ -11,20 +11,51 @@ export interface FilteredMemoStats {
} }
/** /**
* Hook to compute statistics and tags from memos in the store cache. * Convert user name to user stats key.
* * Backend returns UserStats with name "users/{id}/stats" but we pass "users/{id}"
* This provides a unified approach for all pages (Home, Explore, Archived, Profile): * @param userName - User name in format "users/{id}"
* - Uses memos already loaded in the store by PagedMemoList * @returns Stats key in format "users/{id}/stats"
* - 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.
*/ */
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<FilteredMemoStats>({ const [data, setData] = useState<FilteredMemoStats>({
statistics: { statistics: {
activityStats: {}, activityStats: {},
@ -34,33 +65,65 @@ export const useFilteredMemoStats = (): FilteredMemoStats => {
}); });
// 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;
// React to user stats changes (for tag counts)
const userStatsStateId = userStore.state.statsStateId;
useEffect(() => { useEffect(() => {
// Compute statistics and tags from memos already in the store const computeStats = async () => {
// This avoids making a separate API call and relies on PagedMemoList to populate the store let activityStats: Record<string, number> = {};
const computeStatsFromCache = () => { let tagCount: Record<string, number> = {};
const displayTimeList: Date[] = []; let useBackendStats = false;
const tagCount: Record<string, number> = {};
// Use memos already loaded in the store // Try to use backend user stats if userName is provided
const memos = memoStore.state.memos; 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) { if (!userStats) {
// Add display time for calendar try {
if (memo.displayTime) { await userStore.fetchUserStats(userName);
displayTimeList.push(memo.displayTime); 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 (userStats) {
if (memo.tags && memo.tags.length > 0) { // Use activity timestamps from user stats
for (const tag of memo.tags) { if (userStats.memoDisplayTimestamps && userStats.memoDisplayTimestamps.length > 0) {
tagCount[tag] = (tagCount[tag] || 0) + 1; 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 // Fallback: compute from cached memos if backend stats not available
const activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD"))); // 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({ setData({
statistics: { activityStats }, statistics: { activityStats },
@ -69,8 +132,8 @@ export const useFilteredMemoStats = (): FilteredMemoStats => {
}); });
}; };
computeStatsFromCache(); computeStats();
}, [memoStoreStateId]); }, [memoStoreStateId, userStatsStateId, userName]);
return data; return data;
}; };

View File

@ -1,17 +1,21 @@
import { observer } from "mobx-react-lite"; 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 { matchPath, Outlet, useLocation } from "react-router-dom";
import type { MemoExplorerContext } from "@/components/MemoExplorer"; import type { MemoExplorerContext } from "@/components/MemoExplorer";
import { MemoExplorer, MemoExplorerDrawer } 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";
const MainLayout = observer(() => { const MainLayout = observer(() => {
const { md, lg } = useResponsiveWidth(); const { md, lg } = useResponsiveWidth();
const location = useLocation(); const location = useLocation();
const currentUser = useCurrentUser();
const [profileUserName, setProfileUserName] = useState<string | undefined>();
// Determine context based on current route // Determine context based on current route
const context: MemoExplorerContext = useMemo(() => { const context: MemoExplorerContext = useMemo(() => {
@ -22,8 +26,46 @@ const MainLayout = observer(() => {
return "home"; // fallback return "home"; // fallback
}, [location.pathname]); }, [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) // 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 ( 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">

View File

@ -52,7 +52,8 @@ class LocalState {
if (!this.currentUser) { if (!this.currentUser) {
return undefined; return undefined;
} }
return this.userStatsByName[this.currentUser]; // Backend returns stats with key "users/{id}/stats"
return this.userStatsByName[`${this.currentUser}/stats`];
} }
constructor() { constructor() {
@ -267,13 +268,14 @@ const userStore = (() => {
} }
} else { } else {
const userStats = await userServiceClient.getUserStats({ name: user }); const userStats = await userServiceClient.getUserStats({ name: user });
userStatsByName[user] = userStats; userStatsByName[userStats.name] = userStats; // Use userStats.name as key for consistency
} }
state.setPartial({ state.setPartial({
userStatsByName: { userStatsByName: {
...state.userStatsByName, ...state.userStatsByName,
...userStatsByName, ...userStatsByName,
}, },
statsStateId: uniqueId(), // Update state ID to trigger reactivity
}); });
} catch (error) { } catch (error) {
throw StoreError.wrap("FETCH_USER_STATS_FAILED", error); throw StoreError.wrap("FETCH_USER_STATS_FAILED", error);