Commit 76a7982f authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(dashboard): the game's real numbers

The admin dashboard opens on the numbers a store game is run by, from
manager_insights() in the player database (one call, cached 5 minutes,
7/30/90-day windows; test accounts never count): online now, DAU/WAU/MAU
and stickiness, new players, share of guests, retention (back the next
day, after a week, after a month), matches per active player, daily
charts of active and new players and of matches per game, a per-game
table (online vs bot, players, finished vs abandoned, average length),
time in the app and platforms (recorded from today), countries, coins
given and spent by source, social and tournaments. Pending reports and
approvals sit on top when there are any. Charts are plain SVG.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 855056dc
......@@ -85,13 +85,33 @@ class DashboardController
$gamesToday[$game] = $db->count($table, ['created_at' => 'gte.' . $since]);
}
$insights = $this->insights($db, max(7, min(90, (int)($_GET['days'] ?? 30))));
return compact(
'totalPlayers', 'onlinePlayers', 'totalMatches', 'activeMatches',
'activeTournaments', 'pendingReports', 'totalAdminUsers', 'pendingApprovals',
'recentActivity', 'supabaseHealth', 'stockfishHealth', 'swissHealth', 'gamesToday'
'recentActivity', 'supabaseHealth', 'stockfishHealth', 'swissHealth', 'gamesToday', 'insights'
);
}
/**
* The game's numbers (manager_insights() in the player database, one
* call), kept five minutes per window so the dashboard opens fast.
*/
private function insights(Database $db, int $days): array
{
$file = STORAGE_PATH . '/cache/insights_' . $days . '.json';
if (is_file($file) && time() - filemtime($file) < 300 && empty($_GET['fresh'])) {
$cached = json_decode((string)file_get_contents($file), true);
if (is_array($cached)) return $cached;
}
$data = $db->rpc('manager_insights', ['p_days' => $days]);
if (!is_array($data) || !isset($data['players'])) return [];
if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true);
@file_put_contents($file, json_encode($data));
return $data;
}
private function getModeratorData(Database $db): array
{
$pendingReports = $db->count('cheat_reports', ['status' => 'eq.pending']);
......
This diff is collapsed.
......@@ -24,71 +24,7 @@
<?php endforeach; ?>
</div>
<!-- Stat Cards -->
<div class="grid grid-4 stagger mb-6">
<div class="stat-card">
<div class="stat-icon blue">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">اللاعبون المتصلون / الإجمالي</div>
<div class="stat-value"><span data-count="<?= $onlinePlayers ?>">0</span> / <?= number_format($totalPlayers) ?></div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon orange">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M12 12h.01"/><path d="M17 12h.01"/><path d="M7 12h.01"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">المباريات النشطة / الإجمالي</div>
<div class="stat-value"><span data-count="<?= $activeMatches ?>">0</span> / <?= number_format($totalMatches) ?></div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon gold">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">البطولات الجارية</div>
<div class="stat-value" data-count="<?= $activeTournaments ?>">0</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon red">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">البلاغات المعلقة</div>
<div class="stat-value" data-count="<?= $pendingReports ?>">0</div>
</div>
</div>
</div>
<!-- Admin Users & Approvals -->
<div class="grid grid-2 mb-6">
<div class="stat-card">
<div class="stat-icon purple">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">إجمالي مستخدمي الإدارة</div>
<div class="stat-value" data-count="<?= $totalAdminUsers ?>">0</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon warning">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<div class="stat-content">
<div class="stat-label">الموافقات المعلقة</div>
<div class="stat-value" data-count="<?= $pendingApprovals ?>">0</div>
</div>
</div>
</div>
<?php include __DIR__ . '/_insights.php'; ?>
<!-- Service Health -->
<div class="grid grid-2 mb-6">
......
......@@ -984,3 +984,33 @@ textarea.form-input {
.game-domino { --tile-tint: #E4F5EC; }
.game-backgammon { --tile-tint: #FFF3D6; }
@media (max-width: 900px) { .game-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* Dashboard insights (modules/dashboard/views/_insights.php) */
.ins-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.ins-title { font-size: var(--font-size-lg); font-weight: var(--font-weight-bold); }
.ins-range, .ins-attention { display: flex; gap: var(--space-2); flex-wrap: wrap; }
.ins-chip { padding: 6px 12px; border-radius: var(--radius-pill); border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-secondary); font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-decoration: none; }
.ins-chip.is-on { background: var(--accent); border-color: var(--accent); color: var(--text-inverse); }
.ins-chip.is-warn { background: var(--warning-bg); border-color: transparent; color: var(--warning); }
.ins-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); }
.ins-kpi { display: flex; flex-direction: column; gap: 2px; padding: var(--space-4); border-radius: var(--radius-lg); background: var(--bg-secondary); border: 1px solid var(--border); box-shadow: var(--shadow-sm); }
.ins-kpi-label { font-size: var(--font-size-xs); color: var(--text-secondary); font-weight: var(--font-weight-medium); }
.ins-kpi-value { font-size: var(--font-size-xl); font-weight: 800; line-height: 1.2; font-variant-numeric: tabular-nums; }
.ins-kpi-sub { font-size: var(--font-size-xs); color: var(--text-muted); }
.ins-card-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; margin-bottom: var(--space-3); }
.ins-card-head h3, .ins-h3 { font-size: var(--font-size-md); font-weight: var(--font-weight-bold); margin-bottom: var(--space-2); }
.ins-legend { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--font-size-xs); color: var(--text-secondary); }
.ins-legend i { display: inline-block; inline-size: 10px; block-size: 10px; border-radius: 3px; }
.ins-chart { display: block; inline-size: 100%; block-size: 170px; direction: ltr; }
.ins-axis { font-size: 11px; fill: var(--text-muted); font-family: var(--font-family); }
.ins-meter { display: grid; grid-template-columns: 80px 1fr 48px; align-items: center; gap: var(--space-2); margin-block: var(--space-2); font-size: var(--font-size-sm); }
.ins-meter-bar { block-size: 10px; border-radius: var(--radius-pill); background: var(--bg-hover); overflow: hidden; }
.ins-meter-bar i { display: block; block-size: 100%; background: var(--accent); border-radius: inherit; }
.ins-meter-value { font-weight: var(--font-weight-bold); text-align: end; font-variant-numeric: tabular-nums; }
.ins-row { display: flex; justify-content: space-between; gap: var(--space-3); padding-block: 6px; border-block-end: 1px solid var(--border); font-size: var(--font-size-sm); }
.ins-row:last-child { border-block-end: 0; }
.ins-row span { color: var(--text-secondary); }
.ins-row b { font-variant-numeric: tabular-nums; }
.ins-row-sub { font-size: var(--font-size-xs); padding-inline-start: var(--space-3); }
.ins-dot { display: inline-block; inline-size: 10px; block-size: 10px; border-radius: 50%; margin-inline-end: 8px; }
@media (max-width: 1100px) { .ins-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment