├── tests ├── .gitignore ├── bootstrap.php ├── Trait │ └── GuzzleCachedClientTraitTest.php └── Doctrine │ └── Builder │ ├── SkillValueBuilder.php │ ├── ActivityBuilder.php │ └── QuestBuilder.php ├── migrations ├── .gitignore ├── Version20231020183902.php ├── Version20240130182934.php ├── Version20250212235809.php ├── Version20230318074226.php ├── Version20250209124013.php ├── Version20250208113115.php └── Version20230304174431.php ├── src ├── Entity │ ├── .gitignore │ ├── KnownPlayer.php │ └── Player.php ├── Controller │ ├── .gitignore │ ├── NewsFeedController.php │ ├── DashboardController.php │ ├── WelcomeController.php │ ├── GrandExchangeController.php │ ├── AbstractBaseController.php │ ├── QuestsController.php │ ├── ActivityController.php │ └── XpTrackerController.php ├── Repository │ ├── .gitignore │ └── KnownPlayerRepository.php ├── Dto │ ├── JsonbDTOInterface.php │ ├── SkillValue.php │ ├── Quest.php │ ├── QuestResponse.php │ └── Activity.php ├── Message │ ├── AsyncEventInterface.php │ ├── Clan │ │ └── UpdateAllPlayerClanNamesMessage.php │ └── Stats │ │ ├── UpdateSingularPlayerStatsMessage.php │ │ └── UpdateAllPlayerStatsMessage.php ├── Exception │ ├── PlayerApi │ │ ├── PlayerNotFoundException.php │ │ ├── PlayerNotAMemberException.php │ │ ├── PlayerApiHttpException.php │ │ └── PlayerApiDataConversionException.php │ ├── RateLimitedException.php │ └── Jsonb │ │ ├── JsonbConvertToPHPValueException.php │ │ └── JsonbConvertToDatabaseValueException.php ├── Enum │ ├── UpdateAllPlayersType.php │ ├── QuestStatus.php │ ├── QuestDifficulty.php │ ├── XpChartTimeUnit.php │ ├── KnownPlayers.php │ ├── ActivityFilter.php │ ├── CatalogueCategory.php │ ├── SkillEnum.php │ ├── SkillLevelXpEnum.php │ └── EliteSkillLevelXpEnum.php ├── Kernel.php ├── ValueObject │ └── GrandExchange │ │ ├── CataloguePriceDetails.php │ │ ├── CatalogueResponseCategory.php │ │ ├── CatalogueItem.php │ │ ├── CatalogueResponse.php │ │ └── CatalogueResponseCollection.php ├── Doctrine │ └── Type │ │ ├── QuestType.php │ │ ├── SkillValueType.php │ │ ├── ActivityType.php │ │ └── CustomJsonbType.php ├── Scheduler │ └── CronScheduler.php ├── MessageHandler │ ├── Clan │ │ └── UpdateAllPlayerClanNamesHandler.php │ └── Stats │ │ ├── UpdateAllPlayerStatsHandler.php │ │ └── UpdateSingularPlayerStatsHandler.php ├── Command │ ├── RsApiUpdate │ │ ├── UpdateClanCommand.php │ │ ├── Singular.php │ │ ├── All.php │ │ └── HandleSingularPlayerTrait.php │ └── VerifyPlayerDataIntegrityCommand.php ├── Service │ ├── Chart │ │ ├── QuestChartService.php │ │ └── Xp │ │ │ ├── DailyChartService.php │ │ │ └── HourlyChartService.php │ ├── GuzzleCachedClient.php │ ├── DoubleXpService.php │ └── GrandExchangeApiService.php ├── EventListener │ └── RequestListener.php └── Trait │ ├── SerializerAwareTrait.php │ ├── GuzzleCachedClientTrait.php │ └── CreateXpChartTrait.php ├── translations └── .gitignore ├── assets ├── controllers │ ├── .gitignore │ └── activities_controller.js ├── app.js ├── controllers.json └── bootstrap.js ├── config ├── routes.yaml ├── routes │ ├── framework.yaml │ └── web_profiler.yaml ├── packages │ ├── twig.yaml │ ├── translation.yaml │ ├── doctrine_migrations.yaml │ ├── csrf.yaml │ ├── stof_doctrine_extensions.yaml │ ├── routing.yaml │ ├── web_profiler.yaml │ ├── messenger.yaml │ ├── datatables.yaml │ ├── cache.yaml │ ├── framework.yaml │ ├── monolog.yaml │ ├── webpack_encore.yaml │ └── doctrine.yaml ├── preload.php ├── services.yaml └── bundles.php ├── phpstan.neon ├── docker-compose.override.yml ├── .env.test ├── public └── index.php ├── templates ├── includes │ ├── alerts.html.twig │ ├── badges │ │ ├── github.html.twig │ │ └── discord.html.twig │ ├── header.html.twig │ └── sidemenu.html.twig ├── quests.html.twig ├── base.html.twig ├── xp_tracker.html.twig ├── levels_progress.html.twig ├── newsfeed.html.twig ├── grand_exchange │ └── index.html.twig ├── activities.html.twig └── welcome.html.twig ├── docker-compose.yml ├── bin ├── console └── phpunit ├── phpcs.xml.dist ├── .github └── workflows │ └── lintAndTests.yml ├── .gitignore ├── LICENSE ├── package.json ├── phpunit.xml.dist ├── .env ├── webpack.config.js ├── composer.json └── README.md /tests/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Entity/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /translations/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Controller/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Repository/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/controllers/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Dto/JsonbDTOInterface.php: -------------------------------------------------------------------------------- 1 | doctrine/doctrine-bundle ### 5 | database: 6 | ports: 7 | - "5432:5432" 8 | ###< doctrine/doctrine-bundle ### 9 | -------------------------------------------------------------------------------- /src/Exception/PlayerApi/PlayerApiHttpException.php: -------------------------------------------------------------------------------- 1 | 5 | {{ message }} 6 | 7 | {% endfor %} 8 | {% endfor %} 9 | -------------------------------------------------------------------------------- /src/Message/Stats/UpdateSingularPlayerStatsMessage.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__) . '/.env'); 11 | } 12 | 13 | if ($_SERVER['APP_DEBUG']) { 14 | umask(0000); 15 | } 16 | -------------------------------------------------------------------------------- /assets/bootstrap.js: -------------------------------------------------------------------------------- 1 | import { startStimulusApp } from '@symfony/stimulus-bridge'; 2 | 3 | // Registers Stimulus controllers from controllers.json and in the controllers/ directory 4 | export const app = startStimulusApp(require.context( 5 | '@symfony/stimulus-bridge/lazy-controller-loader!./controllers', 6 | true, 7 | /\.[jt]sx?$/ 8 | )); 9 | 10 | // register any custom, 3rd party controllers here 11 | // app.register('some_controller_name', SomeImportedController); 12 | -------------------------------------------------------------------------------- /src/Enum/KnownPlayers.php: -------------------------------------------------------------------------------- 1 | value; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | ###> doctrine/doctrine-bundle ### 4 | database: 5 | image: postgres:${POSTGRES_VERSION:-15}-alpine 6 | restart: always 7 | environment: 8 | POSTGRES_DB: ${POSTGRES_DB:-app} 9 | # You should definitely change the password in production 10 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!} 11 | POSTGRES_USER: ${POSTGRES_USER:-app} 12 | volumes: 13 | - database_data:/var/lib/postgresql/data:rw 14 | 15 | volumes: 16 | database_data: -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | bin/ 14 | config/ 15 | public/ 16 | src/ 17 | tests/ 18 | 19 | -------------------------------------------------------------------------------- /src/ValueObject/GrandExchange/CatalogueItem.php: -------------------------------------------------------------------------------- 1 | value] = strtolower($enumValue->name); 24 | } 25 | 26 | return $values; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ###> symfony/framework-bundle ### 2 | /.env.local 3 | /.env.local.php 4 | /.env.*.local 5 | /config/secrets/prod/prod.decrypt.private.php 6 | /public/bundles/ 7 | /var/ 8 | /vendor/ 9 | ###< symfony/framework-bundle ### 10 | 11 | ###> symfony/webpack-encore-bundle ### 12 | /node_modules/ 13 | /public/build/ 14 | npm-debug.log 15 | yarn-error.log 16 | ###< symfony/webpack-encore-bundle ### 17 | 18 | ###> .idea ### 19 | /.idea/ 20 | ###< .idea ### 21 | 22 | ###> squizlabs/php_codesniffer ### 23 | /.phpcs-cache 24 | /phpcs.xml 25 | ###< squizlabs/php_codesniffer ### 26 | 27 | ###> symfony/phpunit-bridge ### 28 | .phpunit.result.cache 29 | /phpunit.xml 30 | ###< symfony/phpunit-bridge ### 31 | -------------------------------------------------------------------------------- /bin/phpunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getClient(); 16 | $this->assertInstanceOf(Client::class, $client); 17 | } 18 | 19 | public function testGetClientReturnsSameInstance(): void 20 | { 21 | $client1 = $this->getClient(); 22 | $client2 = $this->getClient(); 23 | 24 | $this->assertSame($client1, $client2); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /migrations/Version20231020183902.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE player ALTER total_xp TYPE BIGINT'); 23 | } 24 | 25 | public function down(Schema $schema): void 26 | { 27 | $this->addSql('ALTER TABLE player ALTER total_xp TYPE INT'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config/packages/datatables.yaml: -------------------------------------------------------------------------------- 1 | # Latest documentation available at https://omines.github.io/datatables-bundle/#configuration 2 | datatables: 3 | # Set options, as documented at https://datatables.net/reference/option/ 4 | options: 5 | lengthMenu : [10, 25, 50, 100, 250, 500, 1000, 2500] 6 | pageLength: 50 7 | dom: "<'row' <'col-sm-12' tr>><'row' <'col-sm-6'l><'col-sm-6 text-right'pi>>" 8 | 9 | template_parameters: 10 | # Example classes to integrate nicely with Bootstrap 3.x 11 | className: 'table table-striped table-bordered table-hover data-table' 12 | columnFilter: 'both' 13 | 14 | # You can for example override this to "tables" to keep the translation domains separated nicely 15 | translation_domain: 'messages' 16 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | app: cache.adapter.filesystem 4 | system: cache.adapter.system 5 | # Unique name of your app: used to compute stable namespaces for cache keys. 6 | #prefix_seed: your_vendor_name/app_name 7 | 8 | # The "app" cache stores to the filesystem by default. 9 | # The data in this cache should persist between deploys. 10 | # Other options include: 11 | 12 | # Redis 13 | #app: cache.adapter.redis 14 | #default_redis_provider: redis://localhost 15 | 16 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 17 | #app: cache.adapter.apcu 18 | 19 | # Namespaced pools use the above "app" backend by default 20 | #pools: 21 | #my.dedicated.cache: null 22 | -------------------------------------------------------------------------------- /src/Scheduler/CronScheduler.php: -------------------------------------------------------------------------------- 1 | add(RecurringMessage::cron('*/2 * * * *', new UpdateAllPlayerStatsMessage(UpdateAllPlayersType::ACTIVE))) 20 | ->add(RecurringMessage::cron('0 * * * *', new UpdateAllPlayerClanNamesMessage())); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/ValueObject/GrandExchange/CatalogueResponse.php: -------------------------------------------------------------------------------- 1 | items; 23 | } 24 | 25 | /** 26 | * @param CatalogueItem[] $items 27 | */ 28 | public function setItems(array $items): void 29 | { 30 | $this->items = $items; 31 | } 32 | 33 | /** 34 | * @param CatalogueItem $item 35 | */ 36 | public function addItem(CatalogueItem $item): void 37 | { 38 | $this->items[] = $item; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /templates/includes/badges/github.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 11 | View on GitHub 12 | 13 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | # see https://symfony.com/doc/current/reference/configuration/framework.html 2 | framework: 3 | secret: '%env(APP_SECRET)%' 4 | #csrf_protection: true 5 | http_method_override: false 6 | handle_all_throwables: true 7 | 8 | #disable the translation component 9 | translator: 10 | enabled: false 11 | 12 | # Enables session support. Note that the session will ONLY be started if you read or write from it. 13 | # Remove or comment this section to explicitly disable session support. 14 | session: 15 | handler_id: null 16 | cookie_secure: auto 17 | cookie_samesite: lax 18 | storage_factory_id: session.storage.factory.native 19 | 20 | #esi: true 21 | #fragments: true 22 | php_errors: 23 | log: true 24 | 25 | when@test: 26 | framework: 27 | test: true 28 | session: 29 | storage_factory_id: session.storage.factory.mock_file 30 | -------------------------------------------------------------------------------- /src/ValueObject/GrandExchange/CatalogueResponseCollection.php: -------------------------------------------------------------------------------- 1 | categories; 21 | } 22 | 23 | /** 24 | * @param CatalogueResponseCategory[] $categories 25 | */ 26 | public function setCategories(array $categories): void 27 | { 28 | $this->categories = $categories; 29 | } 30 | 31 | /** 32 | * @param CatalogueResponseCategory $category 33 | */ 34 | public function addCategory(CatalogueResponseCategory $category): void 35 | { 36 | $this->categories[] = $category; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/MessageHandler/Clan/UpdateAllPlayerClanNamesHandler.php: -------------------------------------------------------------------------------- 1 | knownPlayerRepository->findAllNames(); 22 | $players = $this->rsApiService->getClanNames($knownPlayers); 23 | 24 | foreach ($players as $player) { 25 | $this->knownPlayerRepository->updateClanName($player['name'], $player['clan']); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | # This file is the entry point to configure your own services. 2 | # Files in the packages/ subdirectory configure your dependencies. 3 | 4 | # Put parameters here that don't need to change on each machine where the app is deployed 5 | # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration 6 | parameters: 7 | 8 | services: 9 | # default configuration for services in *this* file 10 | _defaults: 11 | autowire: true # Automatically injects dependencies in your services. 12 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 13 | 14 | # makes classes in src/ available to be used as services 15 | # this creates a service per class whose id is the fully-qualified class name 16 | App\: 17 | resource: '../src/' 18 | exclude: 19 | - '../src/DependencyInjection/' 20 | - '../src/Entity/' 21 | - '../src/Kernel.php' 22 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 6 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 7 | Symfony\UX\Chartjs\ChartjsBundle::class => ['all' => true], 8 | Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], 9 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 10 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 11 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 12 | Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true], 13 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 14 | Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], 15 | Omines\DataTablesBundle\DataTablesBundle::class => ['all' => true], 16 | ]; 17 | -------------------------------------------------------------------------------- /assets/controllers/activities_controller.js: -------------------------------------------------------------------------------- 1 | import {Controller} from "@hotwired/stimulus"; 2 | 3 | export default class extends Controller { 4 | connect() { 5 | super.connect(); 6 | 7 | //Get the button 8 | let mybutton = document.getElementById("btn-back-to-top"); 9 | 10 | // When the user scrolls down 20px from the top of the document, show the button 11 | window.onscroll = function () { 12 | if ( 13 | document.body.scrollTop > 20 || 14 | document.documentElement.scrollTop > 20 15 | ) { 16 | mybutton.style.display = "block"; 17 | } else { 18 | mybutton.style.display = "none"; 19 | } 20 | }; 21 | 22 | // When the user clicks on the button, scroll to the top of the document 23 | mybutton.addEventListener("click", function () { 24 | document.body.scrollTop = 0; 25 | document.documentElement.scrollTop = 0; 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Dto/QuestResponse.php: -------------------------------------------------------------------------------- 1 | loggedIn; 15 | } 16 | 17 | public function setLoggedIn(?string $loggedIn): QuestResponse 18 | { 19 | $this->loggedIn = $loggedIn; 20 | return $this; 21 | } 22 | 23 | /** 24 | * @return Quest[]|null 25 | */ 26 | public function getQuests(): ?array 27 | { 28 | return $this->quests; 29 | } 30 | 31 | /** 32 | * @param Quest[]|null $quests 33 | * @return QuestResponse 34 | */ 35 | public function setQuests(?array $quests): QuestResponse 36 | { 37 | $this->quests = $quests; 38 | return $this; 39 | } 40 | 41 | public function addQuest(Quest $quest): QuestResponse 42 | { 43 | $this->quests[] = $quest; 44 | return $this; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Doctrine/Builder/SkillValueBuilder.php: -------------------------------------------------------------------------------- 1 | skillValue = new SkillValue(); 15 | } 16 | 17 | public function build(): SkillValue 18 | { 19 | return $this->skillValue; 20 | } 21 | 22 | public function withId(?SkillEnum $id): self 23 | { 24 | $this->skillValue->id = $id; 25 | return $this; 26 | } 27 | 28 | public function withLevel(?int $level): self 29 | { 30 | $this->skillValue->level = $level; 31 | return $this; 32 | } 33 | 34 | public function withXp(int|float|null $xp): self 35 | { 36 | $this->skillValue->xp = $xp; 37 | return $this; 38 | } 39 | 40 | public function withRank(?int $rank): self 41 | { 42 | $this->skillValue->rank = $rank; 43 | return $this; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /templates/includes/header.html.twig: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |
6 |
7 |
8 | {% include 'includes/badges/github.html.twig' %} 9 | {% include 'includes/badges/discord.html.twig' %} 10 |
11 |
12 |
13 | {% if app.current_route != 'welcome' %} 14 |
{% include 'includes/alerts.html.twig' %}
15 | {% if form is defined %} 16 | {{ form(form) }} 17 | {% endif %} 18 | {% endif %} 19 |
20 |
21 |
22 |
23 |
24 |
25 | -------------------------------------------------------------------------------- /tests/Doctrine/Builder/ActivityBuilder.php: -------------------------------------------------------------------------------- 1 | activity = new Activity(); 15 | } 16 | 17 | public function build(): Activity 18 | { 19 | return $this->activity; 20 | } 21 | 22 | public function withDate(?DateTimeImmutable $dateTimeImmutable = null): self 23 | { 24 | if ($dateTimeImmutable === null) { 25 | $dateTimeImmutable = new DateTimeImmutable(); 26 | } 27 | 28 | $this->activity->date = $dateTimeImmutable; 29 | return $this; 30 | } 31 | 32 | public function withDetails(?string $details = 'details'): self 33 | { 34 | $this->activity->details = $details; 35 | return $this; 36 | } 37 | 38 | public function withText(?string $text = 'text'): self 39 | { 40 | $this->activity->text = $text; 41 | return $this; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Dto/Activity.php: -------------------------------------------------------------------------------- 1 | 'Y-m-d H:i:s']), 24 | new ArrayDenormalizer(), 25 | new ObjectNormalizer(propertyTypeExtractor: new ReflectionExtractor()) 26 | ], 27 | encoders: [ 28 | new JsonEncoder() 29 | ] 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 VincentPS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Controller/NewsFeedController.php: -------------------------------------------------------------------------------- 1 | addFlash( 21 | 'danger', 22 | 'Unable to load the news feed. This is likely due to a temporary issue with the RSS feed.' . 23 | ' Please try again later.' 24 | ); 25 | return $this->redirectToRoute('summary'); 26 | } 27 | 28 | return $this->render('newsfeed.html.twig', [ 29 | 'rss' => $feed, 30 | ]); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /config/packages/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | use_microseconds: false 3 | channels: 4 | - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists 5 | 6 | when@dev: 7 | monolog: 8 | handlers: 9 | main: 10 | type: stream 11 | path: "%kernel.logs_dir%/%kernel.environment%.log" 12 | level: debug 13 | channels: [ "!event", "!doctrine" ] 14 | console: 15 | type: console 16 | process_psr_3_messages: false 17 | channels: [ "!event", "!doctrine", "!console" ] 18 | 19 | when@test: 20 | monolog: 21 | handlers: 22 | main: 23 | type: fingers_crossed 24 | action_level: error 25 | handler: nested 26 | excluded_http_codes: [ 404, 405 ] 27 | channels: [ "!event" ] 28 | nested: 29 | type: stream 30 | path: "%kernel.logs_dir%/%kernel.environment%.log" 31 | level: debug 32 | 33 | when@prod: 34 | monolog: 35 | handlers: 36 | graylog: 37 | type: gelf 38 | publisher: 39 | hostname: "%env(GRAYLOG_HOST)%" 40 | port: "%env(GRAYLOG_PORT)%" 41 | level: '%env(LOG_LEVEL)%' 42 | -------------------------------------------------------------------------------- /migrations/Version20240130182934.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE INDEX IDX_98197A65B5F1AFE5 ON player USING gin(activities)'); 24 | $this->addSql('CREATE INDEX IDX_98197A6515ED18F2 ON player USING gin(skill_values)'); 25 | $this->addSql('CREATE INDEX IDX_98197A65989E5D34 ON player USING gin(quests)'); 26 | } 27 | 28 | public function down(Schema $schema): void 29 | { 30 | // this down() migration is auto-generated, please modify it to your needs 31 | $this->addSql('DROP INDEX IDX_98197A65B5F1AFE5'); 32 | $this->addSql('DROP INDEX IDX_98197A6515ED18F2'); 33 | $this->addSql('DROP INDEX IDX_98197A65989E5D34'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@babel/core": "^7.17.0", 4 | "@babel/plugin-proposal-class-properties": "^7.18.6", 5 | "@babel/preset-env": "^7.16.0", 6 | "@hotwired/stimulus": "^3.0.0", 7 | "@symfony/stimulus-bridge": "^3.2.0", 8 | "@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets", 9 | "@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/assets", 10 | "@symfony/webpack-encore": "^4.2.0", 11 | "bootstrap": "^5.3.2", 12 | "chart.js": "^3.4.1", 13 | "chartjs-plugin-trendline": "^2.1.6", 14 | "chartjs-plugin-zoom": "^2.2.0", 15 | "core-js": "^3.23.0", 16 | "datatables.net": "^1.12.1", 17 | "datatables.net-dt": "^1.12.1", 18 | "jquery": "^3.6.1", 19 | "regenerator-runtime": "^0.13.9", 20 | "webpack": "^5.74.0", 21 | "webpack-cli": "^4.10.0", 22 | "webpack-notifier": "^1.15.0" 23 | }, 24 | "license": "UNLICENSED", 25 | "private": true, 26 | "scripts": { 27 | "dev-server": "encore dev-server", 28 | "dev": "encore dev", 29 | "watch": "encore dev --watch", 30 | "build": "encore production --progress" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Command/RsApiUpdate/UpdateClanCommand.php: -------------------------------------------------------------------------------- 1 | messageBus->dispatch(new UpdateAllPlayerClanNamesMessage()); 29 | $io->success('Clan data update initiated for all known players.'); 30 | 31 | return Command::SUCCESS; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /templates/includes/badges/discord.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 6 | Join us on Discord 7 | 8 | -------------------------------------------------------------------------------- /src/Service/Chart/QuestChartService.php: -------------------------------------------------------------------------------- 1 | chartBuilder->createChart(Chart::TYPE_DOUGHNUT) 19 | ->setOptions(['color' => 'rgb(255, 255, 255)']) 20 | ->setData([ 21 | 'labels' => ['Completed', 'In Progress', 'Not Started'], 22 | 'datasets' => [ 23 | [ 24 | 'backgroundColor' => ['rgb(225,187,52)', 'rgb(52,189,209)', 'rgb(197,32,55)'], 25 | 'borderColor' => 'rgb(0, 0, 0)', 26 | 'data' => [ 27 | $player->getQuestsCompleted(), 28 | $player->getQuestsStarted(), 29 | $player->getQuestsNotStarted() 30 | ] 31 | ] 32 | ] 33 | ]); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | tests 21 | 22 | 23 | 24 | 25 | 26 | src 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /migrations/Version20250212235809.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE known_player ADD last_used_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); 24 | $this->addSql('COMMENT ON COLUMN known_player.last_used_at IS \'(DC2Type:datetime_immutable)\''); 25 | $this->addSql('UPDATE known_player SET last_used_at = NOW()'); 26 | $this->addSql('ALTER TABLE known_player ALTER last_used_at SET NOT NULL'); 27 | } 28 | 29 | public function down(Schema $schema): void 30 | { 31 | // this down() migration is auto-generated, please modify it to your needs 32 | $this->addSql('ALTER TABLE known_player DROP last_used_at'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Doctrine/Type/ActivityType.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | protected function normalizers(): array 32 | { 33 | return [ 34 | new DateTimeNormalizer(['datetime_format' => 'Y-m-d H:i:s']), 35 | new ArrayDenormalizer(), 36 | new BackedEnumNormalizer(), 37 | new ObjectNormalizer(propertyTypeExtractor: new ReflectionExtractor()) 38 | ]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Service/GuzzleCachedClient.php: -------------------------------------------------------------------------------- 1 | kernel->getProjectDir() . '/var/cache'; 24 | $cache_storage = new Psr6CacheStorage( 25 | new FilesystemAdapter( 26 | $requestCacheFolderName, 27 | 600, 28 | $cacheFolderPath 29 | ) 30 | ); 31 | 32 | $stack = HandlerStack::create(); 33 | $stack->push( 34 | new CacheMiddleware( 35 | new GreedyCacheStrategy( 36 | $cache_storage, 37 | 600 38 | ) 39 | ), 40 | 'greedy-cache' 41 | ); 42 | 43 | return new Client(['handler' => $stack]); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/EventListener/RequestListener.php: -------------------------------------------------------------------------------- 1 | isMainRequest()) { 15 | // don't do anything if it's not the main request 16 | return; 17 | } 18 | 19 | $ignoredRoutes = ['welcome']; 20 | 21 | /** @var string $currentRoute */ 22 | $currentRoute = $event->getRequest()->attributes->get('_route'); 23 | 24 | if ( 25 | !str_starts_with($currentRoute, '_wdt') 26 | && !str_starts_with($currentRoute, '_profiler') 27 | && !in_array($currentRoute, $ignoredRoutes, true) 28 | && empty($event->getRequest()->getSession()->get('currentPlayerName')) 29 | ) { 30 | $event->setResponse(new RedirectResponse('/welcome')); 31 | } 32 | 33 | if ($currentRoute === 'welcome' && !empty($event->getRequest()->getSession()->get('currentPlayerName'))) { 34 | $event->setResponse(new RedirectResponse('/')); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /templates/quests.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}RM Tracker - Quests{% endblock %} 4 | 5 | {% block body %} 6 |
7 | {% include 'includes/header.html.twig' %} 8 | 9 | 10 |
11 | {% include 'includes/sidemenu.html.twig' %} 12 | 13 | 14 |
15 | 18 |
19 |
20 |

QUESTS

21 |
22 |
23 |
24 |
Loading...
25 |
26 |
27 |
28 |
29 |
30 |
31 | 32 | 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /tests/Doctrine/Builder/QuestBuilder.php: -------------------------------------------------------------------------------- 1 | quest = new Quest(); 16 | } 17 | 18 | public function build(): Quest 19 | { 20 | return $this->quest; 21 | } 22 | 23 | public function withTitle(?string $title): self 24 | { 25 | $this->quest->title = $title; 26 | return $this; 27 | } 28 | 29 | public function withStatus(?QuestStatus $status): self 30 | { 31 | $this->quest->status = $status; 32 | return $this; 33 | } 34 | 35 | public function withDifficulty(?QuestDifficulty $difficulty): self 36 | { 37 | $this->quest->difficulty = $difficulty; 38 | return $this; 39 | } 40 | 41 | public function withMembers(?bool $members): self 42 | { 43 | $this->quest->members = $members; 44 | return $this; 45 | } 46 | 47 | public function withQuestPoints(?int $questPoints): self 48 | { 49 | $this->quest->questPoints = $questPoints; 50 | return $this; 51 | } 52 | 53 | public function withUserEligible(?bool $userEligible): self 54 | { 55 | $this->quest->userEligible = $userEligible; 56 | return $this; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Controller/DashboardController.php: -------------------------------------------------------------------------------- 1 | headerSearchForm(); 22 | $playerName = $this->getCurrentPlayerName(); 23 | $player = $playerRepository->findLatestByName($playerName); 24 | $knownPlayer = $knownPlayerRepository->findOneByName($playerName); 25 | 26 | if (is_null($player)) { 27 | return $this->redirectToRoute('welcome'); 28 | } 29 | 30 | return $this->render('summary.html.twig', [ 31 | 'chart' => $questChartService->getChart($player), 32 | 'playerInfo' => $player, 33 | 'clanName' => $knownPlayer?->getClanName(), 34 | 'form' => $form->createView(), 35 | 'isDoubleXpLive' => $doubleXpService->isDoubleXpLive(), 36 | ]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/MessageHandler/Stats/UpdateAllPlayerStatsHandler.php: -------------------------------------------------------------------------------- 1 | type) { 27 | UpdateAllPlayersType::ACTIVE => $this->knownPlayerRepository->findAllActive(), 28 | UpdateAllPlayersType::INACTIVE => $this->knownPlayerRepository->findAllInactive(), 29 | }; 30 | } catch (DateMalformedStringException) { 31 | // Do nothing because this cannot happen unless the ACTIVITY_THRESHOLD is changed incorrectly 32 | return; 33 | } 34 | 35 | foreach ($knownPlayers as $knownPlayer) { 36 | $this->messageBus->dispatch(new UpdateSingularPlayerStatsMessage((string)$knownPlayer->getName())); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Trait/SerializerAwareTrait.php: -------------------------------------------------------------------------------- 1 | serializer)) { 22 | $this->serializer = new Serializer( 23 | [ 24 | new DateTimeNormalizer(), 25 | new ArrayDenormalizer(), 26 | new BackedEnumNormalizer(), 27 | new ObjectNormalizer( 28 | classMetadataFactory: new ClassMetadataFactory(new AttributeLoader()), 29 | propertyTypeExtractor: new ReflectionExtractor() 30 | ) 31 | ], 32 | [ 33 | new JsonEncoder() 34 | ] 35 | ); 36 | } 37 | 38 | return $this->serializer; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # * .env.$APP_ENV committed environment-specific defaults 7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 8 | # 9 | # Real environment variables win over .env files. 10 | # 11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 12 | # https://symfony.com/doc/current/configuration/secrets.html 13 | # 14 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 15 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 16 | 17 | ###> symfony/framework-bundle ### 18 | APP_ENV=dev 19 | APP_SECRET=4f7cf8431ac48d54c64fb10a6abb3cc9 20 | ###< symfony/framework-bundle ### 21 | 22 | ###> doctrine/doctrine-bundle ### 23 | DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=15&charset=utf8" 24 | ###< doctrine/doctrine-bundle ### 25 | 26 | ###> symfony/messenger ### 27 | # Choose one of the transports below 28 | # MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages 29 | # MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages 30 | MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 31 | ###< symfony/messenger ### 32 | 33 | ###> graylog2/gelf-php ### 34 | GRAYLOG_HOST="" 35 | GRAYLOG_PORT="" 36 | LOG_LEVEL=debug 37 | ###< graylog2/gelf-php ### -------------------------------------------------------------------------------- /migrations/Version20230318074226.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE player ALTER activities TYPE jsonb'); 24 | $this->addSql('ALTER TABLE player ALTER skill_values TYPE jsonb'); 25 | $this->addSql('ALTER TABLE player ALTER quests TYPE jsonb'); 26 | $this->addSql('CREATE INDEX IDX_98197A655E237E068B8E8428A5D4CED1 ON player (name, created_at, total_xp)'); 27 | $this->addSql('CREATE INDEX IDX_98197A658B8E8428A5D4CED1 ON player (created_at, total_xp)'); 28 | } 29 | 30 | public function down(Schema $schema): void 31 | { 32 | // this down() migration is auto-generated, please modify it to your needs 33 | $this->addSql('CREATE SCHEMA public'); 34 | $this->addSql('DROP INDEX IDX_98197A655E237E068B8E8428A5D4CED1'); 35 | $this->addSql('DROP INDEX IDX_98197A658B8E8428A5D4CED1'); 36 | $this->addSql('ALTER TABLE player ALTER activities TYPE jsonb'); 37 | $this->addSql('ALTER TABLE player ALTER skill_values TYPE jsonb'); 38 | $this->addSql('ALTER TABLE player ALTER quests TYPE jsonb'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Command/RsApiUpdate/Singular.php: -------------------------------------------------------------------------------- 1 | addArgument('playerName', InputArgument::REQUIRED, 'Player to be updated'); 34 | } 35 | 36 | protected function execute(InputInterface $input, OutputInterface $output): int 37 | { 38 | $io = new SymfonyStyle($input, $output); 39 | 40 | $playerName = $input->getArgument('playerName'); 41 | 42 | if (!is_string($playerName)) { 43 | $io->error('Please provide a player name as a string.'); 44 | return Command::FAILURE; 45 | } 46 | 47 | return $this->handleSinglePlayer($io, $playerName); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Command/RsApiUpdate/All.php: -------------------------------------------------------------------------------- 1 | knownPlayerRepository->findAll(); 36 | 37 | foreach ($knownPlayers as $knownPlayer) { 38 | $io->info('Updating data for player: ' . $knownPlayer->getName()); 39 | $this->handleSinglePlayer($io, (string)$knownPlayer->getName()); 40 | } 41 | 42 | $io->success(sprintf('Data updated for %d known players.', count($knownPlayers))); 43 | return Command::SUCCESS; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /migrations/Version20250209124013.php: -------------------------------------------------------------------------------- 1 | addSql('ALTER TABLE known_player ADD clan_name VARCHAR(125) DEFAULT NULL'); 24 | $this->addSql('ALTER TABLE known_player ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); 25 | $this->addSql('COMMENT ON COLUMN known_player.updated_at IS \'(DC2Type:datetime_immutable)\''); 26 | $this->addSql('UPDATE known_player SET updated_at = NOW()'); 27 | $this->addSql('ALTER TABLE known_player ALTER updated_at SET NOT NULL'); 28 | 29 | //set clan name based on player table 30 | $this->addSql("UPDATE known_player kp SET clan_name = NULLIF((SELECT clan FROM player p WHERE p.name = kp.name ORDER BY created_at DESC LIMIT 1), '');"); 31 | $this->addSql('ALTER TABLE player DROP COLUMN clan'); 32 | } 33 | 34 | public function down(Schema $schema): void 35 | { 36 | // this down() migration is auto-generated, please modify it to your needs 37 | $this->addSql('ALTER TABLE known_player DROP clan_name'); 38 | $this->addSql('ALTER TABLE known_player DROP updated_at'); 39 | 40 | $this->addSql('ALTER TABLE player ADD clan VARCHAR(125) DEFAULT NULL'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /config/packages/webpack_encore.yaml: -------------------------------------------------------------------------------- 1 | webpack_encore: 2 | # The path where Encore is building the assets - i.e. Encore.setOutputPath() 3 | output_path: '%kernel.project_dir%/public/build' 4 | # If multiple builds are defined (as shown below), you can disable the default build: 5 | # output_path: false 6 | 7 | # Set attributes that will be rendered on all script and link tags 8 | script_attributes: 9 | defer: true 10 | # Uncomment (also under link_attributes) if using Turbo Drive 11 | # https://turbo.hotwired.dev/handbook/drive#reloading-when-assets-change 12 | # 'data-turbo-track': reload 13 | # link_attributes: 14 | # Uncomment if using Turbo Drive 15 | # 'data-turbo-track': reload 16 | 17 | # If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials') 18 | # crossorigin: 'anonymous' 19 | 20 | # Preload all rendered script and link tags automatically via the HTTP/2 Link header 21 | # preload: true 22 | 23 | # Throw an exception if the entrypoints.json file is missing or an entry is missing from the data 24 | # strict_mode: false 25 | 26 | # If you have multiple builds: 27 | # builds: 28 | # frontend: '%kernel.project_dir%/public/frontend/build' 29 | 30 | # pass the build name as the 3rd argument to the Twig functions 31 | # {{ encore_entry_script_tags('entry1', null, 'frontend') }} 32 | 33 | framework: 34 | assets: 35 | json_manifest_path: '%kernel.project_dir%/public/build/manifest.json' 36 | 37 | #when@prod: 38 | # webpack_encore: 39 | # # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes) 40 | # # Available in version 1.2 41 | # cache: true 42 | 43 | #when@test: 44 | # webpack_encore: 45 | # strict_mode: false 46 | -------------------------------------------------------------------------------- /src/Trait/GuzzleCachedClientTrait.php: -------------------------------------------------------------------------------- 1 | client)) { 19 | $requestCacheFolderName = 'GuzzleFileCache'; 20 | $cacheFolderPath = $this->getProjectDir() . '/var/cache'; 21 | $cache_storage = new Psr6CacheStorage( 22 | new FilesystemAdapter( 23 | $requestCacheFolderName, 24 | 600, 25 | $cacheFolderPath 26 | ) 27 | ); 28 | 29 | $stack = HandlerStack::create(); 30 | $stack->push( 31 | new CacheMiddleware( 32 | new GreedyCacheStrategy($cache_storage, 600) 33 | ), 34 | 'greedy-cache' 35 | ); 36 | 37 | $this->client = new Client(['handler' => $stack]); 38 | } 39 | 40 | return $this->client; 41 | } 42 | 43 | private function getProjectDir(): ?string 44 | { 45 | $markerFiles = ['.env', 'composer.json']; 46 | 47 | $currentDir = realpath((string)getcwd()); 48 | 49 | while ($currentDir !== '/') { 50 | foreach ($markerFiles as $marker) { 51 | $markerPath = $currentDir . DIRECTORY_SEPARATOR . $marker; 52 | 53 | if (file_exists($markerPath)) { 54 | return (string)$currentDir; 55 | } 56 | } 57 | 58 | $currentDir = dirname((string)$currentDir); 59 | } 60 | 61 | return null; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Service/DoubleXpService.php: -------------------------------------------------------------------------------- 1 | getClient()->get('https://rs.runescape.com/en-GB/double-xp'); 17 | $content = $response->getBody()->getContents(); 18 | 19 | preg_match('/ 13 | 16 | 17 | 18 | 20 | 21 | 22 | 23 | {% block stylesheets %} 24 | {{ encore_entry_link_tags('app') }} 25 | {% endblock %} 26 | 27 | {% block javascripts %} 28 | {{ encore_entry_script_tags('app') }} 29 | {% endblock %} 30 | 31 | 32 | {% block body %}{% endblock %} 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/Controller/WelcomeController.php: -------------------------------------------------------------------------------- 1 | headerSearchForm(); 17 | 18 | $playerNameForm = $this->formFactory->createNamedBuilder(name: 'search_form_welcome', options: [ 19 | 'attr' => [ 20 | 'class' => 'd-flex align-items-center' 21 | ] 22 | ]) 23 | ->add('playerNameWelcome', TextType::class, [ 24 | 'attr' => [ 25 | 'class' => 'player-name-search-box form-control', 26 | 'placeholder' => 'Enter your RSN' 27 | ], 28 | 'label' => false 29 | ]) 30 | ->add('searchWelcome', SubmitType::class, [ 31 | 'label' => '', 32 | 'attr' => [ 33 | 'class' => 'player-name-search-button' 34 | ], 35 | 'label_html' => true 36 | ]) 37 | ->getForm(); 38 | 39 | $playerNameForm->handleRequest($request); 40 | 41 | if ($playerNameForm->isSubmitted() && $playerNameForm->isValid()) { 42 | /** @var array{playerNameWelcome: string} $data */ 43 | $data = $playerNameForm->getData(); 44 | $this->setCurrentPlayerNameInSession($data['playerNameWelcome']); 45 | 46 | return $this->redirectToRoute('summary'); 47 | } 48 | 49 | return $this->render('welcome.html.twig', [ 50 | 'form' => $form->createView(), 51 | 'playerNameForm' => $playerNameForm->createView() 52 | ]); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Enum/CatalogueCategory.php: -------------------------------------------------------------------------------- 1 | 55 | */ 56 | public static function getRepresentableCases(): array 57 | { 58 | $cases = self::cases(); 59 | $representableCases = []; 60 | 61 | foreach ($cases as $case) { 62 | $representableCases[str_replace('_', ' ', $case->name)] = $case->value; 63 | } 64 | 65 | return $representableCases; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/MessageHandler/Stats/UpdateSingularPlayerStatsHandler.php: -------------------------------------------------------------------------------- 1 | rsApiService->getProfile($message->player); 33 | 34 | $knownPlayer = $this->knownPlayerRepository->findOneByName($message->player); 35 | if ($knownPlayer !== null) { 36 | $knownPlayer->setUpdatedAt(new DateTimeImmutable()); 37 | $this->entityManager->flush(); 38 | } 39 | } catch (PlayerNotFoundException | PlayerNotAMemberException | PlayerApiDataConversionException $e) { 40 | if ($e instanceof PlayerApiDataConversionException) { 41 | return; // Ignore this exception, it could mean the API temporarily returned an error 42 | } 43 | 44 | // check if player is a KnownPlayer and remove it because it can't be found anymore 45 | $knownPlayer = $this->knownPlayerRepository->findOneByName($message->player); 46 | 47 | if ($knownPlayer !== null) { 48 | $this->entityManager->remove($knownPlayer); 49 | $this->entityManager->flush(); 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Command/RsApiUpdate/HandleSingularPlayerTrait.php: -------------------------------------------------------------------------------- 1 | error('Please provide a player name.'); 15 | } 16 | 17 | $latestDataPointBeforeUpdate = $this->playerRepository->findLatestByName($playerName); 18 | 19 | $io->info('Data will be updated for player: ' . $playerName); 20 | $this->messageBus->dispatch(new UpdateSingularPlayerStatsMessage($playerName)); 21 | 22 | $latestDataPointAfterUpdate = $this->playerRepository->findLatestByName($playerName); 23 | 24 | if (is_null($latestDataPointBeforeUpdate)) { 25 | $io->info([ 26 | 'No data was found for player: ' . $playerName . ' yet.', 27 | 'Attempting to fetch data from the API.', 28 | ]); 29 | } 30 | 31 | if (is_null($latestDataPointAfterUpdate)) { 32 | $io->error([ 33 | 'No data was found for player: ' . $playerName . ' after attempting to fetch data from the API.', 34 | 'Please check the logs for more information.', 35 | ]); 36 | 37 | return Command::FAILURE; 38 | } 39 | 40 | if ($latestDataPointBeforeUpdate?->getCreatedAt() === $latestDataPointAfterUpdate->getCreatedAt()) { 41 | $io->info([ 42 | 'No new data was found', 43 | 'Latest data point was created at: ' . 44 | $latestDataPointBeforeUpdate?->getCreatedAt()?->format('D, d M Y H:i:s'), 45 | ]); 46 | 47 | return Command::SUCCESS; 48 | } 49 | 50 | $io->success([ 51 | 'Data updated for player: ' . $playerName, 52 | 'Latest data point now at: ' . 53 | $latestDataPointAfterUpdate->getCreatedAt()?->format('D, d M Y H:i:s'), 54 | ]); 55 | 56 | return Command::SUCCESS; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /templates/xp_tracker.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}RM Tracker - Levels{% endblock %} 4 | 5 | {% block body %} 6 |
7 | {% include 'includes/header.html.twig' %} 8 | 9 | 10 |
11 | {% include 'includes/sidemenu.html.twig' %} 12 | 13 | 14 |
15 |
16 |
17 |
{{ form(filterForm) }}
18 |
19 |
20 |
21 |
22 |

23 | {% if app.request.attributes.get('_route') == 'app_xp_tracker_hourly' %} 24 | HOURLY 25 | {% elseif app.request.attributes.get('_route') == 'app_xp_tracker_daily' %} 26 | DAILY 27 | {% endif %} 28 | XP 29 |

30 |
31 |
32 |
33 |
34 |
35 | {{ render_chart(chart) }} 36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /src/Entity/KnownPlayer.php: -------------------------------------------------------------------------------- 1 | updatedAt = new DateTimeImmutable(); 39 | $this->lastUsedAt = new DateTimeImmutable(); 40 | } 41 | 42 | public function getId(): ?int 43 | { 44 | return $this->id; 45 | } 46 | 47 | public function getName(): ?string 48 | { 49 | return $this->name; 50 | } 51 | 52 | public function setName(string $name): static 53 | { 54 | $this->name = $name; 55 | 56 | return $this; 57 | } 58 | 59 | public function getClanName(): ?string 60 | { 61 | return $this->clanName; 62 | } 63 | 64 | public function setClanName(?string $clanName): static 65 | { 66 | $this->clanName = $clanName; 67 | 68 | return $this; 69 | } 70 | 71 | public function getUpdatedAt(): DateTimeImmutable 72 | { 73 | return $this->updatedAt; 74 | } 75 | 76 | public function setUpdatedAt(DateTimeImmutable $updatedAt): static 77 | { 78 | $this->updatedAt = $updatedAt; 79 | 80 | return $this; 81 | } 82 | 83 | public function getLastUsedAt(): DateTimeImmutable 84 | { 85 | return $this->lastUsedAt; 86 | } 87 | 88 | public function setLastUsedAt(DateTimeImmutable $lastUsedAt): static 89 | { 90 | $this->lastUsedAt = $lastUsedAt; 91 | 92 | return $this; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /migrations/Version20250208113115.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE SEQUENCE known_player_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); 24 | $this->addSql('CREATE TABLE known_player (id INT NOT NULL, name VARCHAR(24) NOT NULL, PRIMARY KEY(id))'); 25 | $this->addSql('CREATE UNIQUE INDEX UNIQ_ADBEB2A75E237E06 ON known_player (name)'); 26 | $this->addSql('ALTER TABLE messenger_messages ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); 27 | $this->addSql('ALTER TABLE messenger_messages ALTER available_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); 28 | $this->addSql('ALTER TABLE messenger_messages ALTER delivered_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); 29 | $this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'(DC2Type:datetime_immutable)\''); 30 | $this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'(DC2Type:datetime_immutable)\''); 31 | $this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'(DC2Type:datetime_immutable)\''); 32 | } 33 | 34 | public function down(Schema $schema): void 35 | { 36 | // this down() migration is auto-generated, please modify it to your needs 37 | $this->addSql('DROP SEQUENCE known_player_id_seq CASCADE'); 38 | $this->addSql('DROP TABLE known_player'); 39 | $this->addSql('ALTER TABLE messenger_messages ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); 40 | $this->addSql('ALTER TABLE messenger_messages ALTER available_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); 41 | $this->addSql('ALTER TABLE messenger_messages ALTER delivered_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); 42 | $this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS NULL'); 43 | $this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS NULL'); 44 | $this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS NULL'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const Encore = require('@symfony/webpack-encore'); 2 | 3 | // Manually configure the runtime environment if not already configured yet by the "encore" command. 4 | // It's useful when you use tools that rely on webpack.config.js file. 5 | if (!Encore.isRuntimeEnvironmentConfigured()) { 6 | Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev'); 7 | } 8 | 9 | Encore 10 | // directory where compiled assets will be stored 11 | .setOutputPath('public/build/') 12 | // public path used by the web server to access the output path 13 | .setPublicPath('/build') 14 | // only needed for CDN's or sub-directory deploy 15 | //.setManifestKeyPrefix('build/') 16 | 17 | /* 18 | * ENTRY CONFIG 19 | * 20 | * Each entry will result in one JavaScript file (e.g. app.js) 21 | * and one CSS file (e.g. app.css) if your JavaScript imports CSS. 22 | */ 23 | .addEntry('app', './assets/app.js') 24 | 25 | // enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js) 26 | .enableStimulusBridge('./assets/controllers.json') 27 | 28 | // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. 29 | .splitEntryChunks() 30 | 31 | // will require an extra script tag for runtime.js 32 | // but, you probably want this, unless you're building a single-page app 33 | .enableSingleRuntimeChunk() 34 | 35 | /* 36 | * FEATURE CONFIG 37 | * 38 | * Enable & configure other features below. For a full 39 | * list of features, see: 40 | * https://symfony.com/doc/current/frontend.html#adding-more-features 41 | */ 42 | .cleanupOutputBeforeBuild() 43 | .enableBuildNotifications() 44 | .enableSourceMaps(!Encore.isProduction()) 45 | // enables hashed filenames (e.g. app.abc123.css) 46 | .enableVersioning(Encore.isProduction()) 47 | 48 | .configureBabel((config) => { 49 | config.plugins.push('@babel/plugin-proposal-class-properties'); 50 | }) 51 | 52 | // enables @babel/preset-env polyfills 53 | .configureBabelPresetEnv((config) => { 54 | config.useBuiltIns = 'usage'; 55 | config.corejs = 3; 56 | }) 57 | 58 | // enables Sass/SCSS support 59 | //.enableSassLoader() 60 | 61 | // uncomment if you use TypeScript 62 | //.enableTypeScriptLoader() 63 | 64 | // uncomment if you use React 65 | //.enableReactPreset() 66 | 67 | // uncomment to get integrity="..." attributes on your script & link tags 68 | // requires WebpackEncoreBundle 1.4 or higher 69 | //.enableIntegrityHashes(Encore.isProduction()) 70 | 71 | // uncomment if you're having problems with a jQuery plugin 72 | //.autoProvidejQuery() 73 | ; 74 | 75 | module.exports = Encore.getWebpackConfig(); -------------------------------------------------------------------------------- /templates/levels_progress.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}RM Tracker - Levels - Progress{% endblock %} 4 | 5 | {% block body %} 6 |
7 | {% include 'includes/header.html.twig' %} 8 | 9 | 10 |
11 | {% include 'includes/sidemenu.html.twig' %} 12 | 13 | 14 |
15 |
16 |
17 |

Levels - Progress

18 |
19 |
20 |
21 |
22 | 29 |
30 | 31 |
32 | 38 |
39 | 40 |
41 | 47 |
48 |
49 |
50 |
Loading...
51 |
52 |
53 |
54 |
55 |
56 |
57 | 58 | 59 | {% endblock %} 60 | -------------------------------------------------------------------------------- /src/Repository/KnownPlayerRepository.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class KnownPlayerRepository extends ServiceEntityRepository 15 | { 16 | public function __construct(ManagerRegistry $registry) 17 | { 18 | parent::__construct($registry, KnownPlayer::class); 19 | } 20 | 21 | public function findOneByName(string $name): ?KnownPlayer 22 | { 23 | return $this->findOneBy(['name' => $name]); 24 | } 25 | 26 | /** 27 | * @return KnownPlayer[] 28 | * @throws DateMalformedStringException 29 | */ 30 | public function findAllActive(int $maxResults = 12): array 31 | { 32 | $activityThreshold = new DateTimeImmutable(sprintf('-%s', KnownPlayer::ACTIVITY_THRESHOLD)); 33 | 34 | /** @var KnownPlayer[] $result */ 35 | $result = $this->createQueryBuilder('kp') 36 | ->where('kp.lastUsedAt > :lastUsedAt') 37 | ->setParameter('lastUsedAt', $activityThreshold) 38 | ->orderBy('kp.updatedAt', 'ASC') 39 | ->setMaxResults($maxResults) 40 | ->getQuery() 41 | ->getResult(); 42 | 43 | return $result; 44 | } 45 | 46 | /** 47 | * @return KnownPlayer[] 48 | * @throws DateMalformedStringException 49 | */ 50 | public function findAllInactive(int $maxResults = 12): array 51 | { 52 | $activityThreshold = new DateTimeImmutable(sprintf('-%s', KnownPlayer::ACTIVITY_THRESHOLD)); 53 | 54 | /** @var KnownPlayer[] $result */ 55 | $result = $this->createQueryBuilder('kp') 56 | ->where('kp.lastUsedAt <= :lastUsedAt') 57 | ->setParameter('lastUsedAt', $activityThreshold) 58 | ->setMaxResults($maxResults) 59 | ->getQuery() 60 | ->getResult(); 61 | 62 | return $result; 63 | } 64 | 65 | /** 66 | * @return string[] 67 | */ 68 | public function findAllNames(): array 69 | { 70 | /** @var array $result */ 71 | $result = $this->createQueryBuilder('kp') 72 | ->select('kp.name') 73 | ->getQuery() 74 | ->getResult(); 75 | 76 | array_walk($result, function (&$item) { 77 | $item = $item['name']; 78 | }); 79 | 80 | /** @var string[] $result */ 81 | return $result; 82 | } 83 | 84 | public function updateClanName(string $playerName, string $clanName): void 85 | { 86 | $this->createQueryBuilder('kp') 87 | ->update() 88 | ->set('kp.clanName', ':clanName') 89 | ->where('kp.name = :name') 90 | ->setParameter('clanName', $clanName) 91 | ->setParameter('name', $playerName) 92 | ->getQuery() 93 | ->execute(); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Enum/SkillEnum.php: -------------------------------------------------------------------------------- 1 | 39 | */ 40 | public static function toArray(): array 41 | { 42 | $skills = []; 43 | 44 | foreach (self::cases() as $case) { 45 | $skills[$case->name] = $case->value; 46 | } 47 | 48 | return $skills; 49 | } 50 | 51 | public function graphColor(): string 52 | { 53 | return match ($this) { 54 | self::Agility => 'rgb(40, 74, 149)', 55 | self::Archaeology => 'rgb(185, 87, 30)', 56 | self::Attack => 'rgb(152, 20, 20)', 57 | self::Constitution => 'rgb(170, 206, 218)', 58 | self::Construction => 'rgb(168, 186, 188)', 59 | self::Cooking => 'rgb(85, 50, 133)', 60 | self::Crafting => 'rgb(182, 149, 44)', 61 | self::Defence => 'rgb(20, 126, 152)', 62 | self::Divination => 'rgb(148, 63, 186)', 63 | self::Dungeoneering => 'rgb(114, 57, 32)', 64 | self::Farming => 'rgb(31, 125, 84)', 65 | self::Firemaking => 'rgb(247, 95, 40)', 66 | self::Fishing => 'rgb(62, 112, 185)', 67 | self::Fletching => 'rgb(20, 152, 147)', 68 | self::Herblore => 'rgb(18, 69, 58)', 69 | self::Hunter => 'rgb(195, 139, 78)', 70 | self::Invention => 'rgb(247, 181, 40)', 71 | self::Magic => 'rgb(195, 227, 220)', 72 | self::Mining => 'rgb(86, 73, 94)', 73 | self::Necromancy => 'rgb(156, 142, 255)', 74 | self::Prayer => 'rgb(109, 191, 242)', 75 | self::Ranged => 'rgb(19, 183, 81)', 76 | self::Runecrafting => 'rgb(215, 235, 163)', 77 | self::Slayer => 'rgb(72, 65, 47)', 78 | self::Smithing => 'rgb(101, 136, 126)', 79 | self::Strength => 'rgb(19, 183, 135)', 80 | self::Summoning => 'rgb(222, 161, 176)', 81 | self::Thieving => 'rgb(54, 23, 94)', 82 | self::Woodcutting => 'rgb(126, 79, 53)', 83 | }; 84 | } 85 | 86 | public function isElite(): bool 87 | { 88 | return match ($this) { 89 | self::Invention => true, 90 | default => false, 91 | }; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Service/GrandExchangeApiService.php: -------------------------------------------------------------------------------- 1 | createCatalogueReponseCategory($itemName, $category); 35 | $catalogueResponseCollection->addCategory($catalogueCategory); 36 | } 37 | 38 | return $catalogueResponseCollection; 39 | } 40 | 41 | /** 42 | * @throws RateLimitedException 43 | * @throws GuzzleException 44 | */ 45 | public function getItemInformationFromApiByCategory( 46 | string $itemName, 47 | CatalogueCategory $category 48 | ): CatalogueResponseCollection { 49 | $catalogueCategory = $this->createCatalogueReponseCategory($itemName, $category); 50 | 51 | $catalogueResponseCollection = new CatalogueResponseCollection(); 52 | $catalogueResponseCollection->addCategory($catalogueCategory); 53 | 54 | return $catalogueResponseCollection; 55 | } 56 | 57 | /** 58 | * @throws RateLimitedException 59 | * @throws GuzzleException 60 | */ 61 | private function createCatalogueReponseCategory( 62 | string $itemName, 63 | CatalogueCategory $category 64 | ): CatalogueResponseCategory { 65 | $response = $this->getClient()->request( 66 | 'GET', 67 | 'https://secure.runescape.com/m=itemdb_rs/api/catalogue/items.json', 68 | [ 69 | 'query' => [ 70 | 'category' => $category->value, 71 | 'alpha' => strtolower($itemName), 72 | 'page' => '1' 73 | ] 74 | ] 75 | ); 76 | 77 | try { 78 | return new CatalogueResponseCategory( 79 | $category, 80 | $this->getSerializer()->deserialize( 81 | $response->getBody()->getContents(), 82 | CatalogueResponse::class, 83 | 'json' 84 | ) 85 | ); 86 | } catch (Exception) { 87 | throw new RateLimitedException('The Runescape Grand Exchange API is rate limited. Try again later.'); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Controller/GrandExchangeController.php: -------------------------------------------------------------------------------- 1 | createNamedBuilder(name: 'item_form', options: [ 27 | 'attr' => [ 28 | 'class' => 'text-light col-sm-5' 29 | ] 30 | ]) 31 | ->add('itemName', TextType::class, [ 32 | 'attr' => [ 33 | 'placeholder' => 'Search for an item' 34 | ], 35 | 'label' => 'Item Name', 36 | ]) 37 | ->add('itemCategory', ChoiceType::class, [ 38 | 'label' => 'Category', 39 | 'choices' => CatalogueCategory::getRepresentableCases() 40 | ]) 41 | ->add('submit', SubmitType::class, [ 42 | 'attr' => [ 43 | 'class' => 'btn-custom', 44 | ], 45 | 'label' => 'Search', 46 | ]) 47 | ->getForm(); 48 | 49 | $itemForm->handleRequest($request); 50 | 51 | if ($itemForm->isSubmitted() && $itemForm->isValid()) { 52 | /** @var array{itemName: string, itemCategory: string, submit: string} $data */ 53 | $data = $itemForm->getData(); 54 | $itemCategory = CatalogueCategory::from($data['itemCategory']); 55 | 56 | try { 57 | $catalogueResponseCollection = match ($itemCategory) { 58 | CatalogueCategory::All => $grandExchangeApiService 59 | ->getItemInformationFromApi($data['itemName']), 60 | default => $grandExchangeApiService 61 | ->getItemInformationFromApiByCategory($data['itemName'], $itemCategory) 62 | }; 63 | } catch (RateLimitedException | GuzzleException $e) { 64 | $this->addFlash('info', $e->getMessage()); 65 | } 66 | } 67 | 68 | return $this->render( 69 | 'grand_exchange/index.html.twig', 70 | [ 71 | 'itemForm' => $itemForm->createView(), 72 | 'catalogueResponseCollection' => $catalogueResponseCollection ?? null, 73 | 'multipleCategories' => count($catalogueResponseCollection->categories ?? []) > 1 74 | ] 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Command/VerifyPlayerDataIntegrityCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('playerName', InputArgument::REQUIRED, 'Player to be updated'); 34 | } 35 | 36 | protected function execute(InputInterface $input, OutputInterface $output): int 37 | { 38 | $io = new SymfonyStyle($input, $output); 39 | 40 | /** @var string $playerName */ 41 | $playerName = $input->getArgument('playerName'); 42 | 43 | if (empty($playerName) || !is_string($playerName)) { 44 | $io->error('Please provide a player name.'); 45 | 46 | return Command::FAILURE; 47 | } 48 | 49 | $io->info('Data will be verified for player: ' . $playerName); 50 | 51 | $dataPoints = $this->playerRepository->findAllByName($playerName); 52 | 53 | foreach ($dataPoints as $dataPoint) { 54 | foreach ($skillValues = $dataPoint->getSkillValues() as $skillValue) { 55 | if ($skillValue->id === null) { 56 | $io->info('Skill was not found, removing data point.'); 57 | $this->playerRepository->remove($dataPoint); 58 | continue; 59 | } 60 | 61 | $isXpWithinBounds = $this->xpBoundaryService->isXpWithinLevelBoundaries( 62 | $skillValue->id, 63 | (int)$skillValue->level, 64 | (float)$skillValue->xp 65 | ); 66 | 67 | if (!$isXpWithinBounds && $skillValue->level !== 99) { 68 | $io->info(sprintf( 69 | 'Datapoint from %s contains skill %s with level %d and xp %d is not within the ' . 70 | 'boundaries, correcting skill xp.', 71 | $dataPoint->getCreatedAt()?->format('D, d M Y H:i:s') ?? 'unknown', 72 | $skillValue->id->value, 73 | (int)$skillValue->level, 74 | (float)$skillValue->xp 75 | )); 76 | $skillValue->xp /= 10; 77 | } 78 | } 79 | 80 | $dataPoint->setSkillValues($skillValues); 81 | } 82 | 83 | $this->entityManager->flush(); 84 | $io->success('Player data has been verified for player: ' . $playerName); 85 | 86 | return Command::SUCCESS; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(resolve:DATABASE_URL)%' 4 | types: 5 | bool[]: MartinGeorgiev\Doctrine\DBAL\Types\BooleanArray 6 | smallint[]: MartinGeorgiev\Doctrine\DBAL\Types\SmallIntArray 7 | integer[]: MartinGeorgiev\Doctrine\DBAL\Types\IntegerArray 8 | bigint[]: MartinGeorgiev\Doctrine\DBAL\Types\BigIntArray 9 | text[]: MartinGeorgiev\Doctrine\DBAL\Types\TextArray 10 | jsonb: MartinGeorgiev\Doctrine\DBAL\Types\Jsonb 11 | jsonb[]: MartinGeorgiev\Doctrine\DBAL\Types\JsonbArray 12 | activity: App\Doctrine\Type\ActivityType 13 | skillValue: App\Doctrine\Type\SkillValueType 14 | quest: App\Doctrine\Type\QuestType 15 | mapping_types: 16 | bool[]: bool[] 17 | _bool: bool[] 18 | smallint[]: smallint[] 19 | _int2: smallint[] 20 | integer[]: integer[] 21 | _int4: integer[] 22 | bigint[]: bigint[] 23 | _int8: bigint[] 24 | text[]: text[] 25 | _text: text[] 26 | jsonb: jsonb 27 | jsonb[]: jsonb[] 28 | _jsonb: jsonb[] 29 | 30 | # IMPORTANT: You MUST configure your server version, 31 | # either here or in the DATABASE_URL env var (see .env file) 32 | orm: 33 | auto_generate_proxy_classes: true 34 | enable_lazy_ghost_objects: true 35 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 36 | auto_mapping: true 37 | mappings: 38 | App: 39 | is_bundle: false 40 | dir: '%kernel.project_dir%/src/Entity' 41 | prefix: 'App\Entity' 42 | alias: App 43 | dql: 44 | string_functions: 45 | ARRAY_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayAgg 46 | JSONB_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbAgg 47 | datetime_functions: 48 | second: DoctrineExtensions\Query\Postgresql\Second 49 | minute: DoctrineExtensions\Query\Postgresql\Minute 50 | hour: DoctrineExtensions\Query\Postgresql\Hour 51 | day: DoctrineExtensions\Query\Postgresql\Day 52 | month: DoctrineExtensions\Query\Postgresql\Month 53 | year: DoctrineExtensions\Query\Postgresql\Year 54 | date_format: DoctrineExtensions\Query\Postgresql\DateFormat 55 | at_time_zone: DoctrineExtensions\Query\Postgresql\AtTimeZoneFunction 56 | date_part: DoctrineExtensions\Query\Postgresql\DatePart 57 | extract: DoctrineExtensions\Query\Postgresql\ExtractFunction 58 | date_trunc: DoctrineExtensions\Query\Postgresql\DateTrunc 59 | date: DoctrineExtensions\Query\Postgresql\Date 60 | 61 | when@test: 62 | doctrine: 63 | dbal: 64 | # "TEST_TOKEN" is typically set by ParaTest 65 | dbname_suffix: '_test%env(default::TEST_TOKEN)%' 66 | 67 | when@prod: 68 | doctrine: 69 | orm: 70 | auto_generate_proxy_classes: false 71 | proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' 72 | query_cache_driver: 73 | type: pool 74 | pool: doctrine.system_cache_pool 75 | result_cache_driver: 76 | type: pool 77 | pool: doctrine.result_cache_pool 78 | 79 | framework: 80 | cache: 81 | pools: 82 | doctrine.result_cache_pool: 83 | adapter: cache.app 84 | doctrine.system_cache_pool: 85 | adapter: cache.system 86 | -------------------------------------------------------------------------------- /src/Doctrine/Type/CustomJsonbType.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | protected function normalizers(): array 29 | { 30 | return [ 31 | new ArrayDenormalizer(), 32 | new BackedEnumNormalizer(), 33 | new ObjectNormalizer(propertyTypeExtractor: new ReflectionExtractor()) 34 | ]; 35 | } 36 | 37 | /** 38 | * @return array 39 | */ 40 | protected function encoders(): array 41 | { 42 | return [ 43 | new JsonEncoder() 44 | ]; 45 | } 46 | 47 | /** 48 | * @return JsonbDTOInterface[] 49 | * @throws JsonbConvertToPHPValueException 50 | */ 51 | public function convertToPHPValue($value, AbstractPlatform $platform): array 52 | { 53 | if (!is_string($value)) { 54 | throw new JsonbConvertToPHPValueException( 55 | 'CustomJsonbType::convertToPHPValue() must receive a string' 56 | ); 57 | } 58 | 59 | $result = $this->getSerializer()->deserialize($value, $this->getDtoFqcn() . '[]', 'json'); 60 | 61 | if (!is_array($result)) { 62 | throw new JsonbConvertToPHPValueException( 63 | 'CustomJsonbType::convertToPHPValue() must return an array of ' . $this->getDtoFqcn() 64 | ); 65 | } 66 | 67 | return $result; 68 | } 69 | 70 | /** 71 | * @param mixed $value 72 | * @throws JsonbConvertToDatabaseValueException 73 | */ 74 | public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string 75 | { 76 | if (!is_array($value)) { 77 | throw new JsonbConvertToDatabaseValueException( 78 | 'CustomJsonbType::convertToDatabaseValue() must receive an array got ' . gettype($value) 79 | ); 80 | } 81 | 82 | foreach ($value as $item) { 83 | if (!is_a($item, $this->getDtoFqcn())) { 84 | throw new JsonbConvertToDatabaseValueException( 85 | 'CustomJsonbType::convertToDatabaseValue() must receive an array of ' . 86 | $this->getDtoFqcn() . ' got ' . gettype($item) 87 | ); 88 | } 89 | } 90 | 91 | return $this->getSerializer()->serialize($value, 'json'); 92 | } 93 | 94 | private function getSerializer(): Serializer 95 | { 96 | return new Serializer( 97 | normalizers: $this->normalizers(), 98 | encoders: $this->encoders() 99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Trait/CreateXpChartTrait.php: -------------------------------------------------------------------------------- 1 | $labels 12 | * @param array $data 13 | * @return Chart 14 | */ 15 | public function createTotalXpChart( 16 | string $chartType, 17 | array $labels, 18 | array $data 19 | ): Chart { 20 | return $this->chartBuilder->createChart($chartType) 21 | ->setOptions([ 22 | 'scales' => [ 23 | 'y' => ['grid' => ['color' => 'rgba(44, 61, 73, 0.3)']], 24 | 'x' => ['grid' => ['color' => 'rgba(44, 61, 73, 0.3)']] 25 | ], 26 | 'color' => 'rgb(181,153,47)', 27 | 'tension' => 0.3, 28 | 'elements' => [ 29 | 'point' => [ 30 | 'radius' => 3 31 | ] 32 | ], 33 | 'plugins' => [ 34 | 'zoom' => [ 35 | 'zoom' => [ 36 | 'wheel' => ['enabled' => true], 37 | 'pinch' => ['enabled' => true], 38 | 'drag' => ['enabled' => true], 39 | 'mode' => 'x', 40 | ], 41 | ], 42 | ] 43 | ]) 44 | ->setData([ 45 | 'labels' => array_values($labels), 46 | 'datasets' => [ 47 | [ 48 | 'label' => 'Total XP Gain', 49 | 'backgroundColor' => 'rgb(181,153,47)', 50 | 'borderColor' => 'rgb(181,153,47)', 51 | 'data' => array_values($data) 52 | ], 53 | ] 54 | ]); 55 | } 56 | 57 | /** 58 | * @param string $chartType 59 | * @param array{y: array{grid: array{color: string}}, x: array{grid: array{color: string}}} $scales 60 | * @param string[] $labels 61 | * @param array[] $dataSets 62 | * @return Chart 63 | */ 64 | public function createXpPerSkillChart( 65 | string $chartType, 66 | array $scales, 67 | array $labels, 68 | array $dataSets 69 | ): Chart { 70 | return $this->chartBuilder->createChart($chartType) 71 | ->setOptions([ 72 | 'scales' => $scales, 73 | 'color' => '#ffffff', 74 | 'font-family' => 'Cinzel, sarif', 75 | 'tension' => 0.19, 76 | 'elements' => [ 77 | 'point' => [ 78 | 'radius' => 3 79 | ] 80 | ], 81 | 'plugins' => [ 82 | 'zoom' => [ 83 | 'zoom' => [ 84 | 'wheel' => ['enabled' => true], 85 | 'pinch' => ['enabled' => true], 86 | 'drag' => ['enabled' => true], 87 | 'mode' => 'x', 88 | ], 89 | ], 90 | ] 91 | ]) 92 | ->setData([ 93 | 'labels' => array_values($labels), 94 | 'datasets' => $dataSets 95 | ]); 96 | } 97 | 98 | /** 99 | * @return array{y: array{grid: array{color: string}}, x: array{grid: array{color: string}}} 100 | */ 101 | public function createScales(): array 102 | { 103 | return [ 104 | 'y' => [ 105 | 'grid' => [ 106 | 'color' => 'rgba(44, 61, 73, 0.3)' 107 | ] 108 | ], 109 | 'x' => [ 110 | 'grid' => [ 111 | 'color' => 'rgba(44, 61, 73, 0.3)' 112 | ] 113 | ] 114 | ]; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /migrations/Version20230304174431.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE SEQUENCE player_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); 24 | $this->addSql(<<addSql('CREATE INDEX IDX_98197A655E237E06 ON player (name)'); 47 | $this->addSql('CREATE INDEX IDX_98197A658B8E8428 ON player (created_at)'); 48 | $this->addSql(<<addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)'); 63 | $this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)'); 64 | $this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)'); 65 | $this->addSql('CREATE OR REPLACE FUNCTION notify_messenger_messages() RETURNS TRIGGER AS $$ 66 | BEGIN 67 | PERFORM pg_notify(\'messenger_messages\', NEW.queue_name::text); 68 | RETURN NEW; 69 | END; 70 | $$ LANGUAGE plpgsql;'); 71 | $this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON messenger_messages;'); 72 | $this->addSql(<<addSql('CREATE SCHEMA public'); 86 | $this->addSql('DROP SEQUENCE player_id_seq CASCADE'); 87 | $this->addSql('DROP TABLE player'); 88 | $this->addSql('DROP TABLE messenger_messages'); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Enum/SkillLevelXpEnum.php: -------------------------------------------------------------------------------- 1 | =8.4", 8 | "ext-ctype": "*", 9 | "ext-iconv": "*", 10 | "ext-simplexml": "*", 11 | "beberlei/doctrineextensions": "^1.3", 12 | "doctrine/annotations": "^2.0", 13 | "doctrine/dbal": "^3.6", 14 | "doctrine/doctrine-bundle": "^2.8", 15 | "doctrine/doctrine-migrations-bundle": "^3.2", 16 | "doctrine/orm": "^2.14", 17 | "dragonmantank/cron-expression": "^3.3", 18 | "gedmo/doctrine-extensions": "^3.11", 19 | "graylog2/gelf-php": "^2.0", 20 | "guzzlehttp/guzzle": "^7.5", 21 | "kevinrob/guzzle-cache-middleware": "^4.0", 22 | "martin-georgiev/postgresql-for-doctrine": "^2.1", 23 | "omines/datatables-bundle": "^0.9.0", 24 | "stof/doctrine-extensions-bundle": "^1.7", 25 | "symfony/cache": "7.2.*", 26 | "symfony/console": "7.2.*", 27 | "symfony/doctrine-messenger": "7.2.*", 28 | "symfony/dotenv": "7.2.*", 29 | "symfony/event-dispatcher": "7.2.*", 30 | "symfony/flex": "^2", 31 | "symfony/form": "7.2.*", 32 | "symfony/framework-bundle": "7.2.*", 33 | "symfony/http-client": "7.2.*", 34 | "symfony/messenger": "7.2.*", 35 | "symfony/monolog-bundle": "^3.8", 36 | "symfony/property-access": "7.2.*", 37 | "symfony/runtime": "7.2.*", 38 | "symfony/scheduler": "7.2.*", 39 | "symfony/serializer": "7.2.*", 40 | "symfony/twig-bundle": "7.2.*", 41 | "symfony/ux-chartjs": "^2.7", 42 | "symfony/webpack-encore-bundle": "2.1.*", 43 | "symfony/yaml": "7.2.*" 44 | }, 45 | "require-dev": { 46 | "phpstan/phpstan": "^1.10", 47 | "phpstan/phpstan-doctrine": "^1.3", 48 | "phpstan/phpstan-symfony": "^1.2", 49 | "phpunit/phpunit": "^9.5", 50 | "squizlabs/php_codesniffer": "^3.7", 51 | "symfony/browser-kit": "7.2.*", 52 | "symfony/css-selector": "7.2.*", 53 | "symfony/maker-bundle": "^1.48", 54 | "symfony/phpunit-bridge": "7.2.*", 55 | "symfony/stopwatch": "7.2.*", 56 | "symfony/web-profiler-bundle": "7.2.*" 57 | }, 58 | "config": { 59 | "allow-plugins": { 60 | "php-http/discovery": true, 61 | "symfony/flex": true, 62 | "symfony/runtime": true 63 | }, 64 | "sort-packages": true 65 | }, 66 | "autoload": { 67 | "psr-4": { 68 | "App\\": "src/" 69 | } 70 | }, 71 | "autoload-dev": { 72 | "psr-4": { 73 | "App\\Tests\\": "tests/" 74 | } 75 | }, 76 | "replace": { 77 | "symfony/polyfill-ctype": "*", 78 | "symfony/polyfill-iconv": "*", 79 | "symfony/polyfill-php72": "*", 80 | "symfony/polyfill-php73": "*", 81 | "symfony/polyfill-php74": "*", 82 | "symfony/polyfill-php80": "*", 83 | "symfony/polyfill-php81": "*" 84 | }, 85 | "scripts": { 86 | "auto-scripts": { 87 | "cache:clear": "symfony-cmd", 88 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 89 | }, 90 | "post-install-cmd": [ 91 | "@auto-scripts" 92 | ], 93 | "post-update-cmd": [ 94 | "@auto-scripts" 95 | ], 96 | "make-migration": [ 97 | "php bin/console doctrine:migrations:migrate", 98 | "php bin/console make:migration -n" 99 | ], 100 | "setup": [ 101 | "php bin/console doctrine:migrations:migrate" 102 | ], 103 | "lint": [ 104 | "php vendor/bin/phpcs -d memory_limit=128M", 105 | "php vendor/bin/phpstan analyse -c phpstan.neon --memory-limit 1024M" 106 | ], 107 | "lint-fix": [ 108 | "php vendor/bin/phpcbf" 109 | ], 110 | "test": [ 111 | "php vendor/bin/phpunit --colors=always" 112 | ] 113 | }, 114 | "conflict": { 115 | "symfony/symfony": "*" 116 | }, 117 | "extra": { 118 | "symfony": { 119 | "allow-contrib": false, 120 | "require": "7.2.*" 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /templates/newsfeed.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}RM Tracker - Newsfeed{% endblock %} 4 | 5 | {% block body %} 6 |
7 | {% include 'includes/header.html.twig' %} 8 | 9 |
10 | {% include 'includes/sidemenu.html.twig' %} 11 | 12 | 70 |
71 |
72 | 73 | 74 | {% endblock %} 75 | -------------------------------------------------------------------------------- /templates/grand_exchange/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}Runescape Dashboard - Grand Exchange{% endblock %} 4 | 5 | {% block body %} 6 |
7 | {% include 'includes/header.html.twig' %} 8 | 9 | 10 |
11 | {% include 'includes/sidemenu.html.twig' %} 12 | 13 | 14 |
15 |
16 |
17 |

GRAND EXCHANGE

18 |
19 |
20 |
21 | {{ form(itemForm) }} 22 |
23 |
24 | {% if catalogueResponseCollection is not null %} 25 | {% for category in catalogueResponseCollection.categories %} 26 | {% if category.catalogueResponse.items is not empty %} 27 |
28 | {% if multipleCategories %} 29 |

30 | {{ category.category.name|replace({'_': ' '}) }} 31 |

32 | {% endif %} 33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {% for item in category.catalogueResponse.items %} 45 | 46 | 51 | 52 | 53 | 64 | 65 | {% endfor %} 66 | 67 |
ItemPriceMembers
47 | {{ item.name }} 50 | {{ item.name }}{{ item.current.price }} 54 | {% if item.members %} 55 | Members 58 | {% else %} 59 | Non-Members 62 | {% endif %} 63 |
68 |
69 |
70 | {% endif %} 71 | {% endfor %} 72 | {% endif %} 73 |
74 |
75 |
76 |
77 |
78 |
79 | {% endblock %} 80 | -------------------------------------------------------------------------------- /src/Service/Chart/Xp/DailyChartService.php: -------------------------------------------------------------------------------- 1 | playerRepository->findDailyXpRateForTotalXp( 40 | $startDate, 41 | $endDate, 42 | $playerName 43 | ); 44 | 45 | $data = []; 46 | $labels = []; 47 | $currentDate = $startDate; 48 | $count = 0; 49 | 50 | while ($currentDate <= $endDate) { 51 | $date = $currentDate->format('Y-m-d'); 52 | $data[$date] = $days[$count]['total_xp_gain'] ?? 0; 53 | $labels[$date] = $date; 54 | $currentDate = $currentDate->modify('+1 day'); 55 | $count++; 56 | } 57 | 58 | if ($chartType === 'stackedBar') { 59 | $chartType = Chart::TYPE_BAR; 60 | } 61 | 62 | return $this->createTotalXpChart($chartType, $labels, $data); 63 | } 64 | 65 | /** 66 | * @param string $playerName 67 | * @param SkillEnum[] $skills 68 | * @param DateTimeImmutable $startDate 69 | * @param DateTimeImmutable $endDate 70 | * @param string $chartType 71 | * @return Chart 72 | * @throws DateMalformedStringException 73 | * @throws Exception 74 | */ 75 | public function getXpPerSkillChart( 76 | string $playerName, 77 | array $skills, 78 | DateTimeImmutable $startDate = new DateTimeImmutable('-1 month'), 79 | DateTimeImmutable $endDate = new DateTimeImmutable(), 80 | string $chartType = Chart::TYPE_BAR, 81 | ): Chart { 82 | $skillsData = []; 83 | 84 | foreach ($skills as $skill) { 85 | $xpData = $this->playerRepository->findDailyXpRateForSkillAtDate($startDate, $endDate, $playerName, $skill); 86 | 87 | $skillsData[] = [ 88 | 'skill' => $skill, 89 | 'data' => $xpData 90 | ]; 91 | } 92 | 93 | $dataSets = []; 94 | $labels = []; 95 | 96 | foreach ($skillsData as $skillsDataItem) { 97 | $data = []; 98 | $currentDate = $startDate; 99 | 100 | while ($currentDate <= $endDate) { 101 | foreach ($skillsDataItem['data'] as $day) { 102 | if ($day['date'] === $currentDate->format('Y-m-d')) { 103 | $date = $currentDate->format('Y-m-d'); 104 | $data[$date] = $day['xp_difference']; 105 | $labels[$date] = $date; 106 | } 107 | } 108 | 109 | if (!array_key_exists($currentDate->format('Y-m-d'), $data)) { 110 | $date = $currentDate->format('Y-m-d'); 111 | $data[$date] = 0; 112 | $labels[$date] = $date; 113 | } 114 | 115 | $currentDate = $currentDate->modify('+1 day'); 116 | } 117 | 118 | $dataSets[] = [ 119 | 'label' => $skillsDataItem['skill']->name . ' XP', 120 | 'backgroundColor' => $skillsDataItem['skill']->graphColor(), 121 | 'borderColor' => $skillsDataItem['skill']->graphColor(), 122 | 'data' => array_values($data) 123 | ]; 124 | } 125 | 126 | $scales = $this->createScales(); 127 | 128 | if ($chartType === 'stackedBar') { 129 | $chartType = Chart::TYPE_BAR; 130 | $scales['y']['stacked'] = true; 131 | $scales['x']['stacked'] = true; 132 | } 133 | 134 | return $this->createXpPerSkillChart($chartType, $scales, $labels, $dataSets); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Controller/AbstractBaseController.php: -------------------------------------------------------------------------------- 1 | formFactory->createNamedBuilder(name: 'search_form', options: [ 33 | 'attr' => [ 34 | 'class' => 'd-flex align-items-center' 35 | ] 36 | ]) 37 | ->add('playerName', TextType::class, [ 38 | 'attr' => [ 39 | 'class' => 'player-name-search-box form-control', 40 | 'placeholder' => 'Search for a player' 41 | ], 42 | 'label' => false, 43 | 'row_attr' => [ 44 | 'class' => 'empty-class' 45 | ] 46 | ]) 47 | ->add('search', SubmitType::class, [ 48 | 'label' => '', 49 | 'attr' => [ 50 | 'class' => 'player-name-search-button' 51 | ], 52 | 'label_html' => true, 53 | 'row_attr' => [ 54 | 'class' => 'empty-class' 55 | ] 56 | ]) 57 | ->getForm(); 58 | 59 | $form->handleRequest($this->requestStack->getCurrentRequest()); 60 | 61 | if ($form->isSubmitted() && $form->isValid()) { 62 | /** @var array{playerName: string} $data */ 63 | $data = $form->getData(); 64 | $this->setCurrentPlayerNameInSession($data['playerName']); 65 | } 66 | 67 | return $form; 68 | } 69 | 70 | protected function getCurrentPlayerName(): string 71 | { 72 | /** @var string $playerName */ 73 | $playerName = $this->requestStack->getSession()->get('currentPlayerName'); 74 | 75 | if (empty($playerName)) { 76 | $this->redirectToRoute('welcome'); 77 | } 78 | 79 | $playerExists = $this->knownPlayerRepository->findOneByName($playerName); 80 | 81 | if ($playerExists !== null) { 82 | $playerExists->setLastUsedAt(new DateTimeImmutable()); 83 | $this->entityManager->flush(); 84 | } 85 | 86 | return $playerName; 87 | } 88 | 89 | protected function setCurrentPlayerNameInSession(string $playerName): void 90 | { 91 | try { 92 | $playerExists = $this->knownPlayerRepository->findOneByName($playerName); 93 | 94 | // The if/else here is necessary to handle the case-sensitive nature of the API. 95 | // The API will provide the name in the correct case, but the user may have entered it in a different 96 | // case. We want to store the name in the correct case in the database, and in the session. 97 | if ($playerExists === null) { 98 | $player = $this->rsApiService->getProfile($playerName); 99 | $this->requestStack->getSession()->set('currentPlayerName', $player->getName()); 100 | 101 | // fetch the player again to ensure we have the correct player 102 | $playerExists = $this->knownPlayerRepository->findOneByName((string)$player->getName()); 103 | } else { 104 | $this->requestStack->getSession()->set('currentPlayerName', $playerExists->getName()); 105 | } 106 | 107 | if ($playerExists !== null) { 108 | $playerExists->setLastUsedAt(new DateTimeImmutable()); 109 | $this->entityManager->flush(); 110 | } 111 | } catch (PlayerNotFoundException | PlayerNotAMemberException $e) { 112 | $this->addFlash('info', $e->getMessage()); 113 | } catch (Throwable) { 114 | $this->addFlash('danger', 'An error occurred while fetching player data'); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RuneMetrics Re-Creation Project 2 | 3 | [![main branch](https://github.com/VincentPS/runescape-api-symfony/actions/workflows/lintAndTests.yml/badge.svg)](https://github.com/VincentPS/runescape-api-symfony) 4 | [![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D_8.3-8892BF.svg?logo=php)](https://www.php.net/releases/8.3/en.php) 5 | [![Static Badge](https://img.shields.io/badge/symfony-%3E%3D_7.2-green?logo=symfony)](https://symfony.com/releases/7.2) 6 | 7 | Live version [https://rm-tracker.com](https://rm-tracker.com) 8 | 9 | Welcome to the RuneMetrics Re-Creation project! This Symfony 7 application, built using PHP 8.3, aims to recreate the functionality of the RuneMetrics apps from Jagex. It leverages the public RuneScape API to gather player data and provides a user-friendly interface to view and analyze the data. This README will guide you through the setup, configuration, and usage of the project. 10 | 11 | ## Table of Contents 12 | 13 | - Prerequisites 14 | - Installation 15 | - Configuration 16 | - Usage 17 | - API Wrapper 18 | - Contributing 19 | - License 20 | 21 | ## Prerequisites 22 | 23 | Before getting started, ensure you have the following prerequisites installed: 24 | 25 | - PHP 8.3 26 | - Composer (Dependency Manager for PHP) 27 | - Symfony CLI 28 | - Git 29 | - Docker 30 | - npm 31 | - PostgreSQL 32 | 33 | ## Installation 34 | 35 | 1. Clone the repository to your local machine: 36 | ```bash 37 | git clone https://github.com/VincentPS/runescape-api-symfony.git 38 | ``` 39 | 40 | 2. Navigate to the project directory: 41 | ```bash 42 | cd runescape-api-symfony 43 | ``` 44 | 45 | 3. Install project dependencies using Composer: 46 | ```bash 47 | composer install 48 | ``` 49 | 50 | 4. Run Docker 51 | ```bash 52 | docker compose up 53 | ``` 54 | 55 | 5. Set up NPM 56 | ```bash 57 | npm install 58 | npm run dev 59 | ``` 60 | 61 | ## Configuration 62 | 63 | 1. Create a `.env.local` file in the project root and configure your database connection: 64 | DATABASE_URL=mysql://username:password@localhost:3306/database_name 65 | 66 | 2. Configure any other desired settings in the `.env.local` file. 67 | 68 | 3. Create the database schema: 69 | php bin/console doctrine:database:create 70 | php bin/console doctrine:migrations:migrate 71 | 72 | 4. The PHP modules are configured in the `php.ini` file, the following modules are required: 73 | ``` 74 | extension=curl 75 | extension=ftp 76 | extension=intl 77 | extension=openssl 78 | extension=pdo_pgsql 79 | extension=pgsql 80 | ``` 81 | 82 | ## Cetrificate for Curl (Guzzle) 83 | This is only needed for local development, in production the server should have a valid certificate. 84 | Download the latest cacert.pem file from https://curl.se/docs/caextract.html and configure your php.ini file to use it: 85 | ```ini 86 | [curl] 87 | curl.cainfo = "{path to cacert.pem file}" 88 | [openssl] 89 | openssl.cafile = "{path to cacert.pem file}" 90 | ``` 91 | 92 | ## Usage 93 | 94 | 1. Start the Symfony development server: 95 | symfony server:start 96 | 97 | 2. Access the application in your browser at `http://localhost:8000`. 98 | 99 | 3. Explore the various features provided by the application, such as viewing player stats, activities, and other RuneMetrics data. 100 | 101 | 4. If you want to update the data of a user through the CLI you can do so by using the following command: 102 | ```php bin/console rsapi:update ```. 103 | 104 | 5. A scheduled task can be set up to update the data of a user every minute. To do this, you can use the following command: 105 | ```php bin/console messenger:consume scheduler_update_player_data```, which user is updated can be changed in ```KnownPlayers::currentMain()```. 106 | 107 | 6. There is a way to validate the integrity of the data stored for a single user. To do this, you can use the following command: 108 | ```php bin/console db:verify-player-data-integrity ```. 109 | 110 | ## API Wrapper 111 | 112 | This project includes an API wrapper for the RuneScape API to simplify data fetching. The wrapper can be found in the `src/Service/RSApiService.php` file. You can extend this wrapper to add more functionality or customize the API calls as needed. 113 | 114 | ## Contributing 115 | 116 | We welcome contributions to the RuneMetrics Re-Creation project! If you'd like to contribute, please follow these steps: 117 | 118 | 1. Fork the repository. 119 | 2. Create a new branch for your feature or bug fix. 120 | 3. Make your changes and commit them with descriptive messages. 121 | 4. Push your changes to your fork. 122 | 5. Submit a pull request to the main repository. 123 | 124 | ## License 125 | 126 | This project is licensed under the MIT License. Feel free to use and modify the code as per the terms of the license. 127 | 128 | --- 129 | 130 | Thank you for choosing the RuneMetrics Re-Creation project! If you encounter any issues or have suggestions, please open an issue on the GitHub repository. Happy coding! 🚀 131 | 132 | -------------------------------------------------------------------------------- /src/Service/Chart/Xp/HourlyChartService.php: -------------------------------------------------------------------------------- 1 | playerRepository->findHourlyXpRateForTotalXp( 38 | $date->modify('00:00'), 39 | $date->modify('23:59'), 40 | $playerName 41 | ); 42 | 43 | $data = []; 44 | $labels = []; 45 | $currentHour = $date->modify('00:00'); 46 | $lastHour = $date->modify('23:59'); 47 | 48 | while ($currentHour <= $lastHour) { 49 | $formattedHour = $currentHour->format('H:i'); 50 | $data[$formattedHour] = $hours[(int)$currentHour->format('G')]['xp_increase'] ?? 0; 51 | $labels[] = $formattedHour; 52 | $currentHour = $currentHour->modify('+1 hour'); 53 | } 54 | 55 | if ($chartType === 'stackedBar') { 56 | $chartType = Chart::TYPE_BAR; 57 | } 58 | 59 | return $this->createTotalXpChart($chartType, $labels, $data); 60 | } 61 | 62 | /** 63 | * Note: Will only return datasets of the skills that have at least one day with a positive xp difference. 64 | * 65 | * @param string $playerName 66 | * @param SkillEnum[] $skills 67 | * @param DateTimeImmutable $date 68 | * @param string $chartType 69 | * @return Chart 70 | * @throws Exception 71 | * @throws DateMalformedStringException 72 | */ 73 | public function getXpPerSkillChart( 74 | string $playerName, 75 | array $skills, 76 | DateTimeImmutable $date = new DateTimeImmutable(), 77 | string $chartType = Chart::TYPE_BAR, 78 | ): Chart { 79 | $skillsData = []; 80 | 81 | foreach ($skills as $skill) { 82 | $xpData = $this->playerRepository->findHourlyXpRateForSkillAtDate( 83 | $date->modify('00:00'), 84 | $date->modify('23:59'), 85 | $playerName, 86 | $skill 87 | ); 88 | 89 | $skillsData[] = [ 90 | 'skill' => $skill, 91 | 'data' => $xpData 92 | ]; 93 | } 94 | 95 | $dataSets = []; 96 | $labels = []; 97 | 98 | foreach ($skillsData as $skillsDataItem) { 99 | $data = []; 100 | $currentHour = $date->modify('00:00'); 101 | $lastHour = $date->modify('23:00'); 102 | 103 | while ($currentHour <= $lastHour) { 104 | foreach ($skillsDataItem['data'] as $hour) { 105 | if ($hour['date'] === $currentHour->format('H:i')) { 106 | $formattedDate = $currentHour->format('H:i'); 107 | $data[$formattedDate] = $hour['xp_difference']; 108 | $labels[$formattedDate] = $formattedDate; 109 | } 110 | } 111 | 112 | if (!array_key_exists($currentHour->format('H:i'), $data)) { 113 | $formattedDate = $currentHour->format('H:i'); 114 | $data[$formattedDate] = 0; 115 | $labels[$formattedDate] = $formattedDate; 116 | } 117 | 118 | $currentHour = $currentHour->modify('+1 hour'); 119 | } 120 | 121 | $dataSets[] = [ 122 | 'label' => $skillsDataItem['skill']->name . ' XP', 123 | 'backgroundColor' => $skillsDataItem['skill']->graphColor(), 124 | 'borderColor' => $skillsDataItem['skill']->graphColor(), 125 | 'data' => array_values($data) 126 | ]; 127 | } 128 | 129 | $scales = $this->createScales(); 130 | 131 | if ($chartType === 'stackedBar') { 132 | $chartType = Chart::TYPE_BAR; 133 | $scales['y']['stacked'] = true; 134 | $scales['x']['stacked'] = true; 135 | } 136 | 137 | return $this->createXpPerSkillChart($chartType, $scales, $labels, $dataSets); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Controller/QuestsController.php: -------------------------------------------------------------------------------- 1 | headerSearchForm(); 29 | $quests = $playerRepository->findAllQuests($this->getCurrentPlayerName()); 30 | 31 | /** @var array $quests */ 32 | $quests = array_map( 33 | fn(Quest $quest) => $this->getSerializer()->normalize($quest, Quest::class), 34 | $quests 35 | ); 36 | 37 | $table = $dataTableFactory 38 | ->create([ 39 | 'paging' => false, 40 | // 'pagingType' => 'simple_numbers', 41 | 'ordering' => true, 42 | // 'lengthMenu' => [[10, 25, 50, -1], [10, 25, 50, 'All']], 43 | 'jQueryUI' => true, 44 | 'autoWidth' => true, 45 | 'pageLength' => 999, // Show all quests 46 | 'searching' => true, 47 | ]) 48 | ->add( 49 | 'title', 50 | TextColumn::class, 51 | [ 52 | 'searchable' => true, 53 | 'orderable' => true, 54 | 'label' => 'Title', 55 | 'render' => static function (string $value) { 56 | $url = sprintf( 57 | 'https://runescape.wiki/w/%s/Quick_guide', 58 | str_replace(' ', '_', $value) 59 | ); 60 | 61 | return sprintf( 62 | '%s', 63 | $url, 64 | $value 65 | ); 66 | } 67 | ] 68 | ) 69 | ->add( 70 | 'difficulty', 71 | TextColumn::class, 72 | [ 73 | 'orderable' => true, 74 | 'label' => 'Difficulty', 75 | 'render' => fn(int $value) => QuestDifficulty::from($value)->name 76 | ] 77 | ) 78 | ->add( 79 | 'questPoints', 80 | NumberColumn::class, 81 | [ 82 | 'orderable' => true, 83 | 'label' => 'Quest Points' 84 | ] 85 | ) 86 | ->add( 87 | 'members', 88 | BoolColumn::class, 89 | [ 90 | 'orderable' => true, 91 | 'label' => 'Members', 92 | 'render' => fn(string $value) => $value === 'true' 93 | ? 'P2P' 96 | : '' 97 | ] 98 | ) 99 | ->add( 100 | 'status', 101 | TextColumn::class, 102 | [ 103 | 'orderable' => true, 104 | 'label' => 'Status', 105 | 'render' => fn(string $value) => match ($value) { 106 | 'COMPLETED' => 'Completed', 107 | 'STARTED' => 'Started', 108 | 'NOT_STARTED' => 'Not Started', 109 | default => 'Unknown', 110 | } 111 | ] 112 | ) 113 | ->addOrderBy('title') 114 | ->createAdapter(ArrayAdapter::class, $quests) 115 | ->handleRequest($request); 116 | 117 | if ($table->isCallback()) { 118 | return $table->getResponse(); 119 | } 120 | 121 | return $this->render('quests.html.twig', [ 122 | 'datatable' => $table, 123 | 'form' => $form->createView() 124 | ]); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Controller/ActivityController.php: -------------------------------------------------------------------------------- 1 | headerSearchForm(); 26 | $filterForm = $this->filterForm($request); 27 | 28 | try { 29 | $activities = []; 30 | 31 | if ($filterForm->isSubmitted() && $filterForm->isValid()) { 32 | /** @var array{ 33 | * 'acitivityCategory': ActivityFilter, 34 | * 'skillCategory': SkillEnum 35 | * } $formData 36 | */ 37 | $formData = $filterForm->getData(); 38 | 39 | if ($formData['acitivityCategory'] !== ActivityFilter::All) { 40 | if ( 41 | $formData['acitivityCategory'] == ActivityFilter::Skills 42 | && array_key_exists('skillCategory', $formData) 43 | && $formData['skillCategory'] !== null 44 | ) { 45 | $activities = $playerRepository->findAllUniqueActivitiesByPlayerNameAndSkill( 46 | $this->getCurrentPlayerName(), 47 | $formData['skillCategory'] 48 | ); 49 | } else { 50 | $activities = $playerRepository->findAllUniqueActivitiesByPlayerNameAndActivityFilter( 51 | $this->getCurrentPlayerName(), 52 | $formData['acitivityCategory'] 53 | ); 54 | } 55 | } 56 | } 57 | 58 | // If the filter form is not submitted, or if the filter form is submitted but not valid (e.g. no filter 59 | // selected), then we want to show all activities. 60 | if (empty($activities)) { 61 | $activities = $playerRepository->findAllUniqueActivitiesByPlayerName($this->getCurrentPlayerName()); 62 | } 63 | } catch (Exception) { 64 | throw new AccessDeniedHttpException(); 65 | } 66 | 67 | $serializer = Activity::getSerializer(); 68 | 69 | /** @var array $activities */ 70 | $activities = $serializer->deserialize( 71 | $activities, 72 | Activity::class . '[]', 73 | 'json' 74 | ); 75 | 76 | usort($activities, function ($a, $b) { 77 | return $b->date <=> $a->date; 78 | }); 79 | 80 | return $this->render('activities.html.twig', [ 81 | 'activities' => $activities, 82 | 'form' => $form->createView(), 83 | 'filterForm' => $filterForm->createView() 84 | ]); 85 | } 86 | 87 | private function filterForm(Request $request): FormInterface 88 | { 89 | $form = $this->formFactory 90 | ->createNamedBuilder(name: 'filter_activities_form', options: [ 91 | 'attr' => [ 92 | 'class' => 'align-items-center d-flex mb-1' 93 | 94 | ] 95 | ]) 96 | ->add('acitivityCategory', EnumType::class, [ 97 | 'attr' => [ 98 | 'class' => 'form-select col-6', 99 | 'placeholder' => 'filter' 100 | ], 101 | 'label' => false, 102 | 'class' => ActivityFilter::class 103 | ]); 104 | 105 | // If the form is submitted and the activity category skills is selected, then show the skill category 106 | if ( 107 | array_key_exists('filter_activities_form', $request->request->all()) 108 | && is_array($request->request->all()['filter_activities_form']) 109 | && array_key_exists('acitivityCategory', $request->request->all()['filter_activities_form']) 110 | && $request->request->all()['filter_activities_form']['acitivityCategory'] === ActivityFilter::Skills->value 111 | ) { 112 | $form->add('skillCategory', EnumType::class, [ 113 | 'attr' => [ 114 | 'class' => 'form-select ms-2 pe-4', 115 | 'placeholder' => 'filter' 116 | ], 117 | 'label' => false, 118 | 'class' => SkillEnum::class, 119 | ]); 120 | } 121 | 122 | $form->add('search', SubmitType::class, [ 123 | 'label' => 'Filter', 124 | 'attr' => [ 125 | 'class' => 'ms-3 btn btn-custom' 126 | ] 127 | ]); 128 | 129 | $form = $form->getForm(); 130 | $form->handleRequest($request); 131 | 132 | return $form; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Enum/EliteSkillLevelXpEnum.php: -------------------------------------------------------------------------------- 1 | value) { 165 | break; 166 | } 167 | $previousLevel = (int)str_replace('Level', '', $enumLevel->name); 168 | } 169 | 170 | return $previousLevel; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /templates/activities.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}RM Tracker - Activities{% endblock %} 4 | 5 | {% block body %} 6 |
7 | {% include 'includes/header.html.twig' %} 8 | 9 | 10 |
11 | {% include 'includes/sidemenu.html.twig' %} 12 | 13 | 14 |
15 | 18 |
19 |
20 |

ACTIVITY ({{ activities|length }})

21 |
22 |
23 |
{{ form(filterForm) }}
24 |
25 | {% if activities is empty %} 26 | 29 | {% endif %} 30 | {% for adventureLogItem in activities %} 31 |
32 |
33 |
34 |
35 |
36 |

37 | {{ make_activity_log_item_image(adventureLogItem)|raw }} 38 | {% if adventureLogItem.text is not null and adventureLogItem.text is not empty %} 39 | {% if 'XP' in adventureLogItem.text %} 40 | {{ adventureLogItem.text|split('XP')|first|number_format }} XP{{ adventureLogItem.text|split('XP')|last|trim('.') }} 41 | {% else %} 42 | {{ adventureLogItem.text|trim('.') }} 43 | {% endif %} 44 | {% endif %} 45 |

46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | {% set matches = adventureLogItem.details|preg_match('/([0-9,]+) experience points? in the (.+) skill\./') %} 55 | {% if matches is not empty %} 56 | I now have at least {{ matches[1]|number_format }} experience points in the {{ matches[2] }} skill. 57 | {% else %} 58 | {{ adventureLogItem.details }} 59 | {% endif %} 60 |
61 |

62 | 63 | 64 | {{ adventureLogItem.date|date('d-M-Y H:i') }} 65 | 66 |

67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | {% endfor %} 78 |
79 |
80 |
81 |
82 |
83 |
84 | {% endblock %} 85 | -------------------------------------------------------------------------------- /src/Entity/Player.php: -------------------------------------------------------------------------------- 1 | id; 69 | } 70 | 71 | public function getTotalSkill(): ?int 72 | { 73 | return $this->totalSkill; 74 | } 75 | 76 | public function setTotalSkill(int $totalSkill): self 77 | { 78 | $this->totalSkill = $totalSkill; 79 | 80 | return $this; 81 | } 82 | 83 | public function getTotalXp(): ?string 84 | { 85 | return $this->totalXp; 86 | } 87 | 88 | public function setTotalXp(string $totalXp): self 89 | { 90 | $this->totalXp = $totalXp; 91 | 92 | return $this; 93 | } 94 | 95 | public function getRank(): ?string 96 | { 97 | return $this->rank; 98 | } 99 | 100 | public function setRank(?string $rank): self 101 | { 102 | $this->rank = $rank; 103 | 104 | return $this; 105 | } 106 | 107 | public function getCombatLevel(): ?int 108 | { 109 | return $this->combatLevel; 110 | } 111 | 112 | public function setCombatLevel(int $combatLevel): self 113 | { 114 | $this->combatLevel = $combatLevel; 115 | 116 | return $this; 117 | } 118 | 119 | public function getName(): ?string 120 | { 121 | return $this->name; 122 | } 123 | 124 | public function setName(string $name): self 125 | { 126 | $this->name = $name; 127 | 128 | return $this; 129 | } 130 | 131 | public function getQuestsCompleted(): ?int 132 | { 133 | return $this->questsCompleted; 134 | } 135 | 136 | public function setQuestsCompleted(int $questsCompleted): self 137 | { 138 | $this->questsCompleted = $questsCompleted; 139 | 140 | return $this; 141 | } 142 | 143 | public function getQuestsStarted(): ?int 144 | { 145 | return $this->questsStarted; 146 | } 147 | 148 | public function setQuestsStarted(int $questsStarted): self 149 | { 150 | $this->questsStarted = $questsStarted; 151 | 152 | return $this; 153 | } 154 | 155 | public function getQuestsNotStarted(): ?int 156 | { 157 | return $this->questsNotStarted; 158 | } 159 | 160 | public function setQuestsNotStarted(int $questsNotStarted): self 161 | { 162 | $this->questsNotStarted = $questsNotStarted; 163 | 164 | return $this; 165 | } 166 | 167 | /** 168 | * @return Activity[] 169 | */ 170 | public function getActivities(): array 171 | { 172 | return $this->activities; 173 | } 174 | 175 | /** 176 | * @param Activity[] $activities 177 | */ 178 | public function setActivities(array $activities): self 179 | { 180 | $this->activities = $activities; 181 | 182 | return $this; 183 | } 184 | 185 | public function addActivity(Activity $activity): self 186 | { 187 | $this->activities[] = $activity; 188 | return $this; 189 | } 190 | 191 | /** 192 | * @return SkillValue[] 193 | */ 194 | public function getSkillValues(): array 195 | { 196 | usort($this->skillValues, function ($a, $b) { 197 | /** 198 | * @var SkillValue $a 199 | * @var SkillValue $b 200 | */ 201 | 202 | if (is_null($a->id) || is_null($b->id)) { 203 | return 0; 204 | } 205 | 206 | return $a->id->value - $b->id->value; 207 | }); 208 | 209 | return $this->skillValues; 210 | } 211 | 212 | /** 213 | * @param SkillValue[] $skillValues 214 | */ 215 | public function setSkillValues(array $skillValues): self 216 | { 217 | $this->skillValues = $skillValues; 218 | 219 | return $this; 220 | } 221 | 222 | public function addSkillValue(SkillValue $skillValue): self 223 | { 224 | $this->skillValues[] = $skillValue; 225 | return $this; 226 | } 227 | 228 | /** 229 | * @return Quest[] 230 | */ 231 | public function getQuests(): array 232 | { 233 | return $this->quests; 234 | } 235 | 236 | /** 237 | * @param Quest[] $quests 238 | */ 239 | public function setQuests(array $quests): self 240 | { 241 | $this->quests = $quests; 242 | 243 | return $this; 244 | } 245 | 246 | public function addQuest(Quest $quest): self 247 | { 248 | $this->quests[] = $quest; 249 | return $this; 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /templates/includes/sidemenu.html.twig: -------------------------------------------------------------------------------- 1 | {% set searchPlayerName = app.request.request.all['search_form']['playerName'] ?? app.request.get('playerName') ?? '' %} 2 | {% set sideMenuItems = [ 3 | { 4 | label: 'Summary', 5 | icon: 'fa-house', 6 | route: 'summary', 7 | match: ['summary'] 8 | }, 9 | { 10 | label: 'Runescape News', 11 | icon: 'fa-square-rss', 12 | route: 'newsfeed', 13 | match: ['newsfeed'] 14 | }, 15 | { 16 | label: 'Activity', 17 | icon: 'fa-book-open', 18 | route: 'activities', 19 | match: ['activities'] 20 | }, 21 | { 22 | label: 'Drops - Coming Soon', 23 | icon: 'fa-coins', 24 | route: 'summary', 25 | match: ['app_dashboard_drops'] 26 | }, 27 | { 28 | label: 'Xp Tracker', 29 | icon: 'fa-chart-simple', 30 | route: null, 31 | submenu: true, 32 | matchPrefix: 'app_xp_tracker', 33 | children: [ 34 | { 35 | label: 'Daily', 36 | route: 'app_xp_tracker_daily', 37 | match: ['app_xp_tracker_daily'] 38 | }, 39 | { 40 | label: 'Hourly', 41 | route: 'app_xp_tracker_hourly', 42 | match: ['app_xp_tracker_hourly'] 43 | } 44 | ] 45 | }, 46 | { 47 | label: 'Levels - Progress', 48 | icon: 'fa-chart-simple', 49 | route: 'app_dashboard_skill_level_progression', 50 | match: ['app_dashboard_skill_level_progression'] 51 | }, 52 | { 53 | label: 'Quests', 54 | icon: 'fa-book', 55 | route: 'quests', 56 | match: ['quests'] 57 | }, 58 | { 59 | label: 'Support with PayPal', 60 | icon: 'fa-heart', 61 | external: true, 62 | url: 'https://www.paypal.com/donate/?hosted_button_id=9PHUY4TXNVKUC' 63 | }, 64 | { 65 | label: 'Support with Ko-fi', 66 | icon: 'fa-coffee', 67 | external: true, 68 | url: 'https://ko-fi.com/vincentsch' 69 | } 70 | ] %} 71 | 72 | 73 |
74 |
75 |
76 | {% if app.request.session.get('currentPlayerName') %} 77 | 91 | {% endif %} 92 | 93 |
94 | {% for item in sideMenuItems %} 95 | {% set isActive = item.match is defined and item.match|filter(v => app.request.attributes.get('_route') == v)|length > 0 %} 96 | {% set isPrefixActive = item.matchPrefix is defined and app.request.attributes.get('_route') starts with item.matchPrefix %} 97 | 98 | {% if item.submenu is defined and item.submenu %} 99 | 100 |
101 |
102 | 103 | {{ item.label }} 104 | 105 |
106 |
107 |
108 |
109 | {% for sub in item.children %} 110 | {% set isSubActive = sub.match is defined and sub.match|filter(v => app.request.attributes.get('_route') == v)|length > 0 %} 111 | 112 | {{ sub.label }} 113 | 114 | {% endfor %} 115 |
116 | {% elseif item.external is defined and item.external %} 117 | 118 |
119 |
120 | 121 | {{ item.label }} 122 |
123 |
124 |
125 | {% else %} 126 | 127 |
128 |
129 | 130 | {{ item.label }} 131 |
132 |
133 |
134 | {% endif %} 135 | {% endfor %} 136 |
137 |
138 |
139 |
140 | 141 | -------------------------------------------------------------------------------- /src/Controller/XpTrackerController.php: -------------------------------------------------------------------------------- 1 | headerSearchForm(); 25 | $filterForm = $this->formFactory 26 | ->createNamedBuilder(name: 'filter_xp_tracker_form', options: [ 27 | 'attr' => [ 28 | 'class' => 'text-light col-sm-5' 29 | ] 30 | ]) 31 | ->add('skillCategory', ChoiceType::class, [ 32 | 'label' => 'Skill', 33 | 'choices' => SkillEnum::toArray(), 34 | 'multiple' => true, 35 | 'required' => false 36 | ]) 37 | ->add('from', DateType::class, [ 38 | 'label' => 'From', 39 | 'html5' => true, 40 | 'required' => true, 41 | 'data' => new DateTimeImmutable('-1 month'), 42 | ]) 43 | ->add('to', DateType::class, [ 44 | 'label' => 'To', 45 | 'html5' => true, 46 | 'required' => true, 47 | 'data' => new DateTimeImmutable(), 48 | ]) 49 | ->add('chartType', ChoiceType::class, [ 50 | 'label' => 'Graph Type', 51 | 'choices' => [ 52 | 'Stacked Bars' => 'stackedBar', 53 | 'Lines' => Chart::TYPE_LINE 54 | ] 55 | ]) 56 | ->add('search', SubmitType::class, [ 57 | 'label' => 'Filter', 58 | 'attr' => [ 59 | 'class' => 'btn-custom' 60 | ] 61 | ]) 62 | ->getForm(); 63 | 64 | $filterForm->handleRequest($request); 65 | 66 | if ($filterForm->isSubmitted() && $filterForm->isValid()) { 67 | /** @var array{skillCategory: int[], chartType: string, from: DateTime, to: DateTime} $data */ 68 | $data = $filterForm->getData(); 69 | 70 | if (empty($data['skillCategory'])) { 71 | $chart = $chartService->getTotalXpChart( 72 | $this->getCurrentPlayerName(), 73 | DateTimeImmutable::createFromMutable($data['from']), 74 | DateTimeImmutable::createFromMutable($data['to']), 75 | $data['chartType'], 76 | ); 77 | } else { 78 | $chart = $chartService->getXpPerSkillChart( 79 | $this->getCurrentPlayerName(), 80 | array_map(fn($skill) => SkillEnum::from($skill), $data['skillCategory']), 81 | DateTimeImmutable::createFromMutable($data['from']), 82 | DateTimeImmutable::createFromMutable($data['to']), 83 | $data['chartType'], 84 | ); 85 | } 86 | } 87 | 88 | return $this->render('xp_tracker.html.twig', [ 89 | 'chart' => $chart ?? $chartService->getTotalXpChart( 90 | $this->getCurrentPlayerName(), 91 | new DateTimeImmutable('-1 month'), 92 | new DateTimeImmutable(), 93 | ), 94 | 'form' => $form->createView(), 95 | 'filterForm' => $filterForm->createView() 96 | ]); 97 | } 98 | 99 | #[Route(path: '/hourly', name: 'app_xp_tracker_hourly')] 100 | public function hourly(Request $request, HourlyChartService $chartService): Response 101 | { 102 | $form = $this->headerSearchForm(); 103 | $filterForm = $this->formFactory 104 | ->createNamedBuilder(name: 'filter_xp_tracker_form', options: [ 105 | 'attr' => [ 106 | 'class' => 'text-light col-sm-5' 107 | ] 108 | ]) 109 | ->add('skillCategory', ChoiceType::class, [ 110 | 'label' => 'Skill', 111 | 'choices' => SkillEnum::toArray(), 112 | 'multiple' => true, 113 | 'required' => false 114 | ]) 115 | ->add('date', DateType::class, [ 116 | 'label' => 'Date', 117 | 'html5' => true, 118 | 'required' => true, 119 | 'data' => new DateTimeImmutable(), 120 | ]) 121 | ->add('chartType', ChoiceType::class, [ 122 | 'label' => 'Graph Type', 123 | 'choices' => [ 124 | 'Stacked Bars' => 'stackedBar', 125 | 'Lines' => Chart::TYPE_LINE 126 | ] 127 | ]) 128 | ->add('search', SubmitType::class, [ 129 | 'label' => 'Filter', 130 | 'attr' => [ 131 | 'class' => 'btn-custom' 132 | ] 133 | ]) 134 | ->getForm(); 135 | 136 | $filterForm->handleRequest($request); 137 | 138 | if ($filterForm->isSubmitted() && $filterForm->isValid()) { 139 | /** @var array{skillCategory: int[], chartType: string, date: DateTime} $data */ 140 | $data = $filterForm->getData(); 141 | 142 | if (empty($data['skillCategory'])) { 143 | $chart = $chartService->getTotalXpChart( 144 | $this->getCurrentPlayerName(), 145 | DateTimeImmutable::createFromMutable($data['date']), 146 | $data['chartType'], 147 | ); 148 | } else { 149 | $chart = $chartService->getXpPerSkillChart( 150 | $this->getCurrentPlayerName(), 151 | array_map(fn($skill) => SkillEnum::from($skill), $data['skillCategory']), 152 | DateTimeImmutable::createFromMutable($data['date']), 153 | $data['chartType'], 154 | ); 155 | } 156 | } 157 | 158 | $chartToReturn = $chart 159 | ?? $chartService->getXpPerSkillChart( 160 | $this->getCurrentPlayerName(), 161 | array_map(fn($skill) => SkillEnum::from($skill), SkillEnum::toArray()), 162 | new DateTimeImmutable(), 163 | 'stackedBar', 164 | ); 165 | 166 | return $this->render('xp_tracker.html.twig', [ 167 | 'chart' => $chartToReturn, 168 | 'form' => $form->createView(), 169 | 'filterForm' => $filterForm->createView() 170 | ]); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /templates/welcome.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}RM Tracker - Welcome!{% endblock %} 4 | 5 | {% block body %} 6 |
7 | {% include 'includes/header.html.twig' %} 8 | 9 | 10 |
11 | {% include 'includes/sidemenu.html.twig' %} 12 | 13 | 14 |
15 |
16 |
17 |

Welcome

18 |
19 |
20 |
21 |
22 | 112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 | {% endblock %} 120 | --------------------------------------------------------------------------------