diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
new file mode 100644
index 0000000..31b5ce5
--- /dev/null
+++ b/app/dashboard/page.tsx
@@ -0,0 +1,183 @@
+import type { Metadata } from "next"
+import { db } from "@/lib/db/index"
+import { sql } from "drizzle-orm"
+import { DashboardClient } from "@/components/dashboard/dashboard-client"
+
+export const revalidate = 3600
+
+export const metadata: Metadata = {
+ title: "Dashboard — DeckyVault",
+ description:
+ "Community dashboard with trending games, best new releases, and most tested games on DeckyVault.",
+ keywords: [
+ "Steam Deck dashboard",
+ "trending games",
+ "best new releases",
+ "game benchmarks",
+ "community insights",
+ ],
+ alternates: { canonical: "https://deckyvault.xyz/dashboard" },
+ openGraph: {
+ title: "Dashboard — DeckyVault",
+ description:
+ "Community dashboard with trending games, best new releases, and most tested games.",
+ url: "https://deckyvault.xyz/dashboard",
+ siteName: "DeckyVault",
+ type: "website",
+ },
+}
+
+const SEVEN_DAYS_AGO = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
+
+export default async function DashboardPage() {
+ const trending = await db.execute(sql`
+ WITH recent_benchmarks AS (
+ SELECT gv.game_id, COUNT(*) AS cnt
+ FROM performance_entries pe
+ JOIN game_versions gv ON pe.version_id = gv.id
+ WHERE pe.is_removed = false
+ AND pe.created_at >= ${SEVEN_DAYS_AGO}
+ GROUP BY gv.game_id
+ ),
+ recent_comments AS (
+ SELECT gc.game_id, COUNT(*) AS cnt
+ FROM game_comments gc
+ WHERE gc.is_removed = false
+ AND gc.created_at >= ${SEVEN_DAYS_AGO}
+ GROUP BY gc.game_id
+ ),
+ recent_upvotes AS (
+ SELECT gv.game_id, SUM(pe.upvotes) AS total_upvotes
+ FROM performance_entries pe
+ JOIN game_versions gv ON pe.version_id = gv.id
+ WHERE pe.is_removed = false
+ AND pe.updated_at >= ${SEVEN_DAYS_AGO}
+ GROUP BY gv.game_id
+ )
+ SELECT
+ g.id,
+ g.title,
+ g.capsule_image,
+ g.header_image,
+ g.playability_status,
+ COALESCE(rb.cnt, 0) AS benchmark_count,
+ COALESCE(rc.cnt, 0) AS comment_count,
+ COALESCE(ru.total_upvotes, 0) AS upvote_count,
+ (COALESCE(rb.cnt, 0) * 3 + COALESCE(rc.cnt, 0) * 2 + COALESCE(ru.total_upvotes, 0) * 1) AS activity_score
+ FROM games g
+ LEFT JOIN recent_benchmarks rb ON rb.game_id = g.id
+ LEFT JOIN recent_comments rc ON rc.game_id = g.id
+ LEFT JOIN recent_upvotes ru ON ru.game_id = g.id
+ WHERE (rb.cnt IS NOT NULL OR rc.cnt IS NOT NULL OR ru.total_upvotes IS NOT NULL)
+ ORDER BY activity_score DESC
+ LIMIT 10
+ `)
+
+ const bestNew = await db.execute(sql`
+ SELECT
+ g.id,
+ g.title,
+ g.capsule_image,
+ g.header_image,
+ g.release_date,
+ g.created_at,
+ g.playability_status,
+ AVG(pe.fps_avg) AS avg_fps,
+ COUNT(pe.id) AS benchmark_count
+ FROM games g
+ JOIN game_versions gv ON gv.game_id = g.id
+ JOIN performance_entries pe ON pe.version_id = gv.id
+ WHERE pe.is_removed = false
+ AND (g.created_at >= ${new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)}
+ OR g.release_date IS NOT NULL)
+ GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.release_date, g.created_at, g.playability_status
+ HAVING COUNT(pe.id) >= 3
+ ORDER BY avg_fps DESC
+ LIMIT 10
+ `)
+
+ const mostTested = await db.execute(sql`
+ SELECT
+ g.id,
+ g.title,
+ g.capsule_image,
+ g.header_image,
+ g.playability_status,
+ COUNT(pe.id) AS benchmark_count
+ FROM games g
+ JOIN game_versions gv ON gv.game_id = g.id
+ JOIN performance_entries pe ON pe.version_id = gv.id
+ WHERE pe.is_removed = false
+ GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.playability_status
+ ORDER BY benchmark_count DESC
+ LIMIT 10
+ `)
+
+ const mostReported = await db.execute(sql`
+ SELECT
+ g.id,
+ g.title,
+ g.capsule_image,
+ g.header_image,
+ COUNT(DISTINCT r.id) AS report_count
+ FROM games g
+ JOIN game_versions gv ON gv.game_id = g.id
+ JOIN performance_entries pe ON pe.version_id = gv.id
+ JOIN reports r ON r.entry_id = pe.id
+ WHERE r.status = 'open'
+ GROUP BY g.id, g.title, g.capsule_image, g.header_image
+ ORDER BY report_count DESC
+ LIMIT 10
+ `)
+
+ // Serialize rows for the client component
+ const serializedTrending = trending.rows.map((row: any) => ({
+ id: String(row.id),
+ title: String(row.title),
+ capsule_image: row.capsule_image ? String(row.capsule_image) : null,
+ header_image: row.header_image ? String(row.header_image) : null,
+ playability_status: row.playability_status ? String(row.playability_status) : null,
+ benchmark_count: Number(row.benchmark_count ?? 0),
+ comment_count: Number(row.comment_count ?? 0),
+ upvote_count: Number(row.upvote_count ?? 0),
+ activity_score: Number(row.activity_score ?? 0),
+ }))
+
+ const serializedBestNew = bestNew.rows.map((row: any) => ({
+ id: String(row.id),
+ title: String(row.title),
+ capsule_image: row.capsule_image ? String(row.capsule_image) : null,
+ header_image: row.header_image ? String(row.header_image) : null,
+ release_date: row.release_date ? new Date(row.release_date).toISOString() : null,
+ created_at: row.created_at ? new Date(row.created_at).toISOString() : null,
+ playability_status: row.playability_status ? String(row.playability_status) : null,
+ avg_fps: row.avg_fps ? Number(row.avg_fps) : null,
+ benchmark_count: Number(row.benchmark_count ?? 0),
+ }))
+
+ const serializedMostTested = mostTested.rows.map((row: any) => ({
+ id: String(row.id),
+ title: String(row.title),
+ capsule_image: row.capsule_image ? String(row.capsule_image) : null,
+ header_image: row.header_image ? String(row.header_image) : null,
+ playability_status: row.playability_status ? String(row.playability_status) : null,
+ benchmark_count: Number(row.benchmark_count ?? 0),
+ }))
+
+ const serializedMostReported = mostReported.rows.map((row: any) => ({
+ id: String(row.id),
+ title: String(row.title),
+ capsule_image: row.capsule_image ? String(row.capsule_image) : null,
+ header_image: row.header_image ? String(row.header_image) : null,
+ report_count: Number(row.report_count ?? 0),
+ }))
+
+ return (
+
+ )
+}
diff --git a/components/dashboard/best-releases-chart.tsx b/components/dashboard/best-releases-chart.tsx
new file mode 100644
index 0000000..e7b3fd0
--- /dev/null
+++ b/components/dashboard/best-releases-chart.tsx
@@ -0,0 +1,105 @@
+"use client"
+
+import { EChartWrapper, CHART_THEME } from "@/components/charts/EChartWrapper"
+import type { EChartsOption } from "echarts"
+
+interface BestRelease {
+ id: string
+ title: string
+ avg_fps: number | null
+ benchmark_count: number
+}
+
+function buildOption(games: BestRelease[]): EChartsOption {
+ const data = games.map((g) => ({
+ name: g.title,
+ value: [g.benchmark_count, Math.round((g.avg_fps ?? 0) * 10) / 10, g.benchmark_count],
+ itemStyle: {
+ color: {
+ type: "radial" as const,
+ x: 0.5,
+ y: 0.5,
+ r: 0.5,
+ colorStops: [
+ { offset: 0, color: CHART_THEME.accent },
+ { offset: 1, color: CHART_THEME.primary },
+ ],
+ },
+ shadowBlur: 10,
+ shadowColor: CHART_THEME.primary + "40",
+ },
+ }))
+
+ return {
+ backgroundColor: CHART_THEME.bg,
+ tooltip: {
+ trigger: "item",
+ backgroundColor: "#1a1225",
+ borderColor: CHART_THEME.border,
+ textStyle: { color: CHART_THEME.text },
+ formatter: (params: any) => {
+ const v = params.value
+ return `
${params.name}
+Avg FPS: ${v[1]}
+Benchmarks: ${v[2]}
`
+ },
+ },
+ grid: {
+ left: "3%",
+ right: "6%",
+ bottom: "10%",
+ top: "10%",
+ containLabel: true,
+ },
+ xAxis: {
+ name: "Benchmarks",
+ nameLocation: "middle",
+ nameGap: 24,
+ nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 12 },
+ type: "value",
+ splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
+ axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 },
+ axisLine: { lineStyle: { color: CHART_THEME.border } },
+ axisTick: { show: false },
+ },
+ yAxis: {
+ name: "Avg FPS",
+ nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 12 },
+ type: "value",
+ splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
+ axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 },
+ axisLine: { lineStyle: { color: CHART_THEME.border } },
+ axisTick: { show: false },
+ },
+ series: [
+ {
+ type: "scatter",
+ data,
+ symbolSize: (val: number[]) => Math.max(12, Math.min(40, val[2] * 3)),
+ label: {
+ show: true,
+ formatter: (p: any) => p.name,
+ position: "top",
+ color: CHART_THEME.text,
+ fontSize: 10,
+ overflow: "truncate",
+ width: 100,
+ },
+ emphasis: {
+ scale: 1.5,
+ itemStyle: {
+ shadowBlur: 20,
+ shadowColor: CHART_THEME.accent + "60",
+ },
+ },
+ },
+ ],
+ animationDuration: 800,
+ animationEasing: "cubicOut",
+ }
+}
+
+export function BestReleasesChart({ games }: { games: BestRelease[] }) {
+ if (!games.length) return null
+ return
+}
diff --git a/components/dashboard/dashboard-client.tsx b/components/dashboard/dashboard-client.tsx
new file mode 100644
index 0000000..f7a3ad2
--- /dev/null
+++ b/components/dashboard/dashboard-client.tsx
@@ -0,0 +1,117 @@
+"use client"
+
+import { TrendingUpIcon, SparklesIcon, BarChart3Icon } from "lucide-react"
+import { TrendingGamesChart } from "./trending-games-chart"
+import { BestReleasesChart } from "./best-releases-chart"
+import { MostTestedChart } from "./most-tested-chart"
+
+interface TrendingGame {
+ id: string
+ title: string
+ capsule_image: string | null
+ header_image: string | null
+ playability_status: string | null
+ benchmark_count: number
+ comment_count: number
+ upvote_count: number
+ activity_score: number
+}
+
+interface BestRelease {
+ id: string
+ title: string
+ capsule_image: string | null
+ header_image: string | null
+ release_date: string | null
+ created_at: string | null
+ playability_status: string | null
+ avg_fps: number | null
+ benchmark_count: number
+}
+
+interface MostTestedGame {
+ id: string
+ title: string
+ capsule_image: string | null
+ header_image: string | null
+ playability_status: string | null
+ benchmark_count: number
+}
+
+interface MostReportedGame {
+ id: string
+ title: string
+ capsule_image: string | null
+ header_image: string | null
+ report_count: number
+}
+
+interface DashboardClientProps {
+ trending: TrendingGame[]
+ bestNew: BestRelease[]
+ mostTested: MostTestedGame[]
+ mostReported: MostReportedGame[]
+}
+
+export function DashboardClient({
+ trending,
+ bestNew,
+ mostTested,
+ mostReported,
+}: DashboardClientProps) {
+ return (
+
+
+
+ Community Dashboard
+
+
+ Real-time insights from the DeckyVault community — trending games, top
+ performers, and activity highlights.
+
+
+
+ {/* Trending Games */}
+
+
+
+
+ Trending Games (7 days)
+
+
+
+ Top games by community activity: benchmarks, comments, and upvotes.
+
+
+
+
+ {/* Best New Releases */}
+
+
+
+
+ Best Performing New Releases
+
+
+
+ Newly added games with the highest average FPS (minimum 3 benchmarks).
+
+
+
+
+ {/* Most Tested & Most Reported */}
+
+
+
+
+ Most Tested & Most Reported
+
+
+
+ Games with the most benchmarks submitted and the most open reports.
+
+
+
+
+ )
+}
diff --git a/components/dashboard/most-tested-chart.tsx b/components/dashboard/most-tested-chart.tsx
new file mode 100644
index 0000000..db7d828
--- /dev/null
+++ b/components/dashboard/most-tested-chart.tsx
@@ -0,0 +1,159 @@
+"use client"
+
+import { EChartWrapper, CHART_THEME } from "@/components/charts/EChartWrapper"
+import type { EChartsOption } from "echarts"
+
+interface MostTestedGame {
+ id: string
+ title: string
+ benchmark_count: number
+}
+
+interface MostReportedGame {
+ id: string
+ title: string
+ report_count: number
+}
+
+function buildOption(
+ tested: MostTestedGame[],
+ reported: MostReportedGame[],
+): EChartsOption {
+ const testedTitles = tested.map((g) => g.title)
+ const testedCounts = tested.map((g) => g.benchmark_count)
+ const reportedTitles = reported.map((g) => g.title)
+ const reportedCounts = reported.map((g) => g.report_count)
+
+ return {
+ backgroundColor: CHART_THEME.bg,
+ tooltip: {
+ trigger: "axis",
+ axisPointer: { type: "shadow" },
+ backgroundColor: "#1a1225",
+ borderColor: CHART_THEME.border,
+ textStyle: { color: CHART_THEME.text },
+ },
+ legend: {
+ data: ["Most Tested", "Most Reported"],
+ textStyle: { color: CHART_THEME.textMuted },
+ bottom: 0,
+ },
+ grid: [
+ {
+ left: "3%",
+ right: "52%",
+ bottom: "14%",
+ top: "8%",
+ containLabel: true,
+ },
+ {
+ left: "52%",
+ right: "3%",
+ bottom: "14%",
+ top: "8%",
+ containLabel: true,
+ },
+ ],
+ xAxis: [
+ {
+ type: "value",
+ gridIndex: 0,
+ splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
+ axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
+ axisLine: { show: false },
+ axisTick: { show: false },
+ },
+ {
+ type: "value",
+ gridIndex: 1,
+ splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
+ axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
+ axisLine: { show: false },
+ axisTick: { show: false },
+ },
+ ],
+ yAxis: [
+ {
+ type: "category",
+ gridIndex: 0,
+ data: testedTitles,
+ axisLabel: { color: CHART_THEME.text, fontSize: 11, width: 120, overflow: "truncate" },
+ axisLine: { show: false },
+ axisTick: { show: false },
+ },
+ {
+ type: "category",
+ gridIndex: 1,
+ data: reportedTitles,
+ axisLabel: { color: CHART_THEME.text, fontSize: 11, width: 120, overflow: "truncate" },
+ axisLine: { show: false },
+ axisTick: { show: false },
+ },
+ ],
+ series: [
+ {
+ name: "Most Tested",
+ type: "bar",
+ xAxisIndex: 0,
+ yAxisIndex: 0,
+ data: testedCounts,
+ barWidth: "55%",
+ itemStyle: {
+ borderRadius: [0, 4, 4, 0],
+ color: {
+ type: "linear",
+ x: 0,
+ y: 0,
+ x2: 1,
+ y2: 0,
+ colorStops: [
+ { offset: 0, color: CHART_THEME.info },
+ { offset: 1, color: CHART_THEME.success },
+ ],
+ },
+ },
+ },
+ {
+ name: "Most Reported",
+ type: "bar",
+ xAxisIndex: 1,
+ yAxisIndex: 1,
+ data: reportedCounts,
+ barWidth: "55%",
+ itemStyle: {
+ borderRadius: [0, 4, 4, 0],
+ color: {
+ type: "linear",
+ x: 0,
+ y: 0,
+ x2: 1,
+ y2: 0,
+ colorStops: [
+ { offset: 0, color: CHART_THEME.warning },
+ { offset: 1, color: CHART_THEME.accent },
+ ],
+ },
+ },
+ },
+ ],
+ animationDuration: 800,
+ animationEasing: "cubicOut",
+ }
+}
+
+export function MostTestedChart({
+ mostTested,
+ mostReported,
+}: {
+ mostTested: MostTestedGame[]
+ mostReported: MostReportedGame[]
+}) {
+ if (!mostTested.length && !mostReported.length) return null
+ return (
+
+ )
+}
diff --git a/components/dashboard/trending-games-chart.tsx b/components/dashboard/trending-games-chart.tsx
new file mode 100644
index 0000000..98a5f3c
--- /dev/null
+++ b/components/dashboard/trending-games-chart.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import { EChartWrapper, CHART_THEME } from "@/components/charts/EChartWrapper"
+import type { EChartsOption } from "echarts"
+
+interface TrendingGame {
+ id: string
+ title: string
+ activity_score: number
+ benchmark_count: number
+ comment_count: number
+ upvote_count: number
+}
+
+function buildOption(games: TrendingGame[]): EChartsOption {
+ const sorted = [...games].reverse()
+ const titles = sorted.map((g) => g.title)
+ const scores = sorted.map((g) => g.activity_score)
+ const benchmarks = sorted.map((g) => g.benchmark_count)
+ const comments = sorted.map((g) => g.comment_count)
+ const upvotes = sorted.map((g) => g.upvote_count)
+
+ return {
+ backgroundColor: CHART_THEME.bg,
+ tooltip: {
+ trigger: "axis",
+ axisPointer: { type: "shadow" },
+ backgroundColor: "#1a1225",
+ borderColor: CHART_THEME.border,
+ textStyle: { color: CHART_THEME.text },
+ formatter: (params: any) => {
+ const idx = params[0].dataIndex
+ const g = sorted[idx]
+ return `${g.title}
+Activity Score: ${g.activity_score}
+
+Benchmarks: ${g.benchmark_count} · Comments: ${g.comment_count} · Upvotes: ${g.upvote_count}
+
`
+ },
+ },
+ grid: {
+ left: "3%",
+ right: "4%",
+ bottom: "3%",
+ top: "3%",
+ containLabel: true,
+ },
+ xAxis: {
+ type: "value",
+ splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
+ axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 },
+ axisLine: { show: false },
+ axisTick: { show: false },
+ },
+ yAxis: {
+ type: "category",
+ data: titles,
+ axisLabel: { color: CHART_THEME.text, fontSize: 12, width: 160, overflow: "truncate" },
+ axisLine: { show: false },
+ axisTick: { show: false },
+ },
+ series: [
+ {
+ name: "Activity Score",
+ type: "bar",
+ data: scores,
+ barWidth: "60%",
+ itemStyle: {
+ borderRadius: [0, 4, 4, 0],
+ color: {
+ type: "linear",
+ x: 0,
+ y: 0,
+ x2: 1,
+ y2: 0,
+ colorStops: [
+ { offset: 0, color: CHART_THEME.secondary },
+ { offset: 1, color: CHART_THEME.primary },
+ ],
+ },
+ },
+ },
+ {
+ name: "Benchmarks",
+ type: "bar",
+ data: benchmarks,
+ barWidth: "60%",
+ barGap: "-100%",
+ itemStyle: {
+ borderRadius: [0, 4, 4, 0],
+ color: "transparent",
+ borderWidth: 1,
+ borderColor: CHART_THEME.accent,
+ },
+ emphasis: { disabled: true },
+ tooltip: { show: false },
+ silent: true,
+ },
+ ],
+ animationDuration: 800,
+ animationEasing: "cubicOut",
+ }
+}
+
+export function TrendingGamesChart({ games }: { games: TrendingGame[] }) {
+ if (!games.length) return null
+ return
+}