Private
Public Access
1
0

new: usr: add new profile charts and stats - & add new logo to the tech stack #5

This commit is contained in:
2026-04-18 22:12:07 +02:00
parent 9aef27a0eb
commit 09b0d21621
8 changed files with 275 additions and 43 deletions

View File

@@ -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;

View File

@@ -24,6 +24,10 @@
grid-template-columns: repeat(2, 1fr);
}
.profile-charts {
grid-template-columns: 1fr;
}
.profile-header {
flex-direction: column;
text-align: center;

View File

@@ -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 (
<ThemeProvider theme={darkTheme}>
@@ -97,6 +102,36 @@ export default function ProfileCharts({ chartData }) {
</div>
</div>
)}
{hasRecent && (
<div className="profile-chart-block profile-chart-block--wide">
<h2 className="profile-section__title">
<i className="fa fa-line-chart" /> Last {recentGames.labels.length} games mines & bonus
</h2>
<div className="profile-chart-inner">
<LineChart
xAxis={[{ scaleType: 'band', data: recentGames.labels, ...axisStyle }]}
yAxis={[{ ...axisStyle }]}
series={[
{ data: recentGames.mines, label: 'Mines hit', color: MINES_COLOR },
{ data: recentGames.bonus, label: 'Bonus points', color: BONUS_COLOR },
]}
slotProps={{
legend: {
labelStyle: {
fill: 'rgba(255,255,255,0.55)',
fontSize: 13,
fontFamily: '\'Rajdhani\', sans-serif',
},
},
}}
borderRadius={3}
height={220}
margin={{ top: 10, bottom: 30, left: 40, right: 140 }}
/>
</div>
</div>
)}
</div>
</ThemeProvider>
);

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="576.095" height="593.844" viewBox="0 0 432.071 445.383"><g style="fill-rule:nonzero;clip-rule:nonzero;fill:none;stroke:#fff;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4"><path d="M323.205 324.227c2.833-23.601 1.984-27.062 19.563-23.239l4.463.392c13.517.615 31.199-2.174 41.587-7 22.362-10.376 35.622-27.7 13.572-23.148-50.297 10.376-53.755-6.655-53.755-6.655 53.111-78.803 75.313-178.836 56.149-203.322-52.27-66.789-142.748-35.206-144.262-34.386l-.482.089c-9.938-2.062-21.06-3.294-33.554-3.496-22.761-.374-40.032 5.967-53.133 15.904 0 0-161.408-66.498-153.899 83.628 1.597 31.936 45.777 241.655 98.47 178.31 19.259-23.163 37.871-42.748 37.871-42.748 9.242 6.14 20.307 9.272 31.912 8.147l.897-.765c-.281 2.876-.157 5.689.359 9.019-13.572 15.167-9.584 17.83-36.723 23.416-27.457 5.659-11.326 15.734-.797 18.367 12.768 3.193 42.305 7.716 62.268-20.224l-.795 3.188c5.325 4.26 4.965 30.619 5.72 49.452.756 18.834 2.017 36.409 5.856 46.771 3.839 10.36 8.369 37.05 44.036 29.406 29.809-6.388 52.6-15.582 54.677-101.107" style="fill:#000;stroke:#000;stroke-width:37.3953;stroke-linecap:butt;stroke-linejoin:miter"/><path stroke="none" d="M402.395 271.23c-50.302 10.376-53.76-6.655-53.76-6.655 53.111-78.808 75.313-178.843 56.153-203.326-52.27-66.785-142.752-35.2-144.262-34.38l-.486.087c-9.938-2.063-21.06-3.292-33.56-3.496-22.761-.373-40.026 5.967-53.127 15.902 0 0-161.411-66.495-153.904 83.63 1.597 31.938 45.776 241.657 98.471 178.312 19.26-23.163 37.869-42.748 37.869-42.748 9.243 6.14 20.308 9.272 31.908 8.147l.901-.765c-.28 2.876-.152 5.689.361 9.019-13.575 15.167-9.586 17.83-36.723 23.416-27.459 5.659-11.328 15.734-.796 18.367 12.768 3.193 42.307 7.716 62.266-20.224l-.796 3.188c5.319 4.26 9.054 27.711 8.428 48.969s-1.044 35.854 3.147 47.254 8.368 37.05 44.042 29.406c29.809-6.388 45.256-22.942 47.405-50.555 1.525-19.631 4.976-16.729 5.194-34.28l2.768-8.309c3.192-26.611.507-35.196 18.872-31.203l4.463.392c13.517.615 31.208-2.174 41.591-7 22.358-10.376 35.618-27.7 13.573-23.148z" style=""/><path d="M215.866 286.484c-1.385 49.516.348 99.377 5.193 111.495 4.848 12.118 15.223 35.688 50.9 28.045 29.806-6.39 40.651-18.756 45.357-46.051 3.466-20.082 10.148-75.854 11.005-87.281M173.104 38.256S11.583-27.76 19.092 122.365c1.597 31.938 45.779 241.664 98.473 178.316 19.256-23.166 36.671-41.335 36.671-41.335M260.349 26.207c-5.591 1.753 89.848-34.889 144.087 34.417 19.159 24.484-3.043 124.519-56.153 203.329"/><path d="M348.282 263.953s3.461 17.036 53.764 6.653c22.04-4.552 8.776 12.774-13.577 23.155-18.345 8.514-59.474 10.696-60.146-1.069-1.729-30.355 21.647-21.133 19.96-28.739-1.525-6.85-11.979-13.573-18.894-30.338-6.037-14.633-82.796-126.849 21.287-110.183 3.813-.789-27.146-99.002-124.553-100.599-97.385-1.597-94.19 119.762-94.19 119.762" style="stroke-linejoin:bevel"/><path d="M188.604 274.334c-13.577 15.166-9.584 17.829-36.723 23.417-27.459 5.66-11.326 15.733-.797 18.365 12.768 3.195 42.307 7.718 62.266-20.229 6.078-8.509-.036-22.086-8.385-25.547-4.034-1.671-9.428-3.765-16.361 3.994"/><path d="M187.715 274.069c-1.368-8.917 2.93-19.528 7.536-31.942 6.922-18.626 22.893-37.255 10.117-96.339-9.523-44.029-73.396-9.163-73.436-3.193-.039 5.968 2.889 30.26-1.067 58.548-5.162 36.913 23.488 68.132 56.479 64.938"/><path d="M172.517 141.7c-.288 2.039 3.733 7.48 8.976 8.207 5.234.73 9.714-3.522 9.998-5.559.284-2.039-3.732-4.285-8.977-5.015-5.237-.731-9.719.333-9.996 2.367z" style="fill:#fff;stroke-width:4.155;stroke-linecap:butt;stroke-linejoin:miter"/><path d="M331.941 137.543c.284 2.039-3.732 7.48-8.976 8.207-5.238.73-9.718-3.522-10.005-5.559-.277-2.039 3.74-4.285 8.979-5.015s9.718.333 10.002 2.368z" style="fill:#fff;stroke-width:2.0775;stroke-linecap:butt;stroke-linejoin:miter"/><path d="M350.676 123.432c.863 15.994-3.445 26.888-3.988 43.914-.804 24.748 11.799 53.074-7.191 81.435"/></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -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),
'minesHit' => $this->repo->findTotalMinesForUser($user),
'winRate' => $total > 0 ? (int)round($wins / $total * 100) : 0,
'avgScore' => $this->repo->findAvgScoreForUser($user),
'bestScore' => $this->repo->findBestScoreForUser($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 {
@@ -177,10 +183,34 @@ class ProfileController extends AbstractController
'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',

View File

@@ -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 {

View File

@@ -228,6 +228,9 @@
<a href="https://bun.sh" target="_blank" rel="noopener" class="tech-link">
<img src="{{ asset('images/technologies/bun.svg') }}" alt="Bun"/>
</a>
<a href="https://www.postgresql.org" target="_blank" rel="noopener" class="tech-link">
<img src="{{ asset('images/technologies/postgresql.svg') }}" alt="PostgreSQL"/>
</a>
<a href="https://www.jetbrains.com/phpstorm" target="_blank" rel="noopener" class="tech-link">
<img src="{{ asset('images/technologies/phpstorm.svg') }}" alt="PHPStorm"/>
</a>

View File

@@ -1,5 +1,10 @@
{% extends 'Game/index.html.twig' %}
{% macro stat_val(value, suffix) %}
{%- set abbr = value >= 1000 -%}
<span class="profile-stat__value"{% if abbr %} title="{{ value }}"{% endif %}>{% if abbr %}{{ (value / 1000)|round(1, 'floor') }}k{% else %}{{ value }}{% endif %}{% if suffix %}<small>{{ suffix }}</small>{% endif %}</span>
{% endmacro %}
{% block title %} - Profile{% endblock %}
{% block metas %}
@@ -45,44 +50,64 @@
<div class="profile-stats">
<div class="profile-stat">
<i class="fas fa-gamepad profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.total }}</span>
{{ _self.stat_val(stats.total) }}
<span class="profile-stat__label">Games played</span>
</div>
<div class="profile-stat profile-stat--win">
<i class="fas fa-trophy profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.wins }}</span>
{{ _self.stat_val(stats.wins) }}
<span class="profile-stat__label">Victories</span>
</div>
<div class="profile-stat profile-stat--loss">
<i class="fas fa-flag profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.losses }}</span>
{{ _self.stat_val(stats.losses) }}
<span class="profile-stat__label">Defeats</span>
</div>
<div class="profile-stat profile-stat--draw">
<i class="fas fa-minus profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.draws }}</span>
{{ _self.stat_val(stats.draws) }}
<span class="profile-stat__label">Draws</span>
</div>
<div class="profile-stat profile-stat--rate">
<i class="fas fa-percent profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.winRate }}<small>%</small></span>
{{ _self.stat_val(stats.winRate, '%') }}
<span class="profile-stat__label">Win rate</span>
</div>
<div class="profile-stat profile-stat--avg">
<i class="fas fa-chart-line profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.avgScore }}</span>
{{ _self.stat_val(stats.avgScore) }}
<span class="profile-stat__label">Avg score</span>
</div>
<div class="profile-stat profile-stat--best">
<i class="fas fa-star profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.bestScore }}</span>
<span class="profile-stat__label">Best score</span>
</div>
<div class="profile-stat profile-stat--bomb">
<i class="fas fa-bomb profile-stat__icon"></i>
<span class="profile-stat__value">{{ stats.bombs }}</span>
{{ _self.stat_val(stats.minesHit) }}
<span class="profile-stat__label">Mines hit</span>
</div>
<div class="profile-stat profile-stat--bonus">
<i class="fas fa-star profile-stat__icon"></i>
{{ _self.stat_val(stats.bonusPoints) }}
<span class="profile-stat__label">Bonus points</span>
</div>
<div class="profile-stat profile-stat--avg-bonus">
<i class="fas fa-chart-simple profile-stat__icon"></i>
{{ _self.stat_val(stats.avgBonus) }}
<span class="profile-stat__label">Avg bonus</span>
</div>
<div class="profile-stat profile-stat--chain">
<i class="fas fa-link profile-stat__icon"></i>
{{ _self.stat_val(stats.bestChain) }}
<span class="profile-stat__label">Best chain</span>
</div>
<div class="profile-stat profile-stat--blind">
<i class="fas fa-bullseye profile-stat__icon"></i>
{{ _self.stat_val(stats.blindHits) }}
<span class="profile-stat__label">Blind hits</span>
</div>
<div class="profile-stat profile-stat--edge">
<i class="fas fa-border-style profile-stat__icon"></i>
{{ _self.stat_val(stats.edgeMines) }}
<span class="profile-stat__label">Edge mines</span>
</div>
</div>
{% if stats.total > 0 %}