From 665d87cbf44822b60ca7e1618a8ee8e3cc5a4388 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 17 Jul 2026 18:07:42 +0200 Subject: [PATCH] Harden analytics pages against missing tables and RSC dynamic imports. Co-authored-by: Cursor --- prisma/schema.prisma | 6 +- src/app/admin/analytics/activity/page.tsx | 203 ++++++++++++------ src/app/admin/analytics/economy/page.tsx | 216 +++++++++++-------- src/app/admin/analytics/page.tsx | 243 +++++++++++++--------- 4 files changed, 420 insertions(+), 248 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9da53d6c..e1c559aa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -698,9 +698,9 @@ model LogsShopPurchases { catalogItemId Int? @map("catalog_item_id") itemIds String? @db.Text @map("item_ids") catalogName String? @db.VarChar(255) @map("catalog_name") - costCredits Int? - costPoints Int? - pointsType Int? + costCredits Int? @map("cost_credits") + costPoints Int? @map("cost_points") + pointsType Int? @map("points_type") amount Int? @@map("logs_shop_purchases") diff --git a/src/app/admin/analytics/activity/page.tsx b/src/app/admin/analytics/activity/page.tsx index fdab94b4..b5831ba7 100644 --- a/src/app/admin/analytics/activity/page.tsx +++ b/src/app/admin/analytics/activity/page.tsx @@ -5,88 +5,161 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; -async function getActivityData() { +type DayRow = { + label: string; + chats: number; + commands: number; + bans: number; + registrations: number; +}; + +type ActivityData = { + dailyData: DayRow[]; + weekChats: number; + weekCommands: number; + weekBans: number; + weekRegistrations: number; + topChatters: { userId: number; username: string; messages: number }[]; +}; + +async function safeCount(fn: () => Promise): Promise { + try { + return await fn(); + } catch { + return 0; + } +} + +async function safeRawCount(sql: Promise<{ count: bigint }[]>): Promise { + try { + const rows = await sql; + return Number(rows[0]?.count ?? 0); + } catch { + return 0; + } +} + +async function getActivityData(): Promise { const now = Math.floor(Date.now() / 1000); const todayStart = now - (now % 86400); const weekAgo = now - 7 * 86400; - - // Daily activity breakdown — 4 GROUP BY queries instead of 28 sequential queries const rangeStart = todayStart - 6 * 86400; - const [chatRows, cmdRows, banRows, regRows] = await Promise.all([ - prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` - SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count - FROM chatlogs_room WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`, - prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` - SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count - FROM commandlogs WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`, - prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` - SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count - FROM bans WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`, - prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` - SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count - FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`, - ]); - const toMap = (rows: { day_bucket: bigint; count: bigint }[]) => - new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)])); - const chatMap = toMap(chatRows); - const cmdMap = toMap(cmdRows); - const banMap = toMap(banRows); - const regMap = toMap(regRows); - const dailyData = []; + const emptyDays: DayRow[] = []; for (let i = 6; i >= 0; i--) { const dayStart = todayStart - i * 86400; - const bucket = Math.floor(dayStart / 86400); const date = new Date(dayStart * 1000); - dailyData.push({ + emptyDays.push({ label: date.toLocaleDateString("en", { weekday: "short", month: "short", day: "numeric", }), + chats: 0, + commands: 0, + bans: 0, + registrations: 0, + }); + } + + const toMap = (rows: { day_bucket: bigint; count: bigint }[]) => + new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)])); + + let chatMap = new Map(); + let cmdMap = new Map(); + let banMap = new Map(); + let regMap = new Map(); + + try { + const [chatRows, cmdRows, banRows, regRows] = await Promise.all([ + prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` + SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count + FROM chatlogs_room WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`.catch( + () => [], + ), + prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` + SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count + FROM commandlogs WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`.catch( + () => [], + ), + prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` + SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count + FROM bans WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`.catch( + () => [], + ), + prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>` + SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count + FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`.catch( + () => [], + ), + ]); + chatMap = toMap(chatRows); + cmdMap = toMap(cmdRows); + banMap = toMap(banRows); + regMap = toMap(regRows); + } catch { + /* keep empty maps */ + } + + const dailyData = emptyDays.map((day, idx) => { + const dayStart = todayStart - (6 - idx) * 86400; + const bucket = Math.floor(dayStart / 86400); + return { + ...day, chats: chatMap.get(bucket) ?? 0, commands: cmdMap.get(bucket) ?? 0, bans: banMap.get(bucket) ?? 0, registrations: regMap.get(bucket) ?? 0, - }); - } - - // Totals - const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] = - await Promise.all([ - prisma.$queryRaw< - { count: bigint }[] - >`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`, - prisma.$queryRaw< - { count: bigint }[] - >`SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`, - prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }), - prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }), - ]); - const weekChats = Number(weekChatRows[0]?.count ?? 0); - const weekCommands = Number(weekCommandRows[0]?.count ?? 0); - - // Most active users (by chat messages) - const topChatters = await prisma.$queryRaw< - { userId: number; messages: bigint }[] - >` - SELECT user_from_id AS userId, COUNT(*) AS messages - FROM chatlogs_room WHERE timestamp >= ${weekAgo} - GROUP BY user_from_id ORDER BY messages DESC LIMIT 10 - `; - - const userIds = topChatters.map((c) => c.userId); - const users = await prisma.user.findMany({ - where: { id: { in: userIds } }, - select: { id: true, username: true }, + }; }); - const userMap = new Map(users.map((u) => [u.id, u.username])); - const topChattersWithNames = topChatters.map((c) => ({ - userId: c.userId, - username: userMap.get(c.userId) ?? `User #${c.userId}`, - messages: Number(c.messages), - })); + const [weekChats, weekCommands, weekBans, weekRegistrations] = + await Promise.all([ + safeRawCount( + prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`, + ), + safeRawCount( + prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`, + ), + safeCount(() => + prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }), + ), + safeCount(() => + prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }), + ), + ]); + + let topChatters: ActivityData["topChatters"] = []; + try { + const rows = await prisma.$queryRaw< + { userId: number; messages: bigint }[] + >` + SELECT user_from_id AS userId, COUNT(*) AS messages + FROM chatlogs_room WHERE timestamp >= ${weekAgo} + GROUP BY user_from_id ORDER BY messages DESC LIMIT 10 + `; + const userIds = rows.map((c) => Number(c.userId)).filter((n) => n > 0); + const users = + userIds.length > 0 + ? await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, username: true }, + }) + : []; + const userMap = new Map(users.map((u) => [u.id, u.username])); + topChatters = rows.map((c) => { + const userId = Number(c.userId); + return { + userId, + username: userMap.get(userId) ?? `User #${userId}`, + messages: Number(c.messages), + }; + }); + } catch { + topChatters = []; + } return { dailyData, @@ -94,7 +167,7 @@ async function getActivityData() { weekCommands, weekBans, weekRegistrations, - topChatters: topChattersWithNames, + topChatters, }; } @@ -136,7 +209,6 @@ export default async function ActivityPage() {
- {/* Daily breakdown table */} Daily Breakdown (7 days) @@ -175,7 +247,6 @@ export default async function ActivityPage() { - {/* Top chatters */} Most Active Users (7 days) diff --git a/src/app/admin/analytics/economy/page.tsx b/src/app/admin/analytics/economy/page.tsx index 3205542c..2b15f0e3 100644 --- a/src/app/admin/analytics/economy/page.tsx +++ b/src/app/admin/analytics/economy/page.tsx @@ -1,99 +1,149 @@ import { ArrowLeftRight, Coins, TrendingDown, TrendingUp } from "lucide-react"; -import dynamic from "next/dynamic"; import { redirect } from "next/navigation"; +import { RevenueChart } from "@/components/admin/revenue-chart"; import { StatsCard } from "@/components/admin/stats-card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; -const RevenueChart = dynamic( - () => - import("@/components/admin/revenue-chart").then((m) => ({ - default: m.RevenueChart, - })), - { loading: () => }, -); +type EconomyData = { + totalCredits: number; + totalPixels: number; + totalPoints: number; + shopPurchases: number; + shopCreditsSpent: number; + shopPointsSpent: number; + marketplaceActive: number; + marketplaceSold: number; + weekTrades: number; + revenueData: { label: string; credits: number; points: number }[]; +}; -async function getEconomyData() { +const EMPTY: EconomyData = { + totalCredits: 0, + totalPixels: 0, + totalPoints: 0, + shopPurchases: 0, + shopCreditsSpent: 0, + shopPointsSpent: 0, + marketplaceActive: 0, + marketplaceSold: 0, + weekTrades: 0, + revenueData: [], +}; + +async function safeCount(fn: () => Promise): Promise { + try { + return await fn(); + } catch { + return 0; + } +} + +async function getEconomyData(): Promise { const now = Math.floor(Date.now() / 1000); const todayStart = now - (now % 86400); const weekAgo = now - 7 * 86400; - - // Total currency in circulation — parallel instead of sequential - const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([ - prisma.user.aggregate({ _sum: { credits: true } }), - prisma.user.aggregate({ _sum: { pixels: true } }), - prisma.user.aggregate({ _sum: { points: true } }), - ]); - const totalCredits = creditsAgg._sum.credits ?? 0; - const totalPixels = pixelsAgg._sum.pixels ?? 0; - const totalPoints = pointsAgg._sum.points ?? 0; - - // Shop revenue (7 days) - const shopRevenue = await prisma.logsShopPurchases.aggregate({ - where: { timestamp: { gte: weekAgo } }, - _sum: { costCredits: true, costPoints: true }, - _count: true, - }); - - // Marketplace stats - const marketplaceActive = await prisma.marketplaceItems.count({ - where: { state: 1 }, - }); - const marketplaceSold = await prisma.marketplaceItems.count({ - where: { state: 2, soldTimestamp: { gte: weekAgo } }, - }); - - // Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries const rangeStart = todayStart - 6 * 86400; - const revRows = await prisma.$queryRaw< - { day_bucket: bigint; credits: number; points: number }[] - >` - SELECT FLOOR(timestamp / 86400) AS day_bucket, - COALESCE(SUM(cost_credits), 0) AS credits, - COALESCE(SUM(cost_points), 0) AS points - FROM logs_shop_purchases - WHERE timestamp >= ${rangeStart} - GROUP BY day_bucket ORDER BY day_bucket - `; - const revMap = new Map( - revRows.map((r) => [ - Number(r.day_bucket), - { credits: Number(r.credits), points: Number(r.points) }, - ]), - ); - const revenueData = []; - for (let i = 6; i >= 0; i--) { - const dayStart = todayStart - i * 86400; - const bucket = Math.floor(dayStart / 86400); - const date = new Date(dayStart * 1000); - const entry = revMap.get(bucket); - revenueData.push({ - label: date.toLocaleDateString("en", { weekday: "short" }), - credits: entry?.credits ?? 0, - points: entry?.points ?? 0, - }); + + try { + const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([ + prisma.user.aggregate({ _sum: { credits: true } }).catch(() => null), + prisma.user.aggregate({ _sum: { pixels: true } }).catch(() => null), + prisma.user.aggregate({ _sum: { points: true } }).catch(() => null), + ]); + const totalCredits = creditsAgg?._sum.credits ?? 0; + const totalPixels = pixelsAgg?._sum.pixels ?? 0; + const totalPoints = pointsAgg?._sum.points ?? 0; + + let shopPurchases = 0; + let shopCreditsSpent = 0; + let shopPointsSpent = 0; + try { + const shopRevenue = await prisma.logsShopPurchases.aggregate({ + where: { timestamp: { gte: weekAgo } }, + _sum: { costCredits: true, costPoints: true }, + _count: true, + }); + shopPurchases = + typeof shopRevenue._count === "number" ? shopRevenue._count : 0; + shopCreditsSpent = shopRevenue._sum?.costCredits ?? 0; + shopPointsSpent = shopRevenue._sum?.costPoints ?? 0; + } catch { + /* shop logs table/columns may be missing */ + } + + const [marketplaceActive, marketplaceSold, weekTrades] = await Promise.all( + [ + safeCount(() => + prisma.marketplaceItems.count({ where: { state: 1 } }), + ), + safeCount(() => + prisma.marketplaceItems.count({ + where: { state: 2, soldTimestamp: { gte: weekAgo } }, + }), + ), + safeCount(() => + prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }), + ), + ], + ); + + const revenueData: EconomyData["revenueData"] = []; + for (let i = 6; i >= 0; i--) { + const dayStart = todayStart - i * 86400; + const date = new Date(dayStart * 1000); + revenueData.push({ + label: date.toLocaleDateString("en", { weekday: "short" }), + credits: 0, + points: 0, + }); + } + + try { + const revRows = await prisma.$queryRaw< + { day_bucket: bigint; credits: number | bigint; points: number | bigint }[] + >` + SELECT FLOOR(timestamp / 86400) AS day_bucket, + COALESCE(SUM(cost_credits), 0) AS credits, + COALESCE(SUM(cost_points), 0) AS points + FROM logs_shop_purchases + WHERE timestamp >= ${rangeStart} + GROUP BY day_bucket ORDER BY day_bucket + `; + const revMap = new Map( + revRows.map((r) => [ + Number(r.day_bucket), + { credits: Number(r.credits), points: Number(r.points) }, + ]), + ); + for (let i = 0; i < revenueData.length; i++) { + const dayStart = todayStart - (6 - i) * 86400; + const bucket = Math.floor(dayStart / 86400); + const entry = revMap.get(bucket); + if (entry) { + revenueData[i] = { ...revenueData[i], ...entry }; + } + } + } catch { + /* keep zero revenue series */ + } + + return { + totalCredits, + totalPixels, + totalPoints, + shopPurchases, + shopCreditsSpent, + shopPointsSpent, + marketplaceActive, + marketplaceSold, + weekTrades, + revenueData, + }; + } catch { + return EMPTY; } - - // Trades (7 days) - const weekTrades = await prisma.roomTradeLog.count({ - where: { timestamp: { gte: weekAgo } }, - }); - - return { - totalCredits, - totalPixels, - totalPoints, - shopPurchases: - typeof shopRevenue._count === "number" ? shopRevenue._count : 0, - shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0, - shopPointsSpent: shopRevenue._sum?.costPoints ?? 0, - marketplaceActive, - marketplaceSold, - weekTrades, - revenueData, - }; } export default async function EconomyPage() { diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx index 75020ad9..e092ac36 100644 --- a/src/app/admin/analytics/page.tsx +++ b/src/app/admin/analytics/page.tsx @@ -6,117 +6,168 @@ import { TrendingUp, Users, } from "lucide-react"; -import dynamic from "next/dynamic"; import { redirect } from "next/navigation"; +import { DashboardChart } from "@/components/admin/dashboard-chart"; +import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap"; import { StatsCard } from "@/components/admin/stats-card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { prisma } from "@/lib/prisma"; -const DashboardChart = dynamic( - () => - import("@/components/admin/dashboard-chart").then((m) => ({ - default: m.DashboardChart, - })), - { loading: () => }, -); +type AnalyticsData = { + totalUsers: number; + onlineUsers: number; + weekRegistrations: number; + monthRegistrations: number; + totalRooms: number; + activeRooms: number; + weekChatMessages: number; + weekTrades: number; + weekBans: number; + registrationChart: { label: string; value: number }[]; + peakHours: { grid: number[][]; max: number }; +}; -const PeakHoursHeatmap = dynamic( - () => - import("@/components/admin/peak-hours-heatmap").then((m) => ({ - default: m.PeakHoursHeatmap, - })), - { loading: () => }, -); +const EMPTY: AnalyticsData = { + totalUsers: 0, + onlineUsers: 0, + weekRegistrations: 0, + monthRegistrations: 0, + totalRooms: 0, + activeRooms: 0, + weekChatMessages: 0, + weekTrades: 0, + weekBans: 0, + registrationChart: [], + peakHours: { + grid: Array.from({ length: 7 }, () => Array(24).fill(0)), + max: 0, + }, +}; -async function getAnalyticsData() { +async function safeCount(fn: () => Promise): Promise { + try { + return await fn(); + } catch { + return 0; + } +} + +async function getAnalyticsData(): Promise { const now = Math.floor(Date.now() / 1000); const todayStart = now - (now % 86400); const weekAgo = now - 7 * 86400; const monthAgo = now - 30 * 86400; - const [ - totalUsers, - onlineUsers, - weekRegistrations, - monthRegistrations, - totalRooms, - activeRooms, - weekChatMessages, - weekTrades, - weekBans, - ] = await Promise.all([ - prisma.user.count(), - prisma.user.count({ where: { online: "1" } }), - prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }), - prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }), - prisma.rooms.count(), - prisma.rooms.count({ where: { users: { gt: 0 } } }), - prisma.$queryRaw< - { count: bigint }[] - >`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`, - prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }), - prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }), - ]); + try { + const [ + totalUsers, + onlineUsers, + weekRegistrations, + monthRegistrations, + totalRooms, + activeRooms, + weekChatMessages, + weekTrades, + weekBans, + ] = await Promise.all([ + safeCount(() => prisma.user.count()), + safeCount(() => prisma.user.count({ where: { online: "1" } })), + safeCount(() => + prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }), + ), + safeCount(() => + prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }), + ), + safeCount(() => prisma.rooms.count()), + safeCount(() => prisma.rooms.count({ where: { users: { gt: 0 } } })), + (async () => { + try { + const rows = await prisma.$queryRaw<{ count: bigint }[]>` + SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`; + return Number(rows[0]?.count ?? 0); + } catch { + return 0; + } + })(), + safeCount(() => + prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }), + ), + safeCount(() => + prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }), + ), + ]); - // Registration chart (30 days) — single GROUP BY query instead of 30 sequential queries - const regRows = await prisma.$queryRaw< - { day_bucket: bigint; count: bigint }[] - >` - SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count - FROM users - WHERE account_created >= ${monthAgo} - GROUP BY day_bucket ORDER BY day_bucket - `; - const regCountMap = new Map( - regRows.map((r) => [Number(r.day_bucket), Number(r.count)]), - ); - const registrationChart = []; - for (let i = 29; i >= 0; i--) { - const dayStart = todayStart - i * 86400; - const bucket = Math.floor(dayStart / 86400); - const date = new Date(dayStart * 1000); - registrationChart.push({ - label: date.toLocaleDateString("en", { month: "short", day: "numeric" }), - value: regCountMap.get(bucket) ?? 0, - }); + let registrationChart: { label: string; value: number }[] = []; + try { + const regRows = await prisma.$queryRaw< + { day_bucket: bigint; count: bigint }[] + >` + SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count + FROM users + WHERE account_created >= ${monthAgo} + GROUP BY day_bucket ORDER BY day_bucket + `; + const regCountMap = new Map( + regRows.map((r) => [Number(r.day_bucket), Number(r.count)]), + ); + for (let i = 29; i >= 0; i--) { + const dayStart = todayStart - i * 86400; + const bucket = Math.floor(dayStart / 86400); + const date = new Date(dayStart * 1000); + registrationChart.push({ + label: date.toLocaleDateString("en", { + month: "short", + day: "numeric", + }), + value: regCountMap.get(bucket) ?? 0, + }); + } + } catch { + registrationChart = []; + } + + const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0)); + let max = 0; + try { + const peakRows = await prisma.$queryRaw< + { d: number; h: number; c: bigint }[] + >` + SELECT ((DAYOFWEEK(FROM_UNIXTIME(last_online)) + 5) % 7) AS d, + HOUR(FROM_UNIXTIME(last_online)) AS h, + COUNT(*) AS c + FROM users + WHERE last_online >= ${weekAgo} + GROUP BY d, h + `; + for (const r of peakRows) { + const day = Number(r.d); + const hour = Number(r.h); + if (day < 0 || day > 6 || hour < 0 || hour > 23) continue; + const count = Number(r.c); + grid[day][hour] = count; + if (count > max) max = count; + } + } catch { + /* keep empty grid */ + } + + return { + totalUsers, + onlineUsers, + weekRegistrations, + monthRegistrations, + totalRooms, + activeRooms, + weekChatMessages, + weekTrades, + weekBans, + registrationChart, + peakHours: { grid, max }, + }; + } catch { + return EMPTY; } - - // Peak hours — single GROUP BY query instead of fetching all user rows - const peakRows = await prisma.$queryRaw< - { d: number; h: number; c: bigint }[] - >` - SELECT ((DAYOFWEEK(FROM_UNIXTIME(last_online)) + 5) % 7) AS d, - HOUR(FROM_UNIXTIME(last_online)) AS h, - COUNT(*) AS c - FROM users - WHERE last_online >= ${weekAgo} - GROUP BY d, h - `; - const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0)); - let max = 0; - for (const r of peakRows) { - const count = Number(r.c); - grid[r.d][r.h] = count; - if (count > max) max = count; - } - - return { - totalUsers, - onlineUsers, - weekRegistrations, - monthRegistrations, - totalRooms, - activeRooms, - weekChatMessages: Array.isArray(weekChatMessages) - ? Number(weekChatMessages[0]?.count ?? 0) - : 0, - weekTrades, - weekBans, - registrationChart, - peakHours: { grid, max }, - }; } export default async function AnalyticsPage() {