Private
Public Access
1
0

chg: dev: create the UserStats entity what is a Materialized View to store Profile stats for every user - & massive ProfileController refactor #8

This commit is contained in:
2026-04-20 20:44:33 +02:00
parent 6be0d52fb7
commit 6a5ba84b5e
13 changed files with 827 additions and 502 deletions

View File

@@ -14,6 +14,8 @@ doctrine:
enable_native_lazy_objects: true
naming_strategy: doctrine.orm.naming_strategy.underscore
auto_mapping: true
schema_ignore_classes:
- App\Entity\UserStats
mappings:
App:
is_bundle: false

View File

@@ -10,12 +10,18 @@
namespace App\Controller;
use App\Dto\BattleShareDto;
use App\Dto\ProfileChartDataFactory;
use App\Dto\ProfileGameDto;
use App\Dto\ProfileGameDtoFactory;
use App\Dto\ProfileStatsDto;
use App\Dto\ProfileViewDto;
use App\Entity\PlayedGame;
use App\Entity\User;
use App\Repository\PlayedGameRepository;
use App\Repository\UserStatsRepository;
use App\Service\BattleCardGenerator;
use App\Service\WebAuthnService;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
@@ -50,165 +56,41 @@ use function count;
class ProfileController extends AbstractController
{
public function __construct(
private readonly PlayedGameRepository $repo,
private readonly WebAuthnService $webAuthnService,
private readonly LoggerInterface $logger,
private readonly LoggerInterface $logger,
private readonly PlayedGameRepository $repo,
private readonly UserStatsRepository $userStatsRepo,
private readonly WebAuthnService $webAuthnService,
private readonly ProfileGameDtoFactory $profileGameDtoFactory,
private readonly ProfileChartDataFactory $profileChartDataFactory,
) {
}
#[Route('/profile', name: 'MineSeekerBundle_profile')]
public function index(CacheManager $cacheManager): Response
public function index(): Response
{
/** @var User $user */
$user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$total = $this->repo->countFinishedForUser($user);
$wins = $this->repo->countWinsForUser($user);
$losses = $this->repo->countLossesForUser($user);
$draws = $this->repo->countDrawsForUser($user);
/** Build monthly buckets for the last 6 months */
$monthlyData = [];
for ($i = 5; $i >= 0; $i--) {
$dt = new DateTime("first day of -$i months midnight");
$key = $dt->format('Y-m');
$monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0];
}
$since = new DateTime('first day of -5 months midnight');
$recentGames = $this->repo->findFinishedForUserSince($user, $since);
$userId = $user->id;
$stats = ProfileStatsDto::fromUserStats($this->userStatsRepo->findByUserId($userId));
$recent = $this->repo->findRecentFinishedForUser($user, 30);
foreach ($recentGames as $game) {
if (!$game->updated) {
continue;
}
$gamesData = array_map(
fn(PlayedGame $game): ProfileGameDto => $this->profileGameDtoFactory->create($game, $userId),
$recent,
);
$month = $game->updated->format('Y-m');
if (!isset($monthlyData[$month])) {
continue;
}
$chartData = $this->profileChartDataFactory->buildChartData($user, $userId, $stats);
$isRed = $game->red?->id === $userId;
$myPts = $isRed ? $game->redPoints : $game->bluePoints;
$oppPts = $isRed ? $game->bluePoints : $game->redPoints;
$resign = $game->resign;
$myColor = $isRed ? 'red' : 'blue';
$oppColor = $isRed ? 'blue' : 'red';
$view = new ProfileViewDto(
stats: $stats,
recent: $recent,
gamesData: $gamesData,
chartData: $chartData,
);
$result = 'draws';
if ($resign === $myColor) {
$result = 'losses';
} elseif ($resign === $oppColor) {
$result = 'wins';
} elseif ($myPts !== null && $oppPts !== null) {
if ($myPts > $oppPts) $result = 'wins';
elseif ($myPts < $oppPts) $result = 'losses';
}
$monthlyData[$month][$result]++;
}
$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,
'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, 30)),
'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array {
$isRed = $game->red?->id === $userId;
$resign = $game->resign;
$myColor = $isRed ? 'red' : 'blue';
$oppColor = $isRed ? 'blue' : 'red';
$myPts = $isRed ? $game->redPoints : $game->bluePoints;
$oppPts = $isRed ? $game->bluePoints : $game->redPoints;
$result = 'draw';
if ($resign === $myColor) $result = 'loss';
elseif ($resign === $oppColor) $result = 'win';
elseif ($myPts !== null && $oppPts !== null) {
if ($myPts > $oppPts) $result = 'win';
elseif ($myPts < $oppPts) $result = 'loss';
}
$redAvatarPath = $game->red?->avatarPath;
$blueAvatarPath = $game->blue?->avatarPath;
return [
'id' => $game->id,
'uuid' => $game->uuid?->toRfc4122(),
'redName' =>
$game->red?->getUsername() ?? $game->redAnon?->userName ?? 'Guest',
'blueName' =>
$game->blue?->getUsername() ?? $game->blueAnon?->userName ?? 'Guest',
'redAvatar' => $redAvatarPath ? $cacheManager->generateUrl($redAvatarPath, 'avatar_thumb') : null,
'blueAvatar' => $blueAvatarPath ? $cacheManager->generateUrl($blueAvatarPath, 'avatar_thumb') : null,
'redPoints' => $game->redPoints,
'bluePoints' => $game->bluePoints,
'redExplodedBomb' => $game->redExplodedBomb,
'blueExplodedBomb' => $game->blueExplodedBomb,
'resign' => $resign,
'created' => $game->created?->format('Y-m-d H:i'),
'date' => $game->updated?->format('Y-m-d H:i'),
'isRed' => $isRed,
'result' => $result,
'myPoints' => $myPts,
'oppPoints' => $oppPts,
'redBonusPoints' => $game->redBonusPoints ?? 0,
'blueBonusPoints' => $game->blueBonusPoints ?? 0,
'redBonusStats' => $game->redBonusStats ?? [],
'blueBonusStats' => $game->blueBonusStats ?? [],
];
}, $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,
'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->red?->id === $userId;
$labels[] = '#' . ($i + 1);
$mines[] = (int)($isRed ? $game->redPoints : $game->bluePoints);
$bonus[] = (float)($isRed ? $game->redBonusPoints : $game->blueBonusPoints) ?: 0;
}
return ['labels' => $labels, 'mines' => $mines, 'bonus' => $bonus];
return $this->render('Security/profile.html.twig', $view->toTemplateContext());
}
#[Route(
@@ -219,55 +101,13 @@ class ProfileController extends AbstractController
)]
public function battleShare(Uuid $uuid): Response
{
$game = $this->repo->findOneBy(['uuid' => $uuid]);
$game = $this->repo->findOneByUuid($uuid);
if (!$game) {
throw $this->createNotFoundException('Battle not found.');
}
$redName = $game->red?->getUsername() ?? ($game->redAnon !== null ? 'Anonymous' : 'Guest');
$blueName = $game->blue?->getUsername() ?? ($game->blueAnon !== null ? 'Anonymous' : 'Guest');
$redPts = $game->redPoints;
$bluePts = $game->bluePoints;
$resign = $game->resign;
$redAvatar = $game->red?->avatarPath;
$blueAvatar = $game->blue?->avatarPath;
$redBonusPoints = $game->redBonusPoints ?? 0;
$blueBonusPoints = $game->blueBonusPoints ?? 0;
$redBonusStats = $game->redBonusStats ?? [];
$blueBonusStats = $game->blueBonusStats ?? [];
if ($resign === 'red') {
$summary = "$redName resigned — $blueName wins";
} elseif ($resign === 'blue') {
$summary = "$blueName resigned — $redName wins";
} elseif ($redPts !== null && $bluePts !== null) {
if ($redPts > $bluePts) {
$summary = "$redName defeated $blueName ($redPts $bluePts)";
} elseif ($bluePts > $redPts) {
$summary = "$blueName defeated $redName ($bluePts $redPts)";
} else {
$summary = "$redName and $blueName drew ($redPts $bluePts)";
}
} else {
$summary = "$redName vs $blueName";
}
return $this->render('Game/battle_share.html.twig', [
'game' => $game,
'redName' => $redName,
'blueName' => $blueName,
'redPts' => $redPts,
'bluePts' => $bluePts,
'resign' => $resign,
'redAvatar' => $redAvatar,
'blueAvatar' => $blueAvatar,
'redBonusPoints' => $redBonusPoints,
'blueBonusPoints' => $blueBonusPoints,
'redBonusStats' => $redBonusStats,
'blueBonusStats' => $blueBonusStats,
'ogTitle' => "MineSeeker · $summary",
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
]);
return $this->render('Game/battle_share.html.twig', BattleShareDto::fromPlayedGame($game)->toTemplateContext());
}
#[Route(
@@ -306,6 +146,7 @@ class ProfileController extends AbstractController
$user = $this->getUser();
$file = $request->files->get('avatar');
if (!$file instanceof UploadedFile) {
return $this->json(['error' => 'No file uploaded.'], 400);
}

105
src/Dto/BattleShareDto.php Normal file
View File

@@ -0,0 +1,105 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Dto;
use App\Entity\PlayedGame;
/**
* Class BattleShareDto
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final readonly class BattleShareDto
{
public function __construct(
public PlayedGame $game,
public string $redName,
public string $blueName,
public ?int $redPts,
public ?int $bluePts,
public ?string $resign,
public ?string $redAvatar,
public ?string $blueAvatar,
public float $redBonusPoints,
public float $blueBonusPoints,
public array $redBonusStats,
public array $blueBonusStats,
public string $ogTitle,
public string $ogDesc,
) {
}
public static function fromPlayedGame(PlayedGame $game): self
{
$redName = $game->red?->getUsername() ?? ($game->redAnon !== null ? 'Anonymous' : 'Guest');
$blueName = $game->blue?->getUsername() ?? ($game->blueAnon !== null ? 'Anonymous' : 'Guest');
$redPts = $game->redPoints;
$bluePts = $game->bluePoints;
$resign = $game->resign;
$summary = self::buildSummary($redName, $blueName, $redPts, $bluePts, $resign);
return new self(
game: $game,
redName: $redName,
blueName: $blueName,
redPts: $redPts,
bluePts: $bluePts,
resign: $resign,
redAvatar: $game->red?->avatarPath,
blueAvatar: $game->blue?->avatarPath,
redBonusPoints: (float)($game->redBonusPoints ?? 0),
blueBonusPoints: (float)($game->blueBonusPoints ?? 0),
redBonusStats: $game->redBonusStats ?? [],
blueBonusStats: $game->blueBonusStats ?? [],
ogTitle: "MineSeeker · $summary",
ogDesc: "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
);
}
private static function buildSummary(
string $redName,
string $blueName,
?int $redPts,
?int $bluePts,
?string $resign,
): string {
if ($resign === 'red') {
return "$redName resigned — $blueName wins";
}
if ($resign === 'blue') {
return "$blueName resigned — $redName wins";
}
if ($redPts !== null && $bluePts !== null) {
if ($redPts > $bluePts) {
return "$redName defeated $blueName ($redPts $bluePts)";
}
if ($bluePts > $redPts) {
return "$blueName defeated $redName ($bluePts $redPts)";
}
return "$redName and $blueName drew ($redPts $bluePts)";
}
return "$redName vs $blueName";
}
public function toTemplateContext(): array
{
return get_object_vars($this);
}
}

View File

@@ -0,0 +1,43 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Dto;
use JsonSerializable;
/**
* Class ProfileChartDataDto
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final readonly class ProfileChartDataDto implements JsonSerializable
{
public function __construct(
public array $months,
public array $wins,
public array $losses,
public array $draws,
public int $pieWins,
public int $pieLosses,
public int $pieDraws,
public array $recentGames,
) {
}
public function jsonSerialize(): array
{
return get_object_vars($this);
}
}

View File

@@ -0,0 +1,111 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Dto;
use App\Entity\User;
use App\Repository\PlayedGameRepository;
use DateTime;
/**
* Class ProfileChartDataFactory
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
readonly class ProfileChartDataFactory
{
public function __construct(private PlayedGameRepository $repo) { }
public function buildChartData(User $user, int $userId, ProfileStatsDto $stats): ProfileChartDataDto
{
$monthlyData = $this->buildMonthlyData($user, $userId);
return new ProfileChartDataDto(
months: array_column(array_values($monthlyData), 'label'),
wins: array_column(array_values($monthlyData), 'wins'),
losses: array_column(array_values($monthlyData), 'losses'),
draws: array_column(array_values($monthlyData), 'draws'),
pieWins: $stats->wins,
pieLosses: $stats->losses,
pieDraws: $stats->draws,
recentGames: $this->buildRecentGamesSeries($user, $userId),
);
}
private function buildMonthlyData(User $user, int $userId): array
{
$monthlyData = [];
for ($i = 5; $i >= 0; $i--) {
$dt = new DateTime("first day of -$i months midnight");
$key = $dt->format('Y-m');
$monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0];
}
$since = new DateTime('first day of -5 months midnight');
$recentGames = $this->repo->findFinishedForUserSince($user, $since);
foreach ($recentGames as $game) {
if (!$game->updated) {
continue;
}
$month = $game->updated->format('Y-m');
if (!isset($monthlyData[$month])) {
continue;
}
$isRed = $game->red?->id === $userId;
$myPts = $isRed ? $game->redPoints : $game->bluePoints;
$oppPts = $isRed ? $game->bluePoints : $game->redPoints;
$resign = $game->resign;
$myColor = $isRed ? 'red' : 'blue';
$oppColor = $isRed ? 'blue' : 'red';
$result = 'draws';
if ($resign === $myColor) {
$result = 'losses';
} elseif ($resign === $oppColor) {
$result = 'wins';
} elseif ($myPts !== null && $oppPts !== null) {
if ($myPts > $oppPts) $result = 'wins';
elseif ($myPts < $oppPts) $result = 'losses';
}
$monthlyData[$month][$result]++;
}
return $monthlyData;
}
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->red?->id === $userId;
$labels[] = '#' . ($i + 1);
$mines[] = (int)($isRed ? $game->redPoints : $game->bluePoints);
$bonus[] = (float)($isRed ? $game->redBonusPoints : $game->blueBonusPoints) ?: 0;
}
return ['labels' => $labels, 'mines' => $mines, 'bonus' => $bonus];
}
}

View File

@@ -0,0 +1,56 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Dto;
use JsonSerializable;
/**
* Class ProfileGameDto
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final readonly class ProfileGameDto implements JsonSerializable
{
public function __construct(
public ?int $id,
public ?string $uuid,
public string $redName,
public string $blueName,
public ?string $redAvatar,
public ?string $blueAvatar,
public ?int $redPoints,
public ?int $bluePoints,
public ?bool $redExplodedBomb,
public ?bool $blueExplodedBomb,
public ?string $resign,
public ?string $created,
public ?string $date,
public bool $isRed,
public string $result,
public ?int $myPoints,
public ?int $oppPoints,
public float $redBonusPoints,
public float $blueBonusPoints,
public array $redBonusStats,
public array $blueBonusStats,
) {
}
public function jsonSerialize(): array
{
return get_object_vars($this);
}
}

View File

@@ -0,0 +1,94 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Dto;
use App\Entity\PlayedGame;
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
/**
* Class ProfileGameDtoFactory
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final readonly class ProfileGameDtoFactory
{
public function __construct(private CacheManager $cacheManager) { }
public function create(PlayedGame $game, int $userId): ProfileGameDto
{
$isRed = $game->red?->id === $userId;
$resign = $game->resign;
$myColor = $isRed ? 'red' : 'blue';
$oppColor = $isRed ? 'blue' : 'red';
$myPts = $isRed ? $game->redPoints : $game->bluePoints;
$oppPts = $isRed ? $game->bluePoints : $game->redPoints;
$redAvatarPath = $game->red?->avatarPath;
$blueAvatarPath = $game->blue?->avatarPath;
return new ProfileGameDto(
id: $game->id,
uuid: $game->uuid?->toRfc4122(),
redName: $game->red?->getUsername() ?? $game->redAnon?->userName ?? 'Guest',
blueName: $game->blue?->getUsername() ?? $game->blueAnon?->userName ?? 'Guest',
redAvatar: $redAvatarPath ? $this->cacheManager->generateUrl($redAvatarPath, 'avatar_thumb') : null,
blueAvatar: $blueAvatarPath ? $this->cacheManager->generateUrl($blueAvatarPath, 'avatar_thumb') : null,
redPoints: $game->redPoints,
bluePoints: $game->bluePoints,
redExplodedBomb: $game->redExplodedBomb,
blueExplodedBomb: $game->blueExplodedBomb,
resign: $resign,
created: $game->created?->format('Y-m-d H:i'),
date: $game->updated?->format('Y-m-d H:i'),
isRed: $isRed,
result: $this->resolveResult($resign, $myColor, $oppColor, $myPts, $oppPts),
myPoints: $myPts,
oppPoints: $oppPts,
redBonusPoints: $game->redBonusPoints ?? 0.0,
blueBonusPoints: $game->blueBonusPoints ?? 0.0,
redBonusStats: $game->redBonusStats ?? [],
blueBonusStats: $game->blueBonusStats ?? [],
);
}
private function resolveResult(
?string $resign,
string $myColor,
string $oppColor,
?int $myPts,
?int $oppPts,
): string {
if ($resign === $myColor) {
return 'loss';
}
if ($resign === $oppColor) {
return 'win';
}
if ($myPts !== null && $oppPts !== null) {
if ($myPts > $oppPts) {
return 'win';
}
if ($myPts < $oppPts) {
return 'loss';
}
}
return 'draw';
}
}

View File

@@ -0,0 +1,82 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Dto;
use App\Entity\UserStats;
/**
* Class ProfileStatsDto
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final readonly class ProfileStatsDto
{
public function __construct(
public int $total,
public int $wins,
public int $losses,
public int $draws,
public int $minesHit,
public int $winRate,
public int $avgScore,
public float $bonusPoints,
public float $avgBonus,
public int $bestChain,
public int $blindHits,
public int $edgeMines,
) {
}
public static function fromUserStats(?UserStats $stats): self
{
if ($stats === null) {
return self::empty();
}
return new self(
total: $stats->totalGames,
wins: $stats->wins,
losses: $stats->losses,
draws: $stats->draws,
minesHit: $stats->totalMines,
winRate: $stats->getWinRate(),
avgScore: $stats->getAvgScore(),
bonusPoints: (float)$stats->totalBonusPoints,
avgBonus: (float)$stats->avgBonus,
bestChain: $stats->bestChain,
blindHits: $stats->blindHits,
edgeMines: $stats->edgeMines,
);
}
public static function empty(): self
{
return new self(
total: 0,
wins: 0,
losses: 0,
draws: 0,
minesHit: 0,
winRate: 0,
avgScore: 0,
bonusPoints: 0.0,
avgBonus: 0.0,
bestChain: 0,
blindHits: 0,
edgeMines: 0,
);
}
}

View File

@@ -0,0 +1,37 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Dto;
/**
* Class ProfileViewDto
*
* @package App\Dto
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final readonly class ProfileViewDto
{
public function __construct(
public ProfileStatsDto $stats,
public array $recent,
public array $gamesData,
public ProfileChartDataDto $chartData,
) {
}
public function toTemplateContext(): array
{
return get_object_vars($this);
}
}

89
src/Entity/UserStats.php Normal file
View File

@@ -0,0 +1,89 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use App\Repository\UserStatsRepository;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Table;
/**
* Class UserStats
*
* Read-only entity mapped to the user_stats materialized view.
*
* @package App\Entity
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
#[Entity(repositoryClass: UserStatsRepository::class, readOnly: true), Table]
class UserStats
{
#[Id, Column(name: 'user_id')]
public int $userId = 0;
#[Column]
public int $totalGames = 0;
#[Column]
public int $wins = 0;
#[Column]
public int $losses = 0;
#[Column]
public int $draws = 0;
#[Column]
public int $totalMines = 0;
#[Column(type: Types::DECIMAL, precision: 10, scale: 1)]
public string $totalBonusPoints = '0.0';
#[Column(type: Types::DECIMAL, precision: 10, scale: 1)]
public string $avgBonus = '0.0';
#[Column]
public int $bestChain = 0;
#[Column]
public int $blindHits = 0;
#[Column]
public int $edgeMines = 0;
#[Column]
public int $gamesWithScores = 0;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
public ?DateTime $lastGameAt = null;
public function getWinRate(): int
{
return $this->gamesWithScores > 0
? (int)round($this->wins / $this->gamesWithScores * 100)
: 0;
}
public function getAvgScore(): int
{
return $this->gamesWithScores > 0
? (int)round($this->totalMines / $this->gamesWithScores)
: 0;
}
}

View File

@@ -0,0 +1,117 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260420120000
*
* Creates user_stats materialized view for ProfileController optimization.
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
final class Version20260420120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create user_stats materialized view for profile statistics.';
}
public function up(Schema $schema): void
{
$this->addSql('
CREATE MATERIALIZED VIEW user_stats AS
SELECT
u.id AS user_id,
COUNT(CASE WHEN pg.red_id = u.id OR pg.blue_id = u.id THEN 1 END) AS total_games,
COUNT(CASE WHEN
pg.red_id = u.id AND (pg.red_points > pg.blue_points AND pg.resign IS NULL)
OR pg.blue_id = u.id AND (pg.blue_points > pg.red_points AND pg.resign IS NULL)
OR pg.red_id = u.id AND pg.resign = \'blue\'
OR pg.blue_id = u.id AND pg.resign = \'red\'
THEN 1 END) AS wins,
COUNT(CASE WHEN
pg.red_id = u.id AND (pg.blue_points > pg.red_points AND pg.resign IS NULL)
OR pg.blue_id = u.id AND (pg.red_points > pg.blue_points AND pg.resign IS NULL)
OR pg.red_id = u.id AND pg.resign = \'red\'
OR pg.blue_id = u.id AND pg.resign = \'blue\'
THEN 1 END) AS losses,
COUNT(CASE WHEN
(pg.red_id = u.id OR pg.blue_id = u.id)
AND pg.red_points IS NOT NULL
AND pg.blue_points IS NOT NULL
AND pg.resign IS NULL
AND pg.red_points = pg.blue_points
THEN 1 END) AS draws,
COALESCE(SUM(
CASE WHEN pg.red_id = u.id THEN pg.red_points ELSE pg.blue_points END
), 0) AS total_mines,
COALESCE(SUM(
CASE WHEN pg.red_id = u.id THEN pg.red_bonus_points ELSE pg.blue_bonus_points END
), 0)::numeric(10,1) AS total_bonus_points,
COALESCE(AVG(
CASE WHEN pg.red_id = u.id THEN pg.red_bonus_points ELSE pg.blue_bonus_points END
), 0)::numeric(10,1) AS avg_bonus,
COALESCE(MAX(
CASE WHEN pg.red_id = u.id THEN (pg.red_bonus_stats->>\'chainBest\')::int ELSE (pg.blue_bonus_stats->>\'chainBest\')::int END
), 0) AS best_chain,
COALESCE(SUM(
CASE WHEN pg.red_id = u.id THEN (pg.red_bonus_stats->>\'blindHits\')::int ELSE (pg.blue_bonus_stats->>\'blindHits\')::int END
), 0) AS blind_hits,
COALESCE(SUM(
CASE WHEN pg.red_id = u.id THEN (pg.red_bonus_stats->>\'edgeMines\')::int ELSE (pg.blue_bonus_stats->>\'edgeMines\')::int END
), 0) AS edge_mines,
COUNT(CASE WHEN
pg.red_id = u.id AND pg.red_points IS NOT NULL
OR pg.blue_id = u.id AND pg.blue_points IS NOT NULL
THEN 1 END) AS games_with_scores,
MAX(pg.updated) AS last_game_at
FROM app_user u
LEFT JOIN played_game pg ON (pg.red_id = u.id OR pg.blue_id = u.id)
AND (pg.red_points IS NOT NULL OR pg.blue_points IS NOT NULL OR pg.resign IS NOT NULL)
GROUP BY u.id
');
$this->addSql('CREATE UNIQUE INDEX idx_user_stats_user_id ON user_stats (user_id)');
$this->addSql('CREATE INDEX idx_user_stats_last_game_at ON user_stats (last_game_at DESC)');
$this->addSql('
CREATE OR REPLACE FUNCTION refresh_user_stats()
RETURNS TRIGGER AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY user_stats;
RETURN NULL;
END;
$$ LANGUAGE plpgsql
');
$this->addSql('
CREATE TRIGGER trigger_refresh_user_stats
AFTER INSERT OR UPDATE OR DELETE ON played_game
FOR EACH STATEMENT
EXECUTE FUNCTION refresh_user_stats()
');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TRIGGER IF EXISTS trigger_refresh_user_stats ON played_game');
$this->addSql('DROP FUNCTION IF EXISTS refresh_user_stats()');
$this->addSql('DROP MATERIALIZED VIEW IF EXISTS user_stats');
}
}

View File

@@ -14,12 +14,7 @@ use App\Entity\PlayedGame;
use App\Entity\User;
use DateTime;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use RuntimeException;
/**
* Class PlayedGameRepository
@@ -36,319 +31,15 @@ use RuntimeException;
* @method PlayedGame[] findAll()
* @method PlayedGame[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
* @method PlayedGame|null findOneByGameAssoc(mixed $gameAssoc)
* @method PlayedGame|null findOneByUuid(\Symfony\Component\Uid\Uuid $uuid)
*/
class PlayedGameRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry, private readonly LoggerInterface $logger)
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PlayedGame::class);
}
public function countFinishedForUser(User $user): int
{
$qb = $this->createQueryBuilder('g');
try {
return (int)$qb
->select('COUNT(g.id)')
->where($qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->eq('g.red', ':u'),
$qb->expr()->eq('g.blue', ':u'),
),
$qb->expr()->orX(
$qb->expr()->isNotNull('g.redPoints'),
$qb->expr()->isNotNull('g.resign'),
),
))
->setParameter('u', $user)
->getQuery()
->getSingleScalarResult();
} catch (NoResultException $e) {
$this->logger->error($e->getMessage());
throw new RuntimeException(
"Unexpectedly no result found when counting finished games for user: {$user->getUsername()}",
0,
$e,
);
} catch (NonUniqueResultException $e) {
$this->logger->error($e->getMessage());
throw new RuntimeException(
"Unexpectedly multiple results found when counting finished games for user: {$user->getUsername()}",
0,
$e,
);
}
}
public function countWinsForUser(User $user): int
{
$qb = $this->createQueryBuilder('g');
try {
return (int)$qb
->select('COUNT(g.id)')
->where($qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->eq('g.red', ':u'),
$qb->expr()->gt('g.redPoints', 'g.bluePoints'),
$qb->expr()->isNull('g.resign'),
),
$qb->expr()->andX(
$qb->expr()->eq('g.blue', ':u'),
$qb->expr()->gt('g.bluePoints', 'g.redPoints'),
$qb->expr()->isNull('g.resign'),
),
$qb->expr()->andX(
$qb->expr()->eq('g.red', ':u'),
$qb->expr()->eq('g.resign', ':blue'),
),
$qb->expr()->andX(
$qb->expr()->eq('g.blue', ':u'),
$qb->expr()->eq('g.resign', ':red'),
),
))
->setParameter('u', $user)
->setParameter('blue', 'blue')
->setParameter('red', 'red')
->getQuery()
->getSingleScalarResult();
} catch (NoResultException $e) {
$this->logger->error($e->getMessage());
throw new RuntimeException(
"Unexpectedly no result found when counting wins for user: {$user->getUsername()}",
0,
$e,
);
} catch (NonUniqueResultException $e) {
$this->logger->error($e->getMessage());
throw new RuntimeException(
"Unexpectedly multiple results found when counting wins for user: {$user->getUsername()}",
0,
$e,
);
}
}
public function countLossesForUser(User $user): int
{
$qb = $this->createQueryBuilder('g');
try {
return (int)$qb
->select('COUNT(g.id)')
->where($qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->eq('g.red', ':u'),
$qb->expr()->gt('g.bluePoints', 'g.redPoints'),
$qb->expr()->isNull('g.resign'),
),
$qb->expr()->andX(
$qb->expr()->eq('g.blue', ':u'),
$qb->expr()->gt('g.redPoints', 'g.bluePoints'),
$qb->expr()->isNull('g.resign'),
),
$qb->expr()->andX(
$qb->expr()->eq('g.red', ':u'),
$qb->expr()->eq('g.resign', ':red'),
),
$qb->expr()->andX(
$qb->expr()->eq('g.blue', ':u'),
$qb->expr()->eq('g.resign', ':blue'),
),
))
->setParameter('u', $user)
->setParameter('red', 'red')
->setParameter('blue', 'blue')
->getQuery()
->getSingleScalarResult();
} catch (NoResultException $e) {
$this->logger->error($e->getMessage());
throw new RuntimeException(
"Unexpectedly no result found when counting losses for user: {$user->getUsername()}",
0,
$e,
);
} catch (NonUniqueResultException $e) {
$this->logger->error($e->getMessage());
throw new RuntimeException(
"Unexpectedly multiple results found when counting losses for user: {$user->getUsername()}",
0,
$e,
);
}
}
public function countBombsForUser(User $user): int
{
$qb = $this->createQueryBuilder('g');
try {
return (int)$qb
->select('COUNT(g.id)')
->where($qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->eq('g.red', ':u'),
$qb->expr()->eq('g.redExplodedBomb', ':true'),
),
$qb->expr()->andX(
$qb->expr()->eq('g.blue', ':u'),
$qb->expr()->eq('g.blueExplodedBomb', ':true'),
),
))
->setParameter('true', true, Types::BOOLEAN)
->setParameter('u', $user)
->getQuery()
->getSingleScalarResult();
} catch (NoResultException $e) {
$this->logger->error($e->getMessage());
throw new RuntimeException(
"Unexpectedly no result found when counting bombs for user: {$user->getUsername()}",
0,
$e,
);
} catch (NonUniqueResultException $e) {
$this->logger->error($e->getMessage());
throw new RuntimeException(
"Unexpectedly multiple results found when counting bombs for user: {$user->getUsername()}",
0,
$e,
);
}
}
public function countDrawsForUser(User $user): int
{
$qb = $this->createQueryBuilder('g');
return (int)$qb
->select('COUNT(g.id)')
->where($qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->eq('g.red', ':u'),
$qb->expr()->eq('g.blue', ':u'),
),
$qb->expr()->isNotNull('g.redPoints'),
$qb->expr()->isNotNull('g.bluePoints'),
$qb->expr()->isNull('g.resign'),
'g.redPoints = g.bluePoints',
))
->setParameter('u', $user)
->getQuery()
->getSingleScalarResult();
}
public function findTotalMinesForUser(User $user): int
{
$qb = $this->createQueryBuilder('g');
return (int)$qb
->select('COALESCE(SUM(CASE WHEN g.red = :u THEN g.redPoints ELSE g.bluePoints END), 0)')
->where($qb->expr()->orX(
$qb->expr()->eq('g.red', ':u'),
$qb->expr()->eq('g.blue', ':u'),
))
->setParameter('u', $user)
->getQuery()
->getSingleScalarResult();
}
public function findAvgScoreForUser(User $user): int
{
$qb = $this->createQueryBuilder('g');
/** @var array{totalPts: int|string|null, totalGames: int|string} $row */
$row = $qb
->select('SUM(CASE WHEN g.red = :u THEN g.redPoints ELSE g.bluePoints END) AS totalPts')
->addSelect('COUNT(g.id) AS totalGames')
->where($qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->eq('g.red', ':u'),
$qb->expr()->isNotNull('g.redPoints'),
),
$qb->expr()->andX(
$qb->expr()->eq('g.blue', ':u'),
$qb->expr()->isNotNull('g.bluePoints'),
),
))
->setParameter('u', $user)
->getQuery()
->getSingleResult();
if ((int)$row['totalGames'] === 0) {
return 0;
}
return (int)round((float)$row['totalPts'] / (int)$row['totalGames']);
}
/**
* Aggregates bonus points and bonus stats across all finished games for a user.
*/
public function findBonusStatsForUser(User $user): array
{
$userId = $user->id;
$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->red?->id === $userId;
$totalBonusPoints += (float)(($isRed ? $game->redBonusPoints : $game->blueBonusPoints) ?? 0.0);
$stats = ($isRed ? $game->redBonusStats : $game->blueBonusStats) ?? [];
$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 {
$qbRed = $this->createQueryBuilder('g');
$maxRed = (int)$qbRed
->select('MAX(g.redPoints)')
->where($qbRed->expr()->eq('g.red', ':u'))
->setParameter('u', $user)
->getQuery()
->getSingleScalarResult();
$qbBlue = $this->createQueryBuilder('g');
$maxBlue = (int)$qbBlue
->select('MAX(g.bluePoints)')
->where($qbBlue->expr()->eq('g.blue', ':u'))
->setParameter('u', $user)
->getQuery()
->getSingleScalarResult();
return max($maxRed, $maxBlue);
} catch (NoResultException|NonUniqueResultException $e) {
$this->logger->error($e->getMessage());
return 0;
}
}
public function findFinishedForUserSince(User $user, DateTime $since): array
{
$qb = $this->createQueryBuilder('g');

View File

@@ -0,0 +1,57 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository;
use App\Entity\UserStats;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Exception;
use Doctrine\Persistence\ManagerRegistry;
use RuntimeException;
/**
* Class UserStatsRepository
*
* @package App\Repository
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*
* @method UserStats|null find($id, $lockMode = null, $lockVersion = null)
* @method UserStats|null findOneBy(array $criteria, array $orderBy = null)
* @method UserStats[] findAll()
* @method UserStats[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class UserStatsRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserStats::class);
}
public function findByUserId(int $userId): ?UserStats
{
return $this->find($userId);
}
public function refreshMaterializedView(): void
{
try {
$this
->getEntityManager()
->getConnection()
->executeStatement('REFRESH MATERIALIZED VIEW CONCURRENTLY user_stats');
} catch (Exception $e) {
throw new RuntimeException("Failed to refresh materialized view: {$e->getMessage()}", 0, $e);
}
}
}