diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..d90b29a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: sakanjo diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..6b54d57 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,27 @@ +name: PHPStan + +on: + push: + paths: + - '**.php' + - 'phpstan.neon.dist' + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v2 + + - name: Run PHPStan + run: ./vendor/bin/phpstan --error-format=github + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de4a392 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/vendor +/composer.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d5124b7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Salah Kanjo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a0f24a2 --- /dev/null +++ b/README.md @@ -0,0 +1,324 @@ +![Easy metrics banner](./art/banner.png) + +

+ Laravel v10.x + PHP 8.0 +

+ +

đŸ”Ĩ Easy metrics

+ +

Easily create metrics for your application.

+ +> ✨ Help support the maintenance of this package by [sponsoring me](https://github.com/sponsors/sakanjo). + +> Designed to work out-of-the-box with **Laravel**, **Symfony**, **Filament**, and more. + +## 🚀 Supported metrics + +- **Bar** metric +- **Doughnut** metric +- **Line** metric +- **Pie** metric +- **Polar** metric +- **Trend** metric +- **Value** metric + +Table of contents +================= + +* [Install](#install) +* [Usage](#usage) + * [Value metric](#value-metrics) + * [Doughnut metric](#doughnut-metric) + * [Trend metric](#trend-metric) + * [Other metrics](#other-metrics) +* [Ranges](#ranges) + * [Available custom ranges](#available-custom-ranges) +* [Practical examples](#practical-examples) + * [Filamentphp v3 widgets](#filamentphp-v3-widgets) +* [Support the development](#support-the-development) +* [Credits](#credits) +* [License](#license) + +## đŸ“Ļ Install + +``` +composer require sakanjo/laravel-easy-metrics +``` + +## đŸĻ„ Usage + +### Value metric + +```php +use SaKanjo\EasyMetrics\Metrics\Value; +use App\Models\User; + +$data = Value::make(User::class) + ->count(); +``` + +#### Query types + +The currently supported aggregate functions to calculate a given column compared to the previous time interval / range + +##### Min + +```php +Value::make(User::class) + ->min('age'); +``` + +##### Max + +```php +Value::make(User::class) + ->max('age'); +``` + +##### Sum + +```php +Value::make(User::class) + ->sum('age'); +``` + +##### Average + +```php +Value::make(User::class) + ->average('age'); +``` + +##### Count + +```php +Value::make(User::class) + ->count(); +``` + +### Doughnut metric + +```php +use SaKanjo\EasyMetrics\Metrics\Doughnut; +use App\Models\User; +use App\Enums\Gender; + +[$labels, $data] = Doughnut::make(User::class) + ->options(Gender::values()) + ->count('gender'); +``` + +> It's always better to use the `options` method even though it's optional, since the retrieved data may not include all enum options. + +#### Query types + +The currently supported aggregate functions to calculate a given column compared to the previous time interval / range + +##### Min + +```php +Doughnut::make(User::class) + ->min('age', 'gender'); +``` + +##### Max + +```php +Doughnut::make(User::class) + ->max('age', 'gender'); +``` + +##### Sum + +```php +Doughnut::make(User::class) + ->sum('age', 'gender'); +``` + +##### Average + +```php +Doughnut::make(User::class) + ->average('age', 'gender'); +``` + +##### Count + +```php +Doughnut::make(User::class) + ->count('gender'); +``` + +### Trend metric + +```php +use SaKanjo\EasyMetrics\Metrics\Trend; +use App\Models\User; +use App\Enums\Gender; + +[$labels, $data] = Trend::make(User::class) + ->countByMonths(); +``` + + +#### Query types + +The currently supported aggregate functions to calculate a given column compared to the previous time interval / range + +##### Min + +```php +$trend->minByYears('age'); +$trend->minByMonths('age'); +$trend->minByWeeks('age'); +$trend->minByDays('age'); +$trend->minByHours('age'); +$trend->minByMinutes('age'); +``` + +##### Max + +```php +$trend->maxByYears('age'); +$trend->maxByMonths('age'); +$trend->maxByWeeks('age'); +$trend->maxByDays('age'); +$trend->maxByHours('age'); +$trend->maxByMinutes('age'); +``` + +##### Sum + +```php +$trend->sumByYears('age'); +$trend->sumByMonths('age'); +$trend->sumByWeeks('age'); +$trend->sumByDays('age'); +$trend->sumByHours('age'); +$trend->sumByMinutes('age'); +``` + +##### Average + +```php +$trend->averageByYears('age'); +$trend->averageByMonths('age'); +$trend->averageByWeeks('age'); +$trend->averageByDays('age'); +$trend->averageByHours('age'); +$trend->averageByMinutes('age'); +``` + +##### Count + +```php +$trend->countByYears(); +$trend->countByMonths(); +$trend->countByWeeks(); +$trend->countByDays(); +$trend->countByHours(); +$trend->countByMinutes(); +``` + +### Other metrics + +- `Bar extends Trend` +- `Line extends Trend` +- `Doughnut extends Pie` +- `Polar extends Pie` + + +## Ranges + +Every metric class contains a ranges method, that will determine the range of the results based on it's date column. + +```php +use SaKanjo\EasyMetrics\Metrics\Trend; +use SaKanjo\EasyMetrics\Metrics\Enums\Range; +use App\Models\User; + +Value::make(User::class) + ->range(30) + ->ranges([ + 15, 30, 365, + Range::TODAY, // Or 'TODAY' + ]); +``` + +### Available custom ranges + +- `Range::TODAY` +- `Range::YESTERDAY` +- `Range::MTD` +- `Range::QTD` +- `Range::YTD` +- `Range::ALL` + +## đŸ”Ĩ Practical examples + +#### Filamentphp v3 widgets + +```php +range($this->filter) + ->rangesFromOptions($this->getFilters()) + ->countByMonths(); + + return [ + 'datasets' => [ + [ + 'label' => 'Users', + 'data' => $data, + ], + ], + 'labels' => $labels, + ]; + } + + protected function getType(): string + { + return 'line'; + } + + protected function getFilters(): ?array + { + return [ + 15 => '15 Days', + 30 => '30 Days', + 60 => '60 Days', + 365 => '365 Days', + ]; + } +} + +``` + +## 💖 Support the development + +**Do you like this project? Support it by donating** + +Click the ["💖 Sponsor"](https://github.com/sponsors/sakanjo) at the top of this repo. + +## Šī¸ Credits + +- [Salah Kanjo](https://github.com/sakanjo) +- [All Contributors](../../contributors) + +## 📄 License + +[MIT License](https://github.com/sakanjo/laravel-easy-metrics/blob/main/LICENSE) Š 2023-PRESENT [Salah Kanjo](https://github.com/sakanjo) diff --git a/art/banner.png b/art/banner.png new file mode 100644 index 0000000..0018f31 Binary files /dev/null and b/art/banner.png differ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..647615b --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "sakanjo/laravel-easy-metrics", + "description": "Generate metrics with ease and precision.", + "keywords": [ + "laravel", + "metrics", + "charts", + "reports" + ], + "license": "MIT", + "authors": [ + { + "name": "Salah Kanjo", + "email": "dev.salah.kanjo@gmail.com" + } + ], + "require": { + "php": "^8.0", + "laravel/framework": "^10.26" + }, + "require-dev": { + "laravel/pint": "^1.13", + "phpstan/phpstan": "^1.10" + }, + "autoload": { + "psr-4": { + "SaKanjo\\EasyMetrics\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "SaKanjo\\EasyMetrics\\EasyMetricsServiceProvider" + ] + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..91e6453 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,4 @@ +parameters: + level: 5 + paths: + - src diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..93061b6 --- /dev/null +++ b/pint.json @@ -0,0 +1,3 @@ +{ + "preset": "laravel" +} diff --git a/src/Concerns/OnlyIntegers.php b/src/Concerns/OnlyIntegers.php new file mode 100644 index 0000000..8ebb65c --- /dev/null +++ b/src/Concerns/OnlyIntegers.php @@ -0,0 +1,24 @@ +ranges = $ranges; + + return $this; + } + + public function getRange(): int + { + return $this->range ?? $this->getRanges()[0] ?? 15; + } +} diff --git a/src/EasyMetricsServiceProvider.php b/src/EasyMetricsServiceProvider.php new file mode 100644 index 0000000..6c88947 --- /dev/null +++ b/src/EasyMetricsServiceProvider.php @@ -0,0 +1,13 @@ + [ + $now->startOfDay(), + $now, + ], + Range::YESTERDAY => [ + $now->subDay()->startOfDay(), + $now->subDay(), + ], + Range::MTD => [ + $now->startOfMonth(), + $now, + ], + Range::QTD => [ + $now->startOfQuarter(), + $now, + ], + Range::YTD => [ + $now->startOfYear(), + $now, + ], + Range::ALL => null, + }; + } +} diff --git a/src/Metrics/Bar.php b/src/Metrics/Bar.php new file mode 100644 index 0000000..2bbbd63 --- /dev/null +++ b/src/Metrics/Bar.php @@ -0,0 +1,8 @@ +options = $options; + + return $this; + } + + public function min(string $column, string $groupBy) + { + return $this->setType('min', $groupBy, $column); + } + + public function max(string $column, string $groupBy) + { + return $this->setType('max', $groupBy, $column); + } + + public function sum(string $column, string $groupBy) + { + return $this->setType('sum', $groupBy, $column); + } + + public function average(string $column, string $groupBy) + { + return $this->setType('avg', $groupBy, $column); + } + + public function count(string $groupBy, string $column = '*') + { + return $this->setType('count', $groupBy, $column); + } + + protected function setType(string $type, string $groupBy, string $column) + { + $this->type = $type; + $this->column = $column; + $this->groupBy = $groupBy; + + return $this->resolve(); + } + + public function resolve(): Result + { + $column = $this->query->getQuery()->getGrammar()->wrap($this->column); + $range = $this->currentRange(); + + $results = $this->query + ->when($range, fn (Builder $query) => $query + ->whereBetween(...$this->resolveBetween($range)) + ) + ->select([$this->groupBy, DB::raw("{$this->type}($column) as result")]) + ->groupBy($this->groupBy) + ->get() + ->mapWithKeys(function (Model $model) { + $key = $model[$this->groupBy]; + $key = $key instanceof BackedEnum ? $key->value : $key; + + return [ + $key => $this->transformResult($model['result']), + ]; + }) + ->toArray(); + + $options = array_fill_keys($this->options ?? [], 0); + + $data = collect($options) + ->replace($results) + ->toArray(); + + return Result::make( + array_values($data), + array_keys($data) + ); + } +} diff --git a/src/Metrics/Line.php b/src/Metrics/Line.php new file mode 100644 index 0000000..1b8c31e --- /dev/null +++ b/src/Metrics/Line.php @@ -0,0 +1,8 @@ +query = is_string($query) ? $query::query() : $query; + } + + public static function make(string|Builder $query): static + { + return app(static::class, [ + 'query' => $query, + ]); + } + + abstract protected function resolve(): mixed; + + public function modifyQuery(callable $callback): static + { + $callback($this->query); + + return $this; + } + + public function range(int|string|Range|null $range): static + { + $range = Range::tryFrom($range) ?? $range; + + if (in_array($range, $this->getRanges())) { + $this->range = $range; + } + + return $this; + } + + public function ranges(array $ranges): static + { + $this->ranges = Arr::map($ranges, + fn ($range) => is_string($range) ? Range::from($range) : $range + ); + + return $this; + } + + public function rangesFromOptions(array $options): static + { + return $this->ranges( + array_keys($options) + ); + } + + public function getRange(): int|Range + { + return $this->range ?? $this->getRanges()[0] ?? Range::ALL; + } + + public function getRanges(): array + { + return $this->ranges; + } + + protected function getDateColumn(): string + { + return $this->query->getModel()->getCreatedAtColumn(); + } + + protected function resolveBetween(array $range): array + { + return [ + $this->getDateColumn(), + $range, + ]; + } + + protected function currentRange(): ?array + { + $range = $this->getRange(); + + if ($range instanceof Range) { + return $range->getRange(); + } + + return [ + now()->subDays($range), + now(), + ]; + } + + protected function transformResult(int|float $data): float + { + return round($data, 2); + } +} diff --git a/src/Metrics/Pie.php b/src/Metrics/Pie.php new file mode 100644 index 0000000..0bf4afd --- /dev/null +++ b/src/Metrics/Pie.php @@ -0,0 +1,8 @@ +setType('min', 'year', $column); + } + + public function minByMonths(string $column) + { + return $this->setType('min', 'month', $column); + } + + public function minByWeeks(string $column) + { + return $this->setType('min', 'week', $column); + } + + public function minByDays(string $column) + { + return $this->setType('min', 'day', $column); + } + + public function minByHours(string $column) + { + return $this->setType('min', 'hour', $column); + } + + public function minByMinutes(string $column) + { + return $this->setType('min', 'minute', $column); + } + + public function maxByYears(string $column) + { + return $this->setType('max', 'year', $column); + } + + public function maxByMonths(string $column) + { + return $this->setType('max', 'month', $column); + } + + public function maxByWeeks(string $column) + { + return $this->setType('max', 'week', $column); + } + + public function maxByDays(string $column) + { + return $this->setType('max', 'day', $column); + } + + public function maxByHours(string $column) + { + return $this->setType('max', 'hour', $column); + } + + public function maxByMinutes(string $column) + { + return $this->setType('max', 'minute', $column); + } + + public function sumByYears(string $column) + { + return $this->setType('sum', 'year', $column); + } + + public function sumByMonths(string $column) + { + return $this->setType('sum', 'month', $column); + } + + public function sumByWeeks(string $column) + { + return $this->setType('sum', 'week', $column); + } + + public function sumByDays(string $column) + { + return $this->setType('sum', 'day', $column); + } + + public function sumByHours(string $column) + { + return $this->setType('sum', 'hour', $column); + } + + public function sumByMinutes(string $column) + { + return $this->setType('sum', 'minute', $column); + } + + public function averageByYears(string $column) + { + return $this->setType('avg', 'year', $column); + } + + public function averageByMonths(string $column) + { + return $this->setType('avg', 'month', $column); + } + + public function averageByWeeks(string $column) + { + return $this->setType('avg', 'week', $column); + } + + public function averageByDays(string $column) + { + return $this->setType('avg', 'day', $column); + } + + public function averageByHours(string $column) + { + return $this->setType('avg', 'hour', $column); + } + + public function averageByMinutes(string $column) + { + return $this->setType('avg', 'minute', $column); + } + + public function countByYears(string $column = '*') + { + return $this->setType('count', 'year', $column); + } + + public function countByMonths(string $column = '*') + { + return $this->setType('count', 'month', $column); + } + + public function countByWeeks(string $column = '*') + { + return $this->setType('count', 'week', $column); + } + + public function countByDays(string $column = '*') + { + return $this->setType('count', 'day', $column); + } + + public function countByHours(string $column = '*') + { + return $this->setType('count', 'hour', $column); + } + + public function countByMinutes(string $column = '*') + { + return $this->setType('count', 'minute', $column); + } + + protected function setType(string $type, string $unit, string $column) + { + $this->type = $type; + $this->unit = $unit; + $this->column = $column; + + return $this->resolve(); + } + + protected function getExpression(): string + { + $grammar = $this->query->getQuery()->getGrammar(); + $dateColumn = $grammar->wrap($this->getDateColumn()); + + return match ($this->unit) { + 'year' => "date_format($dateColumn, '%Y')", + 'month' => "date_format($dateColumn, '%Y-%m')", + 'week' => "date_format($dateColumn, '%x-%v')", + 'day' => "date_format($dateColumn, '%Y-%m-%d')", + 'hour' => "date_format($dateColumn, '%Y-%m-%d %H:00')", + 'minute' => "date_format($dateColumn, '%Y-%m-%d %H:%i:00')", + default => throw new \InvalidArgumentException("Invalid unit: {$this->unit}"), + }; + } + + protected function getFormat(): string + { + return match ($this->unit) { + 'year' => 'Y', + 'month' => 'Y-m', + 'week' => 'x-v', + 'day' => 'Y-m-d', + 'hour' => 'Y-m-d H:00', + 'minute' => 'Y-m-d H:i:00', + default => throw new \InvalidArgumentException("Invalid unit: {$this->unit}"), + }; + } + + protected function getStartingDate(): CarbonImmutable + { + $now = CarbonImmutable::now(); + $range = $this->getRange() - 1; + + return match ($this->unit) { + 'year' => $now + ->subYearsWithoutOverflow($range) + ->firstOfYear() + ->setTime(0, 0), + 'month' => $now + ->subMonthsWithoutOverflow($range) + ->firstOfMonth() + ->setTime(0, 0), + 'week' => $now + ->subWeeks($range) + ->startOfWeek() + ->setTime(0, 0), + 'day' => $now + ->subDays($range) + ->setTime(0, 0), + 'hour' => $now + ->subHours($range), + 'minute' => $now + ->subMinutes($range), + default => throw new \InvalidArgumentException("Invalid unit: {$this->unit}"), + }; + } + + protected function resolve(): Result + { + $dateColumn = $this->getDateColumn(); + $startingDate = $this->getStartingDate(); + $endingDate = now(); + + $expression = $this->getExpression(); + $column = $this->query->getQuery()->getGrammar()->wrap($this->column); + + $results = $this->query + ->selectRaw("{$expression} as date_result, {$this->type}($column) as result") + ->whereBetween($dateColumn, [$startingDate, $endingDate]) + ->groupBy('date_result') + ->get() + ->mapWithKeys(fn (mixed $result) => [ + $result['date_result'] => $this->transformResult($result['result']), + ]) + ->toArray(); + + $periods = collect(CarbonPeriod::create($startingDate, "1 {$this->unit}", $endingDate)) + ->mapWithKeys(fn (CarbonInterface $date) => [ + $date->format($this->getFormat()) => 0, + ]) + ->toArray(); + + $data = collect(array_replace($periods, $results)) + ->take(-count($periods)) + ->toArray(); + + return Result::make( + array_values($data), + array_keys($data) + ); + } +} diff --git a/src/Metrics/Value.php b/src/Metrics/Value.php new file mode 100644 index 0000000..b35fd35 --- /dev/null +++ b/src/Metrics/Value.php @@ -0,0 +1,54 @@ +setType('min', $column); + } + + public function max(string $column) + { + return $this->setType('max', $column); + } + + public function sum(string $column) + { + return $this->setType('sum', $column); + } + + public function average(string $column) + { + return $this->setType('avg', $column); + } + + public function count(string $column = '*') + { + return $this->setType('count', $column); + } + + protected function setType(string $type, string $column): float + { + $this->type = $type; + $this->column = $column; + + return $this->resolve(); + } + + protected function resolve(): float + { + $range = $this->currentRange(); + + $value = $this->query + ->when($range, fn (Builder $query) => $query + ->whereBetween(...$this->resolveBetween($range)) + ) + ->{$this->type}($this->column); + + return $this->transformResult($value); + } +} diff --git a/src/Result.php b/src/Result.php new file mode 100644 index 0000000..5351669 --- /dev/null +++ b/src/Result.php @@ -0,0 +1,69 @@ +container = [$labels, $data]; + } + + public static function make(array $data, array $labels): static + { + return app(static::class, [ + 'data' => $data, + 'labels' => $labels, + ]); + } + + public function getOptions(): array + { + return array_combine($this->labels, $this->data); + } + + public function getData(): array + { + return $this->data; + } + + public function getLabels(): array + { + return $this->labels; + } + + public function toResponse($request): Response + { + return new Response( + $this->getData() + ); + } + + public function offsetSet($offset, $value): void + { + throw new \Exception('Result is immutable'); + } + + public function offsetExists($offset): bool + { + return isset($this->container[$offset]); + } + + public function offsetUnset($offset): void + { + throw new \Exception('Result is immutable'); + } + + public function offsetGet($offset): mixed + { + return $this->container[$offset] ?? null; + } +}