feat: refactor database access in analyticsStore to use executeAll and executeGet for improved query handling
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user