feat: implement visual analytics and activity charts
This commit is contained in:
@@ -189,4 +189,43 @@ describe("dashboardService", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActivityAggregation", () => {
|
||||
test("should return exactly 24 data points representing the last 24 hours", async () => {
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours(), 0, 0, 0);
|
||||
|
||||
mockSelect.mockImplementationOnce(() => ({
|
||||
// @ts-ignore
|
||||
from: mock(() => ({
|
||||
where: mock(() => ({
|
||||
groupBy: mock(() => ({
|
||||
orderBy: mock(() => Promise.resolve([
|
||||
{
|
||||
hour: now.toISOString(),
|
||||
transactions: "10",
|
||||
commands: "5"
|
||||
}
|
||||
]))
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
|
||||
const activity = await dashboardService.getActivityAggregation();
|
||||
|
||||
expect(activity).toHaveLength(24);
|
||||
|
||||
// Check if the current hour matches our mock
|
||||
const currentHourData = activity.find(a => new Date(a.hour).getTime() === now.getTime());
|
||||
expect(currentHourData).toBeDefined();
|
||||
expect(currentHourData?.transactions).toBe(10);
|
||||
expect(currentHourData?.commands).toBe(5);
|
||||
|
||||
// Check if missing hours are filled with 0
|
||||
const otherHour = activity.find(a => new Date(a.hour).getTime() !== now.getTime());
|
||||
expect(otherHour?.transactions).toBe(0);
|
||||
expect(otherHour?.commands).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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";
|
||||
import type { RecentEvent, ActivityData } from "./dashboard.types";
|
||||
import { TransactionType } from "@shared/lib/constants";
|
||||
|
||||
export const dashboardService = {
|
||||
/**
|
||||
@@ -149,6 +150,57 @@ export const dashboardService = {
|
||||
console.error("Failed to emit system event:", e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get hourly activity aggregation for the last 24 hours
|
||||
*/
|
||||
getActivityAggregation: async (): Promise<ActivityData[]> => {
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// Postgres aggregation
|
||||
// We treat everything as a transaction.
|
||||
// We treat everything except TRANSFER_IN as a 'command' (to avoid double counting transfers)
|
||||
const result = await DrizzleClient
|
||||
.select({
|
||||
hour: sql<string>`date_trunc('hour', ${transactions.createdAt})`,
|
||||
transactions: sql<string>`COUNT(*)`,
|
||||
commands: sql<string>`COUNT(*) FILTER (WHERE ${transactions.type} != ${TransactionType.TRANSFER_IN})`
|
||||
})
|
||||
.from(transactions)
|
||||
.where(gte(transactions.createdAt, twentyFourHoursAgo))
|
||||
.groupBy(sql`1`)
|
||||
.orderBy(sql`1`);
|
||||
|
||||
// Map into a record for easy lookups
|
||||
const dataMap = new Map<string, { commands: number, transactions: number }>();
|
||||
result.forEach(row => {
|
||||
if (!row.hour) return;
|
||||
const dateStr = new Date(row.hour).toISOString();
|
||||
dataMap.set(dateStr, {
|
||||
commands: Number(row.commands),
|
||||
transactions: Number(row.transactions)
|
||||
});
|
||||
});
|
||||
|
||||
// Generate the last 24 hours of data
|
||||
const activity: ActivityData[] = [];
|
||||
const current = new Date();
|
||||
current.setHours(current.getHours(), 0, 0, 0);
|
||||
|
||||
for (let i = 23; i >= 0; i--) {
|
||||
const h = new Date(current.getTime() - i * 60 * 60 * 1000);
|
||||
const iso = h.toISOString();
|
||||
const existing = dataMap.get(iso);
|
||||
|
||||
activity.push({
|
||||
hour: iso,
|
||||
commands: existing?.commands || 0,
|
||||
transactions: existing?.transactions || 0
|
||||
});
|
||||
}
|
||||
|
||||
return activity;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,3 +74,10 @@ export const WsMessageSchema = z.discriminatedUnion("type", [
|
||||
]);
|
||||
|
||||
export type WsMessage = z.infer<typeof WsMessageSchema>;
|
||||
export const ActivityDataSchema = z.object({
|
||||
hour: z.string(),
|
||||
commands: z.number(),
|
||||
transactions: z.number(),
|
||||
});
|
||||
|
||||
export type ActivityData = z.infer<typeof ActivityDataSchema>;
|
||||
|
||||
Reference in New Issue
Block a user