From dbeba29cc5ee2794f800eb7b97a9cf210034b35a Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 00:30:08 +0800 Subject: [PATCH 01/15] feat(entity): Login Event --- migrations/Version20241003162915.php | 37 +++++++++++++++++++++++++ src/Entity/LoginEvent.php | 28 +++++++++++++++++++ src/Entity/User.php | 37 +++++++++++++++++++++++++ src/Repository/LoginEventRepository.php | 35 +++++++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 migrations/Version20241003162915.php create mode 100644 src/Entity/LoginEvent.php create mode 100644 src/Repository/LoginEventRepository.php diff --git a/migrations/Version20241003162915.php b/migrations/Version20241003162915.php new file mode 100644 index 0000000..e0c4f5d --- /dev/null +++ b/migrations/Version20241003162915.php @@ -0,0 +1,37 @@ +addSql('CREATE TABLE login_event (id UUID NOT NULL, account_id INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_3DDECD339B6B5FBA ON login_event (account_id)'); + $this->addSql('COMMENT ON COLUMN login_event.id IS \'(DC2Type:ulid)\''); + $this->addSql('COMMENT ON COLUMN login_event.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE login_event ADD CONSTRAINT FK_3DDECD339B6B5FBA FOREIGN KEY (account_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE login_event DROP CONSTRAINT FK_3DDECD339B6B5FBA'); + $this->addSql('DROP TABLE login_event'); + } +} diff --git a/src/Entity/LoginEvent.php b/src/Entity/LoginEvent.php new file mode 100644 index 0000000..a006274 --- /dev/null +++ b/src/Entity/LoginEvent.php @@ -0,0 +1,28 @@ +account; + } + + public function setAccount(?User $account): static + { + $this->account = $account; + + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 0447e39..8d0f586 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -76,6 +76,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\OneToMany(targetEntity: HintOpenEvent::class, mappedBy: 'opener', orphanRemoval: true)] private Collection $hintOpenEvents; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: LoginEvent::class, mappedBy: 'account', orphanRemoval: true)] + private Collection $loginEvents; + public function __construct() { $this->solutionEvents = new ArrayCollection(); @@ -83,6 +89,7 @@ public function __construct() $this->comments = new ArrayCollection(); $this->commentLikeEvents = new ArrayCollection(); $this->hintOpenEvents = new ArrayCollection(); + $this->loginEvents = new ArrayCollection(); } public function getId(): ?int @@ -343,4 +350,34 @@ public function removeHintOpenEvent(HintOpenEvent $hintOpenEvent): static return $this; } + + /** + * @return Collection + */ + public function getLoginEvents(): Collection + { + return $this->loginEvents; + } + + public function addLoginEvent(LoginEvent $loginEvent): static + { + if (!$this->loginEvents->contains($loginEvent)) { + $this->loginEvents->add($loginEvent); + $loginEvent->setAccount($this); + } + + return $this; + } + + public function removeLoginEvent(LoginEvent $loginEvent): static + { + if ($this->loginEvents->removeElement($loginEvent)) { + // set the owning side to null (unless already changed) + if ($loginEvent->getAccount() === $this) { + $loginEvent->setAccount(null); + } + } + + return $this; + } } diff --git a/src/Repository/LoginEventRepository.php b/src/Repository/LoginEventRepository.php new file mode 100644 index 0000000..a95feb0 --- /dev/null +++ b/src/Repository/LoginEventRepository.php @@ -0,0 +1,35 @@ + + */ +class LoginEventRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, LoginEvent::class); + } + + public function getLoginCount(User $user): int + { + $loginCount = $this->createQueryBuilder('l') + ->select('COUNT(l.id)') + ->andWhere('l.account = :user') + ->setParameter('user', $user) + ->getQuery() + ->getSingleScalarResult(); + + \assert(\is_int($loginCount)); + + return $loginCount; + } +} From 2cd23dd27040590fb4dbba56f9084a5c07da819b Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 00:36:44 +0800 Subject: [PATCH 02/15] feat(admin): LoginEvent --- src/Controller/Admin/DashboardController.php | 2 + .../Admin/LoginEventCrudController.php | 44 +++++++++++++++++++ translations/messages.zh_TW.yaml | 2 + 3 files changed, 48 insertions(+) create mode 100644 src/Controller/Admin/LoginEventCrudController.php diff --git a/src/Controller/Admin/DashboardController.php b/src/Controller/Admin/DashboardController.php index 811055c..c6c8eb1 100644 --- a/src/Controller/Admin/DashboardController.php +++ b/src/Controller/Admin/DashboardController.php @@ -8,6 +8,7 @@ use App\Entity\CommentLikeEvent; use App\Entity\Group; use App\Entity\HintOpenEvent; +use App\Entity\LoginEvent; use App\Entity\Question; use App\Entity\Schema; use App\Entity\SolutionEvent; @@ -54,5 +55,6 @@ public function configureMenuItems(): iterable yield MenuItem::linkToCrud('SolutionEvent', 'fa fa-check', SolutionEvent::class); yield MenuItem::linkToCrud('SolutionVideoEvent', 'fa fa-video', SolutionVideoEvent::class); yield MenuItem::linkToCrud('HintOpenEvent', 'fa fa-lightbulb', HintOpenEvent::class); + yield MenuItem::linkToCrud('LoginEvent', 'fa fa-sign-in', LoginEvent::class); } } diff --git a/src/Controller/Admin/LoginEventCrudController.php b/src/Controller/Admin/LoginEventCrudController.php new file mode 100644 index 0000000..2d84e3e --- /dev/null +++ b/src/Controller/Admin/LoginEventCrudController.php @@ -0,0 +1,44 @@ +add('account'); + } + + public function configureActions(Actions $actions): Actions + { + return $actions + ->disable(Action::DELETE, Action::EDIT, Action::NEW) + ->add(Crud::PAGE_INDEX, Action::DETAIL); + } +} diff --git a/translations/messages.zh_TW.yaml b/translations/messages.zh_TW.yaml index 4641c2d..56fd815 100644 --- a/translations/messages.zh_TW.yaml +++ b/translations/messages.zh_TW.yaml @@ -44,6 +44,8 @@ Back to App: 返回 App Reindex: 進行搜尋索引 HintOpenEvent: 提示打開事件 Response: 回應 +LoginEvent: 登入事件 +Account: 帳號 challenge.error-type.user: 輸入錯誤 challenge.error-type.server: 伺服器錯誤 From 133f8d92b8f33e58981b230784b770babe005e47 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 00:37:03 +0800 Subject: [PATCH 03/15] feat(subscriber): Write login event when logging in --- src/EventSubscriber/LoginSubscriber.php | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/EventSubscriber/LoginSubscriber.php diff --git a/src/EventSubscriber/LoginSubscriber.php b/src/EventSubscriber/LoginSubscriber.php new file mode 100644 index 0000000..d3d3040 --- /dev/null +++ b/src/EventSubscriber/LoginSubscriber.php @@ -0,0 +1,38 @@ +getAuthenticationToken()->getUser(); + \assert($user instanceof User); + + $loginEvent = (new LoginEvent()) + ->setAccount($user); + + $this->entityManager->persist($loginEvent); + $this->entityManager->flush(); + } + + public static function getSubscribedEvents(): array + { + return [ + 'security.authentication.success' => 'onSecurityAuthenticationSuccess', + ]; + } +} From f2d5f186d820f85d21d573f4d9901f5061b8419b Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 00:48:04 +0800 Subject: [PATCH 04/15] feat(profile): Show login events count in profile --- templates/profile/index.html.twig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/templates/profile/index.html.twig b/templates/profile/index.html.twig index 60841a7..ba73f51 100644 --- a/templates/profile/index.html.twig +++ b/templates/profile/index.html.twig @@ -40,6 +40,10 @@ 觀看影片次數:{{ user.solutionVideoEvents.count }} +
  • + + 登入次數:{{ user.loginEvents.count }} +
  • From 8b3e469caf33b69654be572ac78f5611edbfb327 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 01:02:59 +0800 Subject: [PATCH 05/15] feat(questions): Implement pass rate --- assets/styles/app.scss | 14 ++++- src/Twig/Components/Questions/Card.php | 58 +++++++++++++++++++ templates/components/Questions/Card.html.twig | 1 + 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 294eaa5..2fd92eb 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -120,7 +120,19 @@ ul.credit { } &__operations { - @extend .d-flex, .gap-2, .justify-content-start, .align-items-center; + @extend .d-flex, .gap-3, .justify-content-start, .align-items-center; + } + + .question-card__pass-rate { + &[data-pass-rate~="high"] { + @extend .text-success; + } + &[data-pass-rate~="medium"] { + @extend .text-warning; + } + &[data-pass-rate~="low"] { + @extend .text-danger; + } } } diff --git a/src/Twig/Components/Questions/Card.php b/src/Twig/Components/Questions/Card.php index 860d3ca..34bdfe6 100644 --- a/src/Twig/Components/Questions/Card.php +++ b/src/Twig/Components/Questions/Card.php @@ -5,10 +5,68 @@ namespace App\Twig\Components\Questions; use App\Entity\Question; +use App\Entity\SolutionEvent; +use App\Entity\SolutionEventStatus; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] final class Card { public Question $question; + + /** + * 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 pass rate level of the question. + * + * Low: 0% - 40% + * Medium: 41 – 70% + * High: 71% - 100% + */ + public function getPassRateLevel(): string + { + $passRate = $this->getPassRate(); + + return match (true) { + $passRate <= 40 => 'low', + $passRate <= 70 => 'medium', + default => 'high', + }; + } + + /** + * Get the total number of attempts made on the question. + * + * @return int the total number of attempts made on the question + */ + private function getTotalAttemptCount(): int + { + return $this->question->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 + */ + private function getTotalSolvedCount(): int + { + return $this->question->getSolutionEvents() + ->filter( + fn (SolutionEvent $solutionEvent) => SolutionEventStatus::Passed === $solutionEvent->getStatus() + )->count(); + } } diff --git a/templates/components/Questions/Card.html.twig b/templates/components/Questions/Card.html.twig index d006cca..4dcbc20 100644 --- a/templates/components/Questions/Card.html.twig +++ b/templates/components/Questions/Card.html.twig @@ -11,6 +11,7 @@
    進行測驗 +
    通過率 {{ this.passRate }}%
    From 6b541909e8a7b7941e4cd32567d8f05571125246 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 01:08:27 +0800 Subject: [PATCH 06/15] refactor(questions): Move pass rate logic to Question --- src/Entity/Question.php | 38 +++++++++++++++++ src/Twig/Components/Questions/Card.php | 42 +------------------ templates/components/Questions/Card.html.twig | 2 +- 3 files changed, 40 insertions(+), 42 deletions(-) diff --git a/src/Entity/Question.php b/src/Entity/Question.php index 3474022..2c085f5 100644 --- a/src/Entity/Question.php +++ b/src/Entity/Question.php @@ -176,6 +176,44 @@ 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/Twig/Components/Questions/Card.php b/src/Twig/Components/Questions/Card.php index 34bdfe6..0b64468 100644 --- a/src/Twig/Components/Questions/Card.php +++ b/src/Twig/Components/Questions/Card.php @@ -5,8 +5,6 @@ namespace App\Twig\Components\Questions; use App\Entity\Question; -use App\Entity\SolutionEvent; -use App\Entity\SolutionEventStatus; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] @@ -14,21 +12,6 @@ final class Card { public Question $question; - /** - * 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 pass rate level of the question. * @@ -38,7 +21,7 @@ public function getPassRate(): float */ public function getPassRateLevel(): string { - $passRate = $this->getPassRate(); + $passRate = $this->question->getPassRate(); return match (true) { $passRate <= 40 => 'low', @@ -46,27 +29,4 @@ public function getPassRateLevel(): string default => 'high', }; } - - /** - * Get the total number of attempts made on the question. - * - * @return int the total number of attempts made on the question - */ - private function getTotalAttemptCount(): int - { - return $this->question->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 - */ - private function getTotalSolvedCount(): int - { - return $this->question->getSolutionEvents() - ->filter( - fn (SolutionEvent $solutionEvent) => SolutionEventStatus::Passed === $solutionEvent->getStatus() - )->count(); - } } diff --git a/templates/components/Questions/Card.html.twig b/templates/components/Questions/Card.html.twig index 4dcbc20..be75378 100644 --- a/templates/components/Questions/Card.html.twig +++ b/templates/components/Questions/Card.html.twig @@ -11,7 +11,7 @@
    進行測驗 -
    通過率 {{ this.passRate }}%
    +
    通過率 {{ question.passRate }}%
    From c7d9fc20c95920efd087b48d422259d728bf0ce2 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 01:08:50 +0800 Subject: [PATCH 07/15] feat(challenge): Show pass rate --- templates/components/Challenge/Header.html.twig | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/components/Challenge/Header.html.twig b/templates/components/Challenge/Header.html.twig index 3c95f65..f2275d3 100644 --- a/templates/components/Challenge/Header.html.twig +++ b/templates/components/Challenge/Header.html.twig @@ -3,6 +3,7 @@
    • {{ question.difficulty|trans }}
    • {{ question.type }}
    • +
    • 通過率 {{ question.passRate }}%
    From a8a3fb17e45a1c55f716eb68ae3bd6f9f2ca8d98 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 01:29:01 +0800 Subject: [PATCH 08/15] feat(app): De-/Initialize tooltips for page navigation --- assets/app/index.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/assets/app/index.ts b/assets/app/index.ts index 0897364..b998955 100644 --- a/assets/app/index.ts +++ b/assets/app/index.ts @@ -1,2 +1,19 @@ -import "bootstrap"; +import * as bootstrap from "bootstrap"; import "./bootstrap.ts"; + +/** + * Initialize tooltips of Bootstrap + */ +document.addEventListener("turbo:load", () => { + const tooltipTriggerList = document.querySelectorAll("[data-bs-toggle=\"tooltip\"]"); + const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); + + // Destroy tooltips on navigating to a new page + document.addEventListener("turbo:before-visit", () => { + for (const tooltip of tooltipList) { + tooltip.dispose(); + } + }, { + once: true, + }); +}); From ec67dffea0457149d8215cbaa608b53cb59351b4 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 01:29:53 +0800 Subject: [PATCH 09/15] feat(challenge): Implement details of pass rate --- assets/styles/app.scss | 9 +++++++++ templates/components/Challenge/Header.html.twig | 9 ++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 2fd92eb..c8d6ace 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -160,6 +160,15 @@ ul.credit { li { @extend .list-inline-item; } + + &__pass_rate { + &__value { + @extend .text-body; + + border-bottom: 1px dotted black; + text-decoration: none; + } + } } } diff --git a/templates/components/Challenge/Header.html.twig b/templates/components/Challenge/Header.html.twig index f2275d3..576cc33 100644 --- a/templates/components/Challenge/Header.html.twig +++ b/templates/components/Challenge/Header.html.twig @@ -3,7 +3,14 @@
    From 0fa37f05278b0d8dcd085addc8398871d697f26e Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 01:54:22 +0800 Subject: [PATCH 10/15] fix(comments): Set min=0 for comments count --- src/Controller/CommentsController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controller/CommentsController.php b/src/Controller/CommentsController.php index e1e2600..99e13fb 100644 --- a/src/Controller/CommentsController.php +++ b/src/Controller/CommentsController.php @@ -77,6 +77,7 @@ public function likes( 'y' => [ 'ticks' => [ 'beginAtZero' => true, + 'min' => 0, 'stepSize' => 5, ], ], From fa0a6556d77ab972737fe74e6edb2983c7d97f3d Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 01:55:58 +0800 Subject: [PATCH 11/15] feat(overview): Grid layout & Leaderboard --- assets/styles/app.scss | 25 +++++++++++++++++++ src/Controller/OverviewCardsController.php | 8 +++++++ templates/overview/index.html.twig | 28 ++++++++++++---------- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/assets/styles/app.scss b/assets/styles/app.scss index c8d6ace..d180fb1 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -323,3 +323,28 @@ ul.credit { transition: opacity 300ms; } } + +.app-overview-dashboard { + display: grid; + gap: 3rem; + grid-template: + "hello-text hello-text hello-text hello-text" auto + "weekly-metrics weekly-metrics leaderboard leaderboard" 1fr + "historic-statistics historic-statistics leaderboard leaderboard" 1fr; + + &__hello-text { + grid-area: hello-text; + } + + &__weekly-metrics { + grid-area: weekly-metrics; + } + + &__historic-statistics { + grid-area: historic-statistics; + } + + &__leaderboard { + grid-area: leaderboard; + } +} diff --git a/src/Controller/OverviewCardsController.php b/src/Controller/OverviewCardsController.php index f7d0ea2..62d072a 100644 --- a/src/Controller/OverviewCardsController.php +++ b/src/Controller/OverviewCardsController.php @@ -170,6 +170,14 @@ public function eventDailyChart( ], ], ]); + $chart->setOptions([ + 'scales' => [ + 'y' => [ + 'beginAtZero' => true, + 'min' => 0, + ], + ], + ]); return $this->render('overview/cards/events_daily_chart.html.twig', [ 'chart' => $chart, diff --git a/templates/overview/index.html.twig b/templates/overview/index.html.twig index 9819525..d0d7ce3 100644 --- a/templates/overview/index.html.twig +++ b/templates/overview/index.html.twig @@ -4,45 +4,49 @@ {% block title %}學習概況{% endblock %} {% block app %} -
    +
    +

    哈囉,{{ app.user.name }} 👋

    這些是你最近的表現 ↓

    -
    +

    每週學習概況

    -
    -
    +
    +
    -
    -

    WIP: Chart

    +
    +
    -
    +

    學習歷程

    -
    +
    -
    +
    -
    - -
    + +
    +

    週排行榜

    + +
    +
    {% endblock %} From 40605820b5347b3df627b07824fe77c9bd404dc3 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 02:03:13 +0800 Subject: [PATCH 12/15] refactor: RWD optimization --- assets/styles/app.scss | 14 +++++++++++--- templates/overview/index.html.twig | 16 ++++++++-------- templates/profile/index.html.twig | 6 +++--- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/assets/styles/app.scss b/assets/styles/app.scss index d180fb1..4a60c1f 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -328,9 +328,17 @@ ul.credit { display: grid; gap: 3rem; grid-template: - "hello-text hello-text hello-text hello-text" auto - "weekly-metrics weekly-metrics leaderboard leaderboard" 1fr - "historic-statistics historic-statistics leaderboard leaderboard" 1fr; + "hello-text" auto + "weekly-metrics" auto + "historic-statistics" auto + "leaderboard" auto; + + @media (min-width: 768px) { + grid-template: + "hello-text hello-text hello-text hello-text" auto + "weekly-metrics weekly-metrics leaderboard leaderboard" 1fr + "historic-statistics historic-statistics leaderboard leaderboard" 1fr; + } &__hello-text { grid-area: hello-text; diff --git a/templates/overview/index.html.twig b/templates/overview/index.html.twig index d0d7ce3..f67fc1f 100644 --- a/templates/overview/index.html.twig +++ b/templates/overview/index.html.twig @@ -13,15 +13,15 @@

    每週學習概況

    -
    -
    -
    +
    +
    +
    -
    +
    @@ -30,15 +30,15 @@

    學習歷程

    -
    -
    -
    +
    +
    +
    -
    +
    diff --git a/templates/profile/index.html.twig b/templates/profile/index.html.twig index ba73f51..4809fd2 100644 --- a/templates/profile/index.html.twig +++ b/templates/profile/index.html.twig @@ -4,8 +4,8 @@ {% block title %}個人資料{% endblock %} {% block app %} -
    - +
    +

    {{ user.name }} @@ -47,7 +47,7 @@ -
    +

    更新帳號資訊

    From 19dfaf91f4345e98eac6c06404641987564ead5c Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 02:04:23 +0800 Subject: [PATCH 13/15] refactor(overview): Tweak gutter --- templates/overview/index.html.twig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/overview/index.html.twig b/templates/overview/index.html.twig index f67fc1f..c3b3394 100644 --- a/templates/overview/index.html.twig +++ b/templates/overview/index.html.twig @@ -13,7 +13,7 @@

    每週學習概況

    -
    +
    @@ -30,7 +30,7 @@

    學習歷程

    -
    +
    @@ -38,7 +38,7 @@
    -
    +
    From 1d1f45be150beab37335fd16cd3a54a1a030285a Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 02:34:27 +0800 Subject: [PATCH 14/15] feat(overview): Implement Leaderboard --- src/Controller/OverviewCardsController.php | 14 ++++++ src/Repository/SolutionEventRepository.php | 46 +++++++++++++++++++ .../overview/cards/leaderboard.html.twig | 22 +++++++++ templates/overview/index.html.twig | 2 +- 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 templates/overview/cards/leaderboard.html.twig diff --git a/src/Controller/OverviewCardsController.php b/src/Controller/OverviewCardsController.php index 62d072a..0a6ba02 100644 --- a/src/Controller/OverviewCardsController.php +++ b/src/Controller/OverviewCardsController.php @@ -183,4 +183,18 @@ public function eventDailyChart( 'chart' => $chart, ]); } + + /** + * List the top users with the highest solved questions. + */ + #[Route('/leaderboard', name: 'leaderboard')] + public function leaderboard( + SolutionEventRepository $solutionEventRepository, + ): Response { + $leaderboard = $solutionEventRepository->listLeaderboard('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 bd30692..a50de17 100644 --- a/src/Repository/SolutionEventRepository.php +++ b/src/Repository/SolutionEventRepository.php @@ -133,4 +133,50 @@ public function getSolveState(Question $question, User $user): ?SolutionEventSta return null; } + + /** + * List the users leaderboard by the number of questions they have solved. + * + * @param string $interval The interval to count the leaderboard + * + * @return list The leaderboard + */ + public function listLeaderboard(string $interval): array + { + $startedFrom = new \DateTimeImmutable("-$interval"); + + $qb = $this->createQueryBuilder('e') + ->from(User::class, 'u') + ->select('u AS user', 'COUNT(e.id) AS count') + ->where('e.submitter = u') + ->andWhere('e.status = :status') + ->andWhere('e.createdAt >= :startedFrom') + ->groupBy('u.id') + ->orderBy('count', 'DESC') + ->setParameter('status', SolutionEventStatus::Passed) + ->setParameter('startedFrom', $startedFrom); + + $result = $qb->getQuery()->getResult(); + \assert(\is_array($result) && array_is_list($result)); + + /** + * @var list $leaderboard + */ + $leaderboard = []; + + foreach ($result as $item) { + \assert(\is_array($item)); + \assert(\array_key_exists('user', $item)); + \assert(\array_key_exists('count', $item)); + \assert($item['user'] instanceof User); + \assert(\is_int($item['count'])); + + $leaderboard[] = [ + 'user' => $item['user'], + 'count' => $item['count'], + ]; + } + + return $leaderboard; + } } diff --git a/templates/overview/cards/leaderboard.html.twig b/templates/overview/cards/leaderboard.html.twig new file mode 100644 index 0000000..09f145b --- /dev/null +++ b/templates/overview/cards/leaderboard.html.twig @@ -0,0 +1,22 @@ +{% extends 'base.html.twig' %} + +{% block body %} + + + + + + + + + + {% for item in leaderboard %} + + + + + {% endfor %} + +
    名字解題數
    {{ item.user.name }}{{ item.count }}
    +
    +{% endblock %} diff --git a/templates/overview/index.html.twig b/templates/overview/index.html.twig index c3b3394..5c0c545 100644 --- a/templates/overview/index.html.twig +++ b/templates/overview/index.html.twig @@ -46,7 +46,7 @@

    週排行榜

    - +
    {% endblock %} From c043f96ed5a7e3de3e7aa067f79e9581f197f7b6 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Fri, 4 Oct 2024 03:15:40 +0800 Subject: [PATCH 15/15] refactor(point): Optimize query Reduce queries from 27 to 9. --- src/Service/PointCalculationService.php | 86 +++++++++++++++++++------ 1 file changed, 67 insertions(+), 19 deletions(-) diff --git a/src/Service/PointCalculationService.php b/src/Service/PointCalculationService.php index a8e3fda..0e01673 100644 --- a/src/Service/PointCalculationService.php +++ b/src/Service/PointCalculationService.php @@ -8,11 +8,14 @@ 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\QuestionRepository; use App\Repository\SolutionEventRepository; use App\Repository\SolutionVideoEventRepository; +use Psr\Cache\InvalidArgumentException; +use Symfony\Contracts\Cache\ItemInterface; +use Symfony\Contracts\Cache\TagAwareCacheInterface; final class PointCalculationService { @@ -37,11 +40,14 @@ final class PointCalculationService public function __construct( private readonly SolutionEventRepository $solutionEventRepository, private readonly SolutionVideoEventRepository $solutionVideoEventRepository, - private readonly QuestionRepository $questionRepository, private readonly HintOpenEventRepository $hintOpenEventRepository, + private readonly TagAwareCacheInterface $cache, ) { } + /** + * @throws InvalidArgumentException + */ public function calculate(User $user): int { return self::$base @@ -76,40 +82,82 @@ protected function calculateSolutionQuestionPoints(User $user): int * Calculate the points if the user is the first solver of a question. * * 第一位解題成功者加10點。 + * + * @throws InvalidArgumentException */ protected function calculateFirstSolutionPoints(User $user): int { - $questions = $this->questionRepository->findAll(); - + // select the question this user has ever solved + $qb = $this->solutionEventRepository->createQueryBuilder('e') + ->select('q') + ->from(Question::class, 'q') + ->where('e.question = q.id') + ->andWhere('e.status = :status') + ->andWhere('e.submitter = :submitter') + ->setParameter('status', SolutionEventStatus::Passed) + ->setParameter('submitter', $user); + + /** + * @var Question[] $questions + */ + $questions = $qb->getQuery()->getResult(); + + // check if the user is the first solver of each question $points = 0; - // list the first solver of each question foreach ($questions as $question) { - $solutionEvent = $question - ->getSolutionEvents() - ->findFirst(fn ($_, SolutionEvent $event) => SolutionEventStatus::Passed === $event->getStatus()); - - if (!$solutionEvent || $solutionEvent->getSubmitter() !== $user) { - continue; + $firstSolver = $this->listFirstSolversOfQuestion($question); + if ($firstSolver && $firstSolver === $user->getId()) { + $points += self::$firstSolverPoint; } - - $points += self::$firstSolverPoint; } return $points; } + /** + * List and cache the first solvers of each question. + * + * @param Question $question the question to get the first solver + * + * @returns int|null the first solver ID of the question + * + * @throws InvalidArgumentException + */ + protected function listFirstSolversOfQuestion(Question $question): ?int + { + return $this->cache->get( + "question.q{$question->getId()}.first-solver", + function (ItemInterface $item) use ($question) { + $item->tag(['question', 'first-solver']); + + $solutionEvent = $question + ->getSolutionEvents() + ->findFirst(fn ($_, SolutionEvent $event) => SolutionEventStatus::Passed === $event->getStatus()); + + return $solutionEvent?->getSubmitter()?->getId(); + } + ); + } + protected function calculateSolutionVideoPoints(User $user): int { - $solutionVideoEvents = $this->solutionVideoEventRepository->findByUser($user); + /** + * @var Question[] $questions + */ + $questions = $this->solutionVideoEventRepository->createQueryBuilder('sve') + ->from(Question::class, 'q') + ->select('q') + ->where('sve.opener = :user') + ->andWhere('sve.question = q.id') + ->groupBy('q.id') + ->setParameter('user', $user) + ->getQuery() + ->getResult(); $questionPointsPair = []; - foreach ($solutionVideoEvents as $event) { - $question = $event->getQuestion(); - if (!$question) { - continue; - } + foreach ($questions as $question) { $questionPointsPair[$question->getId()] = match ($question->getDifficulty()) { QuestionDifficulty::Easy => self::$solutionVideoEventEasy, QuestionDifficulty::Medium => self::$solutionVideoEventMedium,