From 09b0d21621b00378be912ded069c351651d5664f Mon Sep 17 00:00:00 2001 From: Lang <7system7@gmail.com> Date: Sat, 18 Apr 2026 22:12:07 +0200 Subject: [PATCH] new: usr: add new profile charts and stats - & add new logo to the tech stack #5 --- assets/css/homepage/_profile.scss | 98 ++++++++++++++++++++--- assets/css/homepage/_responsive.scss | 4 + assets/js/components/ProfileCharts.jsx | 37 ++++++++- public/images/technologies/postgresql.svg | 1 + src/Controller/ProfileController.php | 60 ++++++++++---- src/Repository/PlayedGameRepository.php | 58 ++++++++++++++ templates/Game/index.html.twig | 11 ++- templates/Security/profile.html.twig | 49 +++++++++--- 8 files changed, 275 insertions(+), 43 deletions(-) create mode 100644 public/images/technologies/postgresql.svg diff --git a/assets/css/homepage/_profile.scss b/assets/css/homepage/_profile.scss index 2280962..3a0c8ee 100644 --- a/assets/css/homepage/_profile.scss +++ b/assets/css/homepage/_profile.scss @@ -210,11 +210,43 @@ } } - &--best { - border-color: rgba(255, 215, 0, 0.15); + &--bonus { + border-color: rgba(255, 215, 0, 0.18); &:hover { - border-color: rgba(255, 215, 0, 0.4); + border-color: rgba(255, 215, 0, 0.45); + } + } + + &--avg-bonus { + border-color: rgba(230, 184, 60, 0.18); + + &:hover { + border-color: rgba(230, 184, 60, 0.45); + } + } + + &--chain { + border-color: rgba(94, 232, 154, 0.15); + + &:hover { + border-color: rgba(94, 232, 154, 0.4); + } + } + + &--blind { + border-color: rgba(255, 140, 90, 0.15); + + &:hover { + border-color: rgba(255, 140, 90, 0.4); + } + } + + &--edge { + border-color: rgba(168, 210, 255, 0.15); + + &:hover { + border-color: rgba(168, 210, 255, 0.4); } } } @@ -248,8 +280,24 @@ color: rgba(80, 200, 220, 0.35); } - .profile-stat--best & { - color: rgba(255, 215, 0, 0.3); + .profile-stat--bonus & { + color: rgba(255, 215, 0, 0.35); + } + + .profile-stat--avg-bonus & { + color: rgba(230, 184, 60, 0.3); + } + + .profile-stat--chain & { + color: rgba(94, 232, 154, 0.3); + } + + .profile-stat--blind & { + color: rgba(255, 140, 90, 0.3); + } + + .profile-stat--edge & { + color: rgba(168, 210, 255, 0.3); } } @@ -289,9 +337,25 @@ color: #50c8dc; } - .profile-stat--best & { + .profile-stat--bonus & { color: #ffd700; } + + .profile-stat--avg-bonus & { + color: #e6b83c; + } + + .profile-stat--chain & { + color: #5ee89a; + } + + .profile-stat--blind & { + color: #ff8c5a; + } + + .profile-stat--edge & { + color: #a8d2ff; + } } .profile-stat__label { @@ -464,18 +528,17 @@ } .profile-charts { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: 1fr 1fr; gap: 20px; } .profile-chart-block { - flex: 1 1 300px; + min-width: 0; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(35, 111, 135, 0.2); border-radius: 10px; padding: 24px 20px 16px; - backdrop-filter: blur(4px); box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4); display: flex; flex-direction: column; @@ -484,12 +547,25 @@ .profile-section__title { margin: 0; } + + &--wide { + grid-column: 1 / -1; + + .profile-chart-inner { + justify-content: stretch; + overflow: hidden; + + > * { + width: 100% !important; + } + } + } } .profile-chart-inner { display: flex; justify-content: center; - overflow: auto; + overflow: hidden; svg text { font-family: 'Rajdhani', sans-serif !important; diff --git a/assets/css/homepage/_responsive.scss b/assets/css/homepage/_responsive.scss index 06abf91..d32b5a7 100644 --- a/assets/css/homepage/_responsive.scss +++ b/assets/css/homepage/_responsive.scss @@ -24,6 +24,10 @@ grid-template-columns: repeat(2, 1fr); } + .profile-charts { + grid-template-columns: 1fr; + } + .profile-header { flex-direction: column; text-align: center; diff --git a/assets/js/components/ProfileCharts.jsx b/assets/js/components/ProfileCharts.jsx index 071921a..3e6e2be 100644 --- a/assets/js/components/ProfileCharts.jsx +++ b/assets/js/components/ProfileCharts.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { BarChart } from '@mui/x-charts/BarChart'; +import { LineChart } from '@mui/x-charts/LineChart'; import { PieChart } from '@mui/x-charts/PieChart'; import { createTheme, ThemeProvider } from '@mui/material/styles'; @@ -16,6 +17,8 @@ const darkTheme = createTheme({ const WIN_COLOR = '#5ee89a'; const LOSS_COLOR = '#f67d52'; const DRAW_COLOR = '#95cff5'; +const MINES_COLOR = '#f67d52'; +const BONUS_COLOR = '#ffd700'; const axisStyle = { tickLabelStyle: { fill: 'rgba(255,255,255,0.4)', fontSize: 11, fontFamily: '\'Rajdhani\', sans-serif' }, @@ -23,10 +26,12 @@ const axisStyle = { }; export default function ProfileCharts({ chartData }) { - const { months, wins, losses, draws, pieWins, pieLosses, pieDraws } = chartData; + const { months, wins, losses, draws, pieWins, pieLosses, pieDraws, recentGames } = chartData; const total = pieWins + pieLosses + pieDraws; const hasBars = wins.some(v => 0 < v) || losses.some(v => 0 < v) || draws.some(v => 0 < v); + const hasRecent = recentGames + && (recentGames.mines?.some(v => 0 < v) || recentGames.bonus?.some(v => 0 < v)); return ( @@ -97,6 +102,36 @@ export default function ProfileCharts({ chartData }) { )} + + {hasRecent && ( +
+

+ Last {recentGames.labels.length} games — mines & bonus +

+
+ +
+
+ )}
); diff --git a/public/images/technologies/postgresql.svg b/public/images/technologies/postgresql.svg new file mode 100644 index 0000000..61ad9eb --- /dev/null +++ b/public/images/technologies/postgresql.svg @@ -0,0 +1 @@ + diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 1fc24a0..2904dc2 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -112,16 +112,22 @@ class ProfileController extends AbstractController $months = array_column(array_values($monthlyData), 'label'); + $bonus = $this->repo->findBonusStatsForUser($user); + return $this->render('Security/profile.html.twig', [ 'stats' => [ - 'total' => $total, - 'wins' => $wins, - 'losses' => $losses, - 'draws' => $draws, - 'bombs' => $this->repo->countBombsForUser($user), - 'winRate' => $total > 0 ? (int)round($wins / $total * 100) : 0, - 'avgScore' => $this->repo->findAvgScoreForUser($user), - 'bestScore' => $this->repo->findBestScoreForUser($user), + 'total' => $total, + 'wins' => $wins, + 'losses' => $losses, + 'draws' => $draws, + 'minesHit' => $this->repo->findTotalMinesForUser($user), + 'winRate' => $total > 0 ? (int)round($wins / $total * 100) : 0, + 'avgScore' => $this->repo->findAvgScoreForUser($user), + 'bonusPoints' => $bonus['totalBonusPoints'], + 'avgBonus' => $bonus['avgBonusPoints'], + 'bestChain' => $bonus['bestChain'], + 'blindHits' => $bonus['totalBlindHits'], + 'edgeMines' => $bonus['totalEdgeMines'], ], 'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)), 'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array { @@ -170,17 +176,41 @@ class ProfileController extends AbstractController ]; }, $recent), 'chartData' => [ - 'months' => $months, - 'wins' => array_column(array_values($monthlyData), 'wins'), - 'losses' => array_column(array_values($monthlyData), 'losses'), - 'draws' => array_column(array_values($monthlyData), 'draws'), - 'pieWins' => $wins, - 'pieLosses' => $losses, - 'pieDraws' => $draws, + 'months' => $months, + 'wins' => array_column(array_values($monthlyData), 'wins'), + 'losses' => array_column(array_values($monthlyData), 'losses'), + 'draws' => array_column(array_values($monthlyData), 'draws'), + 'pieWins' => $wins, + 'pieLosses' => $losses, + 'pieDraws' => $draws, + 'recentGames' => $this->buildRecentGamesSeries($user, $userId), ], ]); } + /** + * Build per-game data for the last 15 finished games, oldest → newest. + * + * @return array{labels:string[],mines:int[],bonus:float[]} + */ + private function buildRecentGamesSeries(User $user, int $userId): array + { + $recent = $this->repo->findRecentFinishedForUser($user, 15); + $recent = array_reverse($recent); + + $labels = []; + $mines = []; + $bonus = []; + foreach ($recent as $i => $game) { + $isRed = $game->getRed()?->getId() === $userId; + $labels[] = '#' . ($i + 1); + $mines[] = (int) ($isRed ? $game->getRedPoints() : $game->getBluePoints()); + $bonus[] = (float) ($isRed ? $game->getRedBonusPoints() : $game->getBlueBonusPoints()) ?: 0; + } + + return ['labels' => $labels, 'mines' => $mines, 'bonus' => $bonus]; + } + #[Route( '/battle/{uuid}', name: 'MineSeekerBundle_battle_share', diff --git a/src/Repository/PlayedGameRepository.php b/src/Repository/PlayedGameRepository.php index 5efcfda..0fe6116 100644 --- a/src/Repository/PlayedGameRepository.php +++ b/src/Repository/PlayedGameRepository.php @@ -260,6 +260,21 @@ class PlayedGameRepository extends ServiceEntityRepository } } + public function findTotalMinesForUser(User $user): int + { + $conn = $this->getEntityManager()->getConnection(); + + $result = $conn->executeQuery( + 'SELECT + COALESCE(SUM(CASE WHEN g.red_id = :uid THEN g.red_points ELSE g.blue_points END), 0) AS total_pts + FROM played_game g + WHERE (g.red_id = :uid OR g.blue_id = :uid)', + ['uid' => $user->getId()], + )->fetchAssociative(); + + return (int) ($result['total_pts'] ?? 0); + } + public function findAvgScoreForUser(User $user): int { $conn = $this->getEntityManager()->getConnection(); @@ -284,6 +299,49 @@ class PlayedGameRepository extends ServiceEntityRepository return (int) round((float) $result['total_pts'] / (int) $result['total_games']); } + /** + * Aggregates bonus points and bonus stats across all finished games for a user. + * + * @return array{totalBonusPoints:float,avgBonusPoints:float,bestChain:int,totalBlindHits:int,totalEdgeMines:int} + */ + public function findBonusStatsForUser(User $user): array + { + $userId = $user->getId(); + $qb = $this->createQueryBuilder('g'); + $qb->where($qb->expr()->orX( + $qb->expr()->eq('g.red', ':u'), + $qb->expr()->eq('g.blue', ':u'), + ))->setParameter('u', $user); + + /** @var PlayedGame[] $games */ + $games = $qb->getQuery()->getResult(); + + $totalBonusPoints = 0.0; + $bestChain = 0; + $totalBlindHits = 0; + $totalEdgeMines = 0; + $gameCount = 0; + + foreach ($games as $game) { + $isRed = $game->getRed()?->getId() === $userId; + $totalBonusPoints += (float) (($isRed ? $game->getRedBonusPoints() : $game->getBlueBonusPoints()) ?? 0.0); + + $stats = ($isRed ? $game->getRedBonusStats() : $game->getBlueBonusStats()) ?? []; + $bestChain = max($bestChain, (int) ($stats['chainBest'] ?? 0)); + $totalBlindHits += (int) ($stats['blindHits'] ?? 0); + $totalEdgeMines += (int) ($stats['edgeMines'] ?? 0); + $gameCount++; + } + + return [ + 'totalBonusPoints' => round($totalBonusPoints, 1), + 'avgBonusPoints' => 0 < $gameCount ? round($totalBonusPoints / $gameCount, 1) : 0.0, + 'bestChain' => $bestChain, + 'totalBlindHits' => $totalBlindHits, + 'totalEdgeMines' => $totalEdgeMines, + ]; + } + public function findBestScoreForUser(User $user): int { try { diff --git a/templates/Game/index.html.twig b/templates/Game/index.html.twig index 54587d5..22129cd 100644 --- a/templates/Game/index.html.twig +++ b/templates/Game/index.html.twig @@ -225,10 +225,13 @@ Vite - - Bun - - + + Bun + + + PostgreSQL + + PHPStorm diff --git a/templates/Security/profile.html.twig b/templates/Security/profile.html.twig index b2bdaa3..1f73d45 100644 --- a/templates/Security/profile.html.twig +++ b/templates/Security/profile.html.twig @@ -1,5 +1,10 @@ {% extends 'Game/index.html.twig' %} +{% macro stat_val(value, suffix) %} + {%- set abbr = value >= 1000 -%} + {% if abbr %}{{ (value / 1000)|round(1, 'floor') }}k{% else %}{{ value }}{% endif %}{% if suffix %}{{ suffix }}{% endif %} +{% endmacro %} + {% block title %} - Profile{% endblock %} {% block metas %} @@ -45,44 +50,64 @@
- {{ stats.total }} + {{ _self.stat_val(stats.total) }} Games played
- {{ stats.wins }} + {{ _self.stat_val(stats.wins) }} Victories
- {{ stats.losses }} + {{ _self.stat_val(stats.losses) }} Defeats
- {{ stats.draws }} + {{ _self.stat_val(stats.draws) }} Draws
- {{ stats.winRate }}% + {{ _self.stat_val(stats.winRate, '%') }} Win rate
- {{ stats.avgScore }} + {{ _self.stat_val(stats.avgScore) }} Avg score
-
- - {{ stats.bestScore }} - Best score -
- {{ stats.bombs }} + {{ _self.stat_val(stats.minesHit) }} Mines hit
+
+ + {{ _self.stat_val(stats.bonusPoints) }} + Bonus points +
+
+ + {{ _self.stat_val(stats.avgBonus) }} + Avg bonus +
+
+ + {{ _self.stat_val(stats.bestChain) }} + Best chain +
+
+ + {{ _self.stat_val(stats.blindHits) }} + Blind hits +
+
+ + {{ _self.stat_val(stats.edgeMines) }} + Edge mines +
{% if stats.total > 0 %}