Skip to content
This repository was archived by the owner on Oct 15, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions assets/styles/app.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@use "sass:list";
@use "sass:map";
@import "../../vendor/twbs/bootstrap/scss/functions";

// Variables
Expand Down Expand Up @@ -35,8 +36,18 @@ html {
align-content: center;
}

.w-40 {
width: 40%;
.app-login {
@extend .container-sm, .v-center;

width: 75%;

@media (min-width: map.get($container-max-widths, "lg")) {
width: 60%;
}

@media (min-width: map.get($container-max-widths, "xl")) {
width: 40%;
}
}

// components
Expand Down
1 change: 1 addition & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ services:
- "../src/Entity/"
- "../src/Kernel.php"
- "../src/Service/Processes/"
- "../src/Service/Types/"
- "../src/Twig/Components/Challenge/EventConstant.php"

# add more service definitions when explicit configuration is needed
Expand Down
3 changes: 2 additions & 1 deletion src/Controller/OverviewCardsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,10 @@ public function eventDailyChart(
*/
#[Route('/leaderboard', name: 'leaderboard')]
public function leaderboard(
#[CurrentUser] User $user,
SolutionEventRepository $solutionEventRepository,
): Response {
$leaderboard = $solutionEventRepository->listLeaderboard('7 days');
$leaderboard = $solutionEventRepository->listLeaderboard($user->getGroup(), '7 days');

return $this->render('overview/cards/leaderboard.html.twig', [
'leaderboard' => $leaderboard,
Expand Down
11 changes: 8 additions & 3 deletions src/Controller/QuestionsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@

namespace App\Controller;

use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;

class QuestionsController extends AbstractController
{
#[Route('/questions', name: 'app_questions')]
public function index(): Response
{
return $this->render('questions/index.html.twig');
public function index(
#[CurrentUser] User $currentUser,
): Response {
return $this->render('questions/index.html.twig', [
'currentUser' => $currentUser,
]);
}
}
38 changes: 0 additions & 38 deletions src/Entity/Question.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,44 +176,6 @@ public function setSolutionVideo(?string $solution_video): static
return $this;
}

/**
* Get the pass rate of the question.
*
* @return float the pass rate of the question
*/
public function getPassRate(): float
{
$totalAttemptCount = $this->getTotalAttemptCount();
if (0 === $totalAttemptCount) {
return 0;
}

return round($this->getTotalSolvedCount() / $totalAttemptCount * 100, 2);
}

/**
* Get the total number of attempts made on the question.
*
* @return int the total number of attempts made on the question
*/
public function getTotalAttemptCount(): int
{
return $this->getSolutionEvents()->count();
}

/**
* Get the total number of times the question has been solved.
*
* @return int the total number of times the question has been solved
*/
public function getTotalSolvedCount(): int
{
return $this->getSolutionEvents()
->filter(
fn (SolutionEvent $solutionEvent) => SolutionEventStatus::Passed === $solutionEvent->getStatus()
)->count();
}

/**
* @return Collection<int, SolutionEvent>
*/
Expand Down
3 changes: 2 additions & 1 deletion src/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private array $roles = [];

/**
* @var string The hashed password
* @var ?string The hashed password.
* It usually contains a value.
*/
#[ORM\Column]
private ?string $password = null;
Expand Down
44 changes: 42 additions & 2 deletions src/Repository/SolutionEventRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Repository;

use App\Entity\Group;
use App\Entity\Question;
use App\Entity\SolutionEvent;
use App\Entity\SolutionEventStatus;
Expand Down Expand Up @@ -137,11 +138,12 @@ public function getSolveState(Question $question, User $user): ?SolutionEventSta
/**
* List the users leaderboard by the number of questions they have solved.
*
* @param string $interval The interval to count the leaderboard
* @param Group|null $group the group to filter the attempts by (null = no group)
* @param string $interval The interval to count the leaderboard
*
* @return list<array{user: User, count: int}> The leaderboard
*/
public function listLeaderboard(string $interval): array
public function listLeaderboard(?Group $group, string $interval): array
{
$startedFrom = new \DateTimeImmutable("-$interval");

Expand All @@ -156,6 +158,14 @@ public function listLeaderboard(string $interval): array
->setParameter('status', SolutionEventStatus::Passed)
->setParameter('startedFrom', $startedFrom);

// filter by group
if ($group) {
$qb = $qb->andWhere('u.group = :group')
->setParameter('group', $group);
} else {
$qb = $qb->andWhere('u.group IS NULL');
}

$result = $qb->getQuery()->getResult();
\assert(\is_array($result) && array_is_list($result));

Expand All @@ -179,4 +189,34 @@ public function listLeaderboard(string $interval): array

return $leaderboard;
}

/**
* Get the total attempts made on the question.
*
* @param Question $question the question to query
* @param Group|null $group the group to filter the attempts by (null = no group)
*
* @return SolutionEvent[] the total attempts made on the question
*/
public function getTotalAttempts(Question $question, ?Group $group): array
{
$qb = $this->createQueryBuilder('se')
->join('se.submitter', 'submitter')
->where('se.question = :question')
->setParameter('question', $question);

if ($group) {
$qb->andWhere('submitter.group = :group')
->setParameter('group', $group);
} else {
$qb->andWhere('submitter.group IS NULL');
}

/**
* @var SolutionEvent[] $result
*/
$result = $qb->getQuery()->getResult();

return $result;
}
}
36 changes: 36 additions & 0 deletions src/Service/PassRateService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace App\Service;

use App\Entity\Group;
use App\Entity\Question;
use App\Repository\SolutionEventRepository;
use App\Service\Types\PassRate;

/**
* Get the pass rate of a question in the optimized matter.
*/
readonly class PassRateService
{
public function __construct(
private SolutionEventRepository $solutionEventRepository,
) {
}

/**
* Get the pass rate in this group of a question.
*
* @param Question $question the question to calculate the pass rate
* @param Group|null $group the group to calculate the pass rate, null for no group
*
* @return PassRate the pass rate, see {@link PassRate} for details
*/
public function getPassRate(Question $question, ?Group $group): PassRate
{
$attempts = $this->solutionEventRepository->getTotalAttempts($question, $group);

return new PassRate($attempts);
}
}
18 changes: 11 additions & 7 deletions src/Service/PointCalculationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

namespace App\Service;

use App\Entity\Group;
use App\Entity\Question;
use App\Entity\QuestionDifficulty;
use App\Entity\SolutionEvent;
use App\Entity\SolutionEventStatus;
use App\Entity\SolutionVideoEvent;
use App\Entity\User;
use App\Repository\HintOpenEventRepository;
use App\Repository\SolutionEventRepository;
Expand Down Expand Up @@ -106,7 +106,7 @@ protected function calculateFirstSolutionPoints(User $user): int
$points = 0;

foreach ($questions as $question) {
$firstSolver = $this->listFirstSolversOfQuestion($question);
$firstSolver = $this->listFirstSolversOfQuestion($question, $user->getGroup());
if ($firstSolver && $firstSolver === $user->getId()) {
$points += self::$firstSolverPoint;
}
Expand All @@ -118,21 +118,25 @@ protected function calculateFirstSolutionPoints(User $user): int
/**
* List and cache the first solvers of each question.
*
* @param Question $question the question to get the first solver
* @param Question $question the question to get the first solver
* @param Group|null $group the solver group (null = no group)
*
* @returns int|null the first solver ID of the question
*
* @throws InvalidArgumentException
*/
protected function listFirstSolversOfQuestion(Question $question): ?int
protected function listFirstSolversOfQuestion(Question $question, ?Group $group): ?int
{
$groupId = $group ? "{$group->getId()}" : '-none';

return $this->cache->get(
"question.q{$question->getId()}.first-solver",
function (ItemInterface $item) use ($question) {
$item->tag(['question', 'first-solver']);
"question.q{$question->getId()}.g{$groupId}.first-solver",
function (ItemInterface $item) use ($group, $question) {
$item->tag(['question', 'first-solver', 'group']);

$solutionEvent = $question
->getSolutionEvents()
->filter(fn (SolutionEvent $event) => $group === $event->getSubmitter()?->getGroup())
->findFirst(fn ($_, SolutionEvent $event) => SolutionEventStatus::Passed === $event->getStatus());

return $solutionEvent?->getSubmitter()?->getId();
Expand Down
67 changes: 67 additions & 0 deletions src/Service/Types/PassRate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace App\Service\Types;

use App\Entity\SolutionEvent;
use App\Entity\SolutionEventStatus;

/**
* The pass rate of a question.
*
* - `total`: the total number of attempts
* - `passed`: the number of successful attempts
* - `passRate`: the pass rate of the question in percentage
* - `level`: the level of the pass rate, can be 'low', 'medium', or 'high'
*/
readonly class PassRate
{
/**
* @var int the total number of attempts
*/
public int $total;

/**
* @var int the number of successful attempts
*/
public int $passed;

/**
* @param SolutionEvent[] $attempts
*/
public function __construct(
array $attempts,
) {
$this->total = \count($attempts);
$this->passed = \count(array_filter($attempts, fn (SolutionEvent $event) => SolutionEventStatus::Passed == $event->getStatus()));
}

/**
* Calculate the pass rate of a question.
*
* @return float the pass rate of the question in percentage
*/
public function getPassRate(): float
{
if (0 === $this->total) {
return 0;
}

return round($this->passed / $this->total * 100, 2);
}

/**
* @return string the level of the pass rate, can be 'low', 'medium', or 'high'
*/
public function getLevel(): string
{
$passRate = $this->getPassRate();

return match (true) {
$passRate <= 40 => 'low',
$passRate <= 70 => 'medium',
default => 'high',
};
}
}
8 changes: 8 additions & 0 deletions src/Twig/Components/Challenge/Header.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
use App\Entity\User;
use App\Repository\QuestionRepository;
use App\Repository\SolutionEventRepository;
use App\Service\PassRateService;
use App\Service\Types\PassRate;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
Expand All @@ -21,6 +23,7 @@ final class Header
public function __construct(
private readonly SolutionEventRepository $solutionEventRepository,
private readonly QuestionRepository $questionRepository,
private readonly PassRateService $passRateService,
) {
}

Expand All @@ -42,4 +45,9 @@ public function getPreviousPage(): ?int
{
return $this->questionRepository->getPreviousPage($this->question->getId());
}

public function getPassRate(): PassRate
{
return $this->passRateService->getPassRate($this->question, $this->user->getGroup());
}
}
Loading