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 { 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<FilteredMemoStats>({
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<string, number> = {};
const computeStats = async () => {
let activityStats: Record<string, number> = {};
let tagCount: Record<string, number> = {};
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;
};

View File

@ -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<string | undefined>();
// 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 (
<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) {
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);