feat: refactor database access in analyticsStore to use executeAll and executeGet for improved query handling

This commit is contained in:
MythEclipse
2026-05-30 20:40:33 +07:00
parent b19529f135
commit c9e79c8c7c
8 changed files with 326 additions and 342 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import {
Activity,
@@ -17,8 +17,7 @@ import {
} from "lucide-react";
import type { Channel, Guild } from "../../types/voice";
import { useAnalytics } from "../../hooks/useAnalytics";
import type { AnalyticsOverview, HourlyBucket, TopicTrend, UserStat, ViolatorStat } from "../../api/analytics";
import { fetchViolators } from "../../api/analytics";
import type { AnalyticsOverview, HourlyBucket, TopicTrend, UserStat, ViolatorStat } from "../../hooks/useAnalytics";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Select } from "../ui/select";
import { Button } from "../ui/button";
@@ -63,36 +62,25 @@ export function AnalyticsPanel({
onChannelChange,
}: AnalyticsPanelProps) {
const [hours, setHours] = useState(24);
const [violators, setViolators] = useState<ViolatorStat[]>([]);
const [violatorsLoading, setViolatorsLoading] = useState(false);
const { overview, loading, error, refresh } = useAnalytics({
const {
overview,
isLoading,
isFetching,
error,
refresh,
violators,
violatorsLoading,
violatorsFetching,
refreshViolators,
} = useAnalytics({
guildId: selectedGuild,
channelId: selectedChannel || undefined,
hours,
});
const loadViolators = useCallback(async () => {
if (!selectedGuild) return;
setViolatorsLoading(true);
try {
const data = await fetchViolators({
guildId: selectedGuild,
channelId: selectedChannel || undefined,
hours,
limit: 20,
});
setViolators(data);
} catch {
// silent
} finally {
setViolatorsLoading(false);
}
}, [selectedGuild, selectedChannel, hours]);
useEffect(() => {
loadViolators();
}, [loadViolators]);
// Loading is true only on first load (no cached data); fetching means background refresh
const loading = isLoading && !isFetching;
return (
<div className="grid gap-6">
@@ -155,11 +143,11 @@ export function AnalyticsPanel({
))}
</div>
<Button
onClick={() => { refresh(); loadViolators(); }}
disabled={loading}
onClick={() => { refresh(); refreshViolators(); }}
disabled={isFetching}
className="bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white shadow-lg shadow-blue-500/25 transition-all hover:shadow-blue-500/40"
>
{loading ? (
{isFetching ? (
<span className="flex items-center gap-2">
<motion.span
animate={{ rotate: 360 }}
@@ -267,7 +255,7 @@ export function AnalyticsPanel({
User dengan skor pelanggaran tertinggi (flagged × 3 + warned × 1).
</CardDescription>
</div>
<Badge variant="destructive" className="animate-pulse">
<Badge variant="destructive" className={cn(violatorsFetching && "animate-pulse")}>
{violators.length} pelanggar
</Badge>
</div>
+72 -40
View File
@@ -1,60 +1,89 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchAnalyticsOverview, type AnalyticsOverview, type HourlyBucket, type TopicTrend, type UserStat } from "../api/analytics";
import { useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { useCallback, useEffect } from "react";
import {
fetchAnalyticsOverview,
fetchViolators,
type AnalyticsOverview,
type HourlyBucket,
type TopicTrend,
type UserStat,
type ViolatorStat,
} from "../api/analytics";
interface UseAnalyticsOptions {
guildId: string;
channelId?: string;
hours?: number;
autoRefresh?: boolean;
refreshIntervalMs?: number;
}
export function useAnalytics({ guildId, channelId, hours = 24, autoRefresh = true, refreshIntervalMs = 5_000 }: UseAnalyticsOptions) {
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** Shared key factory so WebSocket refresh invalidates all related queries at once. */
function analyticsKeys(guildId: string, channelId: string | undefined, hours: number) {
return {
overview: ["analytics", "overview", guildId, channelId ?? "", hours] as const,
violators: ["analytics", "violators", guildId, channelId ?? "", hours] as const,
all: ["analytics"] as const,
};
}
const load = useCallback(async () => {
export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOptions) {
const queryClient = useQueryClient();
const keys = analyticsKeys(guildId, channelId, hours);
// ── Overview query (stale-while-revalidate) ──────────────────────────
const overviewQuery = useQuery({
queryKey: keys.overview,
queryFn: () => fetchAnalyticsOverview({ guildId, channelId, hours }),
enabled: !!guildId,
staleTime: 30_000, // 30s — data is fresh enough; WebSocket invalidates on change
placeholderData: keepPreviousData, // show previous data while fetching new params
});
// ── Violators query ──────────────────────────────────────────────────
const violatorsQuery = useQuery({
queryKey: keys.violators,
queryFn: () =>
fetchViolators({ guildId, channelId, hours, limit: 20 }),
enabled: !!guildId,
staleTime: 30_000,
placeholderData: keepPreviousData,
});
// ── Refresh: invalidate & refetch ────────────────────────────────────
const refresh = useCallback(() => {
if (!guildId) return;
setLoading(true);
setError(null);
try {
const data = await fetchAnalyticsOverview({ guildId, channelId, hours });
setOverview(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load analytics");
} finally {
setLoading(false);
}
}, [guildId, channelId, hours]);
useEffect(() => {
load();
}, [load]);
// Auto-refresh on interval
useEffect(() => {
if (!autoRefresh || !guildId) return;
intervalRef.current = setInterval(load, refreshIntervalMs);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [load, autoRefresh, refreshIntervalMs, guildId]);
queryClient.invalidateQueries({ queryKey: keys.overview });
queryClient.invalidateQueries({ queryKey: keys.violators });
}, [queryClient, keys.overview, keys.violators, guildId]);
// Real-time refresh via WebSocket-triggered custom event
useEffect(() => {
const handler = () => load();
const handler = () => refresh();
window.addEventListener("analytics_refresh", handler);
return () => window.removeEventListener("analytics_refresh", handler);
}, [load]);
}, [refresh]);
const overview = overviewQuery.data ?? null;
const isFetching = overviewQuery.isFetching && !overviewQuery.isLoading;
const isLoading = overviewQuery.isLoading && !overviewQuery.data;
return {
overview,
loading,
error,
refresh: load,
// Convenience accessors
// isLoading = true only on first load with no cached data
isLoading,
// isFetching = true on background refetch while showing stale data
isFetching,
error: overviewQuery.error instanceof Error ? overviewQuery.error.message : null,
refresh,
// Violators
violators: violatorsQuery.data ?? [],
violatorsLoading: violatorsQuery.isLoading && !violatorsQuery.data,
violatorsFetching: violatorsQuery.isFetching && !violatorsQuery.isLoading,
refreshViolators: () => {
if (guildId) queryClient.invalidateQueries({ queryKey: keys.violators });
},
// Convenience accessors (safe navigation into nullable overview)
hourly: overview?.hourly ?? ([] as HourlyBucket[]),
topics: overview?.topics ?? ([] as TopicTrend[]),
topUsers: overview?.top_users ?? ([] as UserStat[]),
@@ -64,3 +93,6 @@ export function useAnalytics({ guildId, channelId, hours = 24, autoRefresh = tru
totalChannels: overview?.total_channels ?? 0,
};
}
// Re-export for convenience
export type { AnalyticsOverview, HourlyBucket, TopicTrend, UserStat, ViolatorStat };
+15 -1
View File
@@ -1,10 +1,24 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./styles.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // data stays fresh for 30s — no refetch within this window
gcTime: 5 * 60_000, // keep unused data in cache for 5 minutes
refetchOnWindowFocus: false, // avoid spamming the API on tab switches
retry: 2,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);