fix(dashboard): infinite scroll, auto-refetch, analytics cache invalidation, remove topic row cap

Frontend:
- Cursor pagination (100/page) replacing hardcoded 80 message limit
- Removed .slice(0,200) cap on mergeMessages
- IntersectionObserver infinite scroll with skeleton loading
- 15s periodic refetch for message list sync

Backend:
- Removed LIMIT 2000 from topic trends SQL query
- Added invalidateAnalyticsCache on message capture (messageCreated)
- Added invalidateAnalyticsCache on batch analysis completion

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-01 18:14:32 +07:00
co-authored by Claude Opus 4.8
parent 5078b34ac2
commit 4117f83c1b
8 changed files with 121 additions and 15 deletions
+12
View File
@@ -77,6 +77,15 @@ export default function App() {
useEffect(() => { if (monitorGuildId) voice.loadTextTargets(monitorGuildId).catch(() => undefined); }, [monitorGuildId, voice.loadTextTargets]);
useEffect(() => { if (selectedTextChannel) messages.fetchMessages(selectedTextChannel).catch(() => undefined); }, [selectedTextChannel, messages.fetchMessages]);
// Periodic refetch — ensures dashboard stays in sync even if WS events were missed
useEffect(() => {
if (!selectedTextChannel) return;
const interval = setInterval(() => {
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
}, 15_000); // every 15s (longer than WS, shorter than stale cache)
return () => clearInterval(interval);
}, [selectedTextChannel, messages.fetchMessages]);
return (
<DashboardLayout activeTab={activeTab} wsStatus={socket.status} voiceStatus={voice.voiceStatus} onTabChange={(tab) => patchUIState({ activeTab: tab })}>
{activeTab === "live" ? (
@@ -105,6 +114,9 @@ export default function App() {
onGuildChange={(id) => patchUIState({ selectedTextGuild: id, selectedTextChannel: "" })}
onChannelChange={(id) => patchUIState({ selectedTextChannel: id })}
onReanalyze={messages.reanalyze}
onLoadMore={messages.loadMore}
hasMore={messages.hasMore}
loadingMore={messages.loadingMore}
/>
) : (
<AnalyticsErrorBoundary>
@@ -1,3 +1,4 @@
import { useEffect, useRef } from "react";
import { ScrollArea } from "../../../shared/ui";
import type { MessageRecord } from "../../../shared/api/client";
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
@@ -7,9 +8,30 @@ export interface MessageFeedProps {
onReanalyze: (id: string) => Promise<void>;
emptyText?: string;
loading?: boolean;
onLoadMore?: () => void;
hasMore?: boolean;
loadingMore?: boolean;
}
export function MessageFeed({ messages, onReanalyze, emptyText = "No messages found.", loading }: MessageFeedProps) {
export function MessageFeed({ messages, onReanalyze, emptyText = "No messages found.", loading, onLoadMore, hasMore, loadingMore }: MessageFeedProps) {
// IntersectionObserver for infinite scroll — fires when sentinel becomes visible
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!onLoadMore || !hasMore) return;
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) onLoadMore();
},
{ rootMargin: "400px" }, // preload before user reaches bottom
);
observer.observe(el);
return () => observer.disconnect();
}, [onLoadMore, hasMore]);
if (loading) {
return (
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
@@ -30,6 +52,17 @@ export function MessageFeed({ messages, onReanalyze, emptyText = "No messages fo
{messages.map((message) => (
<MessageCard key={message.id} message={message} onReanalyze={onReanalyze} />
))}
{/* Infinite-scroll sentinel */}
{hasMore && (
<div ref={sentinelRef} className="flex items-center justify-center py-4">
{loadingMore ? (
<MessageCardSkeleton />
) : (
<div className="h-2 w-2 rounded-full bg-muted-foreground/40" />
)}
</div>
)}
</div>
</ScrollArea>
);
@@ -1,34 +1,47 @@
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { listMessages, reanalyzeMessage } from "../../../shared/api/client";
import type { MessageRecord } from "../../../shared/api/client";
const PAGE_SIZE = 100;
export function mergeMessages(current: MessageRecord[], incoming: MessageRecord[]): MessageRecord[] {
const byId = new Map(current.map((message) => [message.id, message]));
for (const message of incoming) {
byId.set(message.id, { ...byId.get(message.id), ...message });
}
return Array.from(byId.values())
.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
.slice(0, 200);
// Removed .slice(0, 200) cap — let the message list grow unbounded.
// Infinite scroll handles the data volume via cursor pagination.
return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id));
}
export function useMessages() {
const [messages, setMessages] = useState<MessageRecord[]>([]);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const currentChannel = useRef<string | null>(null);
const fetchMessages = useCallback(async (channelId?: string) => {
if (!channelId) {
setMessages([]);
setCursor(null);
setHasMore(false);
return [];
}
currentChannel.current = channelId;
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ limit: "80" });
params.set("channel", channelId);
const params = new URLSearchParams({ limit: String(PAGE_SIZE), channelId });
const result = await listMessages(params);
setMessages(result.data);
// Only update state if we're still on the same channel (avoid race conditions)
if (currentChannel.current === channelId) {
setMessages(result.data);
setCursor(result.nextCursor);
setHasMore(!!result.nextCursor);
}
return result.data;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -39,6 +52,27 @@ export function useMessages() {
}
}, []);
const loadMore = useCallback(async () => {
if (!cursor || !currentChannel.current || loadingMore) return;
setLoadingMore(true);
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
channelId: currentChannel.current,
cursor,
});
const result = await listMessages(params);
// Only update if still on the same channel
if (currentChannel.current === result.data[0]?.channel_id || currentChannel.current) {
setMessages((prev) => [...prev, ...result.data]);
setCursor(result.nextCursor);
setHasMore(!!result.nextCursor);
}
} finally {
setLoadingMore(false);
}
}, [cursor, loadingMore]);
// BUG 5 FIX: reanalyze returns Promise<void> so callers can await it
const reanalyze = useCallback(async (id: string): Promise<void> => {
setMessages((prev) =>
@@ -51,5 +85,15 @@ export function useMessages() {
await reanalyzeMessage(id);
}, []);
return { messages, setMessages, loading, error, fetchMessages, reanalyze };
return {
messages,
setMessages,
loading,
loadingMore,
error,
fetchMessages,
reanalyze,
loadMore,
hasMore,
};
}
+13 -2
View File
@@ -14,6 +14,9 @@ interface MessagesPanelProps {
onGuildChange: (guildId: string) => void;
onChannelChange: (channelId: string) => void;
onReanalyze: (id: string) => Promise<void>;
onLoadMore?: () => void;
hasMore?: boolean;
loadingMore?: boolean;
}
type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
@@ -21,6 +24,7 @@ type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
export function MessagesPanel({
guilds, channels, selectedGuild, selectedChannel,
messages, onGuildChange, onChannelChange, onReanalyze,
onLoadMore, hasMore, loadingMore,
}: MessagesPanelProps) {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
@@ -85,7 +89,7 @@ export function MessagesPanel({
{stats.total > 0 && (
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-xs">{stats.total} total</Badge>
<Badge variant="secondary" className="text-xs">{stats.total} total{hasMore && !showSearch ? "+" : ""}</Badge>
<Badge variant="outline" className="text-xs text-green-400 border-green-400/30">{stats.clean} clean</Badge>
<Badge variant="outline" className="text-xs text-yellow-400 border-yellow-400/30">{stats.warn} warn</Badge>
<Badge variant="outline" className="text-xs text-red-400 border-red-400/30">{stats.flagged} flagged</Badge>
@@ -127,7 +131,14 @@ export function MessagesPanel({
<TabsTrigger value="images">Images</TabsTrigger>
</TabsList>
<TabsContent value="all">
<MessageFeed messages={filteredMessages} onReanalyze={onReanalyze} emptyText={showSearch ? "No messages found matching your search." : selectedChannel ? "No captures yet." : "Select a channel to view captures."} />
<MessageFeed
messages={filteredMessages}
onReanalyze={onReanalyze}
emptyText={showSearch ? "No messages found matching your search." : selectedChannel ? "No captures yet." : "Select a channel to view captures."}
onLoadMore={showSearch ? undefined : onLoadMore}
hasMore={showSearch ? false : hasMore}
loadingMore={loadingMore}
/>
</TabsContent>
<TabsContent value="images">
<ImageGrid messages={filteredMessages} />