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,
});
});
56 changes: 55 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 Expand Up @@ -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;
}
}
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);
}
}
1 change: 1 addition & 0 deletions src/Controller/CommentsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ public function likes(
'y' => [
'ticks' => [
'beginAtZero' => true,
'min' => 0,
'stepSize' => 5,
],
],
Expand Down
22 changes: 22 additions & 0 deletions src/Controller/OverviewCardsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
}
}
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;
}
}
Loading
Loading