fix: implement tag suggestions functionality

Backend changes:
- Fix ListAllUserStats to calculate and return tag statistics
- Previously only returned name and timestamps, missing TagCount
- Now properly aggregates tags, pinned memos, and memo type stats

Frontend changes:
- Initialize user stats on app startup to populate tag data
- Show all tags when typing just '#' (fix empty Fuse.js search)
- Auto-refresh stats after creating/updating/deleting memos
- Fix Checkbox component ref warning with forwardRef

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steven 2025-11-08 09:15:38 +08:00
parent 7d4d1e8517
commit ef9eee19d6
5 changed files with 68 additions and 8 deletions

View file

@ -49,14 +49,59 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser
userMemoStatMap := make(map[int32]*v1pb.UserStats)
for _, memo := range memos {
// Initialize user stats if not exists
if _, exists := userMemoStatMap[memo.CreatorID]; !exists {
userMemoStatMap[memo.CreatorID] = &v1pb.UserStats{
Name: fmt.Sprintf("users/%d/stats", memo.CreatorID),
TagCount: make(map[string]int32),
MemoDisplayTimestamps: []*timestamppb.Timestamp{},
PinnedMemos: []string{},
MemoTypeStats: &v1pb.UserStats_MemoTypeStats{
LinkCount: 0,
CodeCount: 0,
TodoCount: 0,
UndoCount: 0,
},
}
}
stats := userMemoStatMap[memo.CreatorID]
// Add display timestamp
displayTs := memo.CreatedTs
if instanceMemoRelatedSetting.DisplayWithUpdateTime {
displayTs = memo.UpdatedTs
}
userMemoStatMap[memo.CreatorID] = &v1pb.UserStats{
Name: fmt.Sprintf("users/%d/stats", memo.CreatorID),
stats.MemoDisplayTimestamps = append(stats.MemoDisplayTimestamps, timestamppb.New(time.Unix(displayTs, 0)))
// Count memo stats
stats.TotalMemoCount++
// Count tags and other properties
if memo.Payload != nil {
for _, tag := range memo.Payload.Tags {
stats.TagCount[tag]++
}
if memo.Payload.Property != nil {
if memo.Payload.Property.HasLink {
stats.MemoTypeStats.LinkCount++
}
if memo.Payload.Property.HasCode {
stats.MemoTypeStats.CodeCount++
}
if memo.Payload.Property.HasTaskList {
stats.MemoTypeStats.TodoCount++
}
if memo.Payload.Property.HasIncompleteTasks {
stats.MemoTypeStats.UndoCount++
}
}
}
// Track pinned memos
if memo.Pinned {
stats.PinnedMemos = append(stats.PinnedMemos, fmt.Sprintf("users/%d/memos/%d", memo.CreatorID, memo.ID))
}
userMemoStatMap[memo.CreatorID].MemoDisplayTimestamps = append(userMemoStatMap[memo.CreatorID].MemoDisplayTimestamps, timestamppb.New(time.Unix(displayTs, 0)))
}
userMemoStats := []*v1pb.UserStats{}

View file

@ -38,6 +38,9 @@ const TagSuggestions = observer(({ editorRef, editorActions }: Props) => {
const suggestionsRef = useRef<string[]>([]);
suggestionsRef.current = (() => {
const search = getCurrentWord()[0].slice(1).toLowerCase();
if (search === "") {
return tags; // Show all tags when no search term
}
const fuse = new Fuse(tags);
return fuse.search(search).map((result) => result.item);
})();

View file

@ -3,9 +3,13 @@ import { CheckIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<CheckboxPrimitive.Root
ref={ref}
data-slot="checkbox"
className={cn(
"peer border-border data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow disabled:cursor-not-allowed disabled:opacity-50",
@ -18,6 +22,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
});
Checkbox.displayName = "Checkbox";
export { Checkbox };

View file

@ -3,6 +3,7 @@ import { makeAutoObservable } from "mobx";
import { memoServiceClient } from "@/grpcweb";
import { CreateMemoRequest, ListMemosRequest, Memo } from "@/types/proto/api/v1/memo_service";
import { RequestDeduplicator, createRequestKey, StoreError } from "./store-utils";
import userStore from "./user";
class LocalState {
stateId: string = uniqueId();
@ -112,6 +113,8 @@ const memoStore = (() => {
stateId: uniqueId(),
memoMapByName: memoMap,
});
// Refresh user stats to update tag counts
userStore.fetchUserStats().catch(console.error);
return memo;
};
@ -142,6 +145,8 @@ const memoStore = (() => {
stateId: uniqueId(),
memoMapByName: confirmedMemoMap,
});
// Refresh user stats to update tag counts
userStore.fetchUserStats().catch(console.error);
return memo;
} catch (error) {
// Rollback on error
@ -166,6 +171,8 @@ const memoStore = (() => {
stateId: uniqueId(),
memoMapByName: memoMap,
});
// Refresh user stats to update tag counts
userStore.fetchUserStats().catch(console.error);
};
return {

View file

@ -343,10 +343,10 @@ export const initialUserStore = async () => {
},
});
// Step 3: Fetch user settings
// Step 3: Fetch user settings and stats
// CRITICAL: This must happen after currentUser is set in step 2
// The fetchUserSettings() method checks state.currentUser internally
await userStore.fetchUserSettings();
// The fetchUserSettings() and fetchUserStats() methods check state.currentUser internally
await Promise.all([userStore.fetchUserSettings(), userStore.fetchUserStats()]);
// Step 4: Apply user preferences to instance
// CRITICAL: This must happen after fetchUserSettings() completes