Skip to content
This repository was archived by the owner on Oct 15, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion assets/app/index.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
23 changes: 22 additions & 1 deletion assets/styles/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand Down Expand Up @@ -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;
}
}
}
}

Expand Down
37 changes: 37 additions & 0 deletions migrations/Version20241003162915.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241003162915 extends AbstractMigration
{
public function getDescription(): string
{
return 'Login Event';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
2 changes: 2 additions & 0 deletions src/Controller/Admin/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
44 changes: 44 additions & 0 deletions src/Controller/Admin/LoginEventCrudController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace App\Controller\Admin;

use App\Entity\LoginEvent;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;

class LoginEventCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return LoginEvent::class;
}

public function configureFields(string $pageName): iterable
{
return [
IdField::new('id', 'ID'),
AssociationField::new('account', 'Account'),
DateTimeField::new('createdAt', 'Created at'),
];
}

public function configureFilters(Filters $filters): Filters
{
return $filters->add('account');
}

public function configureActions(Actions $actions): Actions
{
return $actions
->disable(Action::DELETE, Action::EDIT, Action::NEW)
->add(Crud::PAGE_INDEX, Action::DETAIL);
}
}
28 changes: 28 additions & 0 deletions src/Entity/LoginEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\LoginEventRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: LoginEventRepository::class)]
class LoginEvent extends BaseEvent
{
#[ORM\ManyToOne(inversedBy: 'loginEvents')]
#[ORM\JoinColumn(nullable: false)]
private ?User $account = null;

public function getAccount(): ?User
{
return $this->account;
}

public function setAccount(?User $account): static
{
$this->account = $account;

return $this;
}
}
38 changes: 38 additions & 0 deletions src/Entity/Question.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, SolutionEvent>
*/
Expand Down
37 changes: 37 additions & 0 deletions src/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\OneToMany(targetEntity: HintOpenEvent::class, mappedBy: 'opener', orphanRemoval: true)]
private Collection $hintOpenEvents;

/**
* @var Collection<int, LoginEvent>
*/
#[ORM\OneToMany(targetEntity: LoginEvent::class, mappedBy: 'account', orphanRemoval: true)]
private Collection $loginEvents;

public function __construct()
{
$this->solutionEvents = new ArrayCollection();
$this->solutionVideoEvents = new ArrayCollection();
$this->comments = new ArrayCollection();
$this->commentLikeEvents = new ArrayCollection();
$this->hintOpenEvents = new ArrayCollection();
$this->loginEvents = new ArrayCollection();
}

public function getId(): ?int
Expand Down Expand Up @@ -343,4 +350,34 @@ public function removeHintOpenEvent(HintOpenEvent $hintOpenEvent): static

return $this;
}

/**
* @return Collection<int, LoginEvent>
*/
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;
}
}
38 changes: 38 additions & 0 deletions src/EventSubscriber/LoginSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use App\Entity\LoginEvent;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;

class LoginSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {
}

public function onSecurityAuthenticationSuccess(AuthenticationSuccessEvent $event): void
{
$user = $event->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',
];
}
}
35 changes: 35 additions & 0 deletions src/Repository/LoginEventRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\LoginEvent;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
* @extends ServiceEntityRepository<LoginEvent>
*/
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;
}
}
18 changes: 18 additions & 0 deletions src/Twig/Components/Questions/Card.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}
}
8 changes: 8 additions & 0 deletions templates/components/Challenge/Header.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
<ul class="challenge-header__lists">
<li class="challenge-header__lists__difficulty">{{ question.difficulty|trans }}</li>
<li class="challenge-header__lists__type">{{ question.type }}</li>
<li class="challenge-header__lists__pass_rate">
通過率
<a class="challenge-header__lists__pass_rate__value" href="#" rel="help" data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-title="{{ question.totalAttemptCount }} 次挑戰中有 {{ question.totalSolvedCount }} 次成功">
{{ question.passRate }}%
</a>
</li>
</ul>

<div class="d-flex justify-content-between align-items-end mt-1">
Expand Down
Loading