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..c8d6ace 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; + } + } } } 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/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/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/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 }} +
  • 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: 伺服器錯誤