From 727c6e3783f0edb101e45a20f5b6a55dc5f8558b Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 23:12:35 +0800 Subject: [PATCH 1/7] fix(entity): Correct type hint of User --- src/Entity/User.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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; From 02d6eba158c6817646d474d3d0081afc22f73d4a Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 23:15:21 +0800 Subject: [PATCH 2/7] fix(points): Grouping the solver We pick the first solver *in this group* (instead of globally). --- src/Service/PointCalculationService.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/Service/PointCalculationService.php b/src/Service/PointCalculationService.php index 0e01673..a7db3b7 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 ? "g{$group->getId()}" : 'g-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(); From 0d7a324abf11a750fe8290ee820f135c6bbb7a06 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 23:24:50 +0800 Subject: [PATCH 3/7] fix(security): Add 3 layouts of login --- assets/styles/app.scss | 15 +++++++++++++-- templates/security/login.html.twig | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) 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/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 %} -
+

資料庫練功房 From f61de1981b084ee8b3ea6457251edbd704706af5 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Sat, 5 Oct 2024 00:16:00 +0800 Subject: [PATCH 4/7] feat(question): Grouping the solver We calculate for the group of this user instead of calculate for every group. --- src/Controller/QuestionsController.php | 11 +++++--- src/Entity/Question.php | 25 +++++++++++++------ src/Twig/Components/Questions/Card.php | 4 ++- .../Questions/FilterableSection.php | 4 +++ .../components/Challenge/Header.html.twig | 4 +-- templates/components/Questions/Card.html.twig | 2 +- .../Questions/FilterableSection.html.twig | 2 +- templates/questions/index.html.twig | 2 +- 8 files changed, 38 insertions(+), 16 deletions(-) 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..83dda74 100644 --- a/src/Entity/Question.php +++ b/src/Entity/Question.php @@ -179,38 +179,49 @@ public function setSolutionVideo(?string $solution_video): static /** * Get the pass rate of the question. * + * @param Group|null $group the group to filter the attempts by (null = no group) + * * @return float the pass rate of the question */ - public function getPassRate(): float + public function getPassRate(?Group $group): float { - $totalAttemptCount = $this->getTotalAttemptCount(); + $totalAttemptCount = $this->getTotalAttemptCount($group); if (0 === $totalAttemptCount) { return 0; } - return round($this->getTotalSolvedCount() / $totalAttemptCount * 100, 2); + return round($this->getTotalSolvedCount($group) / $totalAttemptCount * 100, 2); } /** * Get the total number of attempts made on the question. * + * @param Group|null $group the group to filter the attempts by (null = no group) + * * @return int the total number of attempts made on the question */ - public function getTotalAttemptCount(): int + public function getTotalAttemptCount(?Group $group): int { - return $this->getSolutionEvents()->count(); + return $this->getSolutionEvents() + ->filter(fn (SolutionEvent $solutionEvent) => $group === $solutionEvent->getSubmitter()?->getGroup()) + ->count(); } /** * Get the total number of times the question has been solved. * + * @param Group|null $group the group to filter the attempts by (null = no group) + * * @return int the total number of times the question has been solved */ - public function getTotalSolvedCount(): int + public function getTotalSolvedCount(?Group $group): int { return $this->getSolutionEvents() ->filter( - fn (SolutionEvent $solutionEvent) => SolutionEventStatus::Passed === $solutionEvent->getStatus() + fn (SolutionEvent $solutionEvent) => ( + SolutionEventStatus::Passed === $solutionEvent->getStatus() + && $group === $solutionEvent->getSubmitter()?->getGroup() + ) )->count(); } diff --git a/src/Twig/Components/Questions/Card.php b/src/Twig/Components/Questions/Card.php index 0b64468..3effae4 100644 --- a/src/Twig/Components/Questions/Card.php +++ b/src/Twig/Components/Questions/Card.php @@ -5,12 +5,14 @@ namespace App\Twig\Components\Questions; use App\Entity\Question; +use App\Entity\User; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] final class Card { public Question $question; + public User $currentUser; /** * Get the pass rate level of the question. @@ -21,7 +23,7 @@ final class Card */ public function getPassRateLevel(): string { - $passRate = $this->question->getPassRate(); + $passRate = $this->question->getPassRate($this->currentUser->getGroup()); return match (true) { $passRate <= 40 => 'low', 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..7f4d167 100644 --- a/templates/components/Challenge/Header.html.twig +++ b/templates/components/Challenge/Header.html.twig @@ -7,8 +7,8 @@ 通過率 - {{ question.passRate }}% + data-bs-title="{{ question.totalAttemptCount(user.group) }} 次挑戰中有 {{ question.totalSolvedCount(user.group) }} 次成功"> + {{ question.passRate(user.group) }}% diff --git a/templates/components/Questions/Card.html.twig b/templates/components/Questions/Card.html.twig index be75378..30659a0 100644 --- a/templates/components/Questions/Card.html.twig +++ b/templates/components/Questions/Card.html.twig @@ -11,7 +11,7 @@
進行測驗 -
通過率 {{ question.passRate }}%
+
通過率 {{ question.passRate(currentUser.group) }}%
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 %} From 027f61e73a74dd9c7ff96009d748b32e6d75df3c Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Sat, 5 Oct 2024 01:05:51 +0800 Subject: [PATCH 5/7] feat: Implement grouping in leaderboard Show only the users in the group of the logged-in user. --- src/Controller/OverviewCardsController.php | 3 ++- src/Repository/SolutionEventRepository.php | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) 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/Repository/SolutionEventRepository.php b/src/Repository/SolutionEventRepository.php index a50de17..bc5d6ea 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)); From ab31326f97fe3412c1599bcd58bb4736be2c0725 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Sat, 5 Oct 2024 01:11:23 +0800 Subject: [PATCH 6/7] fix(points): Remove duplicate "g" --- src/Service/PointCalculationService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service/PointCalculationService.php b/src/Service/PointCalculationService.php index a7db3b7..eac6866 100644 --- a/src/Service/PointCalculationService.php +++ b/src/Service/PointCalculationService.php @@ -127,7 +127,7 @@ protected function calculateFirstSolutionPoints(User $user): int */ protected function listFirstSolversOfQuestion(Question $question, ?Group $group): ?int { - $groupId = $group ? "g{$group->getId()}" : 'g-none'; + $groupId = $group ? "{$group->getId()}" : '-none'; return $this->cache->get( "question.q{$question->getId()}.g{$groupId}.first-solver", From 08aaee6ec8615dc7246657b6370f5fc0ab184532 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Sat, 5 Oct 2024 02:07:58 +0800 Subject: [PATCH 7/7] refactor: Optimize pass rate calculation --- config/services.yaml | 1 + src/Entity/Question.php | 49 -------------- src/Repository/SolutionEventRepository.php | 30 +++++++++ src/Service/PassRateService.php | 36 ++++++++++ src/Service/Types/PassRate.php | 67 +++++++++++++++++++ src/Twig/Components/Challenge/Header.php | 8 +++ src/Twig/Components/Questions/Card.php | 23 +++---- .../components/Challenge/Header.html.twig | 5 +- templates/components/Questions/Card.html.twig | 3 +- 9 files changed, 157 insertions(+), 65 deletions(-) create mode 100644 src/Service/PassRateService.php create mode 100644 src/Service/Types/PassRate.php 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/Entity/Question.php b/src/Entity/Question.php index 83dda74..3474022 100644 --- a/src/Entity/Question.php +++ b/src/Entity/Question.php @@ -176,55 +176,6 @@ public function setSolutionVideo(?string $solution_video): static return $this; } - /** - * Get the pass rate of the question. - * - * @param Group|null $group the group to filter the attempts by (null = no group) - * - * @return float the pass rate of the question - */ - public function getPassRate(?Group $group): float - { - $totalAttemptCount = $this->getTotalAttemptCount($group); - if (0 === $totalAttemptCount) { - return 0; - } - - return round($this->getTotalSolvedCount($group) / $totalAttemptCount * 100, 2); - } - - /** - * Get the total number of attempts made on the question. - * - * @param Group|null $group the group to filter the attempts by (null = no group) - * - * @return int the total number of attempts made on the question - */ - public function getTotalAttemptCount(?Group $group): int - { - return $this->getSolutionEvents() - ->filter(fn (SolutionEvent $solutionEvent) => $group === $solutionEvent->getSubmitter()?->getGroup()) - ->count(); - } - - /** - * Get the total number of times the question has been solved. - * - * @param Group|null $group the group to filter the attempts by (null = no group) - * - * @return int the total number of times the question has been solved - */ - public function getTotalSolvedCount(?Group $group): int - { - return $this->getSolutionEvents() - ->filter( - fn (SolutionEvent $solutionEvent) => ( - SolutionEventStatus::Passed === $solutionEvent->getStatus() - && $group === $solutionEvent->getSubmitter()?->getGroup() - ) - )->count(); - } - /** * @return Collection */ diff --git a/src/Repository/SolutionEventRepository.php b/src/Repository/SolutionEventRepository.php index bc5d6ea..61b55b1 100644 --- a/src/Repository/SolutionEventRepository.php +++ b/src/Repository/SolutionEventRepository.php @@ -189,4 +189,34 @@ public function listLeaderboard(?Group $group, 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/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 3effae4..a5b121d 100644 --- a/src/Twig/Components/Questions/Card.php +++ b/src/Twig/Components/Questions/Card.php @@ -6,6 +6,8 @@ use App\Entity\Question; use App\Entity\User; +use App\Service\PassRateService; +use App\Service\Types\PassRate; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] @@ -14,21 +16,16 @@ 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($this->currentUser->getGroup()); - - return match (true) { - $passRate <= 40 => 'low', - $passRate <= 70 => 'medium', - default => 'high', - }; + return $this->passRateService->getPassRate($this->question, $this->currentUser->getGroup()); } } diff --git a/templates/components/Challenge/Header.html.twig b/templates/components/Challenge/Header.html.twig index 7f4d167..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(user.group) }}% + 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 30659a0..35433aa 100644 --- a/templates/components/Questions/Card.html.twig +++ b/templates/components/Questions/Card.html.twig @@ -11,7 +11,8 @@
    進行測驗 -
    通過率 {{ question.passRate(currentUser.group) }}%
    + {% set passRate = this.passRate %} +
    通過率 {{ passRate.passRate }}%