diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 4c3b44f..31e2e6e 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -1,4 +1,5 @@ @use "sass:list"; +@use "sass:map"; @import "../../vendor/twbs/bootstrap/scss/functions"; // Variables @@ -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 diff --git a/config/services.yaml b/config/services.yaml index 8de680d..b4f0f26 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 diff --git a/src/Controller/OverviewCardsController.php b/src/Controller/OverviewCardsController.php index 0a6ba02..b3d6925 100644 --- a/src/Controller/OverviewCardsController.php +++ b/src/Controller/OverviewCardsController.php @@ -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, diff --git a/src/Controller/QuestionsController.php b/src/Controller/QuestionsController.php index 33c18c1..54e0edc 100644 --- a/src/Controller/QuestionsController.php +++ b/src/Controller/QuestionsController.php @@ -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, + ]); } } diff --git a/src/Entity/Question.php b/src/Entity/Question.php index 2c085f5..3474022 100644 --- a/src/Entity/Question.php +++ b/src/Entity/Question.php @@ -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 */ diff --git a/src/Entity/User.php b/src/Entity/User.php index 8d0f586..7839614 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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; diff --git a/src/Repository/SolutionEventRepository.php b/src/Repository/SolutionEventRepository.php index a50de17..61b55b1 100644 --- a/src/Repository/SolutionEventRepository.php +++ b/src/Repository/SolutionEventRepository.php @@ -4,6 +4,7 @@ namespace App\Repository; +use App\Entity\Group; use App\Entity\Question; use App\Entity\SolutionEvent; use App\Entity\SolutionEventStatus; @@ -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 The leaderboard */ - public function listLeaderboard(string $interval): array + public function listLeaderboard(?Group $group, string $interval): array { $startedFrom = new \DateTimeImmutable("-$interval"); @@ -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)); @@ -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; + } } diff --git a/src/Service/PassRateService.php b/src/Service/PassRateService.php new file mode 100644 index 0000000..db696c2 --- /dev/null +++ b/src/Service/PassRateService.php @@ -0,0 +1,36 @@ +solutionEventRepository->getTotalAttempts($question, $group); + + return new PassRate($attempts); + } +} diff --git a/src/Service/PointCalculationService.php b/src/Service/PointCalculationService.php index 0e01673..eac6866 100644 --- a/src/Service/PointCalculationService.php +++ b/src/Service/PointCalculationService.php @@ -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; @@ -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; } @@ -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(); diff --git a/src/Service/Types/PassRate.php b/src/Service/Types/PassRate.php new file mode 100644 index 0000000..067d080 --- /dev/null +++ b/src/Service/Types/PassRate.php @@ -0,0 +1,67 @@ +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', + }; + } +} diff --git a/src/Twig/Components/Challenge/Header.php b/src/Twig/Components/Challenge/Header.php index a7f1781..18750b4 100644 --- a/src/Twig/Components/Challenge/Header.php +++ b/src/Twig/Components/Challenge/Header.php @@ -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] @@ -21,6 +23,7 @@ final class Header public function __construct( private readonly SolutionEventRepository $solutionEventRepository, private readonly QuestionRepository $questionRepository, + private readonly PassRateService $passRateService, ) { } @@ -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()); + } } diff --git a/src/Twig/Components/Questions/Card.php b/src/Twig/Components/Questions/Card.php index 0b64468..a5b121d 100644 --- a/src/Twig/Components/Questions/Card.php +++ b/src/Twig/Components/Questions/Card.php @@ -5,28 +5,27 @@ namespace App\Twig\Components\Questions; use App\Entity\Question; +use App\Entity\User; +use App\Service\PassRateService; +use App\Service\Types\PassRate; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] final class Card { public Question $question; + public User $currentUser; + + public function __construct( + private readonly PassRateService $passRateService, + ) { + } /** - * Get the pass rate level of the question. - * - * Low: 0% - 40% - * Medium: 41 – 70% - * High: 71% - 100% + * Get the pass rate of the question. */ - public function getPassRateLevel(): string + public function getPassRate(): PassRate { - $passRate = $this->question->getPassRate(); - - return match (true) { - $passRate <= 40 => 'low', - $passRate <= 70 => 'medium', - default => 'high', - }; + return $this->passRateService->getPassRate($this->question, $this->currentUser->getGroup()); } } diff --git a/src/Twig/Components/Questions/FilterableSection.php b/src/Twig/Components/Questions/FilterableSection.php index eb57c3e..1c6b555 100644 --- a/src/Twig/Components/Questions/FilterableSection.php +++ b/src/Twig/Components/Questions/FilterableSection.php @@ -5,6 +5,7 @@ namespace App\Twig\Components\Questions; use App\Entity\Question; +use App\Entity\User; use App\Repository\QuestionRepository; use Meilisearch\Bundle\SearchService; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; @@ -23,6 +24,9 @@ final class FilterableSection public string $title = '題庫一覽'; + #[LiveProp] + public User $currentUser; + #[LiveProp(writable: true, url: new UrlMapping(as: 'q'))] public string $query = ''; diff --git a/templates/components/Challenge/Header.html.twig b/templates/components/Challenge/Header.html.twig index 576cc33..eb8a9c8 100644 --- a/templates/components/Challenge/Header.html.twig +++ b/templates/components/Challenge/Header.html.twig @@ -5,10 +5,11 @@
  • {{ question.type }}
  • 通過率 + {% set passRate = this.passRate %} - {{ question.passRate }}% + data-bs-title="{{ passRate.total }} 次挑戰中有 {{ passRate.passed }} 次成功"> + {{ passRate.passRate }}%
  • diff --git a/templates/components/Questions/Card.html.twig b/templates/components/Questions/Card.html.twig index be75378..35433aa 100644 --- a/templates/components/Questions/Card.html.twig +++ b/templates/components/Questions/Card.html.twig @@ -11,7 +11,8 @@
    進行測驗 -
    通過率 {{ question.passRate }}%
    + {% set passRate = this.passRate %} +
    通過率 {{ passRate.passRate }}%
    diff --git a/templates/components/Questions/FilterableSection.html.twig b/templates/components/Questions/FilterableSection.html.twig index 17f4468..a26b105 100644 --- a/templates/components/Questions/FilterableSection.html.twig +++ b/templates/components/Questions/FilterableSection.html.twig @@ -44,7 +44,7 @@
    {% for item in this.questions %}
    - +
    {% endfor %}
    diff --git a/templates/questions/index.html.twig b/templates/questions/index.html.twig index ae17d6e..e6d3682 100644 --- a/templates/questions/index.html.twig +++ b/templates/questions/index.html.twig @@ -5,5 +5,5 @@ {% block title %}練習題目{% endblock %} {% block app %} - + {% endblock %} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index 901f4cf..7581503 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -3,7 +3,7 @@ {% block title %}登入{% endblock %} {% block body %} -
    +

    資料庫練功房