├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json └── src ├── Adapters ├── AbstractAdapter.php ├── MySqlAdapter.php ├── PgsqlAdapter.php └── SqliteAdapter.php ├── Trend.php ├── TrendServiceProvider.php └── TrendValue.php /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->notName('*.blade.php') 10 | ->ignoreDotFiles(true) 11 | ->ignoreVCS(true); 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRules([ 15 | '@PSR12' => true, 16 | 'array_syntax' => ['syntax' => 'short'], 17 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 18 | 'no_unused_imports' => true, 19 | 'not_operator_with_successor_space' => true, 20 | 'trailing_comma_in_multiline' => true, 21 | 'phpdoc_scalar' => true, 22 | 'unary_operator_spaces' => true, 23 | 'binary_operator_spaces' => true, 24 | 'blank_line_before_statement' => [ 25 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 26 | ], 27 | 'phpdoc_single_line_var_spacing' => true, 28 | 'phpdoc_var_without_name' => true, 29 | 'class_attributes_separation' => [ 30 | 'elements' => [ 31 | 'method' => 'one', 32 | ], 33 | ], 34 | 'method_argument_space' => [ 35 | 'on_multiline' => 'ensure_fully_multiline', 36 | 'keep_multiple_spaces_after_comma' => true, 37 | ], 38 | 'single_trait_insert_per_statement' => true, 39 | ]) 40 | ->setFinder($finder); 41 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-trend` will be documented in this file. 4 | 5 | ## 1.0.0 - 202X-XX-XX 6 | 7 | - initial release 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Flowframe 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 | # Laravel Trend 2 | 3 | Generate trends for your models. Easily generate charts or reports. 4 | 5 | ## Why? 6 | 7 | Most applications require charts or reports to be generated. Doing this over again, and again can be a painful process. That's why we've created a fluent Laravel package to solve this problem. 8 | 9 | You can aggregate average, min, max, and totals per minute, hour, day, month, and year. 10 | 11 | ## Installation 12 | 13 | You can install the package via composer: 14 | 15 | ```bash 16 | composer require flowframe/laravel-trend 17 | ``` 18 | 19 | ## Usage 20 | 21 | To generate a trend for your model, import the `Flowframe\Trend\Trend` class and pass along a model or query. 22 | 23 | Example: 24 | 25 | ```php 26 | // Totals per month 27 | $trend = Trend::model(User::class) 28 | ->between( 29 | start: now()->startOfYear(), 30 | end: now()->endOfYear(), 31 | ) 32 | ->perMonth() 33 | ->count(); 34 | 35 | // Average user weight where name starts with a over a span of 11 years, results are grouped per year 36 | $trend = Trend::query(User::where('name', 'like', 'a%')) 37 | ->between( 38 | start: now()->startOfYear()->subYears(10), 39 | end: now()->endOfYear(), 40 | ) 41 | ->perYear() 42 | ->average('weight'); 43 | ``` 44 | 45 | ## Starting a trend 46 | 47 | You must either start a trend using `::model()` or `::query()`. The difference between the two is that using `::query()` allows you to add additional filters, just like you're used to using eloquent. Using `::model()` will just consume it as it is. 48 | 49 | ```php 50 | // Model 51 | Trend::model(Order::class) 52 | ->between(...) 53 | ->perDay() 54 | ->count(); 55 | 56 | // More specific order query 57 | Trend::query( 58 | Order::query() 59 | ->hasBeenPaid() 60 | ->hasBeenShipped() 61 | ) 62 | ->between(...) 63 | ->perDay() 64 | ->count(); 65 | ``` 66 | 67 | ## Interval 68 | 69 | You can use the following aggregates intervals: 70 | 71 | - `perMinute()` 72 | - `perHour()` 73 | - `perDay()` 74 | - `perMonth()` 75 | - `perYear()` 76 | 77 | ## Aggregates 78 | 79 | You can use the following aggregates: 80 | 81 | - `sum('column')` 82 | - `average('column')` 83 | - `max('column')` 84 | - `min('column')` 85 | - `count('*')` 86 | 87 | ## Date Column 88 | 89 | By default, laravel-trend assumes that the model on which the operation is being performed has a `created_at` date column. If your model uses a different column name for the date or you want to use a different one, you should specify it using the `dateColumn(string $column)` method. 90 | 91 | Example: 92 | 93 | ```php 94 | Trend::model(Order::class) 95 | ->dateColumn('custom_date_column') 96 | ->between(...) 97 | ->perDay() 98 | ->count(); 99 | ``` 100 | 101 | This allows you to work with models that have custom date column names or when you want to analyze data based on a different date column. 102 | 103 | ## Drivers 104 | 105 | We currently support four drivers: 106 | 107 | - MySQL 108 | - MariaDB 109 | - SQLite 110 | - PostgreSQL 111 | 112 | ## Security Vulnerabilities 113 | 114 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 115 | 116 | ## Credits 117 | 118 | - [Lars Klopstra](https://github.com/flowframe) 119 | - [All Contributors](../../contributors) 120 | 121 | ## License 122 | 123 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 124 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flowframe/laravel-trend", 3 | "description": "Easily generate model trends", 4 | "keywords": [ 5 | "Flowframe", 6 | "laravel", 7 | "laravel-trend" 8 | ], 9 | "homepage": "https://github.com/flowframe/laravel-trend", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Lars Klopstra", 14 | "email": "lars@flowframe.nl", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.2", 20 | "spatie/laravel-package-tools": "^1.4.3", 21 | "illuminate/contracts": "^8.37|^9|^10.0|^11.0|^12.0" 22 | }, 23 | "require-dev": { 24 | "nunomaduro/collision": "^5.3|^6.1|^8.0", 25 | "orchestra/testbench": "^6.15|^7.0|^8.0|^9.0|^10.0", 26 | "pestphp/pest": "^1.18|^2.34|^3.7", 27 | "pestphp/pest-plugin-laravel": "^1.1|^2.3|^3.1", 28 | "spatie/laravel-ray": "^1.23", 29 | "vimeo/psalm": "^4.8|^5.6|^6.5" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Flowframe\\Trend\\": "src", 34 | "Flowframe\\Trend\\Database\\Factories\\": "database/factories" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "Flowframe\\Trend\\Tests\\": "tests" 40 | } 41 | }, 42 | "scripts": { 43 | "test": "./vendor/bin/pest --no-coverage", 44 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 45 | }, 46 | "config": { 47 | "sort-packages": true 48 | }, 49 | "extra": { 50 | "laravel": { 51 | "providers": [ 52 | "Flowframe\\Trend\\TrendServiceProvider" 53 | ], 54 | "aliases": { 55 | "Trend": "Flowframe\\Trend\\TrendFacade" 56 | } 57 | } 58 | }, 59 | "minimum-stability": "dev", 60 | "prefer-stable": true 61 | } 62 | -------------------------------------------------------------------------------- /src/Adapters/AbstractAdapter.php: -------------------------------------------------------------------------------- 1 | '%Y-%m-%d %H:%i:00', 13 | 'hour' => '%Y-%m-%d %H:00', 14 | 'day' => '%Y-%m-%d', 15 | 'week' => '%Y-%u', 16 | 'month' => '%Y-%m', 17 | 'year' => '%Y', 18 | default => throw new Error('Invalid interval.'), 19 | }; 20 | 21 | return "date_format({$column}, '{$format}')"; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Adapters/PgsqlAdapter.php: -------------------------------------------------------------------------------- 1 | 'YYYY-MM-DD HH24:MI:00', 13 | 'hour' => 'YYYY-MM-DD HH24:00:00', 14 | 'day' => 'YYYY-MM-DD', 15 | 'week' => 'IYYY-IW', 16 | 'month' => 'YYYY-MM', 17 | 'year' => 'YYYY', 18 | default => throw new Error('Invalid interval.'), 19 | }; 20 | 21 | return "to_char(\"{$column}\", '{$format}')"; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Adapters/SqliteAdapter.php: -------------------------------------------------------------------------------- 1 | '%Y-%m-%d %H:%M:00', 13 | 'hour' => '%Y-%m-%d %H:00', 14 | 'day' => '%Y-%m-%d', 15 | 'week' => '%Y-%W', 16 | 'month' => '%Y-%m', 17 | 'year' => '%Y', 18 | default => throw new Error('Invalid interval.'), 19 | }; 20 | 21 | return "strftime('{$format}', {$column})"; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Trend.php: -------------------------------------------------------------------------------- 1 | start = $start; 43 | $this->end = $end; 44 | 45 | return $this; 46 | } 47 | 48 | public function interval(string $interval): self 49 | { 50 | $this->interval = $interval; 51 | 52 | return $this; 53 | } 54 | 55 | public function perMinute(): self 56 | { 57 | return $this->interval('minute'); 58 | } 59 | 60 | public function perHour(): self 61 | { 62 | return $this->interval('hour'); 63 | } 64 | 65 | public function perDay(): self 66 | { 67 | return $this->interval('day'); 68 | } 69 | 70 | public function perWeek(): self 71 | { 72 | return $this->interval('week'); 73 | } 74 | 75 | public function perMonth(): self 76 | { 77 | return $this->interval('month'); 78 | } 79 | 80 | public function perYear(): self 81 | { 82 | return $this->interval('year'); 83 | } 84 | 85 | public function dateColumn(string $column): self 86 | { 87 | $this->dateColumn = $column; 88 | 89 | return $this; 90 | } 91 | 92 | public function dateAlias(string $alias): self 93 | { 94 | $this->dateAlias = $alias; 95 | 96 | return $this; 97 | } 98 | 99 | public function aggregate(string $column, string $aggregate): Collection 100 | { 101 | $values = $this->builder 102 | ->toBase() 103 | ->selectRaw(" 104 | {$this->getSqlDate()} as {$this->dateAlias}, 105 | {$aggregate}({$column}) as aggregate 106 | ") 107 | ->whereBetween($this->dateColumn, [$this->start, $this->end]) 108 | ->groupBy($this->dateAlias) 109 | ->orderBy($this->dateAlias) 110 | ->get(); 111 | 112 | return $this->mapValuesToDates($values); 113 | } 114 | 115 | public function average(string $column): Collection 116 | { 117 | return $this->aggregate($column, 'avg'); 118 | } 119 | 120 | public function min(string $column): Collection 121 | { 122 | return $this->aggregate($column, 'min'); 123 | } 124 | 125 | public function max(string $column): Collection 126 | { 127 | return $this->aggregate($column, 'max'); 128 | } 129 | 130 | public function sum(string $column): Collection 131 | { 132 | return $this->aggregate($column, 'sum'); 133 | } 134 | 135 | public function count(string $column = '*'): Collection 136 | { 137 | return $this->aggregate($column, 'count'); 138 | } 139 | 140 | public function mapValuesToDates(Collection $values): Collection 141 | { 142 | $values = $values->map(fn ($value) => new TrendValue( 143 | date: $value->{$this->dateAlias}, 144 | aggregate: $value->aggregate, 145 | )); 146 | 147 | $placeholders = $this->getDatePeriod()->map( 148 | fn (CarbonInterface $date) => new TrendValue( 149 | date: $date->format($this->getCarbonDateFormat()), 150 | aggregate: 0, 151 | ) 152 | ); 153 | 154 | return $values 155 | ->merge($placeholders) 156 | ->unique('date') 157 | ->sort() 158 | ->flatten(); 159 | } 160 | 161 | protected function getDatePeriod(): Collection 162 | { 163 | return collect( 164 | CarbonPeriod::between( 165 | $this->start, 166 | $this->end, 167 | )->interval("1 {$this->interval}") 168 | ); 169 | } 170 | 171 | protected function getSqlDate(): string 172 | { 173 | $adapter = match ($this->builder->getConnection()->getDriverName()) { 174 | 'mysql', 'mariadb' => new MySqlAdapter(), 175 | 'sqlite' => new SqliteAdapter(), 176 | 'pgsql' => new PgsqlAdapter(), 177 | default => throw new Error('Unsupported database driver.'), 178 | }; 179 | 180 | return $adapter->format($this->dateColumn, $this->interval); 181 | } 182 | 183 | protected function getCarbonDateFormat(): string 184 | { 185 | return match ($this->interval) { 186 | 'minute' => 'Y-m-d H:i:00', 187 | 'hour' => 'Y-m-d H:00', 188 | 'day' => 'Y-m-d', 189 | 'week' => 'Y-W', 190 | 'month' => 'Y-m', 191 | 'year' => 'Y', 192 | default => throw new Error('Invalid interval.'), 193 | }; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/TrendServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-trend'); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/TrendValue.php: -------------------------------------------------------------------------------- 1 |