├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── kpi.php ├── database ├── factories │ └── KpiFactory.php └── migrations │ └── create_kpis_table.php.stub ├── pint.json ├── resources └── views │ └── .gitkeep └── src ├── Commands ├── KpisSeedCommand.php └── KpisSnapshotCommand.php ├── Contracts └── HasDifference.php ├── Enums ├── KpiAggregate.php └── KpiInterval.php ├── Facades └── Kpi.php ├── KpiDefinition.php ├── KpiFloatDefinition.php ├── KpiJsonDefinition.php ├── KpiMoneyDefinition.php ├── KpiServiceProvider.php ├── KpiStringDefinition.php ├── KpiValue.php ├── Models └── Kpi.php ├── SqlAdapters ├── MySqlAdapter.php ├── PostgreSqlAdapter.php ├── SqlAdapter.php └── SqliteAdapter.php └── Traits └── DiscoverKpiDefinitions.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-kpi` will be documented in this file. 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) ElegantEngineeringTech 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Store, Analyze, and Retrieve KPIs Over Time in Your Laravel App 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/elegantly/laravel-kpi.svg?style=flat-square)](https://packagist.org/packages/elegantly/laravel-kpi) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/ElegantEngineeringTech/laravel-kpi/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/ElegantEngineeringTech/laravel-kpi/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/ElegantEngineeringTech/laravel-kpi/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/ElegantEngineeringTech/laravel-kpi/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/elegantly/laravel-kpi.svg?style=flat-square)](https://packagist.org/packages/elegantly/laravel-kpi) 7 | 8 | ![laravel-seo](https://repository-images.githubusercontent.com/836316961/3690c066-4681-48c9-8a37-be709899bd94) 9 | 10 | This package provides an easy way to store KPIs from your application in your database and retrieve them in various formats. It's especially useful for tracking data related to your models, such as: 11 | 12 | - Number of users 13 | - Number of subscribed users 14 | - Total revenue 15 | - And more... 16 | 17 | It's a perfect tool for building dashboards and displaying stats and charts. 18 | 19 | ## Filament Plugin 20 | 21 | Display your KPIs in a beatiful way with 1 line using our filament plugin: [`elegantly/filament-kpi`](https://github.com/ElegantEngineeringTech/filament-kpi) 22 | 23 | ## Installation 24 | 25 | Install the package via Composer: 26 | 27 | ```bash 28 | composer require elegantly/laravel-kpi 29 | ``` 30 | 31 | Publish and run the migrations with: 32 | 33 | ```bash 34 | php artisan vendor:publish --tag="kpi-migrations" 35 | php artisan migrate 36 | ``` 37 | 38 | Publish the configuration file with: 39 | 40 | ```bash 41 | php artisan vendor:publish --tag="kpi-config" 42 | ``` 43 | 44 | Here is the content of the published config file: 45 | 46 | ```php 47 | return [ 48 | 49 | /* 50 | |-------------------------------------------------------------------------- 51 | | Discover Definitions 52 | |-------------------------------------------------------------------------- 53 | | 54 | | If 'enabled' is set to true, your KPI definitions will be automatically 55 | | discovered when taking snapshots. 56 | | Set the 'path' to specify the directory where your KPI definitions are stored. 57 | | Definitions will be discovered from this path and its subdirectories. 58 | | 59 | */ 60 | 'discover' => [ 61 | 'enabled' => true, 62 | /** 63 | * This path will be used with the `app_path` helper, like `app_path('Kpis')`. 64 | */ 65 | 'path' => 'Kpis', 66 | ], 67 | 68 | /* 69 | |-------------------------------------------------------------------------- 70 | | Registered Definitions 71 | |-------------------------------------------------------------------------- 72 | | 73 | | You can manually register your KPI definitions if you are not using 74 | | "discover" or if you want to add additional definitions located elsewhere. 75 | | 76 | */ 77 | 'definitions' => [], 78 | ]; 79 | ``` 80 | 81 | ## Concepts 82 | 83 | This package is not a query builder. Instead, it is based on a `kpis` table where all KPIs are stored. This allows historical data (e.g., the number of users a year ago) to remain intact, even if models are permanently deleted. 84 | 85 | Retrieving KPIs is also way more efficient when calculating complex values, such as "users who made a purchase last week." 86 | 87 | A KPI can be simple or complex. Examples include: 88 | 89 | - Number of registered users 90 | - Monthly active users 91 | - Total revenue invoiced to customers 92 | - Number of recurring customers 93 | - Average Order Value 94 | - ... 95 | 96 | KPIs can be either "absolute" or "relative": 97 | 98 | - An **absolute KPI** represents a current state, such as the total number of users. 99 | - A **relative KPI** represents a change, such as the number of new users each day. 100 | 101 | Depending on the context, either an absolute or relative KPI may be more relevant. In most cases, relative KPIs can be derived from absolute ones, and vice versa. Therefore, it's often recommended to store KPIs as "absolute" and compute relative values when needed. 102 | 103 | ## Usage 104 | 105 | A KPI consists of two key components: 106 | 107 | - A **definition** 108 | - Its **values** 109 | 110 | The **definition** is a class extending `KpiDefinition`, where you configure the KPI. 111 | 112 | Each KPI definition must have a unique `name`, such as `users:count`. 113 | 114 | The **values** are stored in the `kpis` table and represented by the `Kpi` model. 115 | 116 | A KPI value may contain: 117 | 118 | - A value (float, string, Money, JSON) 119 | - A description (optional) 120 | - Tags (optional) 121 | - Metadata (optional) 122 | - A timestamp 123 | 124 | ### 1. Defining a KPI 125 | 126 | Each KPI is represented by a single `KpiDefinition` class. The package offers predefined classes for each data type: 127 | 128 | - `KpiFloatDefinition` 129 | - `KpiStringDefinition` 130 | - `KpiMoneyDefinition` 131 | - `KpiJsonDefinition` 132 | 133 | You can also extend `KpiDefinition` if you need custom behavior. 134 | 135 | Example: 136 | 137 | ```php 138 | namespace App\Kpis\Users; 139 | 140 | use App\Models\User; 141 | use Elegantly\Kpi\Enums\KpiInterval; 142 | use Elegantly\Kpi\KpiFloatDefinition; 143 | 144 | class UsersCountKpi extends KpiFloatDefinition 145 | { 146 | public static function getName(): string 147 | { 148 | return 'users:count'; 149 | } 150 | 151 | /** 152 | * This KPI is intended to be snapshotted every day. 153 | */ 154 | public static function getSnapshotInterval(): KpiInterval 155 | { 156 | return KpiInterval::Day; 157 | } 158 | 159 | public function getValue(): float 160 | { 161 | return (float) User::query() 162 | ->when($this->date, fn ($query) => $query->where('created_at', '<=', $this->date)) 163 | ->toBase() 164 | ->count(); 165 | } 166 | 167 | /** 168 | * Description to store alongside the KPI value 169 | */ 170 | public function getDescription(): ?string 171 | { 172 | return null; 173 | } 174 | 175 | /** 176 | * Tags to store alongside the KPI value 177 | */ 178 | public function getTags(): ?array 179 | { 180 | return null; 181 | } 182 | 183 | /** 184 | * Metadata to store alongside the KPI value 185 | */ 186 | public function getMetadata(): ?array 187 | { 188 | return null; 189 | } 190 | } 191 | ``` 192 | 193 | As shown, the `KpiDefinition` class has a `date` property, representing the snapshot date. When possible, use `date` in `getValue`, this will allow you to seed your KPIs with past data. 194 | 195 | ### 2. Snapshotting KPIs 196 | 197 | There are two ways to create KPI snapshots: 198 | 199 | - Schedule the `kpis:snapshot` command 200 | - Manually create snapshots 201 | 202 | #### Using the Command and Scheduler 203 | 204 | To capture KPI data at regular intervals (e.g., hourly or daily), schedule the `kpis:snapshot` command in your application's scheduler. 205 | 206 | Example: 207 | 208 | ```php 209 | $schedule->command(SnapshotKpisCommand::class, [ 210 | 'interval' => KpiInterval::Hour, 211 | ])->everyHour(); 212 | 213 | $schedule->command(SnapshotKpisCommand::class, [ 214 | 'interval' => KpiInterval::Day, 215 | ])->daily(); 216 | ``` 217 | 218 | #### Manual Snapshot 219 | 220 | You can manually snapshot a KPI using the `snapshot` method: 221 | 222 | ```php 223 | use App\Kpis\Users\UsersCountKpi; 224 | 225 | UsersCountKpi::snapshot( 226 | date: now() 227 | ); 228 | ``` 229 | 230 | ### 3. Seeding KPIs 231 | 232 | #### Seeding with the Command 233 | 234 | When adding KPIs to an existing project, you may want to seed past data. If your `KpiDefinition` class supports the `date` property, you can seed KPIs using the following command: 235 | 236 | ```bash 237 | php artisan kpis:seed "one year ago" "now" 238 | ``` 239 | 240 | #### Manual Seeding 241 | 242 | You can also seed KPIs manually using the `seed` method: 243 | 244 | ```php 245 | use App\Kpis\Users\UsersCountKpi; 246 | 247 | UsersCountKpi::seed( 248 | from: now()->subYear(), 249 | to: now(), 250 | interval: KpiInterval::Day 251 | ); 252 | ``` 253 | 254 | ### 4. Querying KPIs 255 | 256 | To visualize KPIs in charts or dashboards, the `KpiDefinition` class provides several helper methods: 257 | 258 | ```php 259 | use App\Kpis\Users\UsersCountKpi; 260 | 261 | /** 262 | * Retrieve a collection of KPIs for a given period, keyed by date. 263 | */ 264 | UsersCountKpi::getPeriod( 265 | start: now()->subDays(6), 266 | end: now(), 267 | interval: KpiInterval::Day 268 | ); 269 | 270 | /** 271 | * Retrieve a collection of relative KPIs (i.e., the difference between consecutive snapshots). 272 | */ 273 | UsersCountKpi::getDiffPeriod( 274 | start: now()->subDays(6), 275 | end: now(), 276 | interval: KpiInterval::Day 277 | ); 278 | ``` 279 | 280 | ### 5. Aggregating KPIs 281 | 282 | You can easily aggregate KPIs using the following methods: 283 | 284 | ```php 285 | /** 286 | * Retrieve the KPI with the maximum value for each month. 287 | */ 288 | UsersCountKpi::max( 289 | start: now()->subMonths(6), 290 | end: now(), 291 | interval: KpiInterval::Month 292 | ); 293 | 294 | UsersCountKpi::min(...); 295 | UsersCountKpi::avg(...); 296 | UsersCountKpi::sum(...); 297 | UsersCountKpi::count(...); 298 | ``` 299 | 300 | ### Storing Kpis in a separate database 301 | 302 | To reduce the write load this package creates on your primary database, you can store your KPIs in a different database. 303 | 304 | Follow these steps to set it up: 305 | 306 | #### 1. Create your own Kpi class 307 | 308 | In your application, create a custom model by extending the default one. 309 | 310 | Ensure you define the connection property. You may also define the table property if necessary. 311 | 312 | ```php 313 | namespace \App\Models; 314 | 315 | class Kpi extends \Eleganlty\Kpi\Models\Kpi 316 | { 317 | /** 318 | * The database connection that should be used by the model. 319 | * 320 | * @var string 321 | */ 322 | protected $connection = 'mysql'; 323 | 324 | } 325 | ``` 326 | 327 | #### 2. Publish and edit the config 328 | 329 | In your `kpi` configuration file, specify your custom class as the KPI model: 330 | 331 | ```php 332 | return [ 333 | //... 334 | 335 | 'model' => \App\Models\Kpi::class 336 | 337 | //... 338 | ] 339 | ``` 340 | 341 | #### 3. Publish and edit the migration 342 | 343 | Ensure you specify the correct connection and table in the migration file. 344 | 345 | It should look like this: 346 | 347 | ```php 348 | return new class extends Migration 349 | { 350 | public function up() 351 | { 352 | Schema::connection('mysql')->create('kpis', function (Blueprint $table){ 353 | // ... 354 | }); 355 | } 356 | 357 | public function down(): void 358 | { 359 | Schema::connection('mysql')->dropIfExists('kpis'); 360 | } 361 | 362 | } 363 | ``` 364 | 365 | ## Testing 366 | 367 | Run the tests with: 368 | 369 | ```bash 370 | composer test 371 | ``` 372 | 373 | ## Changelog 374 | 375 | For recent changes, see the [CHANGELOG](CHANGELOG.md). 376 | 377 | ## Contributing 378 | 379 | For contribution guidelines, see [CONTRIBUTING](CONTRIBUTING.md). 380 | 381 | ## Security Vulnerabilities 382 | 383 | For details on reporting security vulnerabilities, review [our security policy](../../security/policy). 384 | 385 | ## Credits 386 | 387 | - [Quentin Gabriele](https://github.com/QuentinGab) 388 | - [All Contributors](../../contributors) 389 | 390 | ## License 391 | 392 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 393 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elegantly/laravel-kpi", 3 | "description": "Advanced KPI for your Laravel application", 4 | "keywords": [ 5 | "ElegantEngineeringTech", 6 | "laravel", 7 | "laravel-kpi", 8 | "kpi", 9 | "metrics" 10 | ], 11 | "homepage": "https://github.com/ElegantEngineeringTech/laravel-kpi", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Quentin Gabriele", 16 | "email": "quentin.gabriele@gmail.com", 17 | "role": "Developer" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.2", 22 | "elegantly/laravel-money": "^2.0.1", 23 | "illuminate/contracts": "^11.0||^12.0", 24 | "spatie/laravel-package-tools": "^1.16", 25 | "spatie/php-structure-discoverer": "^2.2" 26 | }, 27 | "require-dev": { 28 | "laravel/pint": "^1.14", 29 | "nunomaduro/collision": "^8.1.1", 30 | "larastan/larastan": "^3.0", 31 | "orchestra/testbench": "^9.0.0||^10.0.0", 32 | "pestphp/pest": "^3.0", 33 | "pestphp/pest-plugin-arch": "^3.0", 34 | "pestphp/pest-plugin-laravel": "^3.0", 35 | "phpstan/extension-installer": "^1.3||^2.0", 36 | "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", 37 | "phpstan/phpstan-phpunit": "^1.3||^2.0" 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "Elegantly\\Kpi\\": "src/", 42 | "Elegantly\\Kpi\\Database\\Factories\\": "database/factories/" 43 | } 44 | }, 45 | "autoload-dev": { 46 | "psr-4": { 47 | "Elegantly\\Kpi\\Tests\\": "tests/", 48 | "Workbench\\App\\": "workbench/app/" 49 | } 50 | }, 51 | "scripts": { 52 | "post-autoload-dump": "@composer run prepare", 53 | "clear": "@php vendor/bin/testbench package:purge-laravel-kpi --ansi", 54 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 55 | "build": [ 56 | "@composer run prepare", 57 | "@php vendor/bin/testbench workbench:build --ansi" 58 | ], 59 | "start": [ 60 | "Composer\\Config::disableProcessTimeout", 61 | "@composer run build", 62 | "@php vendor/bin/testbench serve" 63 | ], 64 | "analyse": "vendor/bin/phpstan analyse", 65 | "test": "vendor/bin/pest", 66 | "test-coverage": "vendor/bin/pest --coverage", 67 | "format": "vendor/bin/pint" 68 | }, 69 | "config": { 70 | "sort-packages": true, 71 | "allow-plugins": { 72 | "pestphp/pest-plugin": true, 73 | "phpstan/extension-installer": true 74 | } 75 | }, 76 | "extra": { 77 | "laravel": { 78 | "providers": [ 79 | "Elegantly\\Kpi\\KpiServiceProvider" 80 | ], 81 | "aliases": { 82 | "Kpi": "Elegantly\\Kpi\\Facades\\Kpi" 83 | } 84 | } 85 | }, 86 | "minimum-stability": "dev", 87 | "prefer-stable": true 88 | } 89 | -------------------------------------------------------------------------------- /config/kpi.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'enabled' => true, 20 | /** 21 | * This path will be used with the `app_path` helper, like `app_path('Kpis')`. 22 | */ 23 | 'path' => 'Kpis', 24 | ], 25 | 26 | /* 27 | |-------------------------------------------------------------------------- 28 | | Registered Definitions 29 | |-------------------------------------------------------------------------- 30 | | 31 | | You can manually register your KPI definitions if you are not using 32 | | "discover" or if you want to add additional definitions located elsewhere. 33 | | 34 | */ 35 | 'definitions' => [], 36 | 37 | /* 38 | |-------------------------------------------------------------------------- 39 | | Model 40 | |-------------------------------------------------------------------------- 41 | | 42 | | Here you can define a custom class to use for your Kpi model 43 | | 44 | */ 45 | 'model' => \Elegantly\Kpi\Models\Kpi::class, 46 | ]; 47 | -------------------------------------------------------------------------------- /database/factories/KpiFactory.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class KpiFactory extends Factory 14 | { 15 | protected $model = Kpi::class; 16 | 17 | public function definition() 18 | { 19 | return []; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /database/migrations/create_kpis_table.php.stub: -------------------------------------------------------------------------------- 1 | id(); 13 | 14 | $table->string('name'); 15 | $table->datetime('date'); 16 | 17 | $table->string('type'); 18 | 19 | $table->decimal('number_value', 14, 4)->nullable(); 20 | 21 | $table->string('string_value')->nullable(); 22 | 23 | $table->bigInteger('money_value')->nullable(); 24 | $table->string('money_currency')->nullable(); 25 | 26 | $table->json('json_value')->nullable(); 27 | 28 | $table->text('description')->nullable(); // store any comment 29 | $table->json('metadata')->nullable(); 30 | $table->json('tags')->nullable(); 31 | 32 | $table->index(['name', 'date']); 33 | 34 | $table->timestamps(); 35 | }); 36 | } 37 | 38 | public function down(): void 39 | { 40 | Schema::dropIfExists('kpis'); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "declare_strict_types": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /resources/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElegantEngineeringTech/laravel-kpi/e2d25004a7ab231063d259f6e9e88c584c7befc7/resources/views/.gitkeep -------------------------------------------------------------------------------- /src/Commands/KpisSeedCommand.php: -------------------------------------------------------------------------------- 1 | argument('start'); 26 | 27 | return Carbon::parse($start); 28 | } 29 | 30 | public function getEndDate(): Carbon 31 | { 32 | /** 33 | * @var string $end 34 | */ 35 | $end = $this->argument('end'); 36 | 37 | return Carbon::parse($end); 38 | } 39 | 40 | public function handle(): int 41 | { 42 | $start = $this->getStartDate(); 43 | $end = $this->getEndDate(); 44 | 45 | /** 46 | * @var null|string[] $only 47 | */ 48 | $only = $this->option('only'); 49 | 50 | $definitions = $this->getDefinitions(); 51 | 52 | if ($only) { 53 | $definitions = $definitions->filter(function ($className) use ($only) { 54 | return in_array($className, $only) || in_array($className::getName(), $only); 55 | }); 56 | } 57 | 58 | $progress = new Progress( 59 | label: 'Snapshotting...', 60 | steps: $definitions->count(), 61 | ); 62 | 63 | foreach ($definitions as $className) { 64 | $progress->hint($className); 65 | 66 | $interval = $className::getSnapshotInterval(); 67 | 68 | $className::seed( 69 | start: $interval->toStartOf($start->clone()), 70 | end: $interval->toEndOf($end->clone()), 71 | interval: $interval, 72 | ); 73 | 74 | $progress->advance(); 75 | } 76 | 77 | $progress->finish(); 78 | 79 | return self::SUCCESS; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Commands/KpisSnapshotCommand.php: -------------------------------------------------------------------------------- 1 | option('interval'); 23 | 24 | $date = $this->option('date') ? Carbon::parse($this->option('date')) : null; 25 | 26 | $definitions = $this->getDefinitions(); 27 | 28 | if ($interval) { 29 | $definitions = $definitions->filter(function (string $className) use ($interval) { 30 | return $className::getSnapshotInterval()->value === $interval; 31 | }); 32 | } 33 | 34 | $progress = new Progress( 35 | label: 'Snapshotting...', 36 | steps: $definitions->count(), 37 | ); 38 | 39 | foreach ($definitions as $className) { 40 | $className::snapshot($date); 41 | $progress->advance(); 42 | } 43 | 44 | $progress->finish(); 45 | 46 | return self::SUCCESS; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Contracts/HasDifference.php: -------------------------------------------------------------------------------- 1 | DB::raw("MAX({$column}) as {$alias}"), 21 | self::Min => DB::raw("MIN({$column}) as {$alias}"), 22 | self::Count => DB::raw("COUNT({$column}) as {$alias}"), 23 | self::Sum => DB::raw("SUM({$column}) as {$alias}"), 24 | self::Average => DB::raw("AVG({$column}) as {$alias}"), 25 | }; 26 | } 27 | 28 | public function toBuilderFunction(): string 29 | { 30 | return match ($this) { 31 | self::Max => 'max', 32 | self::Min => 'min', 33 | self::Count => 'count', 34 | self::Sum => 'sum', 35 | self::Average => 'avg', 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Enums/KpiInterval.php: -------------------------------------------------------------------------------- 1 | value; 31 | } 32 | 33 | public function toSmallerUnit(): string 34 | { 35 | return match ($this) { 36 | self::Minute => 'second', 37 | self::Hour => self::Minute->toUnit(), 38 | self::Day => self::Hour->toUnit(), 39 | self::Month => self::Day->toUnit(), 40 | self::Year => self::Month->toUnit(), 41 | }; 42 | } 43 | 44 | /** 45 | * This format must exacelty match the SQL format from SqlAdapters 46 | */ 47 | public function toDateFormat(): string 48 | { 49 | return match ($this) { 50 | self::Minute => 'Y-m-d H:i:00', 51 | self::Hour => 'Y-m-d H:00', 52 | self::Day => 'Y-m-d', 53 | self::Month => 'Y-m', 54 | self::Year => 'Y', 55 | }; 56 | } 57 | 58 | public function fromDateFormat(string $date): ?Carbon 59 | { 60 | return Carbon::createFromFormat($this->toDateFormat(), $date); 61 | } 62 | 63 | public function toSqlFormat(string $driver, string $column): string 64 | { 65 | return match ($driver) { 66 | MySqlGrammar::class, 'mysql', MariaDbGrammar::class, 'mariadb' => MySqlAdapter::datetime($this, $column), 67 | SQLiteGrammar::class, 'sqlite' => SqliteAdapter::datetime($this, $column), 68 | PostgresGrammar::class, 'pgsql' => PostgreSqlAdapter::datetime($this, $column), 69 | default => throw new Error('Unsupported database driver.'), 70 | }; 71 | } 72 | 73 | public function toStartOf(?CarbonInterface $date = null): CarbonInterface 74 | { 75 | $date ??= now(); 76 | 77 | return $date->startOf($this->toUnit()); 78 | } 79 | 80 | public function toEndOf(?CarbonInterface $date = null): CarbonInterface 81 | { 82 | $date ??= now(); 83 | 84 | return $date->endOf($this->toUnit()); 85 | } 86 | 87 | public function toPeriod( 88 | CarbonInterface $start, 89 | CarbonInterface $end, 90 | ): CarbonPeriod { 91 | /** 92 | * @var CarbonPeriod 93 | */ 94 | return CarbonPeriod::between( 95 | start: $this->toStartOf($start->clone()), 96 | end: $this->toEndOf($end->clone()) 97 | )->interval($this->toCarbonInterval()); 98 | } 99 | 100 | public function toCarbonInterval(): CarbonInterval 101 | { 102 | return CarbonInterval::fromString("1 {$this->toUnit()}"); 103 | } 104 | 105 | public static function fromCarbonInterval(CarbonInterval $interval): self 106 | { 107 | if ($interval->y) { 108 | return self::Year; 109 | } 110 | if ($interval->m) { 111 | return self::Month; 112 | } 113 | if ($interval->d) { 114 | return self::Day; 115 | } 116 | if ($interval->h) { 117 | return self::Hour; 118 | } 119 | 120 | return self::Minute; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Facades/Kpi.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | abstract class KpiDefinition 21 | { 22 | /** 23 | * @param ?CarbonInterface $date The date to snapshot 24 | */ 25 | final public function __construct( 26 | public ?CarbonInterface $date = null 27 | ) { 28 | // 29 | } 30 | 31 | /** 32 | * The interval at which the KPI will be captured using the kpi:snapshot command. 33 | */ 34 | abstract public static function getSnapshotInterval(): KpiInterval; 35 | 36 | /** 37 | * Unique name like "users:active:count" 38 | */ 39 | abstract public static function getName(): string; 40 | 41 | /** 42 | * When possible, make sure the returned value is relative to the `date` property. 43 | * This will allow you to seed your KPI in the past. When seeding in the past in not possible 44 | * feel free to return any value you want like null or 0. 45 | * 46 | * @return TValue 47 | */ 48 | abstract public function getValue(): null|float|string|Money|array; 49 | 50 | /** 51 | * Display name like "Active Users" 52 | * The default value is the KPI's name 53 | */ 54 | public static function getLabel(): string 55 | { 56 | return static::getName(); 57 | } 58 | 59 | /** 60 | * Tags to store alongside the KPI value 61 | * 62 | * @return null|array 63 | */ 64 | public function getTags(): ?array 65 | { 66 | return null; 67 | } 68 | 69 | /** 70 | * Metadata to store alongside the KPI value 71 | * 72 | * @return null|array 73 | */ 74 | public function getMetadata(): ?array 75 | { 76 | return null; 77 | } 78 | 79 | /** 80 | * Description to store alongside the KPI value 81 | */ 82 | public function getDescription(): ?string 83 | { 84 | return null; 85 | } 86 | 87 | /** 88 | * @return Kpi 89 | */ 90 | public static function snapshot(?CarbonInterface $date = null): Kpi 91 | { 92 | $definition = new static($date); 93 | 94 | /** 95 | * @var Kpi $kpi 96 | */ 97 | $kpi = KpiServiceProvider::makeModelInstance(); 98 | 99 | $date ??= now(); 100 | 101 | $kpi 102 | ->setName(static::getName()) 103 | ->setValue($definition->getValue()) 104 | ->setDate($date->clone()) 105 | ->setMetadata($definition->getMetadata()) 106 | ->setDescription($definition->getDescription()) 107 | ->setTags($definition->getTags()) 108 | ->save(); 109 | 110 | return $kpi; 111 | } 112 | 113 | /** 114 | * @return Collection> 115 | */ 116 | public static function seed( 117 | CarbonInterface $start, 118 | CarbonInterface $end, 119 | KpiInterval|string $interval, 120 | ): Collection { 121 | 122 | /** 123 | * @var CarbonPeriod $period 124 | */ 125 | $period = CarbonPeriod::between( 126 | start: $start->clone(), 127 | end: $end->clone(), 128 | )->interval( 129 | $interval instanceof KpiInterval ? $interval->toCarbonInterval() : $interval 130 | ); 131 | 132 | /** 133 | * @var Collection> $kpis 134 | */ 135 | $kpis = new Collection; 136 | 137 | /** 138 | * @var CarbonInterface $date 139 | */ 140 | foreach ($period as $date) { 141 | $kpis->push(static::snapshot($date)); 142 | } 143 | 144 | return $kpis; 145 | } 146 | 147 | /** 148 | * @return Builder> 149 | */ 150 | public static function query( 151 | ?CarbonInterface $start = null, 152 | ?CarbonInterface $end = null, 153 | ): mixed { 154 | 155 | /** 156 | * @var Builder> 157 | */ 158 | $query = KpiServiceProvider::getModelClass()::query()->where('name', static::getName()); 159 | 160 | if ($start) { 161 | $query->where('date', '>=', $start); 162 | } 163 | if ($end) { 164 | $query->where('date', '<=', $end); 165 | } 166 | 167 | return $query; 168 | } 169 | 170 | /** 171 | * Retreive the latest KPI difference on the given period at the given interval 172 | * Each value is the difference between the current and the previous value. 173 | * Exemple: The new users for each month from `1 year ago` to `now`. 174 | * 175 | * @param Builder $query 176 | * @return SupportCollection> 177 | */ 178 | public static function getDiffPeriod( 179 | CarbonInterface $start, 180 | CarbonInterface $end, 181 | KpiInterval $interval, 182 | ?Builder $query = null, 183 | ): mixed { 184 | 185 | $query ??= static::query(); 186 | 187 | $period = $interval->toPeriod( 188 | start: $start, 189 | end: $end 190 | ); 191 | 192 | /** 193 | * @var Collection $kpis 194 | */ 195 | $kpis = static::latest( 196 | query: $query 197 | ->where( 198 | 'date', 199 | '>=', 200 | $period->getStartDate()->clone()->sub($interval->toUnit(), value: 1) 201 | ) 202 | ->where('date', '<=', $period->getEndDate()), 203 | interval: $interval 204 | )->keyBy(fn (Kpi $kpi) => $kpi->date->format($interval->toDateFormat())); 205 | 206 | $results = new SupportCollection; 207 | 208 | /** 209 | * @var CarbonInterface $date 210 | */ 211 | foreach ($period as $date) { 212 | $key = $date->format($interval->toDateFormat()); 213 | 214 | $previousKey = $date->clone() 215 | ->sub($interval->toUnit(), value: 1) 216 | ->format($interval->toDateFormat()); 217 | 218 | $value = static::diff($kpis->get($previousKey), $kpis->get($key)); 219 | 220 | $results->put( 221 | $key, 222 | new KpiValue( 223 | date: $date, 224 | value: $value 225 | ) 226 | ); 227 | } 228 | 229 | return $results; 230 | } 231 | 232 | /** 233 | * Get the difference between two KPI values 234 | * 235 | * @param Kpi $old 236 | * @param Kpi $new 237 | * @return TValue 238 | */ 239 | abstract public static function diff(?Kpi $old, ?Kpi $new): null|float|string|Money|array; 240 | 241 | /** 242 | * Retreive the latest KPI on the given period at the given interval 243 | * Exemple: The users count at each month from `1 year ago` to `now`. 244 | * 245 | * @param Builder $query 246 | * @return SupportCollection> 247 | */ 248 | public static function getPeriod( 249 | CarbonInterface $start, 250 | CarbonInterface $end, 251 | KpiInterval $interval, 252 | ?Builder $query = null, 253 | ): SupportCollection { 254 | 255 | $query ??= static::query(); 256 | 257 | $period = $interval->toPeriod( 258 | start: $start, 259 | end: $end 260 | ); 261 | 262 | /** 263 | * @var Collection $kpis 264 | */ 265 | $kpis = static::latest( 266 | query: $query 267 | ->where('date', '>=', $period->getStartDate()) 268 | ->where('date', '<=', $period->getEndDate()), 269 | interval: $interval, 270 | )->keyBy(fn (Kpi $kpi) => $kpi->date->format($interval->toDateFormat())); 271 | 272 | $results = new SupportCollection; 273 | 274 | /** 275 | * @var CarbonInterface $date 276 | */ 277 | foreach ($period as $date) { 278 | $key = $date->format($interval->toDateFormat()); 279 | $results->put( 280 | $key, 281 | $kpis->get($key) 282 | ); 283 | } 284 | 285 | return $results; 286 | } 287 | 288 | /** 289 | * Retreive the latest KPI at the given interval 290 | * 291 | * @param Builder $query 292 | * @return Collection 293 | */ 294 | public static function latest( 295 | ?CarbonInterface $start = null, 296 | ?CarbonInterface $end = null, 297 | ?Builder $query = null, 298 | ?KpiInterval $interval = null, 299 | ): Collection { 300 | $query ??= static::query( 301 | start: $start, 302 | end: $end 303 | ); 304 | 305 | if ($interval) { 306 | $grammar = $query->getQuery()->getGrammar(); 307 | 308 | $subquery = static::query() 309 | ->toBase() 310 | ->selectRaw('MAX(id) AS max_id') 311 | ->groupByRaw($interval->toSqlFormat($grammar::class, 'date')); 312 | 313 | $table = KpiServiceProvider::makeModelInstance()->getTable(); 314 | 315 | $query->toBase()->joinSub( 316 | query: $subquery, 317 | as: 'subquery', 318 | first: "{$table}.id", 319 | operator: '=', 320 | second: 'subquery.max_id' 321 | ); 322 | 323 | return $query->latest('date')->get(); 324 | } 325 | 326 | return $query->latest('date')->get(); 327 | } 328 | 329 | /** 330 | * @template T of null|KpiInterval 331 | * 332 | * @param Builder $query 333 | * @param T $interval 334 | * @return (T is null ? int|float : SupportCollection>) 335 | */ 336 | public static function max( 337 | ?CarbonInterface $start = null, 338 | ?CarbonInterface $end = null, 339 | ?Builder $query = null, 340 | string $column = 'number_value', 341 | ?KpiInterval $interval = null, 342 | ): int|float|SupportCollection { 343 | return static::aggregate( 344 | aggregate: KpiAggregate::Max, 345 | start: $start, 346 | end: $end, 347 | query: $query, 348 | column: $column, 349 | interval: $interval 350 | ); 351 | } 352 | 353 | /** 354 | * @template T of null|KpiInterval 355 | * 356 | * @param Builder $query 357 | * @param T $interval 358 | * @return (T is null ? int|float : SupportCollection>) 359 | */ 360 | public static function min( 361 | ?CarbonInterface $start = null, 362 | ?CarbonInterface $end = null, 363 | ?Builder $query = null, 364 | string $column = 'number_value', 365 | ?KpiInterval $interval = null, 366 | ): int|float|SupportCollection { 367 | return static::aggregate( 368 | aggregate: KpiAggregate::Min, 369 | start: $start, 370 | end: $end, 371 | query: $query, 372 | column: $column, 373 | interval: $interval 374 | ); 375 | } 376 | 377 | /** 378 | * @template T of null|KpiInterval 379 | * 380 | * @param Builder $query 381 | * @param T $interval 382 | * @return (T is null ? int|float : SupportCollection>) 383 | */ 384 | public static function sum( 385 | ?CarbonInterface $start = null, 386 | ?CarbonInterface $end = null, 387 | ?Builder $query = null, 388 | string $column = 'number_value', 389 | ?KpiInterval $interval = null, 390 | ): int|float|SupportCollection { 391 | return static::aggregate( 392 | aggregate: KpiAggregate::Sum, 393 | start: $start, 394 | end: $end, 395 | query: $query, 396 | column: $column, 397 | interval: $interval 398 | ); 399 | } 400 | 401 | /** 402 | * @template T of null|KpiInterval 403 | * 404 | * @param Builder $query 405 | * @param T $interval 406 | * @return (T is null ? int|float : SupportCollection>) 407 | */ 408 | public static function avg( 409 | ?CarbonInterface $start = null, 410 | ?CarbonInterface $end = null, 411 | ?Builder $query = null, 412 | string $column = 'number_value', 413 | ?KpiInterval $interval = null, 414 | ): int|float|SupportCollection { 415 | return static::aggregate( 416 | aggregate: KpiAggregate::Average, 417 | start: $start, 418 | end: $end, 419 | query: $query, 420 | column: $column, 421 | interval: $interval 422 | ); 423 | } 424 | 425 | /** 426 | * @template T of null|KpiInterval 427 | * 428 | * @param Builder $query 429 | * @param T $interval 430 | * @return (T is null ? int : SupportCollection>) 431 | */ 432 | public static function count( 433 | ?CarbonInterface $start = null, 434 | ?CarbonInterface $end = null, 435 | ?Builder $query = null, 436 | string $column = 'number_value', 437 | ?KpiInterval $interval = null, 438 | ): int|SupportCollection { 439 | /** 440 | * @var int|SupportCollection> 441 | */ 442 | return static::aggregate( 443 | aggregate: KpiAggregate::Count, 444 | start: $start, 445 | end: $end, 446 | query: $query, 447 | column: $column, 448 | interval: $interval 449 | ); 450 | } 451 | 452 | /** 453 | * @template T of null|KpiInterval 454 | * 455 | * @param Builder $query 456 | * @param T $interval 457 | * @return (T is null ? int|float : SupportCollection>) 458 | */ 459 | public static function aggregate( 460 | KpiAggregate $aggregate, 461 | ?CarbonInterface $start = null, 462 | ?CarbonInterface $end = null, 463 | ?Builder $query = null, 464 | string $column = 'number_value', 465 | ?KpiInterval $interval = null, 466 | ): int|float|SupportCollection { 467 | $query ??= static::query( 468 | start: $start, 469 | end: $end 470 | ); 471 | 472 | if ($interval) { 473 | 474 | $grammar = $query->getQuery()->getGrammar(); 475 | 476 | /** 477 | * @var SupportCollection $results 478 | */ 479 | $results = $query 480 | ->toBase() 481 | ->selectRaw("{$interval->toSqlFormat($grammar::class, 'date')} as date_group") 482 | ->groupBy('date_group') 483 | ->addSelect($aggregate->toSqlSelect($column, 'aggregated_value')) 484 | ->orderBy('date_group') 485 | ->get(); 486 | 487 | return $results->map(fn (object $result) => new KpiValue( 488 | date: $interval->fromDateFormat($result->date_group) ?: now(), 489 | value: $result->aggregated_value, 490 | )); 491 | } 492 | 493 | /** 494 | * @var int|float 495 | */ 496 | return $query 497 | ->toBase() 498 | ->aggregate( 499 | function: $aggregate->toBuilderFunction(), 500 | columns: [$column] 501 | ); 502 | } 503 | } 504 | -------------------------------------------------------------------------------- /src/KpiFloatDefinition.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | abstract class KpiFloatDefinition extends KpiDefinition 13 | { 14 | public static function diff(?Kpi $old, ?Kpi $new): ?float 15 | { 16 | if ($old?->value === null || $new?->value === null) { 17 | return null; 18 | } 19 | 20 | return $new->value - $old->value; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/KpiJsonDefinition.php: -------------------------------------------------------------------------------- 1 | > 14 | */ 15 | abstract class KpiJsonDefinition extends KpiDefinition 16 | { 17 | abstract public static function diff(?Kpi $old, ?Kpi $new): ?array; 18 | } 19 | -------------------------------------------------------------------------------- /src/KpiMoneyDefinition.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | abstract class KpiMoneyDefinition extends KpiDefinition 14 | { 15 | public static function diff(?Kpi $old, ?Kpi $new): ?Money 16 | { 17 | if ($old?->value === null || $new?->value === null) { 18 | return null; 19 | } 20 | 21 | return $new->value->minus($old->value); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/KpiServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-kpi') 24 | ->hasConfigFile() 25 | ->hasMigration('create_kpis_table') 26 | ->hasCommand(KpisSnapshotCommand::class) 27 | ->hasCommand(KpisSeedCommand::class); 28 | } 29 | 30 | /** 31 | * @return class-string 32 | */ 33 | public static function getModelClass(): string 34 | { 35 | /** @var class-string */ 36 | $className = config('kpi.model', Kpi::class); 37 | 38 | return $className; 39 | } 40 | 41 | public static function makeModelInstance(): Kpi 42 | { 43 | $className = static::getModelClass(); 44 | 45 | return new $className; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/KpiStringDefinition.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | abstract class KpiStringDefinition extends KpiDefinition 13 | { 14 | abstract public static function diff(?Kpi $old, ?Kpi $new): ?string; 15 | } 16 | -------------------------------------------------------------------------------- /src/KpiValue.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class KpiValue implements Arrayable, Jsonable 17 | { 18 | /** 19 | * @param TValue $value 20 | */ 21 | public function __construct( 22 | public CarbonInterface $date, 23 | public mixed $value, 24 | ) { 25 | // 26 | } 27 | 28 | public function toArray(): array 29 | { 30 | return [ 31 | 'date' => $this->date, 32 | 'value' => $this->value, 33 | ]; 34 | } 35 | 36 | public function toJson($options = 0): string 37 | { 38 | return json_encode($this->toArray(), $options) ?: ''; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Models/Kpi.php: -------------------------------------------------------------------------------- 1 | 18 | * 19 | * @property string $name 20 | * @property string $type 21 | * @property TValue $value 22 | * @property ?string $string_value 23 | * @property ?float $number_value 24 | * @property ?Money $money_value 25 | * @property ?string $money_currency 26 | * @property ?array $json_value 27 | * @property ?string $description 28 | * @property ?array $tags 29 | * @property ?array $metadata 30 | * @property CarbonInterface $date 31 | * @property CarbonInterface $created_at 32 | * @property CarbonInterface $updated_at 33 | */ 34 | class Kpi extends Model 35 | { 36 | /** 37 | * @use HasFactory 38 | */ 39 | use HasFactory; 40 | 41 | protected $guarded = []; 42 | 43 | protected $casts = [ 44 | 'number_value' => 'float', 45 | 'money_value' => MoneyCast::class.':money_currency', 46 | 'json_value' => 'array', 47 | 'metadata' => 'array', 48 | 'tags' => 'array', 49 | 'date' => 'datetime', 50 | ]; 51 | 52 | /** 53 | * @var array 54 | */ 55 | protected $attributes = [ 56 | 'type' => 'number_value', 57 | ]; 58 | 59 | /** 60 | * @return Attribute> 61 | */ 62 | protected function value(): Attribute 63 | { 64 | return Attribute::make( 65 | get: fn () => $this->getValue(), 66 | set: function (null|int|float|string|Money|array $value) { 67 | $this->setValue($value); 68 | 69 | return Arr::only($this->attributes, [ 70 | 'type', 71 | 'number_value', 72 | 'json_value', 73 | 'string_value', 74 | 'money_value', 75 | 'money_currency', 76 | ]); 77 | } 78 | ); 79 | } 80 | 81 | /** 82 | * @return TValue 83 | */ 84 | public function getValue(): null|float|string|Money|array 85 | { 86 | /** 87 | * @var TValue 88 | */ 89 | return match ($this->type) { 90 | 'number_value' => $this->number_value, 91 | 'string_value' => $this->string_value, 92 | 'money_value' => $this->money_value, 93 | 'json_value' => $this->json_value, 94 | default => null, 95 | }; 96 | } 97 | 98 | /** 99 | * @param null|int|float|string|Money|array $value 100 | */ 101 | public function setValue( 102 | null|int|float|string|Money|array $value 103 | ): static { 104 | $this->resetValue(); 105 | 106 | if (is_int($value) || is_float($value)) { 107 | $this->type = 'number_value'; 108 | $this->number_value = (float) $value; 109 | } elseif (is_string($value)) { 110 | $this->type = 'string_value'; 111 | $this->string_value = $value; 112 | } elseif (is_array($value)) { 113 | $this->type = 'json_value'; 114 | $this->json_value = $value; 115 | } elseif ($value instanceof Money) { 116 | $this->type = 'money_value'; 117 | $this->money_value = $value; 118 | } 119 | 120 | return $this; 121 | } 122 | 123 | public function resetValue(): static 124 | { 125 | $this->type = 'number_value'; 126 | $this->number_value = null; 127 | $this->json_value = null; 128 | $this->string_value = null; 129 | $this->money_value = null; 130 | $this->money_currency = null; 131 | 132 | return $this; 133 | } 134 | 135 | public function setName(string $value): static 136 | { 137 | $this->name = $value; 138 | 139 | return $this; 140 | } 141 | 142 | public function setDescription(?string $value): static 143 | { 144 | $this->description = $value; 145 | 146 | return $this; 147 | } 148 | 149 | public function setDate(CarbonInterface $date): static 150 | { 151 | $this->date = $date; 152 | 153 | return $this; 154 | } 155 | 156 | /** 157 | * @param null|array $value 158 | */ 159 | public function setMetadata(?array $value): static 160 | { 161 | $this->metadata = $value; 162 | 163 | return $this; 164 | } 165 | 166 | /** 167 | * @param null|array $value 168 | */ 169 | public function setTags(?array $value): static 170 | { 171 | $this->tags = $value; 172 | 173 | return $this; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/SqlAdapters/MySqlAdapter.php: -------------------------------------------------------------------------------- 1 | "date_format({$column}, '%Y-%m-%d %H:%i:00')", 17 | KpiInterval::Hour => "date_format({$column}, '%Y-%m-%d %H:00')", 18 | KpiInterval::Day => "date_format({$column}, '%Y-%m-%d')", 19 | KpiInterval::Month => "date_format({$column}, '%Y-%m')", 20 | KpiInterval::Year => "date_format({$column}, '%Y')", 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/SqlAdapters/PostgreSqlAdapter.php: -------------------------------------------------------------------------------- 1 | "TO_CHAR({$column}, 'YYYY-MM-DD HH24:MI:00')", 17 | KpiInterval::Hour => "TO_CHAR({$column}, 'YYYY-MM-DD HH24:00')", 18 | KpiInterval::Day => "TO_CHAR({$column}, 'YYYY-MM-DD')", 19 | KpiInterval::Month => "TO_CHAR({$column}, 'YYYY-MM')", 20 | KpiInterval::Year => "TO_CHAR({$column}, 'YYYY')", 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/SqlAdapters/SqlAdapter.php: -------------------------------------------------------------------------------- 1 | "strftime('%Y-%m-%d %H:%M:00', {$column})", 17 | KpiInterval::Hour => "strftime('%Y-%m-%d %H:00', {$column})", 18 | KpiInterval::Day => "strftime('%Y-%m-%d', {$column})", 19 | KpiInterval::Month => "strftime('%Y-%m', {$column})", 20 | KpiInterval::Year => "strftime('%Y', {$column})", 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Traits/DiscoverKpiDefinitions.php: -------------------------------------------------------------------------------- 1 | > 16 | */ 17 | public function getDiscoveredDefinitions(): Collection 18 | { 19 | /** 20 | * @var Collection> 21 | */ 22 | $definitions = new Collection; 23 | 24 | /** 25 | * @var string $path 26 | */ 27 | $path = config('kpi.discover.path'); 28 | 29 | $discovered = Discover::in( 30 | app_path($path), 31 | dirname(__DIR__) 32 | ) 33 | ->classes() 34 | ->extending(KpiDefinition::class) 35 | ->full() 36 | ->get(); 37 | 38 | foreach ($discovered as $item) { 39 | if (is_string($item)) { 40 | /** 41 | * @var class-string $item 42 | */ 43 | $definitions->push($item); 44 | } elseif ($item instanceof DiscoveredClass && ! $item->isAbstract) { 45 | /** 46 | * @var class-string $className 47 | */ 48 | $className = "{$item->namespace}\\{$item->name}"; 49 | $definitions->push($className); 50 | } 51 | } 52 | 53 | return $definitions; 54 | } 55 | 56 | /** 57 | * @return Collection> 58 | */ 59 | public function getDefinitions(): Collection 60 | { 61 | /** 62 | * @var class-string[] $registered 63 | */ 64 | $registered = config('kpi.definitions') ?? []; 65 | 66 | if (config('kpi.discover.enabled')) { 67 | return $this->getDiscoveredDefinitions() 68 | ->push(...$registered) 69 | ->unique(); 70 | } 71 | 72 | return collect($registered); 73 | } 74 | } 75 | --------------------------------------------------------------------------------