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, + }); +}); diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 294eaa5..4a60c1f 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; + } } } @@ -148,6 +160,15 @@ ul.credit { li { @extend .list-inline-item; } + + &__pass_rate { + &__value { + @extend .text-body; + + border-bottom: 1px dotted black; + text-decoration: none; + } + } } } @@ -302,3 +323,36 @@ ul.credit { transition: opacity 300ms; } } + +.app-overview-dashboard { + display: grid; + gap: 3rem; + grid-template: + "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; + } + + &__weekly-metrics { + grid-area: weekly-metrics; + } + + &__historic-statistics { + grid-area: historic-statistics; + } + + &__leaderboard { + grid-area: leaderboard; + } +} 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/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/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, ], ], diff --git a/src/Controller/OverviewCardsController.php b/src/Controller/OverviewCardsController.php index f7d0ea2..0a6ba02 100644 --- a/src/Controller/OverviewCardsController.php +++ b/src/Controller/OverviewCardsController.php @@ -170,9 +170,31 @@ public function eventDailyChart( ], ], ]); + $chart->setOptions([ + 'scales' => [ + 'y' => [ + 'beginAtZero' => true, + 'min' => 0, + ], + ], + ]); return $this->render('overview/cards/events_daily_chart.html.twig', [ '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/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/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/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/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', + ]; + } +} 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; + } +} 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/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, diff --git a/src/Twig/Components/Questions/Card.php b/src/Twig/Components/Questions/Card.php index 860d3ca..0b64468 100644 --- a/src/Twig/Components/Questions/Card.php +++ b/src/Twig/Components/Questions/Card.php @@ -11,4 +11,22 @@ final class Card { public Question $question; + + /** + * Get the pass rate level of the question. + * + * Low: 0% - 40% + * Medium: 41 – 70% + * High: 71% - 100% + */ + public function getPassRateLevel(): string + { + $passRate = $this->question->getPassRate(); + + return match (true) { + $passRate <= 40 => 'low', + $passRate <= 70 => 'medium', + default => 'high', + }; + } } diff --git a/templates/components/Challenge/Header.html.twig b/templates/components/Challenge/Header.html.twig index 3c95f65..576cc33 100644 --- a/templates/components/Challenge/Header.html.twig +++ b/templates/components/Challenge/Header.html.twig @@ -3,6 +3,14 @@
diff --git a/templates/components/Questions/Card.html.twig b/templates/components/Questions/Card.html.twig index d006cca..be75378 100644 --- a/templates/components/Questions/Card.html.twig +++ b/templates/components/Questions/Card.html.twig @@ -11,6 +11,7 @@
進行測驗 +
通過率 {{ question.passRate }}%
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 9819525..5c0c545 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 %} diff --git a/templates/profile/index.html.twig b/templates/profile/index.html.twig index 60841a7..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 }} @@ -40,10 +40,14 @@ 觀看影片次數:{{ user.solutionVideoEvents.count }} +
  • + + 登入次數:{{ user.loginEvents.count }} +
  • -
    +

    更新帳號資訊

    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: 伺服器錯誤