feat: integrate real data into dashboard

- Created dashboard service with DB queries for users, economy, events
- Added client stats provider with 30s caching for Discord metrics
- Implemented /api/stats endpoint aggregating all dashboard data
- Created useDashboardStats React hook with auto-refresh
- Updated Dashboard.tsx to display real data with loading/error states
- Added comprehensive test coverage (11 tests passing)
- Replaced all mock values with live Discord and database metrics
This commit is contained in:
syntaxbullet
2026-01-08 18:50:44 +01:00
parent a207d511be
commit 17cb70ec00
10 changed files with 861 additions and 35 deletions

View File

@@ -0,0 +1,153 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { dashboardService } from "./dashboard.service";
// Mock DrizzleClient
const mockSelect = mock(() => ({
from: mock(() => Promise.resolve([{ count: "5" }])),
}));
const mockQuery = {
transactions: {
findMany: mock(() => Promise.resolve([])),
},
moderationCases: {
findMany: mock(() => Promise.resolve([])),
},
};
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {
select: mockSelect,
query: mockQuery,
},
}));
describe("dashboardService", () => {
beforeEach(() => {
mockSelect.mockClear();
mockQuery.transactions.findMany.mockClear();
mockQuery.moderationCases.findMany.mockClear();
// Reset default mock implementation
mockSelect.mockImplementation(() => ({
from: mock(() => Promise.resolve([{ count: "5" }])),
}));
});
describe("getActiveUserCount", () => {
test("should return active user count from database", async () => {
mockSelect.mockImplementationOnce(() => ({
from: mock(() => ({
where: mock(() => Promise.resolve([{ count: "5" }])),
})),
}));
const count = await dashboardService.getActiveUserCount();
expect(count).toBe(5);
expect(mockSelect).toHaveBeenCalled();
});
test("should return 0 when no users found", async () => {
mockSelect.mockImplementationOnce(() => ({
from: mock(() => ({
where: mock(() => Promise.resolve([{ count: "0" }])),
})),
}));
const count = await dashboardService.getActiveUserCount();
expect(count).toBe(0);
});
});
describe("getTotalUserCount", () => {
test("should return total user count", async () => {
const count = await dashboardService.getTotalUserCount();
expect(count).toBe(5);
});
});
describe("getRecentTransactions", () => {
test("should return formatted transaction events", async () => {
const mockTx = [
{
type: "DAILY_REWARD",
description: "Daily reward",
createdAt: new Date(),
user: { username: "testuser" },
},
] as any;
mockQuery.transactions.findMany.mockResolvedValueOnce(mockTx);
const events = await dashboardService.getRecentTransactions(10);
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("info");
expect(events[0]?.message).toContain("testuser");
expect(events[0]?.icon).toBe("☀️");
});
test("should handle empty transactions", async () => {
mockQuery.transactions.findMany.mockResolvedValueOnce([]);
const events = await dashboardService.getRecentTransactions(10);
expect(events).toHaveLength(0);
});
});
describe("getRecentModerationCases", () => {
test("should return formatted moderation events", async () => {
const mockCases = [
{
type: "warn",
username: "baduser",
reason: "Spam",
createdAt: new Date(),
},
] as any;
mockQuery.moderationCases.findMany.mockResolvedValueOnce(mockCases);
const events = await dashboardService.getRecentModerationCases(10);
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("error");
expect(events[0]?.message).toContain("WARN");
expect(events[0]?.message).toContain("baduser");
expect(events[0]?.icon).toBe("⚠️");
});
});
describe("getRecentEvents", () => {
test("should combine and sort transactions and moderation events", async () => {
const now = new Date();
const earlier = new Date(now.getTime() - 1000);
mockQuery.transactions.findMany.mockResolvedValueOnce([
{
type: "DAILY_REWARD",
description: "Daily",
createdAt: now,
user: { username: "user1" },
},
] as any);
mockQuery.moderationCases.findMany.mockResolvedValueOnce([
{
type: "warn",
username: "user2",
reason: "Test",
createdAt: earlier,
},
] as any);
const events = await dashboardService.getRecentEvents(10);
expect(events).toHaveLength(2);
// Should be sorted by timestamp (newest first)
expect(events[0]?.timestamp.getTime()).toBeGreaterThanOrEqual(
events[1]?.timestamp.getTime() ?? 0
);
});
});
});

View File

@@ -0,0 +1,157 @@
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users, transactions, moderationCases, inventory } from "@db/schema";
import { desc, sql, and, gte } from "drizzle-orm";
import type { RecentEvent } from "./dashboard.types";
export const dashboardService = {
/**
* Get count of active users from database
*/
getActiveUserCount: async (): Promise<number> => {
const result = await DrizzleClient
.select({ count: sql<string>`COUNT(*)` })
.from(users)
.where(sql`${users.isActive} = true`);
return Number(result[0]?.count || 0);
},
/**
* Get total user count
*/
getTotalUserCount: async (): Promise<number> => {
const result = await DrizzleClient
.select({ count: sql<string>`COUNT(*)` })
.from(users);
return Number(result[0]?.count || 0);
},
/**
* Get economy statistics
*/
getEconomyStats: async (): Promise<{
totalWealth: bigint;
avgLevel: number;
topStreak: number;
}> => {
const allUsers = await DrizzleClient.select().from(users);
const totalWealth = allUsers.reduce(
(acc: bigint, u: any) => acc + (u.balance || 0n),
0n
);
const avgLevel = allUsers.length > 0
? Math.round(
allUsers.reduce((acc: number, u: any) => acc + (u.level || 1), 0) / allUsers.length
)
: 1;
const topStreak = allUsers.reduce(
(max: number, u: any) => Math.max(max, u.dailyStreak || 0),
0
);
return { totalWealth, avgLevel, topStreak };
},
/**
* Get total items in circulation
*/
getTotalItems: async (): Promise<number> => {
const result = await DrizzleClient
.select({ total: sql<string>`COALESCE(SUM(${inventory.quantity}), 0)` })
.from(inventory);
return Number(result[0]?.total || 0);
},
/**
* Get recent transactions as events (last 24 hours)
*/
getRecentTransactions: async (limit: number = 10): Promise<RecentEvent[]> => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentTx = await DrizzleClient.query.transactions.findMany({
limit,
orderBy: [desc(transactions.createdAt)],
where: gte(transactions.createdAt, oneDayAgo),
with: {
user: true,
},
});
return recentTx.map((tx: any) => ({
type: 'info' as const,
message: `${tx.user?.username || 'Unknown'}: ${tx.description || 'Transaction'}`,
timestamp: tx.createdAt || new Date(),
icon: getTransactionIcon(tx.type),
}));
},
/**
* Get recent moderation cases as events (last 24 hours)
*/
getRecentModerationCases: async (limit: number = 10): Promise<RecentEvent[]> => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentCases = await DrizzleClient.query.moderationCases.findMany({
limit,
orderBy: [desc(moderationCases.createdAt)],
where: gte(moderationCases.createdAt, oneDayAgo),
});
return recentCases.map((modCase: any) => ({
type: modCase.type === 'warn' || modCase.type === 'ban' ? 'error' : 'info',
message: `${modCase.type.toUpperCase()}: ${modCase.username} - ${modCase.reason}`,
timestamp: modCase.createdAt || new Date(),
icon: getModerationIcon(modCase.type),
}));
},
/**
* Get combined recent events (transactions + moderation)
*/
getRecentEvents: async (limit: number = 10): Promise<RecentEvent[]> => {
const [txEvents, modEvents] = await Promise.all([
dashboardService.getRecentTransactions(limit),
dashboardService.getRecentModerationCases(limit),
]);
// Combine and sort by timestamp
const allEvents = [...txEvents, ...modEvents]
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, limit);
return allEvents;
},
};
/**
* Helper to get icon for transaction type
*/
function getTransactionIcon(type: string): string {
if (type.includes("LOOT")) return "🌠";
if (type.includes("GIFT")) return "🎁";
if (type.includes("SHOP")) return "🛒";
if (type.includes("DAILY")) return "☀️";
if (type.includes("QUEST")) return "📜";
if (type.includes("TRANSFER")) return "💸";
return "💫";
}
/**
* Helper to get icon for moderation type
*/
function getModerationIcon(type: string): string {
switch (type) {
case 'warn': return '⚠️';
case 'timeout': return '⏸️';
case 'kick': return '👢';
case 'ban': return '🔨';
case 'note': return '📝';
case 'prune': return '🧹';
default: return '🛡️';
}
}

View File

@@ -0,0 +1,41 @@
export interface DashboardStats {
guilds: {
count: number;
changeFromLastMonth?: number;
};
users: {
active: number;
total: number;
changePercentFromLastMonth?: number;
};
commands: {
total: number;
changePercentFromLastMonth?: number;
};
ping: {
avg: number;
changeFromLastHour?: number;
};
economy: {
totalWealth: string; // bigint as string for JSON
avgLevel: number;
topStreak: number;
};
recentEvents: RecentEvent[];
}
export interface RecentEvent {
type: 'success' | 'error' | 'info';
message: string;
timestamp: Date;
icon?: string;
}
export interface ClientStats {
guilds: number;
ping: number;
cachedUsers: number;
commandsRegistered: number;
uptime: number;
lastCommandTimestamp: number | null;
}