mirror of https://github.com/usememos/memos.git
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:
parent
8ec4c9ab63
commit
eb541d04cc
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue