diff --git a/.gitignore b/.gitignore index 7477abbb..eb1a7ab3 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ composer.lock .phpunit.result.cache /phpunit.xml ###< symfony/phpunit-bridge ### + +###> symfony/asset-mapper ### +/public/assets/ +/assets/vendor/ +###< symfony/asset-mapper ### diff --git a/app/Doctrine/Day.php b/app/Doctrine/Day.php new file mode 100644 index 00000000..bfba5fbb --- /dev/null +++ b/app/Doctrine/Day.php @@ -0,0 +1,58 @@ +match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + + $this->date = $parser->ArithmeticPrimary(); + + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + public function getSql(SqlWalker $sqlWalker): string + { + $platform = $sqlWalker->getConnection()->getDatabasePlatform(); + + if ($platform instanceof MySQLPlatform) { + return sprintf('DAY(%s)', $sqlWalker->walkArithmeticPrimary($this->date)); + } + + if ($platform instanceof PostgreSQLPlatform) { + return sprintf('EXTRACT(DAY FROM %s)', $sqlWalker->walkArithmeticPrimary($this->date)); + } + + if ($platform instanceof SqlitePlatform) { + return sprintf('CAST(STRFTIME("%%d", %s) AS NUMBER)', $sqlWalker->walkArithmeticPrimary($this->date)); + } + + throw new \RuntimeException(sprintf('Platform "%s" is not supported!', $platform::class)); + } +} diff --git a/app/Doctrine/Month.php b/app/Doctrine/Month.php new file mode 100644 index 00000000..79c1c694 --- /dev/null +++ b/app/Doctrine/Month.php @@ -0,0 +1,58 @@ +match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + + $this->date = $parser->ArithmeticPrimary(); + + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + public function getSql(SqlWalker $sqlWalker): string + { + $platform = $sqlWalker->getConnection()->getDatabasePlatform(); + + if ($platform instanceof MySQLPlatform) { + return sprintf('MONTH(%s)', $sqlWalker->walkArithmeticPrimary($this->date)); + } + + if ($platform instanceof PostgreSQLPlatform) { + return sprintf('EXTRACT(MONTH FROM %s)', $sqlWalker->walkArithmeticPrimary($this->date)); + } + + if ($platform instanceof SqlitePlatform) { + return sprintf('CAST(STRFTIME("%%m", %s) AS NUMBER)', $sqlWalker->walkArithmeticPrimary($this->date)); + } + + throw new \RuntimeException(sprintf('Platform "%s" is not supported!', $platform::class)); + } +} diff --git a/app/Doctrine/Year.php b/app/Doctrine/Year.php new file mode 100644 index 00000000..ebd2b41f --- /dev/null +++ b/app/Doctrine/Year.php @@ -0,0 +1,58 @@ +match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + + $this->date = $parser->ArithmeticPrimary(); + + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + public function getSql(SqlWalker $sqlWalker): string + { + $platform = $sqlWalker->getConnection()->getDatabasePlatform(); + + if ($platform instanceof MySQLPlatform) { + return sprintf('YEAR(%s)', $sqlWalker->walkArithmeticPrimary($this->date)); + } + + if ($platform instanceof PostgreSQLPlatform) { + return sprintf('EXTRACT(YEAR FROM %s)', $sqlWalker->walkArithmeticPrimary($this->date)); + } + + if ($platform instanceof SqlitePlatform) { + return sprintf('CAST(STRFTIME("%%Y", %s) AS NUMBER)', $sqlWalker->walkArithmeticPrimary($this->date)); + } + + throw new \RuntimeException(sprintf('Platform "%s" is not supported!', $platform::class)); + } +} diff --git a/app/Repository/TalkRepository.php b/app/Repository/TalkRepository.php index d9de3e2e..1518bfde 100644 --- a/app/Repository/TalkRepository.php +++ b/app/Repository/TalkRepository.php @@ -56,4 +56,47 @@ public function findTotalTalks(\DatePeriod $datePeriod): int return (int) $queryBuilder->getQuery()->getSingleScalarResult(); } + + public function findTalkStatisticsPerDay(\DatePeriod $datePeriod): array + { + return $this->findTalkStatistics($datePeriod, [ + 'year' => 'YEAR(o.startsAt) AS year', + 'month' => 'MONTH(o.startsAt) AS month', + 'day' => 'DAY(o.startsAt) AS day', + ]); + } + + public function findTalkStatisticsPerMonth(\DatePeriod $datePeriod): array + { + return $this->findTalkStatistics($datePeriod, [ + 'year' => 'YEAR(o.startsAt) AS year', + 'month' => 'MONTH(o.startsAt) AS month', + ]); + } + + private function findTalkStatistics(\DatePeriod $datePeriod, array $groupBy): array + { + $queryBuilder = $this->createQueryBuilder('o'); + + $queryBuilder + ->select('COUNT(o.id) AS total') + ->andWhere( + $queryBuilder->expr()->gte('o.startsAt', ':start_date'), + ) + ->andWhere( + $queryBuilder->expr()->lt('o.startsAt', ':end_date'), + ) + ->setParameter('start_date', $datePeriod->getStartDate()) + ->setParameter('end_date', $datePeriod->getEndDate()) + ; + + foreach ($groupBy as $name => $select) { + $queryBuilder + ->addSelect($select) + ->addGroupBy($name) + ; + } + + return $queryBuilder->getQuery()->getArrayResult(); + } } diff --git a/app/Statistics/Provider/DayTalksProvider.php b/app/Statistics/Provider/DayTalksProvider.php new file mode 100644 index 00000000..4f24f701 --- /dev/null +++ b/app/Statistics/Provider/DayTalksProvider.php @@ -0,0 +1,56 @@ + + */ + public function provide(\DatePeriod $datePeriod): array + { + $talkStatistics = $this->talkRepository->findTalkStatisticsPerDay($datePeriod); + + $talks = []; + foreach ($datePeriod as $date) { + $talks[] = [ + 'period' => $date, + 'total' => $this->getTotalForDate($talkStatistics, $date), + ]; + } + + return $talks; + } + + /** @param array $totals */ + private function getTotalForDate(array $totals, \DateTimeInterface $date): int + { + $formattedPeriodDate = $date->format('Y-n-j'); + + foreach ($totals as $entry) { + if ($entry['year'] . '-' . $entry['month'] . '-' . $entry['day'] === $formattedPeriodDate) { + return (int) $entry['total']; + } + } + + return 0; + } +} diff --git a/app/Statistics/Provider/MonthTalksProvider.php b/app/Statistics/Provider/MonthTalksProvider.php new file mode 100644 index 00000000..992096b5 --- /dev/null +++ b/app/Statistics/Provider/MonthTalksProvider.php @@ -0,0 +1,57 @@ + + */ + public function provide(\DatePeriod $datePeriod): array + { + $talkStatistics = $this->talkRepository->findTalkStatisticsPerMonth($datePeriod); + + $talks = []; + foreach ($datePeriod as $date) { + $talks[] = [ + 'period' => $date, + 'total' => $this->getTotalForDate($talkStatistics, $date), + ]; + } + + return $talks; + } + + /** @param array $totals */ + private function getTotalForDate(array $totals, \DateTimeInterface $date): int + { + $formattedPeriodDate = $date->format('Y-n'); + + foreach ($totals as $entry) { + $entryDate = $entry['year'] . '-' . $entry['month']; + if ($formattedPeriodDate === $entryDate) { + return (int) $entry['total']; + } + } + + return 0; + } +} diff --git a/app/Statistics/Provider/StatisticsProvider.php b/app/Statistics/Provider/StatisticsProvider.php index 1625bf6a..d95928d8 100644 --- a/app/Statistics/Provider/StatisticsProvider.php +++ b/app/Statistics/Provider/StatisticsProvider.php @@ -18,13 +18,16 @@ use App\Repository\TalkRepository; use App\Statistics\ValueObject\BusinessActivitySummary; use App\Statistics\ValueObject\Statistics; +use Webmozart\Assert\Assert; final class StatisticsProvider { public function __construct( private readonly TalkRepository $talkRepository, private readonly SpeakerRepository $speakerRepository, - private readonly COnferenceRepository $conferenceRepository, + private readonly ConferenceRepository $conferenceRepository, + private readonly DayTalksProvider $dayTalksProvider, + private readonly MonthTalksProvider $monthTalksProvider, ) { } @@ -32,7 +35,16 @@ public function provide( string $intervalType, \DatePeriod $datePeriod, ): Statistics { + $format = $this->getPeriodFormat($intervalType); + + $talkStatistics = match ($intervalType) { + 'day' => $this->dayTalksProvider->provide($datePeriod), + 'month' => $this->monthTalksProvider->provide($datePeriod), + default => throw new \RuntimeException(sprintf('Getting talks statistics for this "%s" period type is not supported.', $intervalType)) + }; + return new Statistics( + talks: $this->withFormattedDates($talkStatistics, $format), businessActivitySummary: new BusinessActivitySummary( totalTalks: $this->talkRepository->findTotalTalks($datePeriod), totalSpeakers: $this->speakerRepository->findTotalTalks($datePeriod), @@ -40,4 +52,29 @@ public function provide( ), ); } + + /** + * @param array $sales + * + * @return array + */ + private function withFormattedDates(array $sales, string $format): array + { + return array_map(fn (array $entry) => [ + 'period' => $entry['period']->format($format), + 'total' => $entry['total'], + ], $sales); + } + + private function getPeriodFormat(string $intervalType): string + { + $formatsMap = [ + 'day' => 'Y-m-d', + 'month' => 'M Y', + ]; + + Assert::keyExists($formatsMap, $intervalType); + + return $formatsMap[$intervalType]; + } } diff --git a/app/Statistics/ValueObject/Statistics.php b/app/Statistics/ValueObject/Statistics.php index 48f3dc9b..4d12d437 100644 --- a/app/Statistics/ValueObject/Statistics.php +++ b/app/Statistics/ValueObject/Statistics.php @@ -16,6 +16,7 @@ final class Statistics { public function __construct( + public array $talks, public BusinessActivitySummary $businessActivitySummary, ) { } diff --git a/app/Twig/Component/StatisticsComponent.php b/app/Twig/Component/StatisticsComponent.php index 102fdee3..e289c483 100644 --- a/app/Twig/Component/StatisticsComponent.php +++ b/app/Twig/Component/StatisticsComponent.php @@ -61,8 +61,17 @@ public function getStatistics(): array ), ); + $talksSummary = [ + 'intervals' => array_column($statistics->talks ?? [], 'period'), + 'talks' => array_map( + static fn (int $total): string => (string) $total, + array_column($statistics->talks ?? [['total' => 2]], 'total'), + ), + ]; + return [ 'business_activity_summary' => $statistics->businessActivitySummary, + 'talks_summary' => $talksSummary, ]; } diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 00000000..9c301b51 --- /dev/null +++ b/assets/app.js @@ -0,0 +1 @@ +import './scripts/statistics_chart.js'; diff --git a/assets/controllers.json b/assets/controllers.json new file mode 100644 index 00000000..45331d0f --- /dev/null +++ b/assets/controllers.json @@ -0,0 +1,4 @@ +{ + "controllers": {}, + "entrypoints": [] +} diff --git a/assets/controllers/.gitignore b/assets/controllers/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/assets/scripts/statistics_chart.js b/assets/scripts/statistics_chart.js new file mode 100644 index 00000000..a0db86b4 --- /dev/null +++ b/assets/scripts/statistics_chart.js @@ -0,0 +1,118 @@ +import ApexCharts from 'apexcharts'; + +let chart = null; +function renderChart() { + // eslint-disable-next-line no-undef + const statisticsChart = document.querySelector('#statistics-chart'); + + if (!statisticsChart) { + return; + } + + const options = { + colors: ['#32be9f'], + fill: { + colors: ['#32be9f'] + }, + series: [{ + name: 'talks', + data: JSON.parse(statisticsChart.dataset.talks) + }], + chart: { + toolbar: { + show: false + }, + height: 350, + type: 'bar' + }, + plotOptions: { + bar: { + borderRadius: 4, + dataLabels: { + position: 'top' // top, center, bottom + } + } + }, + dataLabels: { + enabled: true, + formatter(val) { + return `${val}`; + }, + offsetY: -20, + style: { + fontSize: '12px', + colors: ['#304758'] + } + }, + xaxis: { + categories: JSON.parse(statisticsChart.dataset.intervals), + position: 'top', + axisBorder: { + show: false + }, + axisTicks: { + show: false + }, + crosshairs: { + fill: { + type: 'gradient', + gradient: { + colorFrom: '#32be9f', + colorTo: '#2a9f83', + stops: [0, 100], + opacityFrom: 0.4, + opacityTo: 0.5 + } + } + }, + tooltip: { + enabled: true + } + }, + yaxis: { + axisBorder: { + show: false + }, + axisTicks: { + show: false + }, + labels: { + show: false, + formatter(val) { + return `${val}`; + } + } + + }, + title: { + floating: true, + offsetY: 330, + align: 'center', + style: { + color: '#444' + } + } + }; + + chart = new ApexCharts(statisticsChart, options); + chart.render(); +} + +renderChart(); + +const element = document.querySelector('#statistics-chart'); + +if (element) { + const observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + if (mutation.attributeName === 'data-talks' || mutation.attributeName === 'data-intervals') { + chart.destroy(); + renderChart(); + } + }); + }); + + observer.observe(element, { + attributes: true + }); +} diff --git a/composer.json b/composer.json index dd7def1b..075290b2 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "sylius/grid-bundle": "^1.13", "sylius/resource-bundle": "^1.11", "symfony/asset": "^6.4 || ^7.0", + "symfony/asset-mapper": "^6.4 || ^7.0", "symfony/config": "^6.4 || ^7.0", "symfony/dependency-injection": "^6.4 || ^7.0", "symfony/expression-language": "^6.4 || ^7.0", @@ -109,5 +110,18 @@ "symfony": { "require": "7.1.*" } + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd", + "importmap:install": "symfony-cmd" + }, + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ] } } diff --git a/config/packages/asset_mapper.yaml b/config/packages/asset_mapper.yaml new file mode 100644 index 00000000..f7653e97 --- /dev/null +++ b/config/packages/asset_mapper.yaml @@ -0,0 +1,11 @@ +framework: + asset_mapper: + # The paths to make available to the asset mapper. + paths: + - assets/ + missing_import_mode: strict + +when@prod: + framework: + asset_mapper: + missing_import_mode: warn diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 014612d5..cb99e00a 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -22,6 +22,11 @@ doctrine: dir: '%kernel.project_dir%/app/Entity' prefix: 'App\Entity' alias: App + dql: + string_functions: + DAY: 'App\Doctrine\Day' + MONTH: 'App\Doctrine\Month' + YEAR: 'App\Doctrine\Year' when@test: doctrine: diff --git a/config/sylius/twig_hooks/common/base.php b/config/sylius/twig_hooks/common/base.php new file mode 100644 index 00000000..801ae11f --- /dev/null +++ b/config/sylius/twig_hooks/common/base.php @@ -0,0 +1,27 @@ +extension('sylius_twig_hooks', [ + 'hooks' => [ + 'sylius_admin.base#javascripts' => [ + 'app' => [ + 'priority' => 200, + 'template' => 'base/javascripts/app.html.twig', + ], + ], + ], + ]); +}; diff --git a/importmap.php b/importmap.php new file mode 100644 index 00000000..319d0239 --- /dev/null +++ b/importmap.php @@ -0,0 +1,25 @@ + [ + 'path' => './assets/app.js', + 'entrypoint' => true, + ], + '@hotwired/stimulus' => [ + 'version' => '3.2.2', + ], + 'apexcharts' => [ + 'version' => '4.4.0', + ], +]; diff --git a/templates/base.html.twig b/templates/base.html.twig index d345cd65..b16e2493 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -8,6 +8,7 @@ {% endblock %} {% block javascripts %} + {% block importmap %}{{ importmap('app') }}{% endblock %} {% endblock %} diff --git a/templates/base/javascripts/app.html.twig b/templates/base/javascripts/app.html.twig new file mode 100644 index 00000000..96ac79ea --- /dev/null +++ b/templates/base/javascripts/app.html.twig @@ -0,0 +1 @@ +{{ importmap('app') }} diff --git a/templates/dashboard/index/content/statistics_chart.html.twig b/templates/dashboard/index/content/statistics_chart.html.twig index 5181cdfa..ed851cb5 100644 --- a/templates/dashboard/index/content/statistics_chart.html.twig +++ b/templates/dashboard/index/content/statistics_chart.html.twig @@ -58,5 +58,6 @@ +