+
diff --git a/resources/views/components/stat-tiles.blade.php b/resources/views/components/stat-tiles.blade.php
new file mode 100644
index 0000000..312cb59
--- /dev/null
+++ b/resources/views/components/stat-tiles.blade.php
@@ -0,0 +1,6 @@
+@props(['columns' => 4])
+
+{{-- A responsive grid of stat tiles: one column in narrow containers, up to $columns on wide ones --}}
+
diff --git a/resources/views/time-series/index.blade.php b/resources/views/time-series/index.blade.php
new file mode 100644
index 0000000..d2fe31b
--- /dev/null
+++ b/resources/views/time-series/index.blade.php
@@ -0,0 +1,5 @@
+@extends($layout)
+
+@section($layout_section)
+ @include('stats::partials.page', ['page' => 'time-series'])
+@endsection
diff --git a/src/Cache/StatsCache.php b/src/Cache/StatsCache.php
new file mode 100644
index 0000000..038d7c8
--- /dev/null
+++ b/src/Cache/StatsCache.php
@@ -0,0 +1,209 @@
+cache->store(config('stats.cache.store'));
+ }
+
+ public function prefix(): string
+ {
+ return (string) config('stats.cache.prefix', 'javaabu_stats');
+ }
+
+ /**
+ * Get the value from the cache, or compute and store it
+ *
+ * @template TValue
+ *
+ * @param array $key what the value depends on
+ * @param list $metrics the metrics it's computed from, for invalidation
+ * @param Closure(): TValue $callback
+ * @return TValue
+ */
+ public function remember(array $key, array $metrics, Closure $callback, ?int $ttl = null, ?CarbonInterface $ends_at = null): mixed
+ {
+ if (! $this->enabled() || $ttl === 0) {
+ return $callback();
+ }
+
+ return $this->store()->remember(
+ $this->key($key, $metrics),
+ $ttl ?? $this->ttlFor($ends_at),
+ $callback,
+ );
+ }
+
+ /**
+ * Compute the value and store it, whatever is in the cache
+ *
+ * @template TValue
+ *
+ * @param array $key
+ * @param list $metrics
+ * @param Closure(): TValue $callback
+ * @return TValue
+ */
+ public function refresh(array $key, array $metrics, Closure $callback, ?int $ttl = null, ?CarbonInterface $ends_at = null): mixed
+ {
+ $value = $callback();
+
+ if ($this->enabled() && $ttl !== 0) {
+ $this->store()->put($this->key($key, $metrics), $value, $ttl ?? $this->ttlFor($ends_at));
+ }
+
+ return $value;
+ }
+
+ /**
+ * Whether a value is cached
+ *
+ * @param array $key
+ * @param list $metrics
+ */
+ public function has(array $key, array $metrics): bool
+ {
+ return $this->store()->has($this->key($key, $metrics));
+ }
+
+ /**
+ * The seconds to keep a result whose period ends at the given time
+ */
+ public function ttlFor(?CarbonInterface $ends_at = null): int
+ {
+ if ($ends_at && $ends_at->lt(now()->startOfDay())) {
+ return (int) config('stats.cache.ttl.past', 60 * 60 * 24);
+ }
+
+ return (int) config('stats.cache.ttl.current', 60 * 10);
+ }
+
+ /**
+ * Invalidate what's cached for the metrics (all metrics for `*` or none given)
+ *
+ * @param string|list $metrics
+ */
+ public function forget(string|array $metrics = self::ALL): void
+ {
+ $metrics = (array) $metrics;
+
+ if ($metrics === [] || in_array(self::ALL, $metrics, true)) {
+ $this->flush();
+
+ return;
+ }
+
+ foreach (array_unique($metrics) as $metric) {
+ $this->store()->forever($this->versionKey($metric), $this->newVersion());
+ }
+ }
+
+ /**
+ * Invalidate everything the package cached
+ */
+ public function flush(): void
+ {
+ $this->store()->forever($this->versionKey(self::ALL), $this->newVersion());
+ }
+
+ /**
+ * The cache key for a value
+ *
+ * @param array $key
+ * @param list $metrics
+ */
+ public function key(array $key, array $metrics): string
+ {
+ $metrics = array_values(array_unique($metrics));
+ sort($metrics);
+
+ $versions = [$this->version(self::ALL)];
+
+ foreach ($metrics as $metric) {
+ $versions[] = $this->version($metric);
+ }
+
+ $hash = md5(json_encode([$this->normalize($key), $metrics, $versions], JSON_THROW_ON_ERROR));
+
+ return $this->prefix() . ':' . $hash;
+ }
+
+ /**
+ * The current version of a metric's results
+ */
+ public function version(string $metric): string
+ {
+ return (string) ($this->store()->get($this->versionKey($metric)) ?? '0');
+ }
+
+ protected function versionKey(string $metric): string
+ {
+ return $this->prefix() . ':version:' . ($metric === self::ALL ? '*' : md5($metric));
+ }
+
+ protected function newVersion(): string
+ {
+ return Str::random(12);
+ }
+
+ /**
+ * Dates, enums and objects as stable scalars
+ */
+ protected function normalize(mixed $value): mixed
+ {
+ if ($value instanceof DateTimeInterface) {
+ return $value->format('Y-m-d H:i:s');
+ }
+
+ if ($value instanceof BackedEnum) {
+ return $value->value;
+ }
+
+ if ($value instanceof UnitEnum) {
+ return $value->name;
+ }
+
+ if (is_array($value)) {
+ ksort($value);
+
+ return array_map(fn ($item) => $this->normalize($item), $value);
+ }
+
+ if (is_object($value)) {
+ return get_class($value) . ':' . md5(serialize($value));
+ }
+
+ return $value;
+ }
+}
diff --git a/src/CategoricalStats.php b/src/CategoricalStats.php
index a66c178..f344bb9 100644
--- a/src/CategoricalStats.php
+++ b/src/CategoricalStats.php
@@ -203,12 +203,14 @@ public static function canViewAny(?Authorizable $user = null): bool
public static function defaultMode(): CategoricalModes
{
- return config('stats.default_categorical_mode');
+ $mode = config('stats.default_categorical_mode', CategoricalModes::NON_EMPTY);
+
+ return $mode instanceof CategoricalModes ? $mode : CategoricalModes::from($mode);
}
public static function defaultDateRange(): PresetDateRanges
{
- return config('stats.default_date_range');
+ return TimeSeriesStats::defaultDateRange();
}
public static function categoricalItemsPerPage(): int
@@ -217,7 +219,9 @@ public static function categoricalItemsPerPage(): int
}
/**
- * Register the categorical API routes.
+ * Register the categorical JSON API routes
+ *
+ * @deprecated Use Route::statsApi()
*
* @param list $middleware
*/
@@ -227,17 +231,19 @@ public static function registerApiRoutes(
string $categories_name = 'stats.categorical.categories',
array $middleware = ['stats.view-categorical']
): void {
- Route::get($url, [CategoricalStatsApiController::class, 'index'])
+ Route::get($url, [config('stats.controllers.categorical_api', CategoricalStatsApiController::class), 'index'])
->name($name)
->middleware($middleware);
- Route::get($url.'/categories', [CategoryProviderApiController::class, 'index'])
+ Route::get($url . '/categories', [config('stats.controllers.categories', CategoryProviderApiController::class), 'index'])
->name($categories_name)
->middleware($middleware);
}
/**
- * Register the categorical admin routes.
+ * Register the categorical page and export routes
+ *
+ * @deprecated Use Route::stats()
*
* @param list $middleware
*/
@@ -247,11 +253,13 @@ public static function registerRoutes(
string $export_name = 'stats.categorical.export',
array $middleware = ['stats.view-categorical']
): void {
- Route::get($url, [CategoricalStatsController::class, 'index'])
+ $controller = config('stats.controllers.categorical', CategoricalStatsController::class);
+
+ Route::get($url, [$controller, 'index'])
->name($index_name)
->middleware($middleware);
- Route::post($url, [CategoricalStatsController::class, 'export'])
+ Route::post($url, [$controller, 'export'])
->name($export_name)
->middleware($middleware);
}
diff --git a/src/CategoryProviders/EloquentCategoryProvider.php b/src/CategoryProviders/EloquentCategoryProvider.php
index 23c28a0..b009dff 100644
--- a/src/CategoryProviders/EloquentCategoryProvider.php
+++ b/src/CategoryProviders/EloquentCategoryProvider.php
@@ -121,7 +121,7 @@ public function searchCategoricalStatsItems(?string $search = null, int $per_pag
? $query->getModel()->getCategoricalStatsSearchField()
: ($this->label_field == 'admin_link_name' ? 'name' : $this->label_field);
- $query->where($search_field, 'like', '%'.$search.'%');
+ $query->whereLike($search_field, '%' . $search . '%');
}
}
diff --git a/src/Charts/CategoryChart.php b/src/Charts/CategoryChart.php
new file mode 100644
index 0000000..1e46322
--- /dev/null
+++ b/src/Charts/CategoryChart.php
@@ -0,0 +1,227 @@
+range(PresetDateRanges::THIS_YEAR)->compare();
+ * CategoryChart::make()->stats(['payments_by_island', 'refunds_by_island'])->horizontal()->top(10);
+ * ```
+ */
+class CategoryChart extends Chart
+{
+ use HasCategories;
+ use HasStats;
+
+ public const string BAR = 'bar';
+
+ public const string LINE = 'line';
+
+ public const string AREA = 'area';
+
+ protected DateRange|bool $compare = false;
+
+ protected string $variant = self::BAR;
+
+ protected string $orientation = 'vertical';
+
+ protected bool $stacked = false;
+
+ protected bool $positive_is_good = true;
+
+ public static function make(?string $metric = null, ?string $label = null): static
+ {
+ $chart = new static;
+
+ if ($metric !== null) {
+ $chart->stat($metric, $label);
+ }
+
+ return $chart;
+ }
+
+ public function type(): string
+ {
+ return 'category';
+ }
+
+ public function compare(DateRange|string|bool $with = true): static
+ {
+ $this->compare = is_string($with) ? PresetDateRanges::from($with) : $with;
+
+ return $this;
+ }
+
+ public function getCompareRange(): ?DateRange
+ {
+ if ($this->compare instanceof DateRange) {
+ return $this->compare;
+ }
+
+ return $this->compare ? $this->getRange()->getPreviousDateRange() : null;
+ }
+
+ public function positiveIsGood(bool $positive_is_good = true): static
+ {
+ $this->positive_is_good = $positive_is_good;
+
+ return $this;
+ }
+
+ public function variant(string $variant): static
+ {
+ $this->variant = $variant;
+
+ return $this;
+ }
+
+ public function bar(): static
+ {
+ return $this->variant(self::BAR);
+ }
+
+ public function line(): static
+ {
+ return $this->variant(self::LINE);
+ }
+
+ public function area(): static
+ {
+ return $this->variant(self::AREA);
+ }
+
+ /**
+ * Bars along the side, for long category names
+ */
+ public function horizontal(bool $horizontal = true): static
+ {
+ $this->orientation = $horizontal ? 'horizontal' : 'vertical';
+
+ return $this;
+ }
+
+ public function stacked(bool $stacked = true): static
+ {
+ $this->stacked = $stacked;
+
+ return $this;
+ }
+
+ protected function defaultTitle(): string
+ {
+ $series = $this->series[0] ?? null;
+
+ return $series ? $this->makeStat($series['metric'], $this->getRange(), $series['filters'])->getName() : '';
+ }
+
+ protected function compute(): array
+ {
+ $range = $this->getRange();
+ $compare_range = $this->getCompareRange();
+ $keys = $this->seriesKeys();
+ $columns = [];
+ $first_stat = null;
+
+ foreach ($this->series as $i => $series) {
+ $stat = $this->makeStat($series['metric'], $range, $series['filters']);
+ $first_stat ??= $stat;
+
+ $columns[] = [
+ 'key' => $keys[$i],
+ 'label' => $i === 0 && $compare_range
+ ? TimeSeriesSteps::rangeLabel($range, $stat->getDateFrom(), $stat->getDateTo())
+ : ($series['label'] ?? $stat->getName()),
+ 'values' => $stat->resultsToArray($stat->results()),
+ ];
+ }
+
+ if ($compare_range && $first_stat) {
+ $series = $this->series[0];
+ $stat = $this->makeStat($series['metric'], $compare_range, $series['filters']);
+
+ $columns[] = [
+ 'key' => 'previous',
+ 'label' => TimeSeriesSteps::rangeLabel($compare_range, $stat->getDateFrom(), $stat->getDateTo()),
+ 'values' => $stat->resultsToArray($stat->results()),
+ ];
+ }
+
+ $names = $first_stat ? $this->categoryNames($first_stat, array_column($columns, 'values')) : [];
+ $rows = [];
+
+ foreach ($names as $id => $name) {
+ $row = ['category' => $name, 'categoryId' => $id];
+
+ foreach ($columns as $column) {
+ $row[$column['key']] = ($column['values'][$id] ?? 0) + 0;
+ }
+
+ $rows[] = $row;
+ }
+
+ if ($this->limit !== null && $columns !== []) {
+ $key = $columns[0]['key'];
+
+ usort($rows, fn (array $a, array $b) => $b[$key] <=> $a[$key]);
+
+ $rows = array_slice($rows, 0, $this->limit);
+ }
+
+ $config = [];
+
+ foreach ($columns as $column) {
+ $config[$column['key']] = ['label' => $column['label']];
+ }
+
+ $comparison = $compare_range && $first_stat ? [
+ 'previousKey' => 'previous',
+ 'currentLabel' => $columns[0]['label'],
+ 'previousLabel' => end($columns)['label'],
+ 'positiveIsGood' => $this->positive_is_good,
+ ] : null;
+
+ return [
+ 'data' => $rows,
+ 'config' => $config,
+ 'categoryKey' => 'category',
+ 'categoryLabel' => $first_stat?->getCategoryTitle(),
+ 'aggregateLabel' => $first_stat?->getAggregateFieldLabel(),
+ 'variant' => $this->variant,
+ 'orientation' => $this->variant === self::BAR ? $this->orientation : null,
+ 'stacked' => $this->stacked && $comparison === null,
+ 'comparison' => $comparison,
+ ];
+ }
+
+ protected function cacheKey(): array
+ {
+ $compare = $this->getCompareRange();
+
+ return [
+ ...$this->seriesCacheKey(),
+ ...$this->categoriesCacheKey(),
+ 'range' => static::rangeKey($this->getRange()),
+ 'compare' => $compare ? static::rangeKey($compare) : null,
+ 'variant' => $this->variant,
+ 'orientation' => $this->orientation,
+ 'stacked' => $this->stacked,
+ 'positive_is_good' => $this->positive_is_good,
+ ];
+ }
+
+ protected function endsAt(): ?CarbonInterface
+ {
+ return $this->getRange()->getDateTo();
+ }
+}
diff --git a/src/Charts/Chart.php b/src/Charts/Chart.php
new file mode 100644
index 0000000..af9ee73
--- /dev/null
+++ b/src/Charts/Chart.php
@@ -0,0 +1,327 @@
+`) or as SVG in Blade
+ * (``).
+ *
+ * The data is computed when the chart is serialised, and cached (see
+ * {@see StatsCache}) unless caching is turned off for the chart.
+ *
+ * @implements Arrayable
+ */
+abstract class Chart implements Arrayable, Htmlable, JsonSerializable
+{
+ public const string FORMAT_NUMBER = 'number';
+
+ public const string FORMAT_INTEGER = 'integer';
+
+ public const string FORMAT_COMPACT = 'compact';
+
+ public const string FORMAT_PERCENT = 'percent';
+
+ public const string FORMAT_CURRENCY = 'currency';
+
+ protected ?string $title = null;
+
+ protected ?string $description = null;
+
+ protected ?string $id = null;
+
+ protected ?DateRange $range = null;
+
+ /** @var array */
+ protected array $filters = [];
+
+ protected string $value_format = self::FORMAT_NUMBER;
+
+ protected ?int $height = null;
+
+ /** Seconds to cache the data: null for the default, 0 for none */
+ protected ?int $cache_ttl = null;
+
+ protected bool $cache_enabled = true;
+
+ /** @var array|null */
+ protected ?array $computed = null;
+
+ /**
+ * The chart's type, as the React and Blade renderers know it
+ */
+ abstract public function type(): string;
+
+ /**
+ * The metrics the chart is computed from
+ *
+ * @return list
+ */
+ abstract public function metrics(): array;
+
+ /**
+ * Compute the chart's data
+ *
+ * @return array
+ */
+ abstract protected function compute(): array;
+
+ /**
+ * What the cached data depends on, besides the metrics
+ *
+ * @return array
+ */
+ abstract protected function cacheKey(): array;
+
+ /**
+ * The latest moment the chart's data covers, which decides its TTL
+ */
+ abstract protected function endsAt(): ?CarbonInterface;
+
+ public function title(?string $title): static
+ {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ public function getTitle(): string
+ {
+ return $this->title ?? $this->defaultTitle();
+ }
+
+ /**
+ * The title when none is given: the name of the first stat
+ */
+ abstract protected function defaultTitle(): string;
+
+ public function description(?string $description): static
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ public function id(?string $id): static
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ public function getId(): string
+ {
+ return $this->id ??= 'stats-chart-' . substr(md5(static::class . json_encode($this->cacheKey()) . implode(',', $this->metrics())), 0, 8);
+ }
+
+ /**
+ * The date range of the chart. Default: `config('stats.default_date_range')`
+ */
+ public function range(DateRange|string $range): static
+ {
+ $this->range = is_string($range) ? PresetDateRanges::from($range) : $range;
+
+ $this->computed = null;
+
+ return $this;
+ }
+
+ public function getRange(): DateRange
+ {
+ return $this->range ?? TimeSeriesStats::defaultDateRange();
+ }
+
+ /**
+ * Fixed filters for every stat of the chart
+ *
+ * @param array $filters
+ */
+ public function filters(array $filters): static
+ {
+ $this->filters = $filters;
+
+ $this->computed = null;
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getFilters(): array
+ {
+ return $this->filters;
+ }
+
+ /**
+ * How values are formatted: number, integer, compact, percent or currency (MVR)
+ */
+ public function valueFormat(string $format): static
+ {
+ $this->value_format = $format;
+
+ return $this;
+ }
+
+ public function getValueFormat(): string
+ {
+ return $this->value_format;
+ }
+
+ public function height(?int $height): static
+ {
+ $this->height = $height;
+
+ return $this;
+ }
+
+ public function getHeight(): ?int
+ {
+ return $this->height;
+ }
+
+ /**
+ * Cache the data for the given seconds, or `false` not to cache it
+ */
+ public function cache(int|bool $seconds = true, ?int $minutes = null): static
+ {
+ if ($minutes !== null) {
+ $seconds = $minutes * 60;
+ }
+
+ $this->cache_enabled = $seconds !== false && $seconds !== 0;
+ $this->cache_ttl = is_int($seconds) && $seconds > 0 ? $seconds : null;
+
+ return $this;
+ }
+
+ public function withoutCache(): static
+ {
+ return $this->cache(false);
+ }
+
+ public function isCached(): bool
+ {
+ return $this->cache_enabled;
+ }
+
+ /**
+ * The chart's data, from the cache when it's there
+ *
+ * @return array
+ */
+ public function data(): array
+ {
+ if ($this->computed !== null) {
+ return $this->computed;
+ }
+
+ if (! $this->cache_enabled) {
+ return $this->computed = $this->compute();
+ }
+
+ return $this->computed = $this->statsCache()->remember(
+ $this->fullCacheKey(),
+ $this->metrics(),
+ fn () => $this->compute(),
+ $this->cache_ttl,
+ $this->endsAt(),
+ );
+ }
+
+ /**
+ * Compute the data again and put it in the cache
+ *
+ * @return array
+ */
+ public function refresh(): array
+ {
+ if (! $this->cache_enabled) {
+ return $this->computed = $this->compute();
+ }
+
+ return $this->computed = $this->statsCache()->refresh(
+ $this->fullCacheKey(),
+ $this->metrics(),
+ fn () => $this->compute(),
+ $this->cache_ttl,
+ $this->endsAt(),
+ );
+ }
+
+ /**
+ * Whether the chart's data is in the cache
+ */
+ public function isWarm(): bool
+ {
+ return $this->cache_enabled && $this->statsCache()->has($this->fullCacheKey(), $this->metrics());
+ }
+
+ /**
+ * @return array
+ */
+ protected function fullCacheKey(): array
+ {
+ return [
+ 'type' => $this->type(),
+ 'chart' => static::class,
+ 'locale' => app()->getLocale(),
+ ...$this->cacheKey(),
+ ];
+ }
+
+ protected function statsCache(): StatsCache
+ {
+ return app(StatsCache::class);
+ }
+
+ /**
+ * The chart as the props of the React `` and the Blade components
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'type' => $this->type(),
+ 'id' => $this->getId(),
+ 'title' => $this->getTitle(),
+ 'description' => $this->description,
+ 'valueFormat' => $this->value_format,
+ 'height' => $this->height,
+ ...$this->data(),
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function jsonSerialize(): array
+ {
+ return $this->toArray();
+ }
+
+ /**
+ * The chart as SVG, through the `` Blade component
+ */
+ public function render(): string
+ {
+ return Blade::renderComponent(new ChartComponent($this));
+ }
+
+ public function toHtml(): string
+ {
+ return $this->render();
+ }
+}
diff --git a/src/Charts/Concerns/HasCategories.php b/src/Charts/Concerns/HasCategories.php
new file mode 100644
index 0000000..9b24910
--- /dev/null
+++ b/src/Charts/Concerns/HasCategories.php
@@ -0,0 +1,116 @@
+ */
+ protected array $categories = [];
+
+ protected ?int $limit = null;
+
+ /**
+ * Which categories to show: with data (non_empty), all, or specific ones
+ */
+ public function categoryMode(CategoricalModes|string $mode): static
+ {
+ $this->category_mode = is_string($mode) ? CategoricalModes::from($mode) : $mode;
+
+ return $this;
+ }
+
+ public function getCategoryMode(): CategoricalModes
+ {
+ if ($this->categories !== []) {
+ return CategoricalModes::SPECIFIC;
+ }
+
+ return $this->category_mode ?? CategoricalStats::defaultMode();
+ }
+
+ /**
+ * Show only these categories, by id
+ *
+ * @param list $ids
+ */
+ public function categories(array $ids): static
+ {
+ $this->categories = array_values($ids);
+
+ return $this;
+ }
+
+ /**
+ * Show only the categories with the highest values
+ */
+ public function top(?int $limit): static
+ {
+ $this->limit = $limit;
+
+ return $this;
+ }
+
+ /**
+ * @param array $filters
+ */
+ public function makeStat(string $metric, DateRange $range, array $filters = []): CategoricalStatsRepository
+ {
+ if (! class_exists(CategoricalStats::getClassNameForMetric($metric))) {
+ throw StatsException::unknownMetric($metric);
+ }
+
+ return CategoricalStats::createFromMetric($metric, $range, array_merge($this->filters, $filters));
+ }
+
+ /**
+ * Labels keyed by category id, in the provider's order, for the values given
+ *
+ * @param list> $value_sets
+ * @return array
+ */
+ protected function categoryNames(CategoricalStatsRepository $stat, array $value_sets): array
+ {
+ $mode = $this->getCategoryMode();
+
+ if ($mode === CategoricalModes::SPECIFIC) {
+ return $stat->resolveCategoryNames($mode, $this->categories);
+ }
+
+ if ($mode === CategoricalModes::ALL) {
+ return $stat->resolveCategoryNames($mode);
+ }
+
+ $ids = [];
+
+ foreach ($value_sets as $values) {
+ foreach ($values as $id => $value) {
+ $ids[(string) $id] = $id;
+ }
+ }
+
+ return $ids === [] ? [] : $stat->resolveCategoryNames(CategoricalModes::SPECIFIC, array_values($ids));
+ }
+
+ /**
+ * @return array
+ */
+ protected function categoriesCacheKey(): array
+ {
+ return [
+ 'category_mode' => $this->getCategoryMode()->value,
+ 'categories' => $this->categories,
+ 'limit' => $this->limit,
+ ];
+ }
+}
diff --git a/src/Charts/Concerns/HasStats.php b/src/Charts/Concerns/HasStats.php
new file mode 100644
index 0000000..d14b785
--- /dev/null
+++ b/src/Charts/Concerns/HasStats.php
@@ -0,0 +1,125 @@
+}> */
+ protected array $series = [];
+
+ /**
+ * Add a stat to the chart
+ *
+ * @param array $filters filters for this stat only
+ */
+ public function stat(string $metric, ?string $label = null, array $filters = []): static
+ {
+ $this->series[] = ['metric' => $metric, 'label' => $label, 'filters' => $filters];
+
+ $this->computed = null;
+
+ return $this;
+ }
+
+ /**
+ * Add several stats: a list of metrics, or labels keyed by metric
+ *
+ * @param array $metrics
+ */
+ public function stats(array $metrics): static
+ {
+ foreach ($metrics as $key => $value) {
+ is_int($key) ? $this->stat($value) : $this->stat($key, $value);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return list
+ */
+ public function metrics(): array
+ {
+ return array_values(array_unique(array_column($this->series, 'metric')));
+ }
+
+ /**
+ * @return list}>
+ */
+ public function getSeries(): array
+ {
+ return $this->series;
+ }
+
+ /**
+ * Data keys for the series: CSS-safe and unique (`payments_count`, `payments_count_2`)
+ *
+ * @return list
+ */
+ protected function seriesKeys(): array
+ {
+ $keys = [];
+
+ foreach ($this->series as $series) {
+ $key = Str::of(class_basename($series['metric']))->snake()->slug('_')->toString() ?: 'stat';
+ $base = $key;
+ $i = 2;
+
+ while (in_array($key, $keys, true) || in_array($key, ['date', 'category', 'previous'], true)) {
+ $key = $base . '_' . $i++;
+ }
+
+ $keys[] = $key;
+ }
+
+ return $keys;
+ }
+
+ /**
+ * The chart's fixed filters merged with a series' own
+ *
+ * @param array{metric: string, label: string|null, filters: array} $series
+ * @return array
+ */
+ protected function filtersFor(array $series): array
+ {
+ return array_merge($this->filters, $series['filters']);
+ }
+
+ /**
+ * @return array
+ */
+ protected function seriesCacheKey(): array
+ {
+ return [
+ 'series' => $this->series,
+ 'filters' => $this->filters,
+ ];
+ }
+
+ /**
+ * The dates a range resolves to now, for cache keys. Presets are keyed by
+ * day, so a range that ends "now" (lifetime) keeps its key all day; the
+ * TTL decides how fresh it is.
+ *
+ * @return array{name: string, from: string, to: string}
+ */
+ protected static function rangeKey(DateRange $range): array
+ {
+ $format = $range instanceof PresetDateRanges ? 'Y-m-d' : 'Y-m-d H:i:s';
+
+ return [
+ 'name' => $range->getName(),
+ 'from' => $range->getDateFrom()->format($format),
+ 'to' => $range->getDateTo()->format($format),
+ ];
+ }
+}
diff --git a/src/Charts/PieChart.php b/src/Charts/PieChart.php
new file mode 100644
index 0000000..1659287
--- /dev/null
+++ b/src/Charts/PieChart.php
@@ -0,0 +1,154 @@
+range(PresetDateRanges::THIS_MONTH)->donut();
+ * PieChart::make('applications_by_island')->top(5); // the rest as "Other"
+ * ```
+ */
+class PieChart extends Chart
+{
+ use HasCategories;
+ use HasStats;
+
+ protected bool $donut = false;
+
+ protected ?string $center_value = null;
+
+ protected ?string $center_label = null;
+
+ protected bool $show_center = true;
+
+ protected bool $keep_empty = false;
+
+ public static function make(?string $metric = null, ?string $label = null): static
+ {
+ $chart = new static;
+
+ if ($metric !== null) {
+ $chart->stat($metric, $label);
+ }
+
+ return $chart;
+ }
+
+ public function type(): string
+ {
+ return 'pie';
+ }
+
+ public function donut(bool $donut = true): static
+ {
+ $this->donut = $donut;
+
+ return $this;
+ }
+
+ /**
+ * The donut's centre: a value and a label, or `false` for none. Default: the total
+ */
+ public function centerLabel(string|false|null $value, ?string $label = null): static
+ {
+ $this->show_center = $value !== false;
+ $this->center_value = is_string($value) ? $value : null;
+ $this->center_label = $label;
+
+ return $this;
+ }
+
+ /**
+ * Keep categories with no value as empty slices in the table
+ */
+ public function keepEmpty(bool $keep = true): static
+ {
+ $this->keep_empty = $keep;
+
+ return $this;
+ }
+
+ protected function defaultTitle(): string
+ {
+ $series = $this->series[0] ?? null;
+
+ return $series ? $this->makeStat($series['metric'], $this->getRange(), $series['filters'])->getName() : '';
+ }
+
+ protected function compute(): array
+ {
+ $series = $this->series[0] ?? null;
+
+ if (! $series) {
+ return ['data' => [], 'variant' => $this->donut ? 'donut' : 'pie', 'centerLabel' => null, 'total' => 0];
+ }
+
+ $stat = $this->makeStat($series['metric'], $this->getRange(), $series['filters']);
+ $values = $stat->resultsToArray($stat->results());
+ $slices = [];
+
+ foreach ($this->categoryNames($stat, [$values]) as $id => $name) {
+ $value = ($values[$id] ?? 0) + 0;
+
+ if ($value <= 0 && ! $this->keep_empty) {
+ continue;
+ }
+
+ $slices[] = ['name' => $name, 'value' => $value, 'categoryId' => $id];
+ }
+
+ usort($slices, fn (array $a, array $b) => $b['value'] <=> $a['value']);
+
+ if ($this->limit !== null && count($slices) > $this->limit) {
+ $rest = array_slice($slices, $this->limit);
+
+ $slices = array_slice($slices, 0, $this->limit);
+ $slices[] = [
+ 'name' => __('Other'),
+ 'value' => array_sum(array_column($rest, 'value')),
+ 'categoryId' => null,
+ ];
+ }
+
+ $center = null;
+
+ if ($this->donut && $this->show_center && ($this->center_value !== null || $this->center_label !== null)) {
+ $center = array_filter(['value' => $this->center_value, 'label' => $this->center_label], fn ($value) => $value !== null);
+ }
+
+ return [
+ 'data' => $slices,
+ 'nameKey' => 'name',
+ 'valueKey' => 'value',
+ 'variant' => $this->donut ? 'donut' : 'pie',
+ 'centerLabel' => $this->show_center ? $center : false,
+ 'nameLabel' => $stat->getCategoryTitle(),
+ 'aggregateLabel' => $stat->getAggregateFieldLabel(),
+ 'total' => array_sum(array_column($slices, 'value')),
+ ];
+ }
+
+ protected function cacheKey(): array
+ {
+ return [
+ ...$this->seriesCacheKey(),
+ ...$this->categoriesCacheKey(),
+ 'range' => static::rangeKey($this->getRange()),
+ 'donut' => $this->donut,
+ 'center' => [$this->show_center, $this->center_value, $this->center_label],
+ 'keep_empty' => $this->keep_empty,
+ ];
+ }
+
+ protected function endsAt(): ?CarbonInterface
+ {
+ return $this->getRange()->getDateTo();
+ }
+}
diff --git a/src/Charts/Sparkline.php b/src/Charts/Sparkline.php
new file mode 100644
index 0000000..dfc9a1f
--- /dev/null
+++ b/src/Charts/Sparkline.php
@@ -0,0 +1,128 @@
+range(PresetDateRanges::LAST_30_DAYS)->area();
+ * ```
+ */
+class Sparkline extends Chart
+{
+ use HasStats;
+
+ protected ?TimeSeriesModes $mode = null;
+
+ protected string $variant = 'line';
+
+ protected ?string $color = null;
+
+ public static function make(?string $metric = null, ?string $label = null): static
+ {
+ $chart = new static;
+
+ if ($metric !== null) {
+ $chart->stat($metric, $label);
+ }
+
+ return $chart;
+ }
+
+ public function type(): string
+ {
+ return 'sparkline';
+ }
+
+ public function mode(TimeSeriesModes|string $mode): static
+ {
+ $this->mode = is_string($mode) ? TimeSeriesModes::from($mode) : $mode;
+
+ return $this;
+ }
+
+ public function getMode(): TimeSeriesModes
+ {
+ return $this->mode ?? TimeSeriesStats::defaultMode();
+ }
+
+ public function area(bool $area = true): static
+ {
+ $this->variant = $area ? 'area' : 'line';
+
+ return $this;
+ }
+
+ /**
+ * Any CSS colour or token. Default: `var(--chart-1)`
+ */
+ public function color(?string $color): static
+ {
+ $this->color = $color;
+
+ return $this;
+ }
+
+ protected function defaultTitle(): string
+ {
+ $series = $this->series[0] ?? null;
+
+ return $series ? $this->makeStat($series['metric'], $series['filters'])->getName() : '';
+ }
+
+ /**
+ * @param array $filters
+ */
+ protected function makeStat(string $metric, array $filters = []): TimeSeriesStatsRepository
+ {
+ if (! class_exists(TimeSeriesStats::getClassNameForMetric($metric))) {
+ throw StatsException::unknownMetric($metric);
+ }
+
+ return TimeSeriesStats::createFromMetric($metric, $this->getRange(), array_merge($this->filters, $filters));
+ }
+
+ protected function compute(): array
+ {
+ $series = $this->series[0] ?? null;
+ $values = [];
+
+ if ($series) {
+ $values = array_column(TimeSeriesSteps::for($this->makeStat($series['metric'], $series['filters']), $this->getMode()), 'value');
+ }
+
+ return [
+ 'data' => $values,
+ 'label' => $series['label'] ?? $this->getTitle(),
+ 'variant' => $this->variant,
+ 'color' => $this->color,
+ 'mode' => $this->getMode()->value,
+ ];
+ }
+
+ protected function cacheKey(): array
+ {
+ return [
+ ...$this->seriesCacheKey(),
+ 'mode' => $this->getMode()->value,
+ 'range' => static::rangeKey($this->getRange()),
+ 'variant' => $this->variant,
+ 'color' => $this->color,
+ ];
+ }
+
+ protected function endsAt(): ?CarbonInterface
+ {
+ return $this->getRange()->getDateTo();
+ }
+}
diff --git a/src/Charts/StatTile.php b/src/Charts/StatTile.php
new file mode 100644
index 0000000..3a00bd4
--- /dev/null
+++ b/src/Charts/StatTile.php
@@ -0,0 +1,214 @@
+` in Blade.
+ *
+ * ```php
+ * StatTile::make('payments_count')->range(PresetDateRanges::THIS_MONTH)->compare()->sparkline();
+ * ```
+ */
+class StatTile extends Chart
+{
+ use HasStats;
+
+ protected DateRange|bool $compare = false;
+
+ protected ?TimeSeriesModes $sparkline_mode = null;
+
+ protected bool $has_sparkline = false;
+
+ protected ?string $icon = null;
+
+ protected ?string $href = null;
+
+ protected bool $positive_is_good = true;
+
+ protected ?string $delta_context = null;
+
+ protected ?string $footer = null;
+
+ public static function make(?string $metric = null, ?string $label = null): static
+ {
+ $chart = new static;
+
+ if ($metric !== null) {
+ $chart->stat($metric, $label);
+ }
+
+ return $chart;
+ }
+
+ public function type(): string
+ {
+ return 'stat-tile';
+ }
+
+ /**
+ * The tile's label. Default: the stat's name
+ */
+ public function label(?string $label): static
+ {
+ return $this->title($label);
+ }
+
+ /**
+ * Show the change from the previous period, or from the given period
+ */
+ public function compare(DateRange|string|bool $with = true): static
+ {
+ $this->compare = is_string($with) ? PresetDateRanges::from($with) : $with;
+
+ return $this;
+ }
+
+ public function getCompareRange(): ?DateRange
+ {
+ if ($this->compare instanceof DateRange) {
+ return $this->compare;
+ }
+
+ return $this->compare ? $this->getRange()->getPreviousDateRange() : null;
+ }
+
+ /**
+ * Show a sparkline of the range, grouped by the mode (default: the default mode)
+ */
+ public function sparkline(TimeSeriesModes|string|bool $mode = true): static
+ {
+ $this->has_sparkline = $mode !== false;
+ $this->sparkline_mode = is_string($mode) ? TimeSeriesModes::from($mode) : ($mode instanceof TimeSeriesModes ? $mode : null);
+
+ return $this;
+ }
+
+ /**
+ * An Onu semantic icon name (`file-text`, `credit-card`, …)
+ */
+ public function icon(?string $icon): static
+ {
+ $this->icon = $icon;
+
+ return $this;
+ }
+
+ /**
+ * Make the tile a link
+ */
+ public function href(?string $href): static
+ {
+ $this->href = $href;
+
+ return $this;
+ }
+
+ public function positiveIsGood(bool $positive_is_good = true): static
+ {
+ $this->positive_is_good = $positive_is_good;
+
+ return $this;
+ }
+
+ /**
+ * Text after the change. Default: "vs previous period"
+ */
+ public function deltaContext(?string $context): static
+ {
+ $this->delta_context = $context;
+
+ return $this;
+ }
+
+ public function footer(?string $footer): static
+ {
+ $this->footer = $footer;
+
+ return $this;
+ }
+
+ protected function defaultTitle(): string
+ {
+ $series = $this->series[0] ?? null;
+
+ return $series['label'] ?? ($series ? $this->makeStat($series['metric'], $this->getRange(), $series['filters'])->getName() : '');
+ }
+
+ /**
+ * @param array $filters
+ */
+ protected function makeStat(string $metric, DateRange $range, array $filters = []): TimeSeriesStatsRepository
+ {
+ if (! class_exists(TimeSeriesStats::getClassNameForMetric($metric))) {
+ throw StatsException::unknownMetric($metric);
+ }
+
+ return TimeSeriesStats::createFromMetric($metric, $range, array_merge($this->filters, $filters));
+ }
+
+ protected function compute(): array
+ {
+ $series = $this->series[0] ?? null;
+
+ if (! $series) {
+ return ['value' => null];
+ }
+
+ $stat = $this->makeStat($series['metric'], $this->getRange(), $series['filters']);
+ $compare_range = $this->getCompareRange();
+ $previous = $compare_range ? $this->makeStat($series['metric'], $compare_range, $series['filters'])->total() : null;
+ $sparkline = null;
+
+ if ($this->has_sparkline) {
+ $sparkline = array_column(TimeSeriesSteps::for($stat, $this->sparkline_mode ?? TimeSeriesStats::defaultMode()), 'value');
+ }
+
+ return [
+ 'label' => $this->getTitle(),
+ 'value' => $stat->total(),
+ 'previousValue' => $previous,
+ 'deltaContext' => $compare_range ? ($this->delta_context ?? __('vs previous period')) : null,
+ 'positiveIsGood' => $this->positive_is_good,
+ 'sparkline' => $sparkline,
+ 'icon' => $this->icon,
+ 'href' => $this->href,
+ 'footer' => $this->footer,
+ 'period' => TimeSeriesSteps::rangeLabel($this->getRange(), $stat->getDateFrom(), $stat->getDateTo()),
+ ];
+ }
+
+ protected function cacheKey(): array
+ {
+ $compare = $this->getCompareRange();
+
+ return [
+ ...$this->seriesCacheKey(),
+ 'range' => static::rangeKey($this->getRange()),
+ 'compare' => $compare ? static::rangeKey($compare) : null,
+ 'sparkline' => $this->has_sparkline ? ($this->sparkline_mode ?? TimeSeriesStats::defaultMode())->value : null,
+ 'title' => $this->title,
+ 'icon' => $this->icon,
+ 'href' => $this->href,
+ 'positive_is_good' => $this->positive_is_good,
+ 'delta_context' => $this->delta_context,
+ 'footer' => $this->footer,
+ ];
+ }
+
+ protected function endsAt(): ?CarbonInterface
+ {
+ return $this->getRange()->getDateTo();
+ }
+}
diff --git a/src/Charts/TimeSeriesChart.php b/src/Charts/TimeSeriesChart.php
new file mode 100644
index 0000000..11d79c8
--- /dev/null
+++ b/src/Charts/TimeSeriesChart.php
@@ -0,0 +1,334 @@
+range(PresetDateRanges::LAST_30_DAYS)->compare();
+ * TimeSeriesChart::make()->stats(['payments_count', 'refunds_count'])->variant('bar');
+ * TimeSeriesChart::make('payments_count')->mode(TimeSeriesModes::MONTH)
+ * ->periods([PresetDateRanges::THIS_YEAR, PresetDateRanges::LAST_YEAR]);
+ * ```
+ */
+class TimeSeriesChart extends Chart
+{
+ use HasStats;
+
+ public const string LINE = 'line';
+
+ public const string AREA = 'area';
+
+ public const string BAR = 'bar';
+
+ protected ?TimeSeriesModes $mode = null;
+
+ /** @var list */
+ protected array $periods = [];
+
+ protected DateRange|bool $compare = false;
+
+ protected string $variant = self::LINE;
+
+ protected bool $stacked = false;
+
+ protected bool $brush = false;
+
+ protected bool $positive_is_good = true;
+
+ public static function make(?string $metric = null, ?string $label = null): static
+ {
+ $chart = new static;
+
+ if ($metric !== null) {
+ $chart->stat($metric, $label);
+ }
+
+ return $chart;
+ }
+
+ public function type(): string
+ {
+ return 'time-series';
+ }
+
+ /**
+ * Group by hour, day, week, month or year. Default: `config('stats.default_time_series_mode')`
+ */
+ public function mode(TimeSeriesModes|string $mode): static
+ {
+ $this->mode = is_string($mode) ? TimeSeriesModes::from($mode) : $mode;
+
+ return $this;
+ }
+
+ public function getMode(): TimeSeriesModes
+ {
+ return $this->mode ?? TimeSeriesStats::defaultMode();
+ }
+
+ /**
+ * Plot the first stat over each period, step by step on the first period's dates
+ *
+ * @param list $periods
+ */
+ public function periods(array $periods): static
+ {
+ $this->periods = array_map(fn ($period) => is_string($period) ? PresetDateRanges::from($period) : $period, array_values($periods));
+
+ return $this;
+ }
+
+ /**
+ * Compare the first stat with the previous period, or with the given period
+ */
+ public function compare(DateRange|string|bool $with = true): static
+ {
+ $this->compare = is_string($with) ? PresetDateRanges::from($with) : $with;
+
+ return $this;
+ }
+
+ /**
+ * Whether a rise is good (green) in the comparison. False for costs, complaints, …
+ */
+ public function positiveIsGood(bool $positive_is_good = true): static
+ {
+ $this->positive_is_good = $positive_is_good;
+
+ return $this;
+ }
+
+ /**
+ * How the series are drawn: line, area or bar
+ */
+ public function variant(string $variant): static
+ {
+ $this->variant = $variant;
+
+ return $this;
+ }
+
+ public function line(): static
+ {
+ return $this->variant(self::LINE);
+ }
+
+ public function area(): static
+ {
+ return $this->variant(self::AREA);
+ }
+
+ public function bar(): static
+ {
+ return $this->variant(self::BAR);
+ }
+
+ public function stacked(bool $stacked = true): static
+ {
+ $this->stacked = $stacked;
+
+ return $this;
+ }
+
+ /**
+ * Add a range slider to zoom in (React only)
+ */
+ public function brush(bool $brush = true): static
+ {
+ $this->brush = $brush;
+
+ return $this;
+ }
+
+ public function getCompareRange(): ?DateRange
+ {
+ if ($this->compare instanceof DateRange) {
+ return $this->compare;
+ }
+
+ return $this->compare ? $this->getRange()->getPreviousDateRange() : null;
+ }
+
+ protected function defaultTitle(): string
+ {
+ $metric = $this->series[0]['metric'] ?? null;
+
+ return $metric ? $this->makeStat($metric, $this->getRange(), $this->series[0]['filters'])->getName() : '';
+ }
+
+ public function makeStat(string $metric, DateRange $range, array $filters = []): TimeSeriesStatsRepository
+ {
+ if (! class_exists(TimeSeriesStats::getClassNameForMetric($metric))) {
+ throw StatsException::unknownMetric($metric);
+ }
+
+ return TimeSeriesStats::createFromMetric($metric, $range, array_merge($this->filters, $filters));
+ }
+
+ protected function compute(): array
+ {
+ $mode = $this->getMode();
+
+ $columns = $this->periods !== [] ? $this->periodColumns($mode) : $this->seriesColumns($mode);
+
+ $first = $columns[0] ?? null;
+ $rows = [];
+ $labels = [];
+
+ foreach ($first['steps'] ?? [] as $i => $step) {
+ $row = ['date' => $mode->toIsoDate($step['date'])];
+
+ foreach ($columns as $column) {
+ $row[$column['key']] = $column['steps'][$i]['value'] ?? null;
+ }
+
+ $rows[] = $row;
+ $labels[] = $mode->formatDate($step['date']);
+ }
+
+ $config = [];
+ $totals = [];
+
+ foreach ($columns as $column) {
+ $config[$column['key']] = ['label' => $column['label']];
+ $totals[$column['key']] = array_sum(array_column($column['steps'], 'value'));
+ }
+
+ $comparison = null;
+
+ if ($this->periods === [] && ($previous = collect($columns)->firstWhere('key', 'previous'))) {
+ $comparison = [
+ 'previousKey' => 'previous',
+ 'currentLabel' => $first['label'],
+ 'previousLabel' => $previous['label'],
+ 'positiveIsGood' => $this->positive_is_good,
+ ];
+ }
+
+ return [
+ 'data' => $rows,
+ 'labels' => $labels,
+ 'config' => $config,
+ 'totals' => $totals,
+ 'dateKey' => 'date',
+ 'mode' => $mode->value,
+ 'interval' => $mode->chartInterval(),
+ 'variant' => $this->variant,
+ 'stacked' => $this->stacked && $comparison === null,
+ 'brush' => $this->brush,
+ 'comparison' => $comparison,
+ 'aggregateLabel' => $first['aggregate_label'] ?? null,
+ ];
+ }
+
+ /**
+ * One column per stat, and one for the comparison
+ *
+ * @return list>, aggregate_label: string}>
+ */
+ protected function seriesColumns(TimeSeriesModes $mode): array
+ {
+ $range = $this->getRange();
+ $compare_range = $this->getCompareRange();
+ $keys = $this->seriesKeys();
+ $columns = [];
+
+ foreach ($this->series as $i => $series) {
+ $stat = $this->makeStat($series['metric'], $range, $series['filters']);
+
+ $label = $series['label'] ?? $stat->getName();
+
+ if ($i === 0 && $compare_range) {
+ $label = TimeSeriesSteps::rangeLabel($range, $stat->getDateFrom(), $stat->getDateTo());
+ }
+
+ $columns[] = [
+ 'key' => $keys[$i],
+ 'label' => $label,
+ 'steps' => TimeSeriesSteps::for($stat, $mode),
+ 'aggregate_label' => $stat->getAggregateFieldLabel(),
+ ];
+ }
+
+ if ($compare_range && $this->series !== []) {
+ $series = $this->series[0];
+ $stat = $this->makeStat($series['metric'], $compare_range, $series['filters']);
+
+ $columns[] = [
+ 'key' => 'previous',
+ 'label' => TimeSeriesSteps::rangeLabel($compare_range, $stat->getDateFrom(), $stat->getDateTo()),
+ 'steps' => TimeSeriesSteps::for($stat, $mode),
+ 'aggregate_label' => $stat->getAggregateFieldLabel(),
+ ];
+ }
+
+ return $columns;
+ }
+
+ /**
+ * One column per period of the first stat
+ *
+ * @return list>, aggregate_label: string}>
+ */
+ protected function periodColumns(TimeSeriesModes $mode): array
+ {
+ $series = $this->series[0] ?? null;
+
+ if (! $series) {
+ return [];
+ }
+
+ $columns = [];
+
+ foreach ($this->periods as $i => $period) {
+ $stat = $this->makeStat($series['metric'], $period, $series['filters']);
+
+ $columns[] = [
+ 'key' => 'period_' . ($i + 1),
+ 'label' => TimeSeriesSteps::rangeLabel($period, $stat->getDateFrom(), $stat->getDateTo()),
+ 'steps' => TimeSeriesSteps::for($stat, $mode),
+ 'aggregate_label' => $stat->getAggregateFieldLabel(),
+ ];
+ }
+
+ return $columns;
+ }
+
+ protected function cacheKey(): array
+ {
+ $compare = $this->getCompareRange();
+
+ return [
+ ...$this->seriesCacheKey(),
+ 'mode' => $this->getMode()->value,
+ 'range' => static::rangeKey($this->getRange()),
+ 'compare' => $compare ? static::rangeKey($compare) : null,
+ 'periods' => array_map(fn (DateRange $period) => static::rangeKey($period), $this->periods),
+ 'variant' => $this->variant,
+ 'stacked' => $this->stacked,
+ 'brush' => $this->brush,
+ 'positive_is_good' => $this->positive_is_good,
+ ];
+ }
+
+ protected function endsAt(): ?CarbonInterface
+ {
+ $ranges = $this->periods !== [] ? $this->periods : [$this->getRange()];
+
+ return collect($ranges)->map(fn (DateRange $range) => $range->getDateTo())->max();
+ }
+}
diff --git a/src/Commands/GenerateCategoricalStatCommand.php b/src/Commands/GenerateCategoricalStatCommand.php
index ddcd60b..458d00b 100644
--- a/src/Commands/GenerateCategoricalStatCommand.php
+++ b/src/Commands/GenerateCategoricalStatCommand.php
@@ -64,7 +64,7 @@ public function handle(): int
);
$path = $this->getPath(app_path('Stats/Categorical'), $path);
- $file_name = $generator->getName().'.php';
+ $file_name = $generator->getName() . '.php';
$file_path = $this->getFullFilePath($path, $file_name);
$output = $generator->render();
@@ -112,7 +112,7 @@ protected function registerStat(AbstractCategoricalStatGenerator $generator): vo
[
'search' => 'CategoricalStats::register([',
'keep_search' => true,
- 'content' => $line_ending.$this->getRenderer()->addIndentation("'{$metric}' => {$class_name}::class,", 3),
+ 'content' => $line_ending . $this->getRenderer()->addIndentation("'{$metric}' => {$class_name}::class,", 3),
],
];
diff --git a/src/Commands/GenerateTimeSeriesStatCommand.php b/src/Commands/GenerateTimeSeriesStatCommand.php
index ec5befe..d49a38e 100644
--- a/src/Commands/GenerateTimeSeriesStatCommand.php
+++ b/src/Commands/GenerateTimeSeriesStatCommand.php
@@ -23,7 +23,7 @@ protected function getArguments()
{
return [
['name', InputArgument::REQUIRED, 'The name for the stat class.'],
- ['model', InputArgument::REQUIRED, 'The model class for which you want to generate the stats for. Can be the model class or morph name.']
+ ['model', InputArgument::REQUIRED, 'The model class for which you want to generate the stats for. Can be the model class or morph name.'],
];
}
@@ -73,7 +73,6 @@ protected function getGenerator(string $type, string $name, string $model_class)
return new $class($name, $model_class);
}
-
/**
* @return class-string
*/
diff --git a/src/Commands/WarmStatsCacheCommand.php b/src/Commands/WarmStatsCacheCommand.php
new file mode 100644
index 0000000..0c4dce5
--- /dev/null
+++ b/src/Commands/WarmStatsCacheCommand.php
@@ -0,0 +1,40 @@
+option('flush')) {
+ $stats->flush();
+ }
+
+ if ($this->option('queue')) {
+ WarmStatsCache::dispatch();
+
+ $this->components->info('Dispatched the job that warms the stats cache.');
+
+ return self::SUCCESS;
+ }
+
+ $count = $stats->warmNow(function (Chart $chart) {
+ $this->components->twoColumnDetail($chart->getTitle(), $chart->type());
+ });
+
+ $this->components->info("Warmed {$count} " . str('chart')->plural($count) . '.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/src/Concerns/ExportsCategoricalStats.php b/src/Concerns/ExportsCategoricalStats.php
index 30f3f93..0c3c591 100644
--- a/src/Concerns/ExportsCategoricalStats.php
+++ b/src/Concerns/ExportsCategoricalStats.php
@@ -2,69 +2,36 @@
namespace Javaabu\Stats\Concerns;
-use Illuminate\Validation\Rule;
use Javaabu\Stats\CategoricalStats;
-use Javaabu\Stats\Enums\CategoricalModes;
-use Javaabu\Stats\Enums\PresetDateRanges;
use Javaabu\Stats\Exports\CategoricalStatsExport;
use Javaabu\Stats\Http\Requests\CategoricalStatsRequest;
-use Javaabu\Stats\Support\ExactDateRange;
+use Javaabu\Stats\Support\ExportFileName;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+/**
+ * Downloads a categorical stat as CSV, for controllers
+ */
trait ExportsCategoricalStats
{
/**
- * @param array $filters
+ * @param array $filters extra fixed filters, e.g. the model of a model-specific page
*/
- protected function validateCategoricalStatsFilters(CategoricalStatsRequest $request, array $filters = []): void
+ public function exportCategoricalStats(CategoricalStatsRequest $request, array $filters = [], string $export_title = ''): StreamedResponse
{
- if ($filters) {
- $this->validate($request, [
- 'metric' => [
- 'required',
- 'string',
- Rule::in(CategoricalStats::allowedMetrics($filters, $request->user())),
- ],
- ]);
- }
- }
+ $metric = $request->metric();
- /**
- * @param array $filters
- */
- public function exportCategoricalStats(
- CategoricalStatsRequest $request,
- array $filters = [],
- string $export_title = ''
- ) {
- $this->validateCategoricalStatsFilters($request, $filters);
- $filters = array_merge($request->input('filters', []), $filters);
- $metric = $request->input('metric');
- $mode = CategoricalModes::from($request->input('mode'));
- $categorical_values = $request->input('categorical_values', []);
- $range = $request->filled('date_range')
- ? PresetDateRanges::from($request->input('date_range'))
- : new ExactDateRange($request->input('date_from'), $request->input('date_to'));
- $compare_range = null;
+ abort_unless(in_array($metric, CategoricalStats::allowedMetrics(array_merge($request->fixedFilters(), $filters), $request->statsUser()), true), 403);
+
+ $stats = $request->stats();
+ $compare = $request->compareStats();
- if ($request->filled('compare_date_range')) {
- $compare_range = PresetDateRanges::from($request->input('compare_date_range'));
- } elseif ($request->filled('compare_date_from')) {
- $compare_range = new ExactDateRange(
- $request->input('compare_date_from'),
- $request->input('compare_date_to')
- );
- } elseif ($request->input('compare')) {
- $compare_range = $range->getPreviousDateRange();
+ if ($filters) {
+ $stats->setFilters(array_merge($stats->getFilters(), $filters));
+ $compare?->setFilters(array_merge($compare->getFilters(), $filters));
}
- $stats = CategoricalStats::createFromMetric($metric, $range, $filters);
- $compare = $compare_range ? CategoricalStats::createFromMetric($metric, $compare_range, $filters) : null;
- $exporter = new CategoricalStatsExport($stats, $compare, $mode, $categorical_values);
- $document_title = slug_to_title($export_title ?: get_setting('app_name'), '-').' '.
- $exporter->getReportTitle().' '.
- $stats->formattedDateRange('YYYYMMDD', '-').
- ($compare ? ' '.$compare->formattedDateRange('YYYYMMDD', '-') : '');
+ $exporter = new CategoricalStatsExport($stats, $compare, $request->mode(), $request->categoricalValues());
- return $exporter->download($document_title.'.csv');
+ return $exporter->download(ExportFileName::make($exporter->getReportTitle(), $stats, $compare, $export_title));
}
}
diff --git a/src/Concerns/ExportsTimeSeriesStats.php b/src/Concerns/ExportsTimeSeriesStats.php
index 0d2b4b8..b265329 100644
--- a/src/Concerns/ExportsTimeSeriesStats.php
+++ b/src/Concerns/ExportsTimeSeriesStats.php
@@ -1,89 +1,37 @@
validate($request, [
- 'metric' => [
- 'required',
- 'string',
- Rule::in(TimeSeriesStats::allowedMetrics($filters, $request->user())),
- ],
- ]);
- }
- }
-
- /**
- * Export the stat
- *
- * @param TimeSeriesStatsRequest $request
- * @param array $filters
- * @param string $export_title
- * @return \Illuminate\Http\Response
- * @throws ValidationException
+ * @param array $filters extra fixed filters, e.g. the model of a model-specific page
*/
- public function exportStats(TimeSeriesStatsRequest $request, array $filters = [], string $export_title = '')
+ public function exportStats(TimeSeriesStatsRequest $request, array $filters = [], string $export_title = ''): StreamedResponse
{
- $this->validateStatsFilters($request);
+ $metric = $request->metric();
- $metric = $request->input('metric');
- $mode = TimeSeriesModes::from($request->input('mode'));
+ abort_unless(in_array($metric, TimeSeriesStats::allowedMetrics(array_merge($request->fixedFilters(), $filters), $request->statsUser()), true), 403);
- if ($request->filled('date_range')) {
- $range = PresetDateRanges::from($request->input('date_range'));
- } else {
- $range = new ExactDateRange(
- $request->input('date_from'),
- $request->input('date_to'),
- );
- }
-
- $compare_range = null;
-
- if ($request->filled('compare_date_range')) {
- $compare_range = PresetDateRanges::from($request->input('compare_date_range'));
- } elseif ($request->filled('compare_date_from')) {
- $compare_range = new ExactDateRange(
- $request->input('compare_date_from'),
- $request->input('compare_date_to'),
- );
- } elseif ($request->input('compare')) {
- $compare_range = $range->getPreviousDateRange();
- }
+ $stats = $request->stats();
+ $compare = $request->compareStats();
- $stats = TimeSeriesStats::createFromMetric($metric, $range, $filters);
-
- if ($compare_range) {
- $compare = TimeSeriesStats::createFromMetric($metric, $compare_range, $filters);
- } else {
- $compare = null;
+ if ($filters) {
+ $stats->setFilters(array_merge($stats->getFilters(), $filters));
+ $compare?->setFilters(array_merge($compare->getFilters(), $filters));
}
- $exporter = new TimeSeriesStatsExport($mode, $stats, $compare);
-
- $title = $exporter->getReportTitle();
-
- $document_title = slug_to_title($export_title ?: get_setting('app_name'), '-').
- ' '.$title.' '.
- $stats->formattedDateRange('YYYYMMDD', '-').
- ($compare ? ' '.$compare->formattedDateRange('YYYYMMDD', '-') : '');
+ $exporter = new TimeSeriesStatsExport($request->mode(), $stats, $compare);
- return $exporter->download($document_title.'.csv');
+ return $exporter->download(ExportFileName::make($exporter->getReportTitle(), $stats, $compare, $export_title));
}
}
diff --git a/src/Concerns/HasDateRange.php b/src/Concerns/HasDateRange.php
index 63a0a3f..fe081b3 100644
--- a/src/Concerns/HasDateRange.php
+++ b/src/Concerns/HasDateRange.php
@@ -14,7 +14,9 @@
trait HasDateRange
{
protected Carbon $date_from;
+
protected Carbon $date_to;
+
protected DateRange $date_range;
public function getDateFrom(): Carbon
diff --git a/src/Concerns/HasFilters.php b/src/Concerns/HasFilters.php
index 4e9cf85..8857b14 100644
--- a/src/Concerns/HasFilters.php
+++ b/src/Concerns/HasFilters.php
@@ -63,7 +63,7 @@ public function ensureAllFiltersAllowed(array $filters, bool $throw_on_fail = fa
if ($diff->count()) {
throw InvalidFiltersException::filtersNotAllowed($diff, $allowed_filter_names);
}
- } else {
+ } else {
foreach ($filter_names as $filter) {
if (! $allowed_filter_names->contains($filter)) {
return false;
diff --git a/src/Concerns/InvalidatesStats.php b/src/Concerns/InvalidatesStats.php
new file mode 100644
index 0000000..6e407cf
--- /dev/null
+++ b/src/Concerns/InvalidatesStats.php
@@ -0,0 +1,47 @@
+ app(StatsCache::class)->forget($model->statsToInvalidate());
+
+ static::saved($forget);
+ static::deleted($forget);
+
+ if (method_exists(static::class, 'restored')) {
+ static::restored($forget);
+ }
+ }
+
+ /**
+ * The metrics whose cached results this model affects
+ *
+ * @return list
+ */
+ public function statsToInvalidate(): array
+ {
+ return property_exists($this, 'invalidates_stats') ? $this->invalidates_stats : [StatsCache::ALL];
+ }
+}
diff --git a/src/Concerns/IsCategoricalStatsProvider.php b/src/Concerns/IsCategoricalStatsProvider.php
index cd0669b..1bd892e 100644
--- a/src/Concerns/IsCategoricalStatsProvider.php
+++ b/src/Concerns/IsCategoricalStatsProvider.php
@@ -77,7 +77,7 @@ public function searchCategoricalStatsItems(?string $search = null, int $per_pag
} elseif ($this->hasNamedScope('search')) {
$query->search($search);
} else {
- $query->where($this->getCategoricalStatsSearchField(), 'like', '%'.$search.'%');
+ $query->whereLike($this->getCategoricalStatsSearchField(), '%' . $search . '%');
}
}
diff --git a/src/Contracts/HasFields.php b/src/Contracts/HasFields.php
new file mode 100644
index 0000000..0f5893f
--- /dev/null
+++ b/src/Contracts/HasFields.php
@@ -0,0 +1,37 @@
+
+ */
+ public function getFields(): array;
+
+ /**
+ * The query parameters the filter reads
+ *
+ * @return array
+ */
+ public function getQueryKeys(): array;
+
+ /**
+ * The filter's value from the input, or null when it doesn't apply
+ *
+ * @param array $input
+ */
+ public function resolveValue(array $input): mixed;
+}
diff --git a/src/Contracts/InteractsWithDateRange.php b/src/Contracts/InteractsWithDateRange.php
index e9bb492..8442967 100644
--- a/src/Contracts/InteractsWithDateRange.php
+++ b/src/Contracts/InteractsWithDateRange.php
@@ -23,7 +23,6 @@ public function getDateTo(): Carbon;
*/
public function getDateRange(): DateRange;
-
/**
* Get the formatted date from to
*/
@@ -69,7 +68,6 @@ public function interval(TimeSeriesModes $mode): int;
*/
public function applyDateFilters(Builder $query): Builder;
-
/**
* Get the filtered query without date filters
*/
diff --git a/src/Contracts/InteractsWithFilters.php b/src/Contracts/InteractsWithFilters.php
index ac17cb3..36ff79a 100644
--- a/src/Contracts/InteractsWithFilters.php
+++ b/src/Contracts/InteractsWithFilters.php
@@ -1,4 +1,5 @@
resolveCategoryNames($this, $categorical_values);
}
+ public function getLabel(): string
+ {
+ return match ($this) {
+ self::NON_EMPTY => __('Categories with data'),
+ self::ALL => __('All categories'),
+ self::SPECIFIC => __('Specific categories'),
+ };
+ }
+
public function getDescription(): string
{
return match ($this) {
diff --git a/src/Enums/Concerns/HasLabels.php b/src/Enums/Concerns/HasLabels.php
new file mode 100644
index 0000000..f12c7a8
--- /dev/null
+++ b/src/Enums/Concerns/HasLabels.php
@@ -0,0 +1,40 @@
+
+ */
+ public static function getLabels(): array
+ {
+ $labels = [];
+
+ foreach (self::cases() as $case) {
+ $labels[$case->value] = $case->getLabel();
+ }
+
+ return $labels;
+ }
+
+ /**
+ * The cases as a list of options, in order
+ *
+ * @return list
+ */
+ public static function options(): array
+ {
+ return array_map(fn (self $case) => [
+ 'value' => $case->value,
+ 'label' => $case->getLabel(),
+ ], self::cases());
+ }
+}
diff --git a/src/Enums/PresetDateRanges.php b/src/Enums/PresetDateRanges.php
index e8b88cd..910dc42 100644
--- a/src/Enums/PresetDateRanges.php
+++ b/src/Enums/PresetDateRanges.php
@@ -3,15 +3,17 @@
namespace Javaabu\Stats\Enums;
use Carbon\Carbon;
-use Javaabu\Helpers\Enums\IsEnum;
-use Javaabu\Helpers\Enums\NativeEnumsTrait;
use Javaabu\Stats\Contracts\DateRange;
+use Javaabu\Stats\Enums\Concerns\HasLabels;
use Javaabu\Stats\Support\ExactDateRange;
use Javaabu\Stats\TimeSeriesStats;
-enum PresetDateRanges: string implements IsEnum, DateRange
+/**
+ * Date ranges relative to now, which also give the range before them for comparisons
+ */
+enum PresetDateRanges: string implements DateRange
{
- use NativeEnumsTrait;
+ use HasLabels;
case TODAY = 'today';
case YESTERDAY = 'yesterday';
@@ -71,6 +73,26 @@ public function getName(): string
return $this->value;
}
+ public function getLabel(): string
+ {
+ return match ($this) {
+ self::TODAY => __('Today'),
+ self::YESTERDAY => __('Yesterday'),
+ self::THIS_WEEK => __('This week'),
+ self::LAST_WEEK => __('Last week'),
+ self::THIS_MONTH => __('This month'),
+ self::LAST_MONTH => __('Last month'),
+ self::THIS_YEAR => __('This year'),
+ self::LAST_YEAR => __('Last year'),
+ self::LAST_7_DAYS => __('Last 7 days'),
+ self::LAST_14_DAYS => __('Last 14 days'),
+ self::LAST_30_DAYS => __('Last 30 days'),
+ self::LAST_5_YEARS => __('Last 5 years'),
+ self::LAST_10_YEARS => __('Last 10 years'),
+ self::LIFETIME => __('Lifetime'),
+ };
+ }
+
protected function generatePreviousFromDate(): Carbon
{
$from_date = $this->getDateFrom();
diff --git a/src/Enums/TimeSeriesModes.php b/src/Enums/TimeSeriesModes.php
index 4b4caf8..97d7807 100644
--- a/src/Enums/TimeSeriesModes.php
+++ b/src/Enums/TimeSeriesModes.php
@@ -4,13 +4,16 @@
use Carbon\Carbon;
use Illuminate\Support\Str;
-use Javaabu\Helpers\Enums\IsEnum;
-use Javaabu\Helpers\Enums\NativeEnumsTrait;
+use Javaabu\Stats\Enums\Concerns\HasLabels;
+use Javaabu\Stats\Exceptions\StatsException;
use Javaabu\Stats\TimeSeriesStats;
-enum TimeSeriesModes: string implements IsEnum
+/**
+ * How a time series stat is grouped: one value per hour, day, week, month or year
+ */
+enum TimeSeriesModes: string
{
- use NativeEnumsTrait;
+ use HasLabels;
case HOUR = 'hour';
case DAY = 'day';
@@ -18,15 +21,15 @@ enum TimeSeriesModes: string implements IsEnum
case MONTH = 'month';
case YEAR = 'year';
- public static function getLabels(): array
+ public function getLabel(): string
{
- return [
- self::HOUR->value => __('Hourly'),
- self::DAY->value => __('Day'),
- self::WEEK->value => __('Week'),
- self::MONTH->value => __('Month'),
- self::YEAR->value => __('Year'),
- ];
+ return match ($this) {
+ self::HOUR => __('Hour'),
+ self::DAY => __('Day'),
+ self::WEEK => __('Week'),
+ self::MONTH => __('Month'),
+ self::YEAR => __('Year'),
+ };
}
public function diffMethodName(): string
@@ -44,37 +47,105 @@ public function queryMethodName(): string
return Str::camel($this->value);
}
+ /**
+ * The number of steps between the two dates, not counting the first
+ */
public function interval(Carbon $date_from, Carbon $date_to): int
{
$diff_method = $this->diffMethodName();
- return (int) $date_from->{$diff_method}($date_to, true);
+ return (int) $this->startOfStep($date_from)->{$diff_method}($this->startOfStep($date_to), true);
}
+ /**
+ * The display format (moment.js / Carbon isoFormat), from `config('stats.date_formats')`
+ */
public function getDateFormat(): string
{
return config('stats.date_formats.' . $this->value);
}
+ /**
+ * The isoFormat of the keys the database groups by
+ */
public function getInternalDateFormat(): string
{
return match ($this) {
- self::HOUR => 'YYYY-MM-DD HH:mm',
- self::DAY => 'YYYY-MM-DD',
- self::WEEK => 'ggggww',
- self::MONTH => 'YYYY, MM',
+ self::HOUR => 'YYYY-MM-DD HH:00',
+ self::DAY, self::WEEK => 'YYYY-MM-DD',
+ self::MONTH => 'YYYY-MM',
self::YEAR => 'YYYY',
};
}
- public function getSql(string $date_field): string
+ /**
+ * The SQL that turns a date column into the step's key. Weeks are keyed
+ * by the date they start on, so every database groups them the same way.
+ *
+ * @throws StatsException for a database driver that isn't supported
+ */
+ public function getSql(string $date_field, string $driver = 'mysql'): string
{
+ return match ($driver) {
+ 'mysql', 'mariadb' => $this->mysqlSql($date_field),
+ 'pgsql' => $this->pgsqlSql($date_field),
+ 'sqlite' => $this->sqliteSql($date_field),
+ default => throw StatsException::unsupportedDriver($driver),
+ };
+ }
+
+ protected function mysqlSql(string $date_field): string
+ {
+ $week_start = TimeSeriesStats::firstDayOfWeek();
+
return match ($this) {
- self::HOUR => "DATE_FORMAT($date_field, '%Y-%m-%d %H:00')",
- self::DAY => "DATE($date_field)",
- self::WEEK => "YEARWEEK($date_field, " . TimeSeriesStats::weekMode() . ")",
- self::MONTH => "DATE_FORMAT($date_field, '%Y, %m')",
- self::YEAR => "YEAR($date_field)",
+ self::HOUR => "DATE_FORMAT({$date_field}, '%Y-%m-%d %H:00')",
+ self::DAY => "DATE_FORMAT({$date_field}, '%Y-%m-%d')",
+ self::WEEK => "DATE_FORMAT(DATE_SUB({$date_field}, INTERVAL MOD(DAYOFWEEK({$date_field}) - 1 - {$week_start} + 7, 7) DAY), '%Y-%m-%d')",
+ self::MONTH => "DATE_FORMAT({$date_field}, '%Y-%m')",
+ self::YEAR => "DATE_FORMAT({$date_field}, '%Y')",
+ };
+ }
+
+ protected function pgsqlSql(string $date_field): string
+ {
+ $week_start = TimeSeriesStats::firstDayOfWeek();
+
+ return match ($this) {
+ self::HOUR => "to_char({$date_field}, 'YYYY-MM-DD HH24:00')",
+ self::DAY => "to_char({$date_field}, 'YYYY-MM-DD')",
+ self::WEEK => "to_char(CAST({$date_field} AS date) - ((CAST(EXTRACT(DOW FROM {$date_field}) AS integer) - {$week_start} + 7) % 7), 'YYYY-MM-DD')",
+ self::MONTH => "to_char({$date_field}, 'YYYY-MM')",
+ self::YEAR => "to_char({$date_field}, 'YYYY')",
+ };
+ }
+
+ protected function sqliteSql(string $date_field): string
+ {
+ $week_start = TimeSeriesStats::firstDayOfWeek();
+
+ return match ($this) {
+ self::HOUR => "strftime('%Y-%m-%d %H:00', {$date_field})",
+ self::DAY => "strftime('%Y-%m-%d', {$date_field})",
+ self::WEEK => "date({$date_field}, '-' || ((CAST(strftime('%w', {$date_field}) AS INTEGER) - {$week_start} + 7) % 7) || ' days')",
+ self::MONTH => "strftime('%Y-%m', {$date_field})",
+ self::YEAR => "strftime('%Y', {$date_field})",
+ };
+ }
+
+ /**
+ * The start of the step the date is in
+ */
+ public function startOfStep(Carbon $date): Carbon
+ {
+ $date = $date->copy();
+
+ return match ($this) {
+ self::HOUR => $date->startOfHour(),
+ self::DAY => $date->startOfDay(),
+ self::WEEK => $date->startOfWeek(TimeSeriesStats::firstDayOfWeek())->startOfDay(),
+ self::MONTH => $date->startOfMonth(),
+ self::YEAR => $date->startOfYear(),
};
}
@@ -87,6 +158,9 @@ public function increment(Carbon $date): Carbon
return $date->{$method}();
}
+ /**
+ * Format a date for display, or as the key the database groups by
+ */
public function formatDate(Carbon $date, bool $for_display = true): string
{
$date = $date->copy()->locale(TimeSeriesStats::dateLocale());
@@ -95,7 +169,30 @@ public function formatDate(Carbon $date, bool $for_display = true): string
return $date->isoFormat($this->getDateFormat());
}
+ if ($this === self::WEEK) {
+ $date = $this->startOfStep($date);
+ }
+
return $date->isoFormat($this->getInternalDateFormat());
}
+ /**
+ * The step as an ISO 8601 date, for charts with a time axis
+ */
+ public function toIsoDate(Carbon $date): string
+ {
+ $date = $this->startOfStep($date);
+
+ return $this === self::HOUR
+ ? $date->format('Y-m-d\TH:i:s')
+ : $date->format('Y-m-d');
+ }
+
+ /**
+ * The interval name Onu's TimeSeriesChart understands, if any
+ */
+ public function chartInterval(): ?string
+ {
+ return $this === self::YEAR ? null : $this->value;
+ }
}
diff --git a/src/Exceptions/InvalidFiltersException.php b/src/Exceptions/InvalidFiltersException.php
index 86bb083..de5ba26 100644
--- a/src/Exceptions/InvalidFiltersException.php
+++ b/src/Exceptions/InvalidFiltersException.php
@@ -3,8 +3,9 @@
namespace Javaabu\Stats\Exceptions;
use Illuminate\Support\Collection;
+use InvalidArgumentException;
-class InvalidFiltersException extends \InvalidArgumentException
+class InvalidFiltersException extends InvalidArgumentException
{
public Collection $unknown_filters;
diff --git a/src/Exceptions/StatsException.php b/src/Exceptions/StatsException.php
new file mode 100644
index 0000000..576cc5d
--- /dev/null
+++ b/src/Exceptions/StatsException.php
@@ -0,0 +1,28 @@
+ */
- protected array $categorical_values;
-
/**
* @param list $categorical_values
*/
public function __construct(
- CategoricalStatsRepository $stats,
- ?CategoricalStatsRepository $compare = null,
- CategoricalModes $mode = CategoricalModes::NON_EMPTY,
- array $categorical_values = []
+ protected CategoricalStatsRepository $stats,
+ protected ?CategoricalStatsRepository $compare = null,
+ protected CategoricalModes $mode = CategoricalModes::NON_EMPTY,
+ protected array $categorical_values = [],
) {
$this->formatter = new CombinedCategoricalStatsFormatter;
- $this->stats = $stats;
- $this->compare = $compare;
- $this->mode = $mode;
- $this->categorical_values = $categorical_values;
}
public function getReportTitle(): string
@@ -56,53 +35,43 @@ public function formattedDateRange(string $format = 'YYYYMMDD', string $separato
$date_range = $this->stats->formattedDateRange($format, $separator);
if ($compare = $this->compare) {
- $date_range .= ' '.$compare->formattedDateRange($format, $separator);
+ $date_range .= ' ' . $compare->formattedDateRange($format, $separator);
}
return $date_range;
}
- public static function beforeSheet(BeforeSheet $event)
- {
- $sheet = $event->sheet;
- /** @var CategoricalStatsExport $export */
- $export = $event->getConcernable();
-
- $sheet->append([
- ['# '.str_repeat('-', 40)],
- ['# '.$export->getReportTitle()],
- ['# '.$export->formattedDateRange()],
- ['# '.str_repeat('-', 40)],
- [' '],
- ]);
- }
-
- /** @return list> */
+ /**
+ * @return list>
+ */
public function array(): array
{
return $this->formatter->format($this->stats, $this->compare, $this->mode, $this->categorical_values);
}
- /** @return list */
+ /**
+ * @return list
+ */
public function headings(): array
{
$headings = [$this->stats->getCategoryIdTitle(), $this->stats->getCategoryTitle()];
- if ($this->compare) {
- $headings[] = $this->stats->getAggregateFieldLabel().' - '.$this->stats->formattedDateRange('YYYYMMDD', '-');
- $headings[] = $this->stats->getAggregateFieldLabel().' - '.$this->compare->formattedDateRange('YYYYMMDD', '-');
- } else {
+ if (! $this->compare) {
$headings[] = $this->stats->getAggregateFieldLabel();
+
+ return $headings;
}
+ $headings[] = $this->stats->getAggregateFieldLabel() . ' - ' . $this->stats->formattedDateRange('YYYYMMDD', '-');
+ $headings[] = $this->stats->getAggregateFieldLabel() . ' - ' . $this->compare->formattedDateRange('YYYYMMDD', '-');
+
return $headings;
}
/**
- * @param array $row
* @return list
*/
- public function map($row): array
+ public function map(mixed $row): array
{
$values = [
$row[$this->stats->getCategoryFieldAlias()],
@@ -111,25 +80,9 @@ public function map($row): array
];
if ($this->compare) {
- $values[] = $row['compare_'.$this->stats->getAggregateFieldName()];
+ $values[] = $row['compare_' . $this->stats->getAggregateFieldName()];
}
return $values;
}
-
- /** @return array */
- public function columnFormats(): array
- {
- $formats = [
- 'A' => NumberFormat::FORMAT_TEXT,
- 'B' => NumberFormat::FORMAT_TEXT,
- 'C' => NumberFormat::FORMAT_NUMBER,
- ];
-
- if ($this->compare) {
- $formats['D'] = NumberFormat::FORMAT_NUMBER;
- }
-
- return $formats;
- }
}
diff --git a/src/Exports/CsvExport.php b/src/Exports/CsvExport.php
new file mode 100644
index 0000000..d83846c
--- /dev/null
+++ b/src/Exports/CsvExport.php
@@ -0,0 +1,110 @@
+
+ */
+ abstract public function array(): array;
+
+ /**
+ * @return list
+ */
+ abstract public function headings(): array;
+
+ /**
+ * One row of the file
+ *
+ * @return list
+ */
+ abstract public function map(mixed $row): array;
+
+ abstract public function getReportTitle(): string;
+
+ abstract public function formattedDateRange(string $format = 'YYYYMMDD', string $separator = '-'): string;
+
+ /**
+ * The comment lines at the top of the file
+ *
+ * @return list
+ */
+ public function preamble(): array
+ {
+ return [
+ '# ' . str_repeat('-', 40),
+ '# ' . $this->getReportTitle(),
+ '# ' . $this->formattedDateRange(),
+ '# ' . str_repeat('-', 40),
+ ];
+ }
+
+ /**
+ * Every line of the file, as lists of cells
+ *
+ * @return list>
+ */
+ public function lines(): array
+ {
+ $lines = array_map(fn (string $line) => [$line], $this->preamble());
+ $lines[] = [];
+ $lines[] = $this->headings();
+
+ foreach ($this->array() as $row) {
+ $lines[] = $this->map($row);
+ }
+
+ return $lines;
+ }
+
+ /**
+ * The file's contents
+ */
+ public function toCsv(): string
+ {
+ $handle = fopen('php://temp', 'r+');
+
+ $this->write($handle);
+
+ rewind($handle);
+ $csv = stream_get_contents($handle);
+ fclose($handle);
+
+ return $csv;
+ }
+
+ public function download(string $file_name): StreamedResponse
+ {
+ return response()->streamDownload(function () {
+ $handle = fopen('php://output', 'w');
+
+ $this->write($handle);
+
+ fclose($handle);
+ }, $file_name, [
+ 'Content-Type' => 'text/csv; charset=UTF-8',
+ ]);
+ }
+
+ /**
+ * @param resource $handle
+ */
+ protected function write($handle): void
+ {
+ fwrite($handle, "\u{FEFF}");
+
+ foreach ($this->lines() as $line) {
+ fputcsv($handle, $line, escape: '');
+ }
+ }
+}
diff --git a/src/Exports/TimeSeriesStatsExport.php b/src/Exports/TimeSeriesStatsExport.php
index 20e088d..f41d384 100644
--- a/src/Exports/TimeSeriesStatsExport.php
+++ b/src/Exports/TimeSeriesStatsExport.php
@@ -1,72 +1,36 @@
formatter = new CombinedStatsFormatter();
- $this->mode = $mode;
- $this->stats = $stats;
- $this->compare = $compare;
+ public function __construct(
+ protected TimeSeriesModes $mode,
+ protected TimeSeriesStatsRepository $stats,
+ protected ?TimeSeriesStatsRepository $compare = null,
+ ) {
+ $this->formatter = new CombinedStatsFormatter;
}
- /**
- * Get the metric
- */
public function metric(): string
{
return $this->stats->metric();
}
- /**
- * Get the report title
- */
public function getReportTitle(): string
{
return $this->stats->getName();
}
- /**
- * Get the formatted date range
- */
public function formattedDateRange(string $format = 'YYYYMMDD', string $separator = '-'): string
{
$date_range = $this->stats->formattedDateRange($format, $separator);
@@ -79,41 +43,22 @@ public function formattedDateRange(string $format = 'YYYYMMDD', string $separato
}
/**
- * Before sheet event handler
- *
- * @param BeforeSheet $event
+ * @return list>
*/
- public static function beforeSheet(BeforeSheet $event)
- {
- $sheet = $event->sheet;
- /** @var TimeSeriesStatsExport $export */
- $export = $event->getConcernable();
-
- $sheet->append([
- ['# '.str_repeat('-', 40)],
- ['# '.$export->getReportTitle()],
- ['# '.$export->formattedDateRange()],
- ['# '.str_repeat('-', 40)],
- [' '],
- ]);
- }
-
public function array(): array
{
return $this->formatter->format($this->mode, $this->stats, $this->compare);
}
/**
- * @return array
+ * @return list
*/
public function headings(): array
{
- $headings = [
- $this->mode->getLabel(),
- ];
+ $headings = [$this->mode->getLabel()];
if ($this->compare) {
- $headings[] = 'Date Range';
+ $headings[] = __('Date Range');
}
$headings[] = $this->stats->getAggregateFieldLabel();
@@ -122,15 +67,11 @@ public function headings(): array
}
/**
- * @param mixed $row
- *
- * @return array
+ * @return list
*/
- public function map($row): array
+ public function map(mixed $row): array
{
- $values = [];
-
- $values[] = $row[$this->mode->value];
+ $values = [$row[$this->mode->value]];
if ($this->compare) {
$values[] = $row['date_range'];
@@ -140,24 +81,4 @@ public function map($row): array
return $values;
}
-
- /**
- * Format the columns
- *
- * @return array
- */
- public function columnFormats(): array
- {
- $formats = [];
-
- $formats['A'] = NumberFormat::FORMAT_TEXT;
-
- if ($this->compare) {
- $formats['B'] = NumberFormat::FORMAT_TEXT;
- }
-
- $formats[$this->compare ? 'C' : 'B'] = NumberFormat::FORMAT_NUMBER;
-
- return $formats;
- }
}
diff --git a/src/Facades/Stats.php b/src/Facades/Stats.php
new file mode 100644
index 0000000..f096953
--- /dev/null
+++ b/src/Facades/Stats.php
@@ -0,0 +1,30 @@
+callback = $callback;
diff --git a/src/Filters/ModelFilter.php b/src/Filters/ModelFilter.php
new file mode 100644
index 0000000..8171784
--- /dev/null
+++ b/src/Filters/ModelFilter.php
@@ -0,0 +1,81 @@
+options(PaymentStatus::class)),
+ * StatsFilter::from(SelectFilter::make('island_id')->relationship('island', 'name')),
+ * ];
+ * }
+ * ```
+ */
+class ModelFilter implements Filter, HasFields
+{
+ /** Parameters the stats forms use, which a filter can't be named after */
+ public const array RESERVED = [
+ 'metric', 'with', 'mode', 'variant', 'format', 'filters', 'categorical_values',
+ 'date_range', 'date_from', 'date_to',
+ 'compare', 'compare_to', 'compare_date_range', 'compare_date_from', 'compare_date_to',
+ 'page', 'per_page', 'search',
+ ];
+
+ public function __construct(protected FiltersFilter $filter)
+ {
+ foreach ([$filter->getName(), ...$filter->getQueryKeys()] as $name) {
+ if (in_array($name, self::RESERVED, true)) {
+ throw StatsException::reservedFilterName($name);
+ }
+ }
+ }
+
+ public function getName(): string
+ {
+ return $this->filter->getName();
+ }
+
+ public function getFilter(): FiltersFilter
+ {
+ return $this->filter;
+ }
+
+ public function apply(Builder $query, $value, InteractsWithFilters $stat): Builder
+ {
+ $value = $this->filter->normalizeValue($value);
+
+ if ($value !== null) {
+ $this->filter->apply($query, $value);
+ }
+
+ return $query;
+ }
+
+ public function getFields(): array
+ {
+ return $this->filter->getComponents();
+ }
+
+ public function getQueryKeys(): array
+ {
+ return $this->filter->getQueryKeys();
+ }
+
+ public function resolveValue(array $input): mixed
+ {
+ return $this->filter->resolveValue($input);
+ }
+}
diff --git a/src/Filters/ScopeFilter.php b/src/Filters/ScopeFilter.php
index 48f04de..25bd122 100644
--- a/src/Filters/ScopeFilter.php
+++ b/src/Filters/ScopeFilter.php
@@ -12,9 +12,9 @@ public function __construct(string $name, string $internal_name = '')
{
if (! $internal_name) {
$internal_name = Str::of($name)
- ->snake(' ')
- ->camel()
- ->toString();
+ ->snake(' ')
+ ->camel()
+ ->toString();
}
parent::__construct($name, $internal_name);
diff --git a/src/Filters/StatsFilter.php b/src/Filters/StatsFilter.php
index b6d6924..57ad3fc 100644
--- a/src/Filters/StatsFilter.php
+++ b/src/Filters/StatsFilter.php
@@ -2,6 +2,9 @@
namespace Javaabu\Stats\Filters;
+use Closure;
+use Javaabu\Filters\Filter;
+
class StatsFilter
{
public static function scope(string $name, string $internal_name = ''): ScopeFilter
@@ -14,8 +17,16 @@ public static function exact(string $name, string $internal_name = ''): ExactFil
return new ExactFilter($name, $internal_name);
}
- public static function closure(string $name, \Closure $callback): ClosureFilter
+ public static function closure(string $name, Closure $callback): ClosureFilter
{
return new ClosureFilter($name, $callback);
}
+
+ /**
+ * A javaabu/filters filter, with fields in the stats form
+ */
+ public static function from(Filter $filter): ModelFilter
+ {
+ return new ModelFilter($filter);
+ }
}
diff --git a/src/Formatters/Categorical/CombinedCategoricalStatsFormatter.php b/src/Formatters/Categorical/CombinedCategoricalStatsFormatter.php
index bb2533f..5e80285 100644
--- a/src/Formatters/Categorical/CombinedCategoricalStatsFormatter.php
+++ b/src/Formatters/Categorical/CombinedCategoricalStatsFormatter.php
@@ -29,7 +29,7 @@ public function format(
$stats->getCategoryFieldAlias() => $id,
$stats->getCategoryNameFieldAlias() => $label,
$stats->getAggregateFieldName() => $stats_values[$id] ?? 0,
- 'compare_'.$stats->getAggregateFieldName() => $compare ? ($compare_values[$id] ?? 0) : null,
+ 'compare_' . $stats->getAggregateFieldName() => $compare ? ($compare_values[$id] ?? 0) : null,
];
}
diff --git a/src/Formatters/TimeSeries/AbstractTimeSeriesStatsFormatter.php b/src/Formatters/TimeSeries/AbstractTimeSeriesStatsFormatter.php
index 9682116..6aca8c3 100644
--- a/src/Formatters/TimeSeries/AbstractTimeSeriesStatsFormatter.php
+++ b/src/Formatters/TimeSeries/AbstractTimeSeriesStatsFormatter.php
@@ -1,4 +1,5 @@
formatDate($compare_next_date, false);
$comparison = $compare ? $compare->get($compare_date_str) : null;
$compare_data[] = $comparison ? $comparison + 0 : 0;
- $label .= '#'.$mode->formatDate($compare_next_date);
+ $label .= '#' . $mode->formatDate($compare_next_date);
$compare_next_date = $mode->increment($compare_next_date);
}
diff --git a/src/Formatters/TimeSeries/CombinedStatsFormatter.php b/src/Formatters/TimeSeries/CombinedStatsFormatter.php
index c3d8ce5..f87dc92 100644
--- a/src/Formatters/TimeSeries/CombinedStatsFormatter.php
+++ b/src/Formatters/TimeSeries/CombinedStatsFormatter.php
@@ -1,14 +1,13 @@
interval($mode);
- //dd($stats_interval);
+ // dd($stats_interval);
$compare_interval = $compare_repo ? $compare_repo->interval($mode) : 0;
$max_interval = max($stats_interval, $compare_interval);
diff --git a/src/Formatters/TimeSeries/DefaultStatsFormatter.php b/src/Formatters/TimeSeries/DefaultStatsFormatter.php
index 21be7c7..229e9ac 100644
--- a/src/Formatters/TimeSeries/DefaultStatsFormatter.php
+++ b/src/Formatters/TimeSeries/DefaultStatsFormatter.php
@@ -1,4 +1,5 @@
metricField(),
+ Select::make('mode')
+ ->label(__('Categories'))
+ ->options(CategoricalModes::getLabels())
+ ->default(CategoricalStats::defaultMode()->value)
+ ->clearable(false)
+ ->required(),
+ ...$this->dateRangeFields(),
+ CategorySelect::make('categorical_values')
+ ->label(__('Choose categories'))
+ ->ajax($this->categoriesUrl(), 'label', 'id')
+ ->dependsOn('#metric', 'metric')
+ ->columnSpan(['md' => 2])
+ ->visibleWhen('mode', CategoricalModes::SPECIFIC->value)
+ ->required(),
+ $this->withField(),
+ Select::make('variant')
+ ->label(__('Chart'))
+ ->options([
+ 'bar' => __('Bar'),
+ 'horizontal' => __('Horizontal bar'),
+ 'line' => __('Line'),
+ self::PIE => __('Pie'),
+ ])
+ ->default('bar')
+ ->clearable(false),
+ ...$this->filterFields(),
+ ...$this->fixedFilterFields(),
+ ];
+ }
+
+ protected function categoriesUrl(): string
+ {
+ $url = (string) $this->context('categories_url', '');
+ $filters = $this->fixedFilters();
+
+ if ($filters === [] || $url === '') {
+ return $url;
+ }
+
+ return $url . (str_contains($url, '?') ? '&' : '?') . http_build_query(['filters' => $filters]);
+ }
+
+ protected function statFor(string $metric): InteractsWithFilters
+ {
+ return CategoricalStats::createFromMetric($metric, filters: $this->fixedFilters());
+ }
+}
diff --git a/src/Forms/Fields/CategorySelect.php b/src/Forms/Fields/CategorySelect.php
new file mode 100644
index 0000000..e481d69
--- /dev/null
+++ b/src/Forms/Fields/CategorySelect.php
@@ -0,0 +1,45 @@
+searchable()->multiple();
+ }
+
+ /**
+ * Load the options for the value of another field
+ */
+ public function dependsOn(string|Closure $parent_selector, string|Closure $filter_field): static
+ {
+ $this->parent_selector = $parent_selector;
+ $this->filter_field = $filter_field;
+
+ return $this;
+ }
+
+ protected function getFieldProps(): array
+ {
+ return array_filter([
+ ...parent::getFieldProps(),
+ 'parent' => $this->evaluate($this->parent_selector),
+ 'filterField' => $this->evaluate($this->filter_field),
+ ], fn ($value) => $value !== null);
+ }
+}
diff --git a/src/Forms/StatsForm.php b/src/Forms/StatsForm.php
new file mode 100644
index 0000000..3a83c6d
--- /dev/null
+++ b/src/Forms/StatsForm.php
@@ -0,0 +1,154 @@
+method('GET')
+ ->columns(['md' => 2, 'lg' => 4])
+ ->submit(__('Generate'));
+ }
+
+ /**
+ * The stat of a metric, to read its filters
+ */
+ abstract protected function statFor(string $metric): InteractsWithFilters;
+
+ /**
+ * @return array
+ */
+ protected function metricOptions(): array
+ {
+ return (array) $this->context('metrics', []);
+ }
+
+ /**
+ * @return array
+ */
+ protected function fixedFilters(): array
+ {
+ return (array) $this->context('filters', []);
+ }
+
+ protected function metricField(): Select
+ {
+ return Select::make('metric')
+ ->label(__('Stat'))
+ ->options($this->metricOptions())
+ ->searchable()
+ ->clearable(false)
+ ->required();
+ }
+
+ protected function withField(): Select
+ {
+ return Select::make('with')
+ ->label(__('Plot with'))
+ ->help(__('Other stats to show on the same chart.'))
+ ->options($this->metricOptions())
+ ->multiple()
+ ->searchable();
+ }
+
+ /**
+ * @return array
+ */
+ protected function dateRangeFields(): array
+ {
+ return [
+ Select::make('date_range')
+ ->label(__('Date range'))
+ ->options([...PresetDateRanges::getLabels(), TimeSeriesStatsRequest::CUSTOM_RANGE => __('Custom range')])
+ ->clearable(false)
+ ->required(),
+ Select::make('compare_to')
+ ->label(__('Compare to'))
+ ->placeholder(__('No comparison'))
+ ->options([
+ TimeSeriesStatsRequest::COMPARE_PREVIOUS => __('Previous period'),
+ TimeSeriesStatsRequest::COMPARE_CUSTOM => __('Custom period'),
+ ]),
+ Group::make([
+ DatePicker::make('date_from')->label(__('From'))->required(),
+ DatePicker::make('date_to')->label(__('To'))->required(),
+ ])->columns(2)->columnSpan(['md' => 2])->visibleWhen('date_range', TimeSeriesStatsRequest::CUSTOM_RANGE),
+ Group::make([
+ DatePicker::make('compare_date_from')->label(__('Compare from'))->required(),
+ DatePicker::make('compare_date_to')->label(__('Compare to date'))->required(),
+ ])->columns(2)->columnSpan(['md' => 2])->visibleWhen('compare_to', TimeSeriesStatsRequest::COMPARE_CUSTOM),
+ ];
+ }
+
+ /**
+ * The fields of every filter the stats allow, each shown while a stat
+ * that has it is selected
+ *
+ * @return array
+ */
+ protected function filterFields(): array
+ {
+ $fixed = array_keys($this->fixedFilters());
+ $filters = [];
+ $metrics_by_filter = [];
+
+ foreach (array_keys($this->metricOptions()) as $metric) {
+ foreach ($this->statFor($metric)->allowedFilters() as $filter) {
+ if (! $filter instanceof HasFields || in_array($filter->getName(), $fixed, true)) {
+ continue;
+ }
+
+ $filters[$filter->getName()] ??= $filter;
+ $metrics_by_filter[$filter->getName()][] = $metric;
+ }
+ }
+
+ $components = [];
+
+ foreach ($filters as $name => $filter) {
+ $components[] = Group::make($filter->getFields())
+ ->visibleWhen('metric', $metrics_by_filter[$name]);
+ }
+
+ return $components;
+ }
+
+ /**
+ * Hidden `filters[...]` fields for the fixed filters
+ *
+ * @return array
+ */
+ protected function fixedFilterFields(): array
+ {
+ $fields = [];
+
+ foreach ($this->fixedFilters() as $name => $value) {
+ $fields[] = Hidden::make('filters.' . $name)->default($value);
+ }
+
+ return $fields;
+ }
+}
diff --git a/src/Forms/TimeSeriesStatsForm.php b/src/Forms/TimeSeriesStatsForm.php
new file mode 100644
index 0000000..7158f65
--- /dev/null
+++ b/src/Forms/TimeSeriesStatsForm.php
@@ -0,0 +1,54 @@
+with(['metrics' => TimeSeriesStats::getMetricNames([], $user), 'filters' => []])
+ * ->model($request->query())
+ * ->action(route('stats.time-series.index'));
+ * ```
+ */
+class TimeSeriesStatsForm extends StatsForm
+{
+ protected function definition(): array
+ {
+ return [
+ $this->metricField(),
+ Select::make('mode')
+ ->label(__('Group by'))
+ ->options(TimeSeriesModes::getLabels())
+ ->default(TimeSeriesStats::defaultMode()->value)
+ ->clearable(false)
+ ->required(),
+ ...$this->dateRangeFields(),
+ $this->withField()->columnSpan(['md' => 2]),
+ Select::make('variant')
+ ->label(__('Chart'))
+ ->options([
+ TimeSeriesChart::LINE => __('Line'),
+ TimeSeriesChart::AREA => __('Area'),
+ TimeSeriesChart::BAR => __('Bar'),
+ ])
+ ->default(TimeSeriesChart::LINE)
+ ->clearable(false),
+ ...$this->filterFields(),
+ ...$this->fixedFilterFields(),
+ ];
+ }
+
+ protected function statFor(string $metric): InteractsWithFilters
+ {
+ return TimeSeriesStats::createFromMetric($metric, filters: $this->fixedFilters());
+ }
+}
diff --git a/src/Generators/AbstractCategoricalStatGenerator.php b/src/Generators/AbstractCategoricalStatGenerator.php
index 7c611ca..57b2d59 100644
--- a/src/Generators/AbstractCategoricalStatGenerator.php
+++ b/src/Generators/AbstractCategoricalStatGenerator.php
@@ -2,6 +2,7 @@
namespace Javaabu\Stats\Generators;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
abstract class AbstractCategoricalStatGenerator extends AbstractStatGenerator
@@ -98,16 +99,16 @@ public function getCategoryFieldAlias(): string
public function getFullClassName(): string
{
- return '\\App\\Stats\\Categorical\\'.$this->getName();
+ return '\\App\\Stats\\Categorical\\' . $this->getName();
}
protected function qualifyField(string $field): string
{
- return str_contains($field, '.') ? $field : $this->getTable().'.'.$field;
+ return str_contains($field, '.') ? $field : $this->getTable() . '.' . $field;
}
/**
- * @param class-string<\Illuminate\Database\Eloquent\Model> $category_model_class
+ * @param class-string $category_model_class
*/
protected function resolveCategoryFieldAlias(string $category_model_class): string
{
diff --git a/src/Generators/AbstractStatGenerator.php b/src/Generators/AbstractStatGenerator.php
index 099e6be..6910c62 100644
--- a/src/Generators/AbstractStatGenerator.php
+++ b/src/Generators/AbstractStatGenerator.php
@@ -10,9 +10,13 @@
abstract class AbstractStatGenerator
{
protected string $name;
+
protected string $model_class;
+
protected string $table;
+
protected string $metric;
+
protected StubRenderer $renderer;
/**
@@ -59,7 +63,7 @@ public function render(): string
], $template);
}
- public abstract function getStub(): string;
+ abstract public function getStub(): string;
public function getRenderer(): StubRenderer
{
diff --git a/src/Http/Controllers/Api/CategoricalStatsApiController.php b/src/Http/Controllers/Api/CategoricalStatsApiController.php
index eac5fea..067e9aa 100644
--- a/src/Http/Controllers/Api/CategoricalStatsApiController.php
+++ b/src/Http/Controllers/Api/CategoricalStatsApiController.php
@@ -2,87 +2,59 @@
namespace Javaabu\Stats\Http\Controllers\Api;
-use Javaabu\Helpers\Http\Controllers\Controller;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Routing\Controller;
use Javaabu\Stats\CategoricalStats;
-use Javaabu\Stats\Enums\CategoricalModes;
-use Javaabu\Stats\Enums\PresetDateRanges;
+use Javaabu\Stats\Contracts\InteractsWithDateRange;
use Javaabu\Stats\Http\Requests\CategoricalStatsRequest;
-use Javaabu\Stats\Support\ExactDateRange;
+/**
+ * @group Stats
+ */
class CategoricalStatsApiController extends Controller
{
- public function index(CategoricalStatsRequest $request)
+ /**
+ * Categorical stats
+ *
+ * Generate categorical stats for the given metric in the given date range and comparison date range.
+ */
+ public function index(CategoricalStatsRequest $request): JsonResponse
{
- $metric = $request->input('metric');
- $mode = CategoricalModes::from($request->input('mode'));
- $categorical_values = $request->input('categorical_values', []);
- $filters = $request->input('filters', []);
- $range = $request->filled('date_range')
- ? PresetDateRanges::from($request->input('date_range'))
- : new ExactDateRange($request->input('date_from'), $request->input('date_to'));
- $compare_range = null;
- $compare_date_range = 'custom';
+ abort_unless(CategoricalStats::canViewAny($request->statsUser()), 403);
- if ($request->filled('compare_date_range')) {
- $compare_range = PresetDateRanges::from($request->input('compare_date_range'));
- $compare_date_range = $compare_range->value;
- } elseif ($request->filled('compare_date_from')) {
- $compare_range = new ExactDateRange(
- $request->input('compare_date_from'),
- $request->input('compare_date_to')
- );
- } elseif ($request->input('compare')) {
- $compare_date_range = 'previous';
- $compare_range = $range->getPreviousDateRange();
- }
-
- $stats = CategoricalStats::createFromMetric($metric, $range, $filters);
- $compare = $compare_range ? CategoricalStats::createFromMetric($metric, $compare_range, $filters) : null;
+ $mode = $request->mode();
+ $stats = $request->stats();
+ $compare = $request->compareStats();
$format = $request->input('format', 'default');
+
$result = CategoricalStats::createFromFormat($format)
- ->format($stats, $compare, $mode, $categorical_values);
- $metric_name = $stats->getName();
- $aggregate_field = $stats->getAggregateFieldName();
- $aggregate_field_label = $stats->getAggregateFieldLabel();
- $date_range = $stats->getDateRange()->getName();
- $date_from = $stats->getDateFrom()->toDateTimeString();
- $date_to = $stats->getDateTo()->toDateTimeString();
- $date_range_title = __('Primary Period - :date_from - :date_to', [
+ ->format($stats, $compare, $mode, $request->categoricalValues());
+
+ return new JsonResponse([
+ 'metric' => $request->metric(),
+ 'metric_name' => $stats->getName(),
+ 'mode' => $mode,
+ 'aggregate_field' => $stats->getAggregateFieldName(),
+ 'aggregate_field_label' => $stats->getAggregateFieldLabel(),
+ 'date_range_title' => $this->periodTitle('Primary Period - :date_from - :date_to', $stats),
+ 'date_range' => $stats->getDateRange()->getName(),
+ 'date_from' => $stats->getDateFrom()->toDateTimeString(),
+ 'date_to' => $stats->getDateTo()->toDateTimeString(),
+ 'compare_date_range_title' => $compare ? $this->periodTitle('Comparison Period - :date_from - :date_to', $compare) : null,
+ 'compare_date_range' => $compare ? $request->compareDateRangeName() : null,
+ 'compare_date_from' => $compare?->getDateFrom()->toDateTimeString(),
+ 'compare_date_to' => $compare?->getDateTo()->toDateTimeString(),
+ 'format' => $format,
+ 'result' => $result,
+ 'filters' => $stats->getFilters(),
+ ]);
+ }
+
+ protected function periodTitle(string $key, InteractsWithDateRange $stats): string
+ {
+ return __($key, [
'date_from' => $stats->getDateFrom()->isoFormat('MMM D, YYYY'),
'date_to' => $stats->getDateTo()->isoFormat('MMM D, YYYY'),
]);
-
- if ($compare) {
- $compare_date_from = $compare->getDateFrom()->toDateTimeString();
- $compare_date_to = $compare->getDateTo()->toDateTimeString();
- $compare_date_range_title = __('Comparison Period - :date_from - :date_to', [
- 'date_from' => $compare->getDateFrom()->isoFormat('MMM D, YYYY'),
- 'date_to' => $compare->getDateTo()->isoFormat('MMM D, YYYY'),
- ]);
- } else {
- $compare_date_range = null;
- $compare_date_from = null;
- $compare_date_to = null;
- $compare_date_range_title = null;
- }
-
- return response()->json(compact(
- 'metric',
- 'metric_name',
- 'mode',
- 'aggregate_field',
- 'aggregate_field_label',
- 'date_range_title',
- 'date_range',
- 'date_from',
- 'date_to',
- 'compare_date_range_title',
- 'compare_date_range',
- 'compare_date_from',
- 'compare_date_to',
- 'format',
- 'result',
- 'filters'
- ));
}
}
diff --git a/src/Http/Controllers/Api/CategoryProviderApiController.php b/src/Http/Controllers/Api/CategoryProviderApiController.php
index bcee036..623ba41 100644
--- a/src/Http/Controllers/Api/CategoryProviderApiController.php
+++ b/src/Http/Controllers/Api/CategoryProviderApiController.php
@@ -2,36 +2,46 @@
namespace Javaabu\Stats\Http\Controllers\Api;
+use Illuminate\Contracts\Auth\Access\Authorizable;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
-use Javaabu\Helpers\Http\Controllers\Controller;
+use Illuminate\Routing\Controller;
use Javaabu\Stats\CategoricalStats;
+use Javaabu\Stats\Routing\StatsRoutes;
+/**
+ * Searches the categories of a categorical stat, in the shape javaabu/forms'
+ * AJAX selects read: `filter[metric]`, `filter[search]`, `page`, `per_page`
+ */
class CategoryProviderApiController extends Controller
{
- public function index(Request $request)
+ public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'filter' => ['required', 'array'],
'filter.metric' => ['required', 'string'],
'filter.search' => ['nullable', 'string'],
+ 'search' => ['nullable', 'string'],
'page' => ['nullable', 'integer', 'min:1'],
- 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
- 'filters' => ['array'],
+ 'per_page' => ['nullable', 'integer', 'min:-1', 'max:100'],
+ 'filters' => ['nullable', 'array'],
]);
$metric = $validated['filter']['metric'];
$filters = $request->input('filters', []);
+ $user = $request->user(StatsRoutes::guard($request));
abort_unless(
- in_array($metric, CategoricalStats::allowedMetrics($filters, $request->user()), true),
+ in_array($metric, CategoricalStats::allowedMetrics($filters, $user instanceof Authorizable ? $user : null), true),
403,
'Cannot view this categorical stat.'
);
- $search = $validated['filter']['search'] ?? null;
- $per_page = $validated['per_page'] ?? CategoricalStats::categoricalItemsPerPage();
+ $search = $validated['filter']['search'] ?? $validated['search'] ?? null;
+ $per_page = (int) ($validated['per_page'] ?? CategoricalStats::categoricalItemsPerPage());
+ $per_page = $per_page < 1 ? 100 : $per_page;
$provider = CategoricalStats::createFromMetric($metric, filters: $filters)->getCategoryProvider();
- return response()->json($provider->searchCategoricalStatsItems($search, $per_page));
+ return new JsonResponse($provider->searchCategoricalStatsItems($search, $per_page));
}
}
diff --git a/src/Http/Controllers/Api/TimeSeriesStatsApiController.php b/src/Http/Controllers/Api/TimeSeriesStatsApiController.php
index f014f8e..79fbe26 100644
--- a/src/Http/Controllers/Api/TimeSeriesStatsApiController.php
+++ b/src/Http/Controllers/Api/TimeSeriesStatsApiController.php
@@ -1,12 +1,11 @@
-input('metric');
- $mode = TimeSeriesModes::from($request->input('mode'));
- $filters = $request->input('filters', []);
- $metric_name = TimeSeriesStats::getMetricName($metric);
-
- if ($request->filled('date_range')) {
- $range = PresetDateRanges::from($request->input('date_range'));
- } else {
- $range = new ExactDateRange(
- $request->input('date_from'),
- $request->input('date_to'),
- );
- }
-
- $compare_range = null;
- $compare_date_range = 'custom';
-
- if ($request->filled('compare_date_range')) {
- $compare_range = PresetDateRanges::from($request->input('compare_date_range'));
- $compare_date_range = $compare_range->value;
- } elseif ($request->filled('compare_date_from')) {
- $compare_range = new ExactDateRange(
- $request->input('compare_date_from'),
- $request->input('compare_date_to'),
- );
- } elseif ($request->input('compare')) {
- $compare_date_range = 'previous';
- $compare_range = $range->getPreviousDateRange();
- }
-
- $stats = TimeSeriesStats::createFromMetric($metric, $range, $filters);
- $date_range = $stats->getDateRange()->getName();
- $date_from = $stats->getDateFrom()->toDateTimeString();
- $date_to = $stats->getDateTo()->toDateTimeString();
- $aggregate_field = $stats->getAggregateFieldName();
- $aggregate_field_label = $stats->getAggregateFieldLabel();
-
- if ($compare_range) {
- $compare = TimeSeriesStats::createFromMetric($metric, $compare_range, $filters);
- $compare_date_from = $compare->getDateFrom()->toDateTimeString();
- $compare_date_to = $compare->getDateTo()->toDateTimeString();
- } else {
- $compare = null;
- $compare_date_range = null;
- $compare_date_from = null;
- $compare_date_to = null;
- }
+ abort_unless(TimeSeriesStats::canViewAny($request->statsUser()), 403);
+ $mode = $request->mode();
+ $stats = $request->stats();
+ $compare = $request->compareStats();
$format = $request->input('format', 'default');
- $formatter = TimeSeriesStats::createFromFormat($format);
- $result = $formatter->format($mode, $stats, $compare);
- $date_range_title = __('Primary Period - :date_from - :date_to', [
- 'date_from' => $stats->getDateFrom()->isoFormat('MMM D, YYYY'),
- 'date_to' => $stats->getDateTo()->isoFormat('MMM D, YYYY')
+ $result = TimeSeriesStats::createFromFormat($format)->format($mode, $stats, $compare);
+
+ return new JsonResponse([
+ 'metric' => $request->metric(),
+ 'metric_name' => $stats->getName(),
+ 'mode' => $mode,
+ 'aggregate_field' => $stats->getAggregateFieldName(),
+ 'aggregate_field_label' => $stats->getAggregateFieldLabel(),
+ 'date_range_title' => $this->periodTitle('Primary Period - :date_from - :date_to', $stats),
+ 'date_range' => $stats->getDateRange()->getName(),
+ 'date_from' => $stats->getDateFrom()->toDateTimeString(),
+ 'date_to' => $stats->getDateTo()->toDateTimeString(),
+ 'compare_date_range_title' => $compare ? $this->periodTitle('Comparison Period - :date_from - :date_to', $compare) : null,
+ 'compare_date_range' => $compare ? $request->compareDateRangeName() : null,
+ 'compare_date_from' => $compare?->getDateFrom()->toDateTimeString(),
+ 'compare_date_to' => $compare?->getDateTo()->toDateTimeString(),
+ 'format' => $format,
+ 'result' => $result,
+ 'filters' => $stats->getFilters(),
]);
+ }
- $compare_date_range_title = null;
-
- if ($compare) {
- $compare_date_range_title = __('Comparison Period - :date_from - :date_to', [
- 'date_from' => $compare->getDateFrom()->isoFormat('MMM D, YYYY'),
- 'date_to' => $compare->getDateTo()->isoFormat('MMM D, YYYY')
- ]);
- }
-
-
- return response()->json(
- compact(
- 'metric',
- 'metric_name',
- 'mode',
- 'aggregate_field',
- 'aggregate_field_label',
- 'date_range_title',
- 'date_range',
- 'date_from',
- 'date_to',
- 'compare_date_range_title',
- 'compare_date_range',
- 'compare_date_from',
- 'compare_date_to',
- 'format',
- 'result',
- 'filters'
- )
- );
+ protected function periodTitle(string $key, InteractsWithDateRange $stats): string
+ {
+ return __($key, [
+ 'date_from' => $stats->getDateFrom()->isoFormat('MMM D, YYYY'),
+ 'date_to' => $stats->getDateTo()->isoFormat('MMM D, YYYY'),
+ ]);
}
}
diff --git a/src/Http/Controllers/CategoricalStatsController.php b/src/Http/Controllers/CategoricalStatsController.php
index bde3de6..90016d2 100644
--- a/src/Http/Controllers/CategoricalStatsController.php
+++ b/src/Http/Controllers/CategoricalStatsController.php
@@ -2,21 +2,139 @@
namespace Javaabu\Stats\Http\Controllers;
-use Javaabu\Helpers\Http\Controllers\Controller;
+use Illuminate\Contracts\Support\Responsable;
+use Illuminate\Contracts\View\View;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Controller;
+use Javaabu\Stats\CategoricalStats;
+use Javaabu\Stats\Charts\CategoryChart;
+use Javaabu\Stats\Charts\Chart;
+use Javaabu\Stats\Charts\PieChart;
use Javaabu\Stats\Concerns\ExportsCategoricalStats;
+use Javaabu\Stats\Enums\CategoricalModes;
+use Javaabu\Stats\Forms\CategoricalStatsForm;
+use Javaabu\Stats\Http\Controllers\Concerns\RendersStatsPage;
use Javaabu\Stats\Http\Requests\CategoricalStatsRequest;
+use Javaabu\Stats\Routing\StatsRoutes;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+/**
+ * The categorical stats page: the form, the chart and a CSV download. Extend
+ * it and set `config('stats.controllers.categorical')` to change what it does.
+ */
class CategoricalStatsController extends Controller
{
use ExportsCategoricalStats;
+ use RendersStatsPage;
- public function index()
+ /**
+ * Fixed filters for every stat on the page, e.g. for a model-specific page
+ *
+ * @return array
+ */
+ protected function filters(Request $request): array
{
- return view(config('stats.categorical_stats_view'));
+ return [];
}
- public function export(CategoricalStatsRequest $request)
+ public function index(Request $request): View|Responsable
{
- return $this->exportCategoricalStats($request);
+ $user = $this->statsUser($request);
+ $filters = array_merge((array) $request->query('filters', []), $this->filters($request));
+ $metrics = CategoricalStats::getMetricNames($filters, $user);
+
+ abort_if($metrics === [], 403);
+
+ [$stats_request, $validator] = $this->pageRequest($request, CategoricalStatsRequest::class, [
+ 'metric' => array_key_first($metrics),
+ 'mode' => CategoricalStats::defaultMode()->value,
+ 'date_range' => CategoricalStats::defaultDateRange()->value,
+ 'variant' => 'bar',
+ 'filters' => $filters ?: null,
+ ]);
+
+ $chart = null;
+
+ if (! $validator->fails()) {
+ $chart = $this->chart($stats_request);
+ } else {
+ $this->shareErrors($validator);
+ }
+
+ $form = CategoricalStatsForm::make()
+ ->with([
+ 'metrics' => $metrics,
+ 'filters' => $filters,
+ 'categories_url' => route(StatsRoutes::namePrefix($request) . '.categorical.categories'),
+ ])
+ ->model($stats_request->all())
+ ->action($request->url());
+
+ return $this->views()->render('categorical.index', $this->viewData($request, $form, $chart, $stats_request, $metrics));
+ }
+
+ /**
+ * The chart for a valid request: a pie for one stat without a comparison
+ * when asked for, otherwise a category chart
+ */
+ protected function chart(CategoricalStatsRequest $request): Chart
+ {
+ $stats = $request->stats();
+ $variant = (string) $request->input('variant', 'bar');
+ $compare = $request->compareStats();
+
+ if ($variant === CategoricalStatsForm::PIE && ! $compare && $request->otherMetrics() === []) {
+ $chart = PieChart::make()->stat($request->metric(), filters: $stats->getFilters())->donut();
+ } else {
+ $chart = CategoryChart::make()
+ ->stat($request->metric(), filters: $stats->getFilters())
+ ->variant($variant === 'horizontal' || $variant === CategoricalStatsForm::PIE ? CategoryChart::BAR : $variant)
+ ->horizontal($variant === 'horizontal');
+
+ foreach ($request->otherMetrics() as $metric) {
+ $chart->stat($metric, filters: $request->stats($metric)->getFilters());
+ }
+
+ if ($compare) {
+ $chart->compare($compare->getDateRange());
+ }
+ }
+
+ $chart->range($stats->getDateRange())
+ ->categoryMode($request->mode())
+ ->title($stats->getName())
+ ->cache((bool) config('stats.cache.pages', false));
+
+ if ($request->mode() === CategoricalModes::SPECIFIC) {
+ $chart->categories($request->categoricalValues());
+ }
+
+ return $chart;
+ }
+
+ /**
+ * @param array $metrics
+ * @return array
+ */
+ protected function viewData(Request $request, CategoricalStatsForm $form, ?Chart $chart, CategoricalStatsRequest $stats_request, array $metrics): array
+ {
+ $inertia = $this->views()->isInertia();
+
+ return [
+ 'form' => $inertia ? $form->render('json') : $form,
+ 'chart' => $chart?->toArray(),
+ 'export' => $chart ? [
+ 'url' => route(StatsRoutes::namePrefix($request) . '.categorical.export'),
+ 'params' => $stats_request->all(),
+ ] : null,
+ 'strings' => $this->pageStrings(__('Categorical Stats')),
+ 'layout' => config('stats.layout', 'layouts.app'),
+ 'layout_section' => config('stats.layout_section', 'content'),
+ ];
+ }
+
+ public function export(CategoricalStatsRequest $request): StreamedResponse
+ {
+ return $this->exportCategoricalStats($request, $this->filters($request));
}
}
diff --git a/src/Http/Controllers/Concerns/RendersStatsPage.php b/src/Http/Controllers/Concerns/RendersStatsPage.php
new file mode 100644
index 0000000..0c6ee7d
--- /dev/null
+++ b/src/Http/Controllers/Concerns/RendersStatsPage.php
@@ -0,0 +1,103 @@
+user(StatsRoutes::guard($request));
+
+ return $user instanceof Authorizable ? $user : null;
+ }
+
+ /**
+ * The page's request, filled from the query string with defaults, and
+ * its validator
+ *
+ * @template TRequest of FormRequest
+ *
+ * @param class-string $class
+ * @param array $defaults
+ * @return array{0: TRequest, 1: ValidatorContract}
+ */
+ protected function pageRequest(Request $request, string $class, array $defaults): array
+ {
+ $input = array_merge($defaults, array_filter($request->query(), fn ($value) => $value !== null && $value !== ''));
+
+ /** @var TRequest $stats_request */
+ $stats_request = $class::createFrom($request);
+ $stats_request->replace($input);
+ $stats_request->setContainer(app())->setRedirector(app('redirect'));
+ $stats_request->setUserResolver($request->getUserResolver());
+ $stats_request->setRouteResolver($request->getRouteResolver());
+
+ $validator = validator($input, $stats_request->rules(), [], $this->attributeNames());
+
+ return [$stats_request, $validator];
+ }
+
+ /**
+ * Share the validator's errors with the views, as a failed form request would
+ */
+ protected function shareErrors(ValidatorContract $validator): MessageBag
+ {
+ $errors = $validator->errors();
+
+ view()->share('errors', (new ViewErrorBag)->put('default', $errors));
+
+ return $errors;
+ }
+
+ /**
+ * @return array
+ */
+ protected function attributeNames(): array
+ {
+ return [
+ 'metric' => __('Stat'),
+ 'mode' => __('Group by'),
+ 'date_range' => __('Date range'),
+ 'date_from' => __('From'),
+ 'date_to' => __('To'),
+ 'compare_to' => __('Compare to'),
+ 'compare_date_from' => __('Compare from'),
+ 'compare_date_to' => __('Compare to date'),
+ 'categorical_values' => __('Choose categories'),
+ 'with' => __('Plot with'),
+ ];
+ }
+
+ /**
+ * Strings the pages show, translated
+ *
+ * @return array
+ */
+ protected function pageStrings(string $title): array
+ {
+ return [
+ 'title' => $title,
+ 'export' => __('Download CSV'),
+ 'empty' => __('Choose a stat and a date range, then select Generate.'),
+ 'invalid' => __('Fix the errors in the form to see the stat.'),
+ ];
+ }
+}
diff --git a/src/Http/Controllers/TimeSeriesStatsController.php b/src/Http/Controllers/TimeSeriesStatsController.php
index 30e038c..282b35a 100644
--- a/src/Http/Controllers/TimeSeriesStatsController.php
+++ b/src/Http/Controllers/TimeSeriesStatsController.php
@@ -2,27 +2,121 @@
namespace Javaabu\Stats\Http\Controllers;
-use Javaabu\Helpers\Http\Controllers\Controller;
+use Illuminate\Contracts\Support\Responsable;
+use Illuminate\Contracts\View\View;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Controller;
+use Javaabu\Stats\Charts\TimeSeriesChart;
use Javaabu\Stats\Concerns\ExportsTimeSeriesStats;
+use Javaabu\Stats\Forms\TimeSeriesStatsForm;
+use Javaabu\Stats\Http\Controllers\Concerns\RendersStatsPage;
use Javaabu\Stats\Http\Requests\TimeSeriesStatsRequest;
+use Javaabu\Stats\Routing\StatsRoutes;
+use Javaabu\Stats\TimeSeriesStats;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+/**
+ * The time series stats page: the form, the chart and a CSV download. Extend
+ * it and set `config('stats.controllers.time_series')` (or pass `controllers`
+ * to Route::stats()) to change what it does.
+ */
class TimeSeriesStatsController extends Controller
{
use ExportsTimeSeriesStats;
+ use RendersStatsPage;
/**
- * Display the stats form
+ * Fixed filters for every stat on the page, e.g. for a model-specific page
+ *
+ * @return array
*/
- public function index()
+ protected function filters(Request $request): array
{
- return view(config('stats.time_series_stats_view'));
+ return [];
+ }
+
+ public function index(Request $request): View|Responsable
+ {
+ $user = $this->statsUser($request);
+ $filters = array_merge((array) $request->query('filters', []), $this->filters($request));
+ $metrics = TimeSeriesStats::getMetricNames($filters, $user);
+
+ abort_if($metrics === [], 403);
+
+ [$stats_request, $validator] = $this->pageRequest($request, TimeSeriesStatsRequest::class, [
+ 'metric' => array_key_first($metrics),
+ 'mode' => TimeSeriesStats::defaultMode()->value,
+ 'date_range' => TimeSeriesStats::defaultDateRange()->value,
+ 'variant' => TimeSeriesChart::LINE,
+ 'filters' => $filters ?: null,
+ ]);
+
+ $chart = null;
+
+ if (! $validator->fails()) {
+ $chart = $this->chart($stats_request);
+ } else {
+ $this->shareErrors($validator);
+ }
+
+ $form = TimeSeriesStatsForm::make()
+ ->with(['metrics' => $metrics, 'filters' => $filters])
+ ->model($stats_request->all())
+ ->action($request->url());
+
+ return $this->views()->render('time-series.index', $this->viewData($request, $form, $chart, $stats_request, $metrics));
}
/**
- * Export the stats
+ * The chart for a valid request
*/
- public function export(TimeSeriesStatsRequest $request)
+ protected function chart(TimeSeriesStatsRequest $request): TimeSeriesChart
+ {
+ $stats = $request->stats();
+ $chart = TimeSeriesChart::make()
+ ->stat($request->metric(), filters: $stats->getFilters())
+ ->mode($request->mode())
+ ->range($stats->getDateRange())
+ ->variant((string) $request->input('variant', TimeSeriesChart::LINE))
+ ->title($stats->getName())
+ ->cache((bool) config('stats.cache.pages', false));
+
+ foreach ($request->otherMetrics() as $metric) {
+ $chart->stat($metric, filters: $request->stats($metric)->getFilters());
+ }
+
+ if ($compare = $request->compareStats()) {
+ $chart->compare($compare->getDateRange());
+ }
+
+ return $chart;
+ }
+
+ /**
+ * What the Blade view and the Inertia page get
+ *
+ * @param array $metrics
+ * @return array
+ */
+ protected function viewData(Request $request, TimeSeriesStatsForm $form, ?TimeSeriesChart $chart, TimeSeriesStatsRequest $stats_request, array $metrics): array
+ {
+ $inertia = $this->views()->isInertia();
+
+ return [
+ 'form' => $inertia ? $form->render('json') : $form,
+ 'chart' => $chart?->toArray(),
+ 'export' => $chart ? [
+ 'url' => route(StatsRoutes::namePrefix($request) . '.time-series.export'),
+ 'params' => $stats_request->all(),
+ ] : null,
+ 'strings' => $this->pageStrings(__('Time Series Stats')),
+ 'layout' => config('stats.layout', 'layouts.app'),
+ 'layout_section' => config('stats.layout_section', 'content'),
+ ];
+ }
+
+ public function export(TimeSeriesStatsRequest $request): StreamedResponse
{
- return $this->exportStats($request);
+ return $this->exportStats($request, $this->filters($request));
}
}
diff --git a/src/Http/Middleware/AbortIfCannotViewAnyTimeSeriesStats.php b/src/Http/Middleware/AbortIfCannotViewAnyTimeSeriesStats.php
index 2156bdb..3794a31 100644
--- a/src/Http/Middleware/AbortIfCannotViewAnyTimeSeriesStats.php
+++ b/src/Http/Middleware/AbortIfCannotViewAnyTimeSeriesStats.php
@@ -4,6 +4,9 @@
use Closure;
use Illuminate\Contracts\Auth\Access\Authorizable;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Javaabu\Stats\TimeSeriesStats;
@@ -12,9 +15,8 @@ class AbortIfCannotViewAnyTimeSeriesStats
/**
* Handle an incoming request.
*
- * @param \Illuminate\Http\Request $request
- * @param \Closure $next
- * @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse|null
+ * @param Request $request
+ * @return Response|RedirectResponse|null
*/
public function handle($request, Closure $next, ...$guards)
{
diff --git a/src/Http/Requests/CategoricalStatsRequest.php b/src/Http/Requests/CategoricalStatsRequest.php
index 95b0a2c..0154781 100644
--- a/src/Http/Requests/CategoricalStatsRequest.php
+++ b/src/Http/Requests/CategoricalStatsRequest.php
@@ -5,94 +5,98 @@
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Javaabu\Stats\CategoricalStats;
+use Javaabu\Stats\Contracts\CategoricalStatsRepository;
use Javaabu\Stats\Enums\CategoricalModes;
-use Javaabu\Stats\Enums\PresetDateRanges;
+use Javaabu\Stats\Http\Requests\Concerns\ResolvesStats;
+/**
+ * A categorical stat for a metric, category mode and date range, optionally
+ * compared with another period. See {@see ResolvesStats} for the date parameters.
+ */
class CategoricalStatsRequest extends FormRequest
{
+ use ResolvesStats;
+
public function authorize(): bool
{
return true;
}
- /** @return array> */
+ /**
+ * @return array
+ */
public function rules(): array
{
- $filters = $this->input('filters', []);
- $user = $this->user();
+ $metrics = CategoricalStats::allowedMetrics($this->fixedFilters(), $this->statsUser());
return [
- 'metric' => [
- 'required',
- 'string',
- Rule::in(CategoricalStats::allowedMetrics($filters, $user)),
- ],
- 'mode' => [
- 'required',
- 'string',
- Rule::enum(CategoricalModes::class),
- ],
- 'categorical_values' => [
- 'required_if:mode,'.CategoricalModes::SPECIFIC->value,
- 'array',
- 'min:1',
- ],
- 'categorical_values.*' => [
- 'required',
- ],
- 'date_range' => [
- 'string',
- Rule::enum(PresetDateRanges::class),
- ],
- 'date_from' => [
- 'nullable',
- 'string',
- 'date',
- 'required_without:date_range',
- ],
- 'date_to' => [
- 'nullable',
- 'string',
- 'date',
- 'required_without:date_range',
- ],
- 'compare_date_range' => [
- 'string',
- Rule::enum(PresetDateRanges::class),
- ],
- 'compare_date_from' => [
- 'nullable',
- 'string',
- 'date',
- 'required_with:compare_date_to',
- ],
- 'compare_date_to' => [
- 'nullable',
- 'string',
- 'date',
- 'required_with:compare_date_from',
- ],
- 'compare' => [
- 'nullable',
- 'boolean',
- ],
- 'format' => [
- 'string',
- Rule::in(CategoricalStats::allowedFormats()),
- ],
- 'filters' => [
- 'array',
- ],
+ 'metric' => ['required', 'string', Rule::in($metrics)],
+ 'with' => ['nullable', 'array'],
+ 'with.*' => ['string', Rule::in($metrics)],
+ 'mode' => ['required', 'string', Rule::enum(CategoricalModes::class)],
+ 'categorical_values' => ['required_if:mode,' . CategoricalModes::SPECIFIC->value, 'array', 'min:1'],
+ 'categorical_values.*' => ['required'],
+ 'format' => ['nullable', 'string', Rule::in(CategoricalStats::allowedFormats())],
+ ...$this->dateRangeRules(),
];
}
- /** @return array> */
+ public function metric(): string
+ {
+ return (string) $this->input('metric');
+ }
+
+ /**
+ * Other metrics to plot with the main one
+ *
+ * @return list
+ */
+ public function otherMetrics(): array
+ {
+ return array_values(array_diff(array_unique((array) $this->input('with', [])), [$this->metric()]));
+ }
+
+ public function mode(): CategoricalModes
+ {
+ return CategoricalModes::tryFrom((string) $this->input('mode')) ?? CategoricalStats::defaultMode();
+ }
+
+ /**
+ * @return list
+ */
+ public function categoricalValues(): array
+ {
+ return array_values((array) $this->input('categorical_values', []));
+ }
+
+ public function stats(?string $metric = null): CategoricalStatsRepository
+ {
+ return $this->makeStat($metric ?? $this->metric(), $this->dateRange());
+ }
+
+ public function compareStats(?string $metric = null): ?CategoricalStatsRepository
+ {
+ $range = $this->compareDateRange();
+
+ return $range ? $this->makeStat($metric ?? $this->metric(), $range) : null;
+ }
+
+ protected function makeStat(string $metric, $range): CategoricalStatsRepository
+ {
+ $filters = $this->filtersFor(CategoricalStats::createFromMetric($metric, filters: $this->fixedFilters()));
+
+ return CategoricalStats::createFromMetric($metric, $range, $filters);
+ }
+
+ /**
+ * @return array>
+ */
public function bodyParameters(): array
{
return [
'metric' => [
'description' => 'The registered metric. Availability depends on user permissions and requested filters.',
- 'example' => CategoricalStats::allowedMetrics([], $this->user())[0] ?? '',
+ 'example' => CategoricalStats::allowedMetrics([], $this->statsUser())[0] ?? '',
],
'mode' => [
'description' => 'Categories to include: non_empty, all, or specific.',
@@ -102,27 +106,30 @@ public function bodyParameters(): array
'description' => 'Category ids. Required when mode is specific.',
],
'date_range' => [
- 'description' => 'Preset date range used to filter results.',
- 'example' => PresetDateRanges::THIS_YEAR->value,
+ 'description' => 'Preset date range used to filter results, or `custom` to use date_from and date_to.',
+ 'example' => 'this_year',
],
'date_from' => [
- 'description' => 'Custom starting date when not using a preset date range.',
+ 'description' => 'Start of a custom date range.',
],
'date_to' => [
- 'description' => 'Custom ending date when not using a preset date range.',
+ 'description' => 'End of a custom date range. A date without a time includes the whole day.',
],
'compare' => [
'description' => 'Whether to compare with the previous period.',
'example' => true,
],
+ 'compare_to' => [
+ 'description' => '`previous` for the previous period, or `custom` for compare_date_from and compare_date_to.',
+ ],
'compare_date_range' => [
'description' => 'Preset comparison date range.',
],
'compare_date_from' => [
- 'description' => 'Custom comparison starting date.',
+ 'description' => 'Start of a custom comparison period.',
],
'compare_date_to' => [
- 'description' => 'Custom comparison ending date.',
+ 'description' => 'End of a custom comparison period.',
],
'format' => [
'description' => 'Registered output formatter.',
diff --git a/src/Http/Requests/Concerns/ResolvesStats.php b/src/Http/Requests/Concerns/ResolvesStats.php
new file mode 100644
index 0000000..b39f82c
--- /dev/null
+++ b/src/Http/Requests/Concerns/ResolvesStats.php
@@ -0,0 +1,177 @@
+user(StatsRoutes::guard($this));
+
+ return $user instanceof Authorizable ? $user : null;
+ }
+
+ /**
+ * The fixed filters from `filters[...]`
+ *
+ * @return array
+ */
+ public function fixedFilters(): array
+ {
+ $filters = $this->input('filters', []);
+
+ return is_array($filters) ? $filters : [];
+ }
+
+ /**
+ * The fixed filters plus the values of the stat's filters that have fields
+ *
+ * @return array
+ */
+ public function filtersFor(InteractsWithFilters $stat): array
+ {
+ $filters = $this->fixedFilters();
+ $input = $this->query();
+
+ foreach ($stat->allowedFilters() as $filter) {
+ if (! $filter instanceof HasFields || array_key_exists($filter->getName(), $filters)) {
+ continue;
+ }
+
+ $value = $filter->resolveValue($input);
+
+ if ($value !== null) {
+ $filters[$filter->getName()] = $value;
+ }
+ }
+
+ return $filters;
+ }
+
+ public function hasCustomDateRange(): bool
+ {
+ $date_range = $this->input('date_range');
+
+ return blank($date_range) || $date_range === self::CUSTOM_RANGE;
+ }
+
+ public function dateRange(?DateRange $default = null): DateRange
+ {
+ if (! $this->hasCustomDateRange()) {
+ return PresetDateRanges::from($this->input('date_range'));
+ }
+
+ if ($this->filled('date_from') && $this->filled('date_to')) {
+ return new ExactDateRange($this->dateInput('date_from'), $this->dateInput('date_to', end_of_day: true));
+ }
+
+ return $default ?? TimeSeriesStats::defaultDateRange();
+ }
+
+ /**
+ * A date from the input. A date without a time covers the whole day.
+ */
+ protected function dateInput(string $key, bool $end_of_day = false): Carbon
+ {
+ $value = (string) $this->input($key);
+ $date = Carbon::parse($value);
+
+ if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', trim($value))) {
+ return $date;
+ }
+
+ return $end_of_day ? $date->endOfDay() : $date->startOfDay();
+ }
+
+ /**
+ * What the comparison is: a preset's name, `previous`, `custom`, or null for none
+ */
+ public function compareDateRangeName(): ?string
+ {
+ if ($this->filled('compare_date_range')) {
+ return $this->input('compare_date_range');
+ }
+
+ $compare_to = $this->input('compare_to');
+
+ if ($compare_to === self::COMPARE_CUSTOM || (blank($compare_to) && $this->filled('compare_date_from'))) {
+ return $this->filled('compare_date_from') && $this->filled('compare_date_to') ? self::COMPARE_CUSTOM : null;
+ }
+
+ if ($compare_to === self::COMPARE_PREVIOUS || $this->boolean('compare')) {
+ return self::COMPARE_PREVIOUS;
+ }
+
+ return null;
+ }
+
+ public function compareDateRange(?DateRange $date_range = null): ?DateRange
+ {
+ return match ($this->compareDateRangeName()) {
+ null => null,
+ self::COMPARE_PREVIOUS => ($date_range ?? $this->dateRange())->getPreviousDateRange(),
+ self::COMPARE_CUSTOM => new ExactDateRange(
+ $this->dateInput('compare_date_from'),
+ $this->dateInput('compare_date_to', end_of_day: true),
+ ),
+ default => PresetDateRanges::from($this->input('compare_date_range')),
+ };
+ }
+
+ /**
+ * Rules for the date range and comparison parameters
+ *
+ * @return array>
+ */
+ protected function dateRangeRules(): array
+ {
+ $presets = array_map(fn (PresetDateRanges $range) => $range->value, PresetDateRanges::cases());
+
+ return [
+ 'date_range' => ['nullable', 'string', 'in:' . implode(',', [...$presets, self::CUSTOM_RANGE])],
+ 'date_from' => ['nullable', 'date', 'required_without:date_range', 'required_if:date_range,' . self::CUSTOM_RANGE],
+ 'date_to' => ['nullable', 'date', 'required_without:date_range', 'required_if:date_range,' . self::CUSTOM_RANGE, 'after_or_equal:date_from'],
+ 'compare_to' => ['nullable', 'string', 'in:' . self::COMPARE_PREVIOUS . ',' . self::COMPARE_CUSTOM],
+ 'compare' => ['nullable', 'boolean'],
+ 'compare_date_range' => ['nullable', 'string', 'in:' . implode(',', $presets)],
+ 'compare_date_from' => ['nullable', 'date', 'required_with:compare_date_to', 'required_if:compare_to,' . self::COMPARE_CUSTOM],
+ 'compare_date_to' => ['nullable', 'date', 'required_with:compare_date_from', 'required_if:compare_to,' . self::COMPARE_CUSTOM, 'after_or_equal:compare_date_from'],
+ 'filters' => ['nullable', 'array'],
+ ];
+ }
+}
diff --git a/src/Http/Requests/TimeSeriesStatsRequest.php b/src/Http/Requests/TimeSeriesStatsRequest.php
index f65f839..b82c460 100644
--- a/src/Http/Requests/TimeSeriesStatsRequest.php
+++ b/src/Http/Requests/TimeSeriesStatsRequest.php
@@ -4,147 +4,130 @@
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
-use Javaabu\Stats\Enums\PresetDateRanges;
+use Javaabu\Stats\Contracts\TimeSeriesStatsRepository;
use Javaabu\Stats\Enums\TimeSeriesModes;
+use Javaabu\Stats\Http\Requests\Concerns\ResolvesStats;
use Javaabu\Stats\TimeSeriesStats;
+/**
+ * A time series stat for a metric, mode and date range, optionally compared
+ * with another period. See {@see ResolvesStats} for the date parameters.
+ */
class TimeSeriesStatsRequest extends FormRequest
{
- /**
- * Determine if the user is authorized to make this request.
- *
- * @return bool
- */
- public function authorize()
+ use ResolvesStats;
+
+ public function authorize(): bool
{
return true;
}
/**
- * Get the validation rules that apply to the request.
- *
- * @return array
+ * @return array
*/
- public function rules()
+ public function rules(): array
{
- $user = request()->user();
- $filters = $this->input('filters', []);
+ $metrics = TimeSeriesStats::allowedMetrics($this->fixedFilters(), $this->statsUser());
- $rules = [
- 'mode' => [
- 'required',
- 'string',
- Rule::enum(TimeSeriesModes::class),
- ],
-
- 'metric' => [
- 'required',
- 'string',
- Rule::in(TimeSeriesStats::allowedMetrics($filters, $user)),
- ],
-
- 'date_range' => [
- 'string',
- Rule::enum(PresetDateRanges::class),
- ],
-
- 'date_from' => [
- 'nullable',
- 'string',
- 'date',
- 'required_without:date_range',
- ],
+ return [
+ 'metric' => ['required', 'string', Rule::in($metrics)],
+ 'with' => ['nullable', 'array'],
+ 'with.*' => ['string', Rule::in($metrics)],
+ 'mode' => ['required', 'string', Rule::enum(TimeSeriesModes::class)],
+ 'format' => ['nullable', 'string', Rule::in(TimeSeriesStats::allowedFormats())],
+ ...$this->dateRangeRules(),
+ ];
+ }
- 'date_to' => [
- 'nullable',
- 'string',
- 'date',
- 'required_without:date_range',
- ],
+ public function metric(): string
+ {
+ return (string) $this->input('metric');
+ }
- 'compare_date_range' => [
- 'string',
- Rule::enum(PresetDateRanges::class),
- ],
+ /**
+ * Other metrics to plot with the main one
+ *
+ * @return list
+ */
+ public function otherMetrics(): array
+ {
+ return array_values(array_diff(array_unique((array) $this->input('with', [])), [$this->metric()]));
+ }
- 'compare_date_from' => [
- 'nullable',
- 'string',
- 'date',
- 'required_with:compare_date_to'
- ],
+ public function mode(): TimeSeriesModes
+ {
+ return TimeSeriesModes::tryFrom((string) $this->input('mode')) ?? TimeSeriesStats::defaultMode();
+ }
- 'compare_date_to' => [
- 'nullable',
- 'string',
- 'date',
- 'required_with:compare_date_from',
- ],
+ /**
+ * The stat for the date range, with its filters
+ */
+ public function stats(?string $metric = null): TimeSeriesStatsRepository
+ {
+ return $this->makeStat($metric ?? $this->metric(), $this->dateRange());
+ }
- 'compare' => [
- 'nullable',
- 'boolean',
- ],
+ /**
+ * The stat for the comparison period, if there is one
+ */
+ public function compareStats(?string $metric = null): ?TimeSeriesStatsRepository
+ {
+ $range = $this->compareDateRange();
- 'format' => [
- 'string',
- Rule::in(TimeSeriesStats::allowedFormats()),
- ],
+ return $range ? $this->makeStat($metric ?? $this->metric(), $range) : null;
+ }
- 'filters' => 'array',
- ];
+ protected function makeStat(string $metric, $range): TimeSeriesStatsRepository
+ {
+ $filters = $this->filtersFor(TimeSeriesStats::createFromMetric($metric, filters: $this->fixedFilters()));
- return $rules;
+ return TimeSeriesStats::createFromMetric($metric, $range, $filters);
}
- public function bodyParameters()
+ /**
+ * @return array>
+ */
+ public function bodyParameters(): array
{
return [
+ 'metric' => [
+ 'description' => 'Which metric to use for the stat. The available metrics depend on the user\'s permissions and the filters used.',
+ 'example' => TimeSeriesStats::allowedMetrics([], $this->statsUser())[0] ?? '',
+ ],
'mode' => [
- 'description' => 'Which date frequency to group the data by, like `hour`, `day`, etc.',
+ 'description' => 'How to group the data: `hour`, `day`, `week`, `month` or `year`.',
'example' => TimeSeriesModes::MONTH->value,
],
-
- 'metric' => [
- 'description' => 'Which metric to use for the stat. The available metrics will depend on the user permissions and the filters used.',
- 'example' => TimeSeriesStats::allowedMetrics([], request()->user())[0] ?? '',
- ],
-
'date_range' => [
- 'description' => 'Preset date range to filter results by.',
- 'example' => PresetDateRanges::THIS_YEAR->value,
+ 'description' => 'Preset date range to filter results by, or `custom` to use date_from and date_to.',
+ 'example' => 'this_year',
],
-
'date_from' => [
- 'description' => 'Custom starting date when not using a preset date range.',
+ 'description' => 'Start of a custom date range.',
],
-
'date_to' => [
- 'description' => 'Custom ending date when not using a preset date range.',
+ 'description' => 'End of a custom date range. A date without a time includes the whole day.',
],
-
'compare' => [
- 'description' => 'Whether to compare the stat with the comparison date range.',
+ 'description' => 'Compare with the previous period.',
'example' => true,
],
-
+ 'compare_to' => [
+ 'description' => '`previous` for the previous period, or `custom` for compare_date_from and compare_date_to.',
+ ],
'compare_date_range' => [
- 'description' => 'Preset date range to compare results with. If not set, the previous date range will be compared.',
- 'example' => 'No-example'
+ 'description' => 'Preset date range to compare with.',
+ 'example' => 'No-example',
],
-
'compare_date_from' => [
- 'description' => 'Custom starting compare date when not using a preset compare date range.',
+ 'description' => 'Start of a custom comparison period.',
],
-
'compare_date_to' => [
- 'description' => 'Custom ending compare date when not using a preset compare date range.',
+ 'description' => 'End of a custom comparison period.',
],
-
'format' => [
'description' => 'Which format to return the results in.',
],
-
'filters' => [
'description' => 'Filters to apply to the stat.',
],
diff --git a/src/Jobs/WarmStatsCache.php b/src/Jobs/WarmStatsCache.php
new file mode 100644
index 0000000..d62013e
--- /dev/null
+++ b/src/Jobs/WarmStatsCache.php
@@ -0,0 +1,38 @@
+everyFiveMinutes();
+ * ```
+ */
+class WarmStatsCache implements ShouldBeUnique, ShouldQueue
+{
+ use Dispatchable;
+ use InteractsWithQueue;
+ use Queueable;
+
+ public int $uniqueFor = 600;
+
+ public function __construct()
+ {
+ $this->onConnection(config('stats.cache.warm.connection'));
+ $this->onQueue(config('stats.cache.warm.queue'));
+ }
+
+ public function handle(StatsManager $stats): void
+ {
+ $stats->warmNow();
+ }
+}
diff --git a/src/Policies/StatsPolicy.php b/src/Policies/StatsPolicy.php
new file mode 100644
index 0000000..57ba9f2
--- /dev/null
+++ b/src/Policies/StatsPolicy.php
@@ -0,0 +1,34 @@
+can(self::VIEW);
+ }
+
+ public function export(Authorizable $user, TimeSeriesStatsRepository|CategoricalStatsRepository $stat): bool
+ {
+ return $this->view($user, $stat);
+ }
+}
diff --git a/src/Repositories/Categorical/AbstractCategoricalStatsRepository.php b/src/Repositories/Categorical/AbstractCategoricalStatsRepository.php
index d1bdec8..6f5cb17 100644
--- a/src/Repositories/Categorical/AbstractCategoricalStatsRepository.php
+++ b/src/Repositories/Categorical/AbstractCategoricalStatsRepository.php
@@ -7,8 +7,8 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
-use Javaabu\GeneratorHelpers\StringCaser;
use Javaabu\Stats\CategoricalStats;
use Javaabu\Stats\Concerns\HasDateRange;
use Javaabu\Stats\Concerns\HasFilters;
@@ -47,7 +47,11 @@ public function getCategoryProvider(): CategoryProvider
public function canView(?Authorizable $user = null): bool
{
- return $user && $user->can('view_stats');
+ if (! $user) {
+ return false;
+ }
+
+ return Gate::forUser($user)->allows('view', $this);
}
public function metric(): string
@@ -57,7 +61,7 @@ public function metric(): string
protected function generateName(): string
{
- $class_name = StringCaser::title(class_basename($this));
+ $class_name = Str::headline(class_basename($this));
if (Str::endsWith($class_name, 'Repository')) {
$class_name = trim(Str::beforeLast($class_name, 'Repository'));
@@ -73,7 +77,7 @@ public function getName(): string
public function getAggregateFieldLabel(): string
{
- return __(StringCaser::title($this->getAggregateFieldName()));
+ return __(Str::headline($this->getAggregateFieldName()));
}
public function getCategoryFieldAlias(): string
@@ -83,7 +87,7 @@ public function getCategoryFieldAlias(): string
public function getCategoryNameFieldAlias(): string
{
- return $this->getCategoryFieldAlias().'_name';
+ return $this->getCategoryFieldAlias() . '_name';
}
public function getCategoryTitle(): string
@@ -108,7 +112,7 @@ public function groupByCategory(): Builder
return $query
->select(DB::raw(
- $this->getCategoryField().' as '.$category_field_alias.', '.$this->getAggregateSql()
+ $this->getCategoryField() . ' as ' . $category_field_alias . ', ' . $this->getAggregateSql()
))
->groupBy(DB::raw($this->getCategoryField()))
->orderBy(DB::raw($this->getCategoryField()));
diff --git a/src/Repositories/Categorical/CountCategoricalStatsRepository.php b/src/Repositories/Categorical/CountCategoricalStatsRepository.php
index d841a06..e943b78 100644
--- a/src/Repositories/Categorical/CountCategoricalStatsRepository.php
+++ b/src/Repositories/Categorical/CountCategoricalStatsRepository.php
@@ -8,7 +8,7 @@ abstract class CountCategoricalStatsRepository extends AbstractCategoricalStatsR
{
public function getAggregateSql(): string
{
- return 'count(*) as '.$this->getAggregateFieldName();
+ return 'count(*) as ' . $this->getAggregateFieldName();
}
protected function generateName(): string
diff --git a/src/Repositories/Categorical/SumCategoricalStatsRepository.php b/src/Repositories/Categorical/SumCategoricalStatsRepository.php
index c012383..83abc82 100644
--- a/src/Repositories/Categorical/SumCategoricalStatsRepository.php
+++ b/src/Repositories/Categorical/SumCategoricalStatsRepository.php
@@ -8,6 +8,6 @@ abstract public function getFieldToSum(): string;
public function getAggregateSql(): string
{
- return 'sum('.$this->getFieldToSum().') as '.$this->getAggregateFieldName();
+ return 'sum(' . $this->getFieldToSum() . ') as ' . $this->getAggregateFieldName();
}
}
diff --git a/src/Repositories/TimeSeries/AbstractTimeSeriesStatsRepository.php b/src/Repositories/TimeSeries/AbstractTimeSeriesStatsRepository.php
index 3214ef5..0762773 100644
--- a/src/Repositories/TimeSeries/AbstractTimeSeriesStatsRepository.php
+++ b/src/Repositories/TimeSeries/AbstractTimeSeriesStatsRepository.php
@@ -1,4 +1,5 @@
can('view_stats');
+ if (! $user) {
+ return false;
+ }
+
+ return Gate::forUser($user)->allows('view', $this);
}
/**
@@ -57,6 +62,7 @@ public function canView(?Authorizable $user = null): bool
public function results(TimeSeriesModes $mode): Collection
{
$mode_method = $mode->queryMethodName();
+
return $this->{$mode_method}()->get();
}
@@ -65,7 +71,7 @@ public function results(TimeSeriesModes $mode): Collection
*/
public function getAggregateFieldLabel(): string
{
- $aggregate_field = StringCaser::title($this->getAggregateFieldName());
+ $aggregate_field = Str::headline($this->getAggregateFieldName());
return __($aggregate_field);
}
@@ -80,7 +86,7 @@ public function metric(): string
protected function generateName(): string
{
- $class_name = StringCaser::title(class_basename($this));
+ $class_name = Str::headline(class_basename($this));
if (Str::endsWith($class_name, 'Repository')) {
$class_name = trim(Str::beforeLast($class_name, 'Repository'));
diff --git a/src/Repositories/TimeSeries/ActivityLogStatsRepository.php b/src/Repositories/TimeSeries/ActivityLogStatsRepository.php
index c40c92f..75dd213 100644
--- a/src/Repositories/TimeSeries/ActivityLogStatsRepository.php
+++ b/src/Repositories/TimeSeries/ActivityLogStatsRepository.php
@@ -6,7 +6,6 @@
abstract class ActivityLogStatsRepository extends CountStatsRepository
{
-
/**
* Get the main table for the repository
*/
@@ -23,7 +22,7 @@ public function baseQuery(): Builder
$model = config('activitylog.activity_model');
return $model::query()
- ->whereDescription($this->eventDescription());
+ ->whereDescription($this->eventDescription());
}
/**
@@ -37,5 +36,5 @@ public function query(): Builder
/**
* Get the event to count
*/
- public abstract function eventDescription(): string;
+ abstract public function eventDescription(): string;
}
diff --git a/src/Repositories/TimeSeries/AggregateStatsRepository.php b/src/Repositories/TimeSeries/AggregateStatsRepository.php
index 364699d..f59c2fb 100644
--- a/src/Repositories/TimeSeries/AggregateStatsRepository.php
+++ b/src/Repositories/TimeSeries/AggregateStatsRepository.php
@@ -1,34 +1,40 @@
getSql($this->getDateField());
+ $driver = $this->query()->getConnection()->getDriverName();
+
+ return $mode->getSql($this->getDateField(), $driver);
}
/**
- * Get the date field name for the repository
+ * The date column, without the table
*/
public function getDateFieldName(): string
{
@@ -36,100 +42,62 @@ public function getDateFieldName(): string
}
/**
- * Get the date field for the repository
+ * The date column, qualified with the table
*/
public function getDateField(): string
{
- return $this->getTable().'.'.$this->getDateFieldName();
+ return $this->getTable() . '.' . $this->getDateFieldName();
}
/**
- * Get the hourly query
- *
- * @return Builder
+ * The filtered query grouped into steps of the mode, with the step's key
+ * in a column named after the mode
*/
- public function hour(): Builder
+ public function groupedQuery(TimeSeriesModes $mode): Builder
{
- $this->setCurrentMode(TimeSeriesModes::HOUR);
+ $this->setCurrentMode($mode);
+
+ $alias = $mode->value;
return $this->filteredQuery()
- ->select(DB::raw($this->getAggregateSql().", " . $this->getDateSqlForTimeMode(TimeSeriesModes::HOUR) . " as hour"))
- ->groupBy('hour')
- ->orderBy('hour', 'ASC');
+ ->select(DB::raw($this->getAggregateSql() . ', ' . $this->getDateSqlForTimeMode($mode) . ' as ' . $alias))
+ ->groupBy($alias)
+ ->orderBy($alias);
}
- /**
- * Get the day query
- *
- * @return Builder
- */
- public function day(): Builder
+ public function hour(): Builder
{
- $this->setCurrentMode(TimeSeriesModes::DAY);
+ return $this->groupedQuery(TimeSeriesModes::HOUR);
+ }
- return $this->filteredQuery()
- ->select(DB::raw($this->getAggregateSql().", " . $this->getDateSqlForTimeMode(TimeSeriesModes::DAY) . " as day"))
- ->groupBy('day')
- ->orderBy('day', 'ASC');
+ public function day(): Builder
+ {
+ return $this->groupedQuery(TimeSeriesModes::DAY);
}
- /**
- * Get the week query
- *
- * @return Builder
- */
public function week(): Builder
{
- $this->setCurrentMode(TimeSeriesModes::WEEK);
-
- return $this->filteredQuery()
- //->select(DB::raw($this->getAggregateSql().", DATE_FORMAT(".$this->getDateField().", '%X, %V') as week"))
- ->select(DB::raw($this->getAggregateSql().", " . $this->getDateSqlForTimeMode(TimeSeriesModes::WEEK) . " as week"))
- ->groupBy('week')
- ->orderBy('week', 'ASC');
+ return $this->groupedQuery(TimeSeriesModes::WEEK);
}
- /**
- * Get the month query
- *
- * @return Builder
- */
public function month(): Builder
{
- $this->setCurrentMode(TimeSeriesModes::MONTH);
-
- return $this->filteredQuery()
- ->select(DB::raw($this->getAggregateSql().", " . $this->getDateSqlForTimeMode(TimeSeriesModes::MONTH) . " as month"))
- ->groupBy('month')
- ->orderBy('month', 'ASC');
+ return $this->groupedQuery(TimeSeriesModes::MONTH);
}
- /**
- * Get the year query
- *
- * @return Builder
- */
public function year(): Builder
{
- $this->setCurrentMode(TimeSeriesModes::YEAR);
-
- return $this->filteredQuery()
- ->select(DB::raw($this->getAggregateSql().", " . $this->getDateSqlForTimeMode(TimeSeriesModes::YEAR) . " as year"))
- ->groupBy('year')
- ->orderBy('year', 'ASC');
+ return $this->groupedQuery(TimeSeriesModes::YEAR);
}
- /**
- * Get the total
- */
public function total(): float|int
{
$this->setCurrentMode(null);
$total = $this->filteredQuery()
- ->select(DB::raw($this->getAggregateSql()))
- ->value($this->getAggregateFieldName());
+ ->select(DB::raw($this->getAggregateSql()))
+ ->value($this->getAggregateFieldName());
- return is_null($total) ? 0 : $total;
+ return is_null($total) ? 0 : $total + 0;
}
}
diff --git a/src/Repositories/TimeSeries/CountStatsRepository.php b/src/Repositories/TimeSeries/CountStatsRepository.php
index cf7b333..9bd56a0 100644
--- a/src/Repositories/TimeSeries/CountStatsRepository.php
+++ b/src/Repositories/TimeSeries/CountStatsRepository.php
@@ -1,4 +1,5 @@
getAggregateFieldName();
+ return 'count(*) as ' . $this->getAggregateFieldName();
}
protected function generateName(): string
diff --git a/src/Repositories/TimeSeries/LoginsRepository.php b/src/Repositories/TimeSeries/LoginsRepository.php
index 9dd9033..0e99ba4 100644
--- a/src/Repositories/TimeSeries/LoginsRepository.php
+++ b/src/Repositories/TimeSeries/LoginsRepository.php
@@ -32,7 +32,7 @@ public function allowedFilters(): array
public function query(): Builder
{
return $this->baseQuery()
- ->whereCauserType($this->userMorphClass());
+ ->whereCauserType($this->userMorphClass());
}
public function eventDescription(): string
@@ -50,10 +50,10 @@ public function userMorphClass(): string
return (new $user_class)->getMorphClass();
}
-
/**
* Get the model class
+ *
* @return class-string
*/
- public abstract function userModelClass(): string;
+ abstract public function userModelClass(): string;
}
diff --git a/src/Repositories/TimeSeries/SignupsRepository.php b/src/Repositories/TimeSeries/SignupsRepository.php
index 36b7176..d045d01 100644
--- a/src/Repositories/TimeSeries/SignupsRepository.php
+++ b/src/Repositories/TimeSeries/SignupsRepository.php
@@ -45,10 +45,10 @@ public function userMorphClass(): string
return (new $user_class)->getMorphClass();
}
-
/**
* Get the model class
+ *
* @return class-string
*/
- public abstract function userModelClass(): string;
+ abstract public function userModelClass(): string;
}
diff --git a/src/Repositories/TimeSeries/SumStatsRepository.php b/src/Repositories/TimeSeries/SumStatsRepository.php
index f760d22..e6aabf8 100644
--- a/src/Repositories/TimeSeries/SumStatsRepository.php
+++ b/src/Repositories/TimeSeries/SumStatsRepository.php
@@ -1,4 +1,5 @@
getFieldToSum().') as '.$this->getAggregateFieldName();
+ return 'sum(' . $this->getFieldToSum() . ') as ' . $this->getAggregateFieldName();
}
/**
* Get the field to sum for the repository
*/
- public abstract function getFieldToSum(): string;
+ abstract public function getFieldToSum(): string;
}
diff --git a/src/Repositories/TimeSeries/UserLoginsRepository.php b/src/Repositories/TimeSeries/UserLoginsRepository.php
index 70ac777..7dd4bf3 100644
--- a/src/Repositories/TimeSeries/UserLoginsRepository.php
+++ b/src/Repositories/TimeSeries/UserLoginsRepository.php
@@ -2,15 +2,19 @@
namespace Javaabu\Stats\Repositories\TimeSeries;
+/**
+ * Logins of the users of `config('stats.user_model')`, or of the model of the
+ * default auth guard's provider, from spatie/laravel-activitylog's `login` events
+ */
class UserLoginsRepository extends LoginsRepository
{
public function userModelClass(): string
{
- return \App\Models\User::class;
+ return UserSignupsRepository::defaultUserModel();
}
public function getName(): string
{
- return __('Admin User Logins');
+ return __('User Logins');
}
}
diff --git a/src/Repositories/TimeSeries/UserSignupsRepository.php b/src/Repositories/TimeSeries/UserSignupsRepository.php
index acd1b7a..296ed2b 100644
--- a/src/Repositories/TimeSeries/UserSignupsRepository.php
+++ b/src/Repositories/TimeSeries/UserSignupsRepository.php
@@ -2,17 +2,38 @@
namespace Javaabu\Stats\Repositories\TimeSeries;
+use Illuminate\Database\Eloquent\Model;
+/**
+ * Sign-ups of the users of `config('stats.user_model')`, or of the model of
+ * the default auth guard's provider
+ */
class UserSignupsRepository extends SignupsRepository
{
+ /**
+ * @return class-string|null
+ */
+ public static function defaultUserModel(): ?string
+ {
+ $model = config('stats.user_model');
+
+ if ($model) {
+ return $model;
+ }
+
+ $guard = config('auth.defaults.guard');
+ $provider = config("auth.guards.{$guard}.provider");
+
+ return config("auth.providers.{$provider}.model");
+ }
public function userModelClass(): string
{
- return \App\Models\User::class;
+ return static::defaultUserModel();
}
public function getName(): string
{
- return __('Admin User Signups');
+ return __('User Signups');
}
}
diff --git a/src/Routing/StatsRoutes.php b/src/Routing/StatsRoutes.php
new file mode 100644
index 0000000..0991800
--- /dev/null
+++ b/src/Routing/StatsRoutes.php
@@ -0,0 +1,145 @@
+ 'web_admin', 'prefix' => 'admin/stats', 'name' => 'admin.stats']);
+ * Route::statsApi(['middleware' => ['auth:api']]); // /stats/..., api.stats.*
+ * ```
+ *
+ * `Route::stats()`:
+ *
+ * | Route | Name | Action |
+ * | ------------------------------------ | ---------------------------------- | --------------- |
+ * | GET {prefix}/time-series | {name}.time-series.index | index |
+ * | POST {prefix}/time-series | {name}.time-series.export | export (CSV) |
+ * | GET {prefix}/categorical | {name}.categorical.index | index |
+ * | POST {prefix}/categorical | {name}.categorical.export | export (CSV) |
+ * | GET {prefix}/categorical/categories | {name}.categorical.categories | category search |
+ *
+ * `Route::statsApi()` registers `GET {prefix}/time-series`, `GET {prefix}/categorical`
+ * and `GET {prefix}/categorical/categories`, named `{name}.time-series.index`,
+ * `{name}.categorical.index` and `{name}.categorical.categories`.
+ */
+class StatsRoutes
+{
+ /** The route action key that holds the guard the routes were registered for */
+ public const string GUARD_ACTION = 'stats_guard';
+
+ /**
+ * @param array{
+ * guard?: string|null,
+ * prefix?: string,
+ * name?: string,
+ * middleware?: string|array|null,
+ * time_series?: bool,
+ * categorical?: bool,
+ * controllers?: array{time_series?: class-string, categorical?: class-string, categories?: class-string}
+ * } $options
+ */
+ public static function register(Router $router, array $options = []): void
+ {
+ $guard = $options['guard'] ?? null;
+ $controllers = $options['controllers'] ?? [];
+
+ $time_series = $controllers['time_series'] ?? config('stats.controllers.time_series', TimeSeriesStatsController::class);
+ $categorical = $controllers['categorical'] ?? config('stats.controllers.categorical', CategoricalStatsController::class);
+ $categories = $controllers['categories'] ?? config('stats.controllers.categories', CategoryProviderApiController::class);
+
+ $router->group(static::groupAttributes($options, 'stats', 'stats', [$guard ? "auth:{$guard}" : 'auth']), function (Router $router) use ($options, $time_series, $categorical, $categories) {
+ if ($options['time_series'] ?? true) {
+ $router->get('time-series', [$time_series, 'index'])->name('time-series.index');
+ $router->post('time-series', [$time_series, 'export'])->name('time-series.export');
+ }
+
+ if ($options['categorical'] ?? true) {
+ $router->get('categorical', [$categorical, 'index'])->name('categorical.index');
+ $router->post('categorical', [$categorical, 'export'])->name('categorical.export');
+ $router->get('categorical/categories', [$categories, 'index'])->name('categorical.categories');
+ }
+ });
+ }
+
+ /**
+ * @param array{
+ * guard?: string|null,
+ * prefix?: string,
+ * name?: string,
+ * middleware?: string|array|null,
+ * time_series?: bool,
+ * categorical?: bool,
+ * controllers?: array{time_series_api?: class-string, categorical_api?: class-string, categories?: class-string}
+ * } $options
+ */
+ public static function registerApi(Router $router, array $options = []): void
+ {
+ $guard = $options['guard'] ?? null;
+ $controllers = $options['controllers'] ?? [];
+
+ $time_series = $controllers['time_series_api'] ?? config('stats.controllers.time_series_api', TimeSeriesStatsApiController::class);
+ $categorical = $controllers['categorical_api'] ?? config('stats.controllers.categorical_api', CategoricalStatsApiController::class);
+ $categories = $controllers['categories'] ?? config('stats.controllers.categories', CategoryProviderApiController::class);
+
+ $router->group(static::groupAttributes($options, 'stats', 'api.stats', [$guard ? "auth:{$guard}" : 'auth']), function (Router $router) use ($options, $time_series, $categorical, $categories) {
+ if ($options['time_series'] ?? true) {
+ $router->get('time-series', [$time_series, 'index'])->name('time-series.index');
+ }
+
+ if ($options['categorical'] ?? true) {
+ $router->get('categorical', [$categorical, 'index'])->name('categorical.index');
+ $router->get('categorical/categories', [$categories, 'index'])->name('categorical.categories');
+ }
+ });
+ }
+
+ /**
+ * @param array $options
+ * @param array $default_middleware
+ * @return array
+ */
+ protected static function groupAttributes(array $options, string $default_prefix, string $default_name, array $default_middleware): array
+ {
+ return [
+ 'prefix' => trim($options['prefix'] ?? $default_prefix, '/'),
+ 'as' => rtrim($options['name'] ?? $default_name, '.') . '.',
+ 'middleware' => $options['middleware'] ?? $default_middleware,
+ self::GUARD_ACTION => $options['guard'] ?? null,
+ ];
+ }
+
+ /**
+ * The guard the current route was registered for, or null for the default guard
+ */
+ public static function guard(Request $request): ?string
+ {
+ return $request->route()?->getAction(self::GUARD_ACTION);
+ }
+
+ /**
+ * The route name prefix of the current route, e.g. `admin.stats` for `admin.stats.time-series.index`
+ */
+ public static function namePrefix(Request $request, string $default = 'stats'): string
+ {
+ $name = (string) $request->route()?->getName();
+
+ foreach (['.time-series.', '.categorical.'] as $segment) {
+ if (str_contains($name, $segment)) {
+ return substr($name, 0, strpos($name, $segment));
+ }
+ }
+
+ return $default;
+ }
+}
diff --git a/src/StatsManager.php b/src/StatsManager.php
new file mode 100644
index 0000000..b3e6dc1
--- /dev/null
+++ b/src/StatsManager.php
@@ -0,0 +1,148 @@
+range(PresetDateRanges::LAST_30_DAYS);
+ * Stats::warm(fn () => [Stats::tile('payments_count')->compare()]);
+ * Stats::forget('payments_count');
+ * ```
+ */
+class StatsManager
+{
+ /** @var list)> */
+ protected array $warmables = [];
+
+ public function __construct(protected StatsCache $cache) {}
+
+ public function timeSeries(?string $metric = null, ?string $label = null): TimeSeriesChart
+ {
+ return TimeSeriesChart::make($metric, $label);
+ }
+
+ public function category(?string $metric = null, ?string $label = null): CategoryChart
+ {
+ return CategoryChart::make($metric, $label);
+ }
+
+ public function pie(?string $metric = null, ?string $label = null): PieChart
+ {
+ return PieChart::make($metric, $label);
+ }
+
+ public function sparkline(?string $metric = null, ?string $label = null): Sparkline
+ {
+ return Sparkline::make($metric, $label);
+ }
+
+ public function tile(?string $metric = null, ?string $label = null): StatTile
+ {
+ return StatTile::make($metric, $label);
+ }
+
+ public function cache(): StatsCache
+ {
+ return $this->cache;
+ }
+
+ /**
+ * Invalidate the cached results of the metrics (all of them with `*` or none)
+ *
+ * @param string|list $metrics
+ */
+ public function forget(string|array $metrics = StatsCache::ALL): void
+ {
+ $this->cache->forget($metrics);
+ }
+
+ /**
+ * Invalidate every cached stat
+ */
+ public function flush(): void
+ {
+ $this->cache->flush();
+ }
+
+ /**
+ * Charts to keep warm with `stats:warm` or the WarmStatsCache job. Pass the
+ * charts, or a closure that returns them so they're built when warmed.
+ *
+ * @param Chart|iterable|Closure(): (Chart|iterable) $charts
+ */
+ public function warm(Chart|iterable|Closure $charts): static
+ {
+ if ($charts instanceof Chart || $charts instanceof Closure) {
+ $this->warmables[] = $charts;
+
+ return $this;
+ }
+
+ foreach ($charts as $chart) {
+ $this->warmables[] = $chart;
+ }
+
+ return $this;
+ }
+
+ /**
+ * The charts registered for warming
+ *
+ * @return list
+ */
+ public function warmables(): array
+ {
+ $charts = [];
+
+ foreach ($this->warmables as $warmable) {
+ $resolved = $warmable instanceof Closure ? app()->call($warmable) : $warmable;
+
+ foreach ($resolved instanceof Chart ? [$resolved] : $resolved as $chart) {
+ $charts[] = $chart;
+ }
+ }
+
+ return $charts;
+ }
+
+ /**
+ * Forget the registered charts (for tests)
+ */
+ public function clearWarmables(): void
+ {
+ $this->warmables = [];
+ }
+
+ /**
+ * Compute every registered chart and put it in the cache
+ *
+ * @param (Closure(Chart): void)|null $after called after each chart
+ * @return int the number of charts warmed
+ */
+ public function warmNow(?Closure $after = null): int
+ {
+ $count = 0;
+
+ foreach ($this->warmables() as $chart) {
+ $chart->refresh();
+ $count++;
+
+ if ($after) {
+ $after($chart);
+ }
+ }
+
+ return $count;
+ }
+}
diff --git a/src/StatsServiceProvider.php b/src/StatsServiceProvider.php
index c7cb615..c94cc9e 100644
--- a/src/StatsServiceProvider.php
+++ b/src/StatsServiceProvider.php
@@ -2,11 +2,20 @@
namespace Javaabu\Stats;
+use Carbon\Translator;
use Illuminate\Support\Facades\Blade;
+use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Javaabu\GeneratorHelpers\StubRenderer;
+use Javaabu\Manifest\Concerns\RegistersAssets;
+use Javaabu\Manifest\Package;
+use Javaabu\Permissions\Permissions;
+use Javaabu\Stats\Cache\StatsCache;
use Javaabu\Stats\Commands\GenerateCategoricalStatCommand;
use Javaabu\Stats\Commands\GenerateTimeSeriesStatCommand;
+use Javaabu\Stats\Commands\WarmStatsCacheCommand;
+use Javaabu\Stats\Contracts\CategoricalStatsRepository;
+use Javaabu\Stats\Contracts\TimeSeriesStatsRepository;
use Javaabu\Stats\Formatters\Categorical\ChartjsCategoricalStatsFormatter;
use Javaabu\Stats\Formatters\Categorical\CombinedCategoricalStatsFormatter;
use Javaabu\Stats\Formatters\Categorical\DefaultCategoricalStatsFormatter;
@@ -17,65 +26,80 @@
use Javaabu\Stats\Formatters\TimeSeries\SparklineChartsStatsFormatter;
use Javaabu\Stats\Http\Middleware\AbortIfCannotViewAnyCategoricalStats;
use Javaabu\Stats\Http\Middleware\AbortIfCannotViewAnyTimeSeriesStats;
-use Javaabu\Stats\Repositories\TimeSeries\UserLoginsRepository;
-use Javaabu\Stats\Repositories\TimeSeries\UserSignupsRepository;
+use Javaabu\Stats\Policies\StatsPolicy;
+use Javaabu\Stats\Routing\StatsRoutes;
+use Javaabu\Stats\Views\ViewRenderer;
class StatsServiceProvider extends ServiceProvider
{
- /**
- * Bootstrap the application services.
- */
- public function boot()
- {
- // declare publishes
- if ($this->app->runningInConsole()) {
- $this->publishes([
- __DIR__ . '/../config/stats.php' => config_path('stats.php'),
- ], 'stats-config');
+ use RegistersAssets;
- $this->publishes([
- __DIR__ . '/../resources/views' => resource_path('views/vendor/stats'),
- ], 'stats-views');
+ public function register(): void
+ {
+ $this->mergeConfigFrom(__DIR__ . '/../config/stats.php', 'stats');
- $this->publishes([
- __DIR__ . '/../stubs' => base_path('stubs/vendor/stats'),
- ], 'stats-stubs');
+ $this->app->singleton(StatsCache::class);
+ $this->app->singleton(StatsManager::class);
+ $this->app->singleton(ViewRenderer::class);
- $this->commands([
- GenerateCategoricalStatCommand::class,
- GenerateTimeSeriesStatCommand::class,
- ]);
- }
+ $this->registerMiddlewareAliases();
+ }
+ public function boot(): void
+ {
+ $this->loadJsonTranslationsFrom(__DIR__ . '/../lang');
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'stats');
- StubRenderer::loadStubsFrom(__DIR__ . '/../stubs', 'stats');
-
Blade::componentNamespace('Javaabu\\Stats\\Views\\Components', 'stats');
- \Carbon\Translator::get(TimeSeriesStats::dateLocale())->setTranslations([
+ StubRenderer::loadStubsFrom(__DIR__ . '/../stubs', 'stats');
+
+ Translator::get(TimeSeriesStats::dateLocale())->setTranslations([
'first_day_of_week' => TimeSeriesStats::firstDayOfWeek(),
]);
+ $this->registerFrontendAssets();
$this->registerFormatters();
+ $this->registerStats();
+ $this->registerRouteMacros();
+ $this->registerPermissions();
- if (! TimeSeriesStats::shouldExcludeDefaultStats()) {
- TimeSeriesStats::registerDefaultStats();
+ if ($this->app->runningInConsole()) {
+ $this->registerPublishing();
+
+ $this->commands([
+ GenerateCategoricalStatCommand::class,
+ GenerateTimeSeriesStatCommand::class,
+ WarmStatsCacheCommand::class,
+ ]);
}
}
/**
- * Register the application services.
+ * The chart styles for Blade, the Inertia pages (stats::TimeSeries/Index,
+ * stats::Categorical/Index) and the components apps import as @javaabu/stats
*/
- public function register()
+ protected function registerFrontendAssets(): void
{
- // merge package config with user defined config
- $this->mergeConfigFrom(__DIR__ . '/../config/stats.php', 'stats');
-
- $this->registerMiddlewareAliases();
+ $this->registerAssets('stats', __DIR__ . '/../resources', function (Package $package) {
+ $package
+ ->js()
+ ->pages()
+ ->dist('../dist')
+ ->style('charts', 'css/charts.css')
+ ->npm([
+ '@javaabu/forms' => 'github:Javaabu/react-forms#next-gen',
+ '@javaabu/onu' => '^0.2.1',
+ 'recharts' => '^3.10.1',
+ ], peer_dependencies: [
+ 'react' => '^19.0',
+ 'react-dom' => '^19.0',
+ '@inertiajs/react' => '^2.0 || ^3.0',
+ ]);
+ });
}
- protected function registerFormatters()
+ protected function registerFormatters(): void
{
TimeSeriesStats::registerFormatters([
'default' => DefaultStatsFormatter::class,
@@ -92,9 +116,76 @@ protected function registerFormatters()
]);
}
- protected function registerMiddlewareAliases()
+ /**
+ * The stats in the config, and the built-in ones
+ */
+ protected function registerStats(): void
+ {
+ if ($stats = (array) config('stats.time_series_stats', [])) {
+ TimeSeriesStats::register($stats);
+ }
+
+ if ($stats = (array) config('stats.categorical_stats', [])) {
+ CategoricalStats::register($stats);
+ }
+
+ if (config('stats.default_stats', true) && ! TimeSeriesStats::shouldExcludeDefaultStats()) {
+ TimeSeriesStats::registerDefaultStats();
+ }
+ }
+
+ protected function registerRouteMacros(): void
{
- app('router')->aliasMiddleware('stats.view-time-series', AbortIfCannotViewAnyTimeSeriesStats::class);
- app('router')->aliasMiddleware('stats.view-categorical', AbortIfCannotViewAnyCategoricalStats::class);
+ Route::macro('stats', function (array $options = []) {
+ StatsRoutes::register($this, $options);
+ });
+
+ Route::macro('statsApi', function (array $options = []) {
+ StatsRoutes::registerApi($this, $options);
+ });
+ }
+
+ /**
+ * view_stats, and the default policy for every stat, which the app can override
+ */
+ protected function registerPermissions(): void
+ {
+ if (config('stats.register_permissions', true)) {
+ Permissions::register('stats', [
+ StatsPolicy::VIEW => 'View stats',
+ ], label: 'Stats');
+ }
+
+ $policy = config('stats.policy', StatsPolicy::class);
+
+ Permissions::policy(TimeSeriesStatsRepository::class, $policy);
+ Permissions::policy(CategoricalStatsRepository::class, $policy);
+ }
+
+ protected function registerMiddlewareAliases(): void
+ {
+ $router = $this->app['router'];
+
+ $router->aliasMiddleware('stats.view-time-series', AbortIfCannotViewAnyTimeSeriesStats::class);
+ $router->aliasMiddleware('stats.view-categorical', AbortIfCannotViewAnyCategoricalStats::class);
+ }
+
+ protected function registerPublishing(): void
+ {
+ $this->publishes([
+ __DIR__ . '/../config/stats.php' => config_path('stats.php'),
+ ], 'stats-config');
+
+ $this->publishes([
+ __DIR__ . '/../resources/views' => resource_path('views/vendor/stats'),
+ ], 'stats-views');
+
+ $this->publishes([
+ __DIR__ . '/../lang' => lang_path('vendor/stats'),
+ ], 'stats-translations');
+
+ $this->publishes([
+ __DIR__ . '/../stubs' => base_path('stubs/vendor/stats'),
+ ], 'stats-stubs');
}
}
diff --git a/src/Support/CategoryProviderFactory.php b/src/Support/CategoryProviderFactory.php
index 578ba34..c160f5d 100644
--- a/src/Support/CategoryProviderFactory.php
+++ b/src/Support/CategoryProviderFactory.php
@@ -45,6 +45,6 @@ public static function make(mixed $provider): CategoryProvider
return new EloquentCategoryProvider($provider);
}
- throw new InvalidArgumentException('Unsupported categorical provider ['.get_debug_type($provider).'].');
+ throw new InvalidArgumentException('Unsupported categorical provider [' . get_debug_type($provider) . '].');
}
}
diff --git a/src/Support/CategoryProviderTitleResolver.php b/src/Support/CategoryProviderTitleResolver.php
index 63df0bd..1407129 100644
--- a/src/Support/CategoryProviderTitleResolver.php
+++ b/src/Support/CategoryProviderTitleResolver.php
@@ -3,7 +3,7 @@
namespace Javaabu\Stats\Support;
use Illuminate\Database\Eloquent\Model;
-use Javaabu\GeneratorHelpers\StringCaser;
+use Illuminate\Support\Str;
use UnitEnum;
class CategoryProviderTitleResolver
@@ -16,7 +16,7 @@ public static function forModel(Model $model): string
? class_basename($model)
: (string) $morph_class;
- return __(StringCaser::title($name));
+ return __(Str::headline($name));
}
/**
@@ -24,7 +24,7 @@ public static function forModel(Model $model): string
*/
public static function forEnum(string $enum): string
{
- return __(StringCaser::title(class_basename($enum)));
+ return __(Str::headline(class_basename($enum)));
}
public static function idTitle(string $category_title): string
diff --git a/src/Support/ExactDateRange.php b/src/Support/ExactDateRange.php
index d104202..ce8d9f6 100644
--- a/src/Support/ExactDateRange.php
+++ b/src/Support/ExactDateRange.php
@@ -6,12 +6,13 @@
use Carbon\Month;
use Carbon\WeekDay;
use DateTimeInterface;
-use Illuminate\Testing\Exceptions\InvalidArgumentException;
+use InvalidArgumentException;
use Javaabu\Stats\Contracts\DateRange;
class ExactDateRange implements DateRange
{
protected Carbon $date_from;
+
protected Carbon $date_to;
public function __construct(
@@ -44,15 +45,15 @@ public function getName(): string
return 'custom';
}
+ /**
+ * The period of the same length that ends the second before this one starts
+ */
public function getPreviousDateRange(): DateRange
{
- $date_from = $this->date_from;
- $date_to = $this->date_to;
-
- $interval = $date_from->diff($date_to);
+ $length = (int) floor($this->date_from->diffInSeconds($this->date_to, true));
- $prev_date_from = $date_from->copy()->sub($interval)->subSecond();
- $prev_date_to = $prev_date_from->copy()->add($interval);
+ $prev_date_to = $this->date_from->copy()->startOfSecond()->subSecond();
+ $prev_date_from = $prev_date_to->copy()->subSeconds($length);
return new ExactDateRange($prev_date_from, $prev_date_to);
}
diff --git a/src/Support/ExportFileName.php b/src/Support/ExportFileName.php
new file mode 100644
index 0000000..16ee2c3
--- /dev/null
+++ b/src/Support/ExportFileName.php
@@ -0,0 +1,25 @@
+formattedDateRange('YYYYMMDD', '-');
+
+ if ($compare) {
+ $name .= ' ' . $compare->formattedDateRange('YYYYMMDD', '-');
+ }
+
+ return trim(str_replace(['/', '\\'], '-', $name)) . '.csv';
+ }
+}
diff --git a/src/Support/Numbers.php b/src/Support/Numbers.php
new file mode 100644
index 0000000..127fda5
--- /dev/null
+++ b/src/Support/Numbers.php
@@ -0,0 +1,97 @@
+ static::number(round($value), 0, $locale),
+ Chart::FORMAT_COMPACT => static::compact($value, $locale),
+ Chart::FORMAT_PERCENT => static::number($value * 100, 1, $locale) . '%',
+ Chart::FORMAT_CURRENCY => static::currency($value, $locale),
+ default => static::number($value, 2, $locale),
+ };
+ }
+
+ /**
+ * A short label for an axis tick: 12K, 1.5M
+ */
+ public static function tick(int|float $value, string $format = Chart::FORMAT_NUMBER, ?string $locale = null): string
+ {
+ if ($format === Chart::FORMAT_PERCENT) {
+ return static::format($value, $format, $locale);
+ }
+
+ $locale = static::numberLocale($locale);
+ $compact = static::compact($value, $locale);
+
+ return $format === Chart::FORMAT_CURRENCY ? static::currencySymbol() . $compact : $compact;
+ }
+
+ public static function number(int|float $value, int $max_precision, string $locale): string
+ {
+ if (class_exists(NumberFormatter::class)) {
+ return (string) Number::format($value, maxPrecision: $max_precision, locale: $locale);
+ }
+
+ $decimals = floor($value) == $value ? 0 : $max_precision;
+
+ return rtrim(rtrim(number_format($value, $decimals), '0'), '.') ?: '0';
+ }
+
+ public static function compact(int|float $value, string $locale): string
+ {
+ $abs = abs($value);
+
+ foreach ([1e12 => 'T', 1e9 => 'B', 1e6 => 'M', 1e3 => 'K'] as $size => $suffix) {
+ if ($abs >= $size) {
+ return static::number($value / $size, 1, $locale) . $suffix;
+ }
+ }
+
+ return static::number($value, 1, $locale);
+ }
+
+ public static function currency(int|float $value, string $locale): string
+ {
+ $formatted = class_exists(NumberFormatter::class)
+ ? (string) Number::format($value, precision: 2, locale: $locale)
+ : number_format($value, 2);
+
+ return static::currencySymbol() . $formatted;
+ }
+
+ /**
+ * MVR, or ރ. in Dhivehi
+ */
+ public static function currencySymbol(): string
+ {
+ return app()->getLocale() === 'dv' ? 'ރ. ' : 'MVR ';
+ }
+
+ /**
+ * The locale for numbers: Latin digits for Dhivehi, as Onu shows them
+ */
+ protected static function numberLocale(?string $locale): string
+ {
+ $locale ??= app()->getLocale();
+
+ return $locale === 'dv' ? 'en' : str_replace('-', '_', $locale);
+ }
+}
diff --git a/src/Support/TimeSeriesSteps.php b/src/Support/TimeSeriesSteps.php
new file mode 100644
index 0000000..5c4ae54
--- /dev/null
+++ b/src/Support/TimeSeriesSteps.php
@@ -0,0 +1,58 @@
+
+ */
+ public static function for(TimeSeriesStatsRepository $stats, TimeSeriesModes $mode): array
+ {
+ $values = $stats->results($mode)->pluck($stats->getAggregateFieldName(), $mode->value);
+ $date = $stats->getDateFrom()->copy();
+ $count = $stats->interval($mode);
+ $steps = [];
+
+ for ($i = 0; $i <= $count; $i++) {
+ $key = $mode->formatDate($date, false);
+ $value = $values->get($key);
+
+ $steps[] = [
+ 'date' => $mode->startOfStep($date),
+ 'key' => $key,
+ 'value' => $value === null ? 0 : $value + 0,
+ ];
+
+ $date = $mode->increment($date);
+ }
+
+ return $steps;
+ }
+
+ /**
+ * A short name for a date range: the preset's label, or its dates
+ */
+ public static function rangeLabel(DateRange $range, ?Carbon $from = null, ?Carbon $to = null): string
+ {
+ if ($range instanceof PresetDateRanges && $range !== PresetDateRanges::LIFETIME) {
+ return $range->getLabel();
+ }
+
+ $from ??= $range->getDateFrom();
+ $to ??= $range->getDateTo();
+ $locale = app()->getLocale();
+
+ return $from->copy()->locale($locale)->isoFormat('D MMM YYYY') . ' – ' . $to->copy()->locale($locale)->isoFormat('D MMM YYYY');
+ }
+}
diff --git a/src/TimeSeriesStats.php b/src/TimeSeriesStats.php
index 18fa12a..cf0dbde 100644
--- a/src/TimeSeriesStats.php
+++ b/src/TimeSeriesStats.php
@@ -16,10 +16,12 @@
use Javaabu\Stats\Http\Controllers\TimeSeriesStatsController;
use Javaabu\Stats\Repositories\TimeSeries\UserLoginsRepository;
use Javaabu\Stats\Repositories\TimeSeries\UserSignupsRepository;
+use Spatie\Activitylog\Models\Activity;
class TimeSeriesStats
{
protected static array $stats_map = [];
+
protected static array $formatters_map = [];
protected static bool $exclude_default_stats = false;
@@ -33,35 +35,41 @@ public static function shouldExcludeDefaultStats(): bool
}
/**
- * Exclude predefined stats
+ * Don't register the built-in stats, whatever `stats.default_stats` says
*/
- public static function excludeDefaultStats()
+ public static function excludeDefaultStats(): void
{
self::$exclude_default_stats = true;
}
/**
- * Register default stats
+ * Register the built-in stats: `user_signups` for the users of the auth
+ * provider's model, and `user_logins` too when spatie/laravel-activitylog
+ * is installed. Called at boot when `stats.default_stats` is on.
*/
- public static function registerDefaultStats()
+ public static function registerDefaultStats(): void
{
- if (class_exists(\App\Models\User::class)) {
+ $user_model = UserSignupsRepository::defaultUserModel();
+
+ if (! $user_model || ! class_exists($user_model)) {
+ return;
+ }
+
+ self::register([
+ 'user_signups' => UserSignupsRepository::class,
+ ]);
+
+ if (class_exists(Activity::class)) {
self::register([
- 'user_signups' => UserSignupsRepository::class,
+ 'user_logins' => UserLoginsRepository::class,
]);
-
- if (class_exists(\Spatie\Activitylog\Models\Activity::class)) {
- self::register([
- 'user_logins' => UserLoginsRepository::class,
- ]);
- }
}
}
/**
* Register a formatter or a number of formatters
*
- * @param array> $formatters
+ * @param array> $formatters
*/
public static function registerFormatters(array $formatters, bool $merge = true)
{
@@ -71,7 +79,7 @@ public static function registerFormatters(array $formatters, bool $merge = true)
/**
* Get the formatters map
*
- * @return array>
+ * @return array>
*/
public static function formattersMap(): array
{
@@ -99,7 +107,7 @@ public static function getClassNameForFormat(string $name): string
/**
* Get the name for the formatter
*
- * @param class-string $formatter
+ * @param class-string $formatter
*/
public static function getNameForFormatter(string $formatter): string
{
@@ -118,13 +126,14 @@ public static function getNameForFormatter(string $formatter): string
public static function createFromFormat(string $format): TimeSeriesStatsFormatter
{
$class = static::getClassNameForFormat($format);
- return new $class();
+
+ return new $class;
}
/**
* Register a stat or a number of stats
*
- * @param array> $stats
+ * @param array> $stats
*/
public static function register(array $stats, bool $merge = true)
{
@@ -134,7 +143,7 @@ public static function register(array $stats, bool $merge = true)
/**
* Get the stats map
*
- * @return array>
+ * @return array>
*/
public static function statsMap(): array
{
@@ -154,7 +163,7 @@ public static function getClassNameForMetric(string $metric): string
/**
* Get the metric name for the stat
*
- * @param class-string $stat
+ * @param class-string $stat
*/
public static function getMetricForStat(string $stat): string
{
@@ -173,6 +182,7 @@ public static function getMetricForStat(string $stat): string
public static function createFromMetric(string $metric, DateRange $date_range = PresetDateRanges::THIS_YEAR, array $filters = []): TimeSeriesStatsRepository
{
$class = static::getClassNameForMetric($metric);
+
return new $class($date_range, $filters);
}
@@ -274,7 +284,9 @@ public static function weekMode(): int
*/
public static function defaultMode(): TimeSeriesModes
{
- return config('stats.default_time_series_mode');
+ $mode = config('stats.default_time_series_mode', TimeSeriesModes::DAY);
+
+ return $mode instanceof TimeSeriesModes ? $mode : TimeSeriesModes::from($mode);
}
/**
@@ -282,38 +294,48 @@ public static function defaultMode(): TimeSeriesModes
*/
public static function defaultDateRange(): PresetDateRanges
{
- return config('stats.default_date_range');
+ $range = config('stats.default_date_range', PresetDateRanges::LAST_7_DAYS);
+
+ return $range instanceof PresetDateRanges ? $range : PresetDateRanges::from($range);
}
/**
- * Register the api routes
+ * Register the JSON API route
+ *
+ * @deprecated Use Route::statsApi(), which also registers the categorical routes
+ *
+ * @param list $middleware
*/
public static function registerApiRoute(
string $url = '/stats/time-series',
string $name = 'stats.time-series.index',
array $middleware = ['stats.view-time-series']
- ): \Illuminate\Routing\Route
- {
- return Route::get($url, [TimeSeriesStatsApiController::class, 'index'])
- ->name($name)
- ->middleware($middleware);
+ ): \Illuminate\Routing\Route {
+ return Route::get($url, [config('stats.controllers.time_series_api', TimeSeriesStatsApiController::class), 'index'])
+ ->name($name)
+ ->middleware($middleware);
}
/**
- * Register the admin routes
+ * Register the stats page and export routes
+ *
+ * @deprecated Use Route::stats(), which also registers the categorical pages
+ *
+ * @param list $middleware
*/
public static function registerRoutes(
string $url = '/stats/time-series',
string $index_name = 'stats.time-series.index',
string $export_name = 'stats.time-series.export',
- array $middleware = ['stats.view-time-series']
- )
- {
- Route::get($url, [TimeSeriesStatsController::class, 'index'])
+ array $middleware = ['stats.view-time-series']
+ ): void {
+ $controller = config('stats.controllers.time_series', TimeSeriesStatsController::class);
+
+ Route::get($url, [$controller, 'index'])
->name($index_name)
->middleware($middleware);
- Route::post($url, [TimeSeriesStatsController::class, 'export'])
+ Route::post($url, [$controller, 'export'])
->name($export_name)
->middleware($middleware);
}
diff --git a/src/Views/Components/Categorical.php b/src/Views/Components/Categorical.php
deleted file mode 100644
index 3427162..0000000
--- a/src/Views/Components/Categorical.php
+++ /dev/null
@@ -1,59 +0,0 @@
- */
- public array $filters;
-
- public string $url;
-
- public string $apiUrl;
-
- /** @var array */
- public array $metrics;
-
- /** @var array */
- public array $category_urls;
-
- public ?Authorizable $user;
-
- /**
- * @param array $filters
- * @param array $metrics
- */
- public function __construct(
- string $url = '',
- string $apiUrl = '',
- array $filters = [],
- array $metrics = [],
- ?Authorizable $user = null,
- string $framework = ''
- ) {
- parent::__construct($framework);
-
- $this->filters = $filters;
- $this->user = $user ?: auth()->user();
- $this->url = $url ?: action([CategoricalStatsController::class, 'export']);
- $this->apiUrl = $apiUrl ?: action([CategoricalStatsApiController::class, 'index']);
- $this->metrics = $metrics ?: CategoricalStats::getMetricNames($filters, $this->user);
- $this->category_urls = [];
-
- foreach (array_keys($this->metrics) as $metric) {
- $category_url = action([CategoryProviderApiController::class, 'index']);
- $this->category_urls[$metric] = add_query_arg([
- 'filter' => ['metric' => $metric],
- 'filters' => $filters,
- ], $category_url);
- }
- }
-}
diff --git a/src/Views/Components/CategoryChart.php b/src/Views/Components/CategoryChart.php
new file mode 100644
index 0000000..602dec1
--- /dev/null
+++ b/src/Views/Components/CategoryChart.php
@@ -0,0 +1,8 @@
+`, the same as ``
+ */
+class CategoryChart extends Chart {}
diff --git a/src/Views/Components/Chart.php b/src/Views/Components/Chart.php
new file mode 100644
index 0000000..e6d7cfa
--- /dev/null
+++ b/src/Views/Components/Chart.php
@@ -0,0 +1,380 @@
+
+ * ```
+ *
+ * `$chart` is a chart (`TimeSeriesChart`, `CategoryChart`, `PieChart`,
+ * `Sparkline`, `StatTile`) or its array.
+ */
+class Chart extends Component
+{
+ /** @var array */
+ public array $data;
+
+ public bool $rtl;
+
+ /**
+ * @param StatsChart|array $chart
+ */
+ public function __construct(StatsChart|array $chart, ?bool $rtl = null)
+ {
+ $this->data = $chart instanceof StatsChart ? $chart->toArray() : $chart;
+ $this->rtl = $rtl ?? in_array(app()->getLocale(), (array) config('stats.rtl_locales', []), true);
+
+ Manifest::require('stats::charts');
+ }
+
+ public function render(): View
+ {
+ return match ($this->data['type'] ?? null) {
+ 'pie' => view('stats::components.charts.pie', $this->pieModel()),
+ 'sparkline' => view('stats::components.charts.sparkline', $this->sparklineModel()),
+ 'stat-tile' => view('stats::components.charts.stat-tile', $this->statTileModel()),
+ default => view('stats::components.charts.cartesian', $this->cartesianModel()),
+ };
+ }
+
+ protected function format(int|float|null $value): string
+ {
+ return Numbers::format($value, $this->data['valueFormat'] ?? StatsChart::FORMAT_NUMBER);
+ }
+
+ /**
+ * @return array
+ */
+ protected function common(): array
+ {
+ $id = $this->data['id'] ?? 'stats-chart-' . substr(md5(json_encode($this->data)), 0, 8);
+
+ return [
+ 'id' => $id,
+ 'title' => (string) ($this->data['title'] ?? ''),
+ 'description' => $this->data['description'] ?? null,
+ 'rtl' => $this->rtl,
+ 'empty' => __('No data to show'),
+ 'strings' => [
+ 'view_table' => __('View as table'),
+ ],
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ protected function cartesianModel(): array
+ {
+ $data = $this->data;
+ $config = $data['config'] ?? [];
+ $rows = $data['data'] ?? [];
+ $svg = CartesianChart::make($data, $this->rtl);
+ $is_time = ($data['type'] ?? null) === 'time-series';
+ $labels = $is_time ? ($data['labels'] ?? array_column($rows, 'date')) : array_column($rows, $data['categoryKey'] ?? 'category');
+
+ $headers = [$is_time ? __('Date') : ($data['categoryLabel'] ?? __('Category'))];
+
+ foreach ($config as $series) {
+ $headers[] = $series['label'];
+ }
+
+ $comparison = $data['comparison'] ?? null;
+
+ if ($comparison) {
+ $headers[] = __('Change');
+ }
+
+ $table = [];
+
+ foreach ($rows as $i => $row) {
+ $cells = [];
+
+ foreach (array_keys($config) as $key) {
+ $cells[] = $this->format(isset($row[$key]) ? $row[$key] + 0 : null);
+ }
+
+ if ($comparison) {
+ $cells[] = $this->deltaText(static::delta($row[array_key_first($config)] ?? null, $row[$comparison['previousKey']] ?? null));
+ }
+
+ $table[] = ['label' => (string) ($labels[$i] ?? ''), 'cells' => $cells];
+ }
+
+ return [
+ ...$this->common(),
+ 'svg' => $svg,
+ 'legend' => count($config) > 1 ? array_map(fn (array $series) => [
+ 'label' => $series['label'],
+ 'color' => $series['color'],
+ 'dashed' => $series['dashed'],
+ ], $svg['series']) : [],
+ 'table' => ['headers' => $headers, 'rows' => $table],
+ 'totals' => $comparison ? $this->comparisonTotals($rows, array_key_first($config), $comparison) : null,
+ 'has_data' => $rows !== [],
+ 'summary' => $this->cartesianSummary($data, $labels),
+ ];
+ }
+
+ /**
+ * The headline totals of a comparison
+ *
+ * @param list> $rows
+ * @param array $comparison
+ * @return array
+ */
+ protected function comparisonTotals(array $rows, ?string $current_key, array $comparison): array
+ {
+ $current = array_sum(array_map(fn ($row) => (float) ($row[$current_key] ?? 0), $rows));
+ $previous = array_sum(array_map(fn ($row) => (float) ($row[$comparison['previousKey']] ?? 0), $rows));
+
+ return [
+ 'current_label' => $comparison['currentLabel'] ?? __('This period'),
+ 'previous_label' => $comparison['previousLabel'] ?? __('Previous period'),
+ 'current' => $this->format($current),
+ 'previous' => $this->format($previous),
+ 'delta' => $this->deltaModel(static::delta($current, $previous), (bool) ($comparison['positiveIsGood'] ?? true)),
+ ];
+ }
+
+ /**
+ * @param array $data
+ * @param list $labels
+ */
+ protected function cartesianSummary(array $data, array $labels): string
+ {
+ $variant = match ($data['variant'] ?? 'line') {
+ 'bar' => __('Bar chart'),
+ 'area' => __('Area chart'),
+ default => __('Line chart'),
+ };
+
+ $config = $data['config'] ?? [];
+ $rows = $data['data'] ?? [];
+
+ $parts = [__(':chart with :series series and :points data points.', [
+ 'chart' => $variant,
+ 'series' => count($config),
+ 'points' => count($rows),
+ ])];
+
+ if (($data['type'] ?? null) === 'time-series' && $labels !== []) {
+ $parts[] = __('From :from to :to.', ['from' => reset($labels), 'to' => end($labels)]);
+ }
+
+ foreach ($config as $key => $series) {
+ $values = array_values(array_filter(array_column($rows, $key), 'is_numeric'));
+
+ if ($values === []) {
+ continue;
+ }
+
+ $parts[] = __(':series: lowest :min, highest :max.', [
+ 'series' => $series['label'],
+ 'min' => $this->format(min($values) + 0),
+ 'max' => $this->format(max($values) + 0),
+ ]);
+ }
+
+ return implode(' ', $parts);
+ }
+
+ /**
+ * @return array
+ */
+ protected function pieModel(): array
+ {
+ $data = $this->data;
+ $slices = $data['data'] ?? [];
+ $name_key = $data['nameKey'] ?? 'name';
+ $value_key = $data['valueKey'] ?? 'value';
+ $donut = ($data['variant'] ?? 'pie') === 'donut';
+ $values = array_map(fn ($slice) => (float) ($slice[$value_key] ?? 0), $slices);
+ $total = array_sum($values);
+ $geometry = PieGeometry::slices($values, $donut, $this->rtl);
+ $items = [];
+
+ foreach ($slices as $i => $slice) {
+ $items[] = [
+ 'name' => (string) $slice[$name_key],
+ 'value' => $this->format($values[$i]),
+ 'share' => Numbers::format($geometry[$i]['share'], StatsChart::FORMAT_PERCENT),
+ 'path' => $geometry[$i]['path'],
+ 'color' => 'var(--chart-' . ($i % 8 + 1) . ')',
+ ];
+ }
+
+ $center = $data['centerLabel'] ?? null;
+
+ if ($donut && $center === null) {
+ $center = ['value' => $this->format($total), 'label' => __('Total')];
+ }
+
+ $largest = $items[0] ?? null;
+
+ $summary = $largest
+ ? __(':chart with :count slices. Total :total. Largest: :name, :share.', [
+ 'chart' => $donut ? __('Donut chart') : __('Pie chart'),
+ 'count' => count($items),
+ 'total' => $this->format($total),
+ 'name' => $largest['name'],
+ 'share' => $largest['share'],
+ ])
+ : '';
+
+ return [
+ ...$this->common(),
+ 'size' => PieGeometry::SIZE,
+ 'slices' => $items,
+ 'donut' => $donut,
+ 'center' => $donut && is_array($center) ? $center : null,
+ 'name_label' => $data['nameLabel'] ?? __('Category'),
+ 'value_label' => $data['aggregateLabel'] ?? __('Value'),
+ 'share_label' => __('Share'),
+ 'has_data' => $items !== [] && $total > 0,
+ 'summary' => $summary,
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ protected function sparklineModel(): array
+ {
+ $values = array_map(fn ($value) => $value === null ? null : $value + 0, $this->data['data'] ?? []);
+
+ return [
+ ...$this->common(),
+ 'svg' => SparklineGeometry::make($values, (int) ($this->data['height'] ?? 32), $this->rtl),
+ 'variant' => $this->data['variant'] ?? 'line',
+ 'color' => $this->data['color'] ?? 'var(--chart-1)',
+ 'label' => static::sparklineSummary((string) ($this->data['label'] ?? $this->data['title'] ?? ''), $values, fn ($value) => $this->format($value)),
+ ];
+ }
+
+ /**
+ * "Daily sign-ups: 8 values, from 12 to 24. Lowest 12, highest 24."
+ *
+ * @param list $values
+ */
+ public static function sparklineSummary(string $label, array $values, callable $format): string
+ {
+ $numbers = array_values(array_filter($values, fn ($value) => $value !== null));
+
+ if ($numbers === []) {
+ return $label;
+ }
+
+ return __(':label: :count values, from :first to :last. Lowest :min, highest :max.', [
+ 'label' => $label,
+ 'count' => count($values),
+ 'first' => $format($numbers[0]),
+ 'last' => $format(end($numbers)),
+ 'min' => $format(min($numbers)),
+ 'max' => $format(max($numbers)),
+ ]);
+ }
+
+ /**
+ * @return array
+ */
+ protected function statTileModel(): array
+ {
+ $data = $this->data;
+ $value = $data['value'] ?? null;
+ $previous = $data['previousValue'] ?? null;
+ $sparkline = $data['sparkline'] ?? null;
+ $label = (string) ($data['label'] ?? $data['title'] ?? '');
+
+ return [
+ ...$this->common(),
+ 'label' => $label,
+ 'value' => is_numeric($value) ? $this->format($value + 0) : (string) ($value ?? '—'),
+ 'delta' => $previous !== null && is_numeric($value)
+ ? $this->deltaModel(static::delta($value + 0, $previous + 0), (bool) ($data['positiveIsGood'] ?? true))
+ : null,
+ 'delta_context' => $data['deltaContext'] ?? null,
+ 'icon' => $data['icon'] ?? null,
+ 'href' => $data['href'] ?? null,
+ 'footer' => $data['footer'] ?? null,
+ 'period' => $data['period'] ?? null,
+ 'sparkline' => $sparkline ? [
+ 'svg' => SparklineGeometry::make(array_map(fn ($v) => $v === null ? null : $v + 0, $sparkline), 46, $this->rtl),
+ 'label' => static::sparklineSummary($label, $sparkline, fn ($v) => $this->format($v)),
+ ] : null,
+ ];
+ }
+
+ /**
+ * The change as a ratio, or null when there's nothing to compare with
+ */
+ public static function delta(int|float|string|null $current, int|float|string|null $previous): ?float
+ {
+ if (! is_numeric($current) || ! is_numeric($previous) || (float) $previous == 0.0) {
+ return null;
+ }
+
+ return ((float) $current - (float) $previous) / abs((float) $previous);
+ }
+
+ /**
+ * The delta's direction, sentiment and text, as Onu's DeltaIndicator shows it
+ *
+ * @return array|null
+ */
+ protected function deltaModel(?float $delta, bool $positive_is_good = true): ?array
+ {
+ if ($delta === null) {
+ return null;
+ }
+
+ $direction = abs($delta) < 0.0005 ? 'flat' : ($delta > 0 ? 'up' : 'down');
+
+ $sentiment = match (true) {
+ $direction === 'flat' => 'neutral',
+ ($direction === 'up') === $positive_is_good => 'positive',
+ default => 'negative',
+ };
+
+ $percent = Numbers::format(abs($delta), StatsChart::FORMAT_PERCENT);
+
+ return [
+ 'direction' => $direction,
+ 'sentiment' => $sentiment,
+ 'text' => ($direction === 'down' ? '−' : ($direction === 'up' ? '+' : '')) . $percent,
+ 'spoken' => match ($direction) {
+ 'up' => __('Up :percent', ['percent' => $percent]),
+ 'down' => __('Down :percent', ['percent' => $percent]),
+ default => __('No change'),
+ },
+ ];
+ }
+
+ protected function deltaText(?float $delta): string
+ {
+ $model = $this->deltaModel($delta);
+
+ return $model ? $model['text'] : '—';
+ }
+
+ /**
+ * Dates for display in the current locale
+ */
+ public static function date(Carbon $date, string $format): string
+ {
+ return $date->copy()->locale(app()->getLocale())->isoFormat($format);
+ }
+}
diff --git a/src/Views/Components/Component.php b/src/Views/Components/Component.php
deleted file mode 100644
index db4519d..0000000
--- a/src/Views/Components/Component.php
+++ /dev/null
@@ -1,53 +0,0 @@
-framework = $framework;
- }
-
- public function getView(): string
- {
- return 'stats::{framework}.' . $this->view;
- }
-
- /**
- * {@inheritDoc}
- */
- public function render()
- {
- $view = $this->getView();
-
- $framework = $this->framework;
-
- return str_replace('{framework}', $framework, $view);
- }
-}
diff --git a/src/Views/Components/PieChart.php b/src/Views/Components/PieChart.php
new file mode 100644
index 0000000..13f51d6
--- /dev/null
+++ b/src/Views/Components/PieChart.php
@@ -0,0 +1,8 @@
+`, the same as ``
+ */
+class PieChart extends Chart {}
diff --git a/src/Views/Components/Sparkline.php b/src/Views/Components/Sparkline.php
new file mode 100644
index 0000000..072e8d2
--- /dev/null
+++ b/src/Views/Components/Sparkline.php
@@ -0,0 +1,8 @@
+`, the same as ``
+ */
+class Sparkline extends Chart {}
diff --git a/src/Views/Components/StatTile.php b/src/Views/Components/StatTile.php
new file mode 100644
index 0000000..d7a0cdb
--- /dev/null
+++ b/src/Views/Components/StatTile.php
@@ -0,0 +1,26 @@
+
+ *
+ *
+ * ```
+ */
+class StatTile extends Chart
+{
+ /**
+ * @param StatsChart|array|null $tile
+ * @param StatsChart|array|null $chart
+ */
+ public function __construct(StatsChart|array|null $tile = null, StatsChart|array|null $chart = null, ?bool $rtl = null)
+ {
+ parent::__construct($tile ?? $chart ?? ['type' => 'stat-tile'], $rtl);
+ }
+}
diff --git a/src/Views/Components/TimeSeries.php b/src/Views/Components/TimeSeries.php
deleted file mode 100644
index 547555a..0000000
--- a/src/Views/Components/TimeSeries.php
+++ /dev/null
@@ -1,41 +0,0 @@
-filters = $filters;
- $this->user = $user ?: auth()->user();
- $this->url = $url ?: action([TimeSeriesStatsController::class, 'export']);
- $this->apiUrl = $apiUrl ?: action([TimeSeriesStatsApiController::class, 'index']);
- $this->metrics = $metrics ?: TimeSeriesStats::getMetricNames($this->filters, $this->user);
- }
-}
diff --git a/src/Views/Components/TimeSeriesChart.php b/src/Views/Components/TimeSeriesChart.php
new file mode 100644
index 0000000..1c8cf5d
--- /dev/null
+++ b/src/Views/Components/TimeSeriesChart.php
@@ -0,0 +1,8 @@
+`, the same as ``
+ */
+class TimeSeriesChart extends Chart {}
diff --git a/src/Views/Svg/CartesianChart.php b/src/Views/Svg/CartesianChart.php
new file mode 100644
index 0000000..3ea88f5
--- /dev/null
+++ b/src/Views/Svg/CartesianChart.php
@@ -0,0 +1,460 @@
+ $chart a TimeSeriesChart or CategoryChart's array
+ * @return array
+ */
+ public static function make(array $chart, bool $rtl = false): array
+ {
+ return (new static($chart, $rtl))->build();
+ }
+
+ /** @var list> */
+ protected array $rows;
+
+ /** @var list */
+ protected array $labels;
+
+ /** @var list */
+ protected array $keys;
+
+ protected string $variant;
+
+ protected bool $stacked;
+
+ protected bool $horizontal;
+
+ protected ?string $previous_key;
+
+ protected string $format;
+
+ protected int $width = self::WIDTH;
+
+ protected int $height;
+
+ /**
+ * @param array $chart
+ */
+ public function __construct(protected array $chart, protected bool $rtl = false)
+ {
+ $this->rows = array_values($chart['data'] ?? []);
+ $this->keys = array_keys($chart['config'] ?? []);
+ $this->variant = $chart['variant'] ?? 'line';
+ $this->stacked = (bool) ($chart['stacked'] ?? false);
+ $this->horizontal = ($chart['orientation'] ?? null) === 'horizontal' && $this->variant === 'bar';
+ $this->previous_key = $chart['comparison']['previousKey'] ?? null;
+ $this->format = $chart['valueFormat'] ?? Chart::FORMAT_NUMBER;
+ $this->height = (int) ($chart['height'] ?? self::DEFAULT_HEIGHT);
+
+ $category_key = $chart['categoryKey'] ?? null;
+
+ $this->labels = $category_key
+ ? array_map(fn (array $row) => (string) ($row[$category_key] ?? ''), $this->rows)
+ : array_map('strval', $chart['labels'] ?? array_column($this->rows, $chart['dateKey'] ?? 'date'));
+ }
+
+ /**
+ * @return array
+ */
+ public function build(): array
+ {
+ $plot = $this->plotArea();
+ $scale = new Scale($this->domainValues());
+
+ $model = [
+ 'width' => $this->width,
+ 'height' => $this->height,
+ 'rtl' => $this->rtl,
+ 'horizontal' => $this->horizontal,
+ 'variant' => $this->variant,
+ 'grid' => $this->grid($scale, $plot),
+ 'axis_labels' => $this->axisLabels($plot),
+ 'series' => $this->series($scale, $plot),
+ 'zero' => $this->zeroLine($scale, $plot),
+ ];
+
+ return $model;
+ }
+
+ /**
+ * @return array{left: float, right: float, top: float, bottom: float}
+ */
+ protected function plotArea(): array
+ {
+ if ($this->horizontal) {
+ $longest = max(array_map('mb_strlen', $this->labels ?: ['']));
+
+ return ['left' => min(180, max(60, $longest * 7 + 12)), 'right' => $this->width - 12, 'top' => 8, 'bottom' => $this->height - 24];
+ }
+
+ return ['left' => 52, 'right' => $this->width - 12, 'top' => 8, 'bottom' => $this->height - 28];
+ }
+
+ /**
+ * Every value the value axis must show, with stacks added up
+ *
+ * @return list
+ */
+ protected function domainValues(): array
+ {
+ $values = [];
+
+ foreach ($this->rows as $row) {
+ $positive = 0;
+ $negative = 0;
+
+ foreach ($this->keys as $key) {
+ $value = $row[$key] ?? null;
+
+ if (! is_numeric($value)) {
+ continue;
+ }
+
+ if ($this->stacked && $key !== $this->previous_key) {
+ $value >= 0 ? $positive += $value : $negative += $value;
+
+ continue;
+ }
+
+ $values[] = (float) $value;
+ }
+
+ if ($this->stacked) {
+ $values[] = $positive;
+ $values[] = $negative;
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * @param array{left: float, right: float, top: float, bottom: float} $plot
+ * @return list>
+ */
+ protected function grid(Scale $scale, array $plot): array
+ {
+ $lines = [];
+
+ foreach ($scale->ticks as $tick) {
+ $label = Numbers::tick($tick, $this->format);
+
+ if ($this->horizontal) {
+ $x = $scale->position($tick, $plot['left'], $plot['right']);
+
+ $lines[] = [
+ 'x1' => $this->x($x), 'x2' => $this->x($x), 'y1' => $plot['top'], 'y2' => $plot['bottom'],
+ 'label' => $label, 'label_x' => $this->x($x), 'label_y' => $plot['bottom'] + 16, 'anchor' => 'middle',
+ ];
+
+ continue;
+ }
+
+ $y = $scale->position($tick, $plot['bottom'], $plot['top']);
+
+ $lines[] = [
+ 'x1' => $this->x($plot['left']), 'x2' => $this->x($plot['right']), 'y1' => $y, 'y2' => $y,
+ 'label' => $label, 'label_x' => $this->x($plot['left'] - 8), 'label_y' => $y + 4, 'anchor' => $this->anchor('end'),
+ ];
+ }
+
+ return $lines;
+ }
+
+ /**
+ * Category or date labels along the category axis, thinned to fit
+ *
+ * @param array{left: float, right: float, top: float, bottom: float} $plot
+ * @return list>
+ */
+ protected function axisLabels(array $plot): array
+ {
+ $count = count($this->labels);
+ $labels = [];
+
+ if ($this->horizontal) {
+ foreach ($this->labels as $i => $label) {
+ [$start, $band] = $this->band($i, $plot['top'], $plot['bottom']);
+
+ $labels[] = [
+ 'x' => $this->x($plot['left'] - 8), 'y' => $start + $band / 2 + 4,
+ 'label' => $label, 'anchor' => $this->anchor('end'),
+ ];
+ }
+
+ return $labels;
+ }
+
+ $every = max(1, (int) ceil($count / self::MAX_X_LABELS));
+
+ foreach ($this->labels as $i => $label) {
+ if ($i % $every !== 0) {
+ continue;
+ }
+
+ $labels[] = [
+ 'x' => $this->x($this->categoryPosition($i, $plot)), 'y' => $plot['bottom'] + 18,
+ 'label' => $label, 'anchor' => 'middle',
+ ];
+ }
+
+ return $labels;
+ }
+
+ /**
+ * @param array{left: float, right: float, top: float, bottom: float} $plot
+ * @return array|null
+ */
+ protected function zeroLine(Scale $scale, array $plot): ?array
+ {
+ if ($scale->min >= 0) {
+ return null;
+ }
+
+ if ($this->horizontal) {
+ $x = $this->x($scale->position(0, $plot['left'], $plot['right']));
+
+ return ['x1' => $x, 'x2' => $x, 'y1' => $plot['top'], 'y2' => $plot['bottom']];
+ }
+
+ $y = $scale->position(0, $plot['bottom'], $plot['top']);
+
+ return ['x1' => $this->x($plot['left']), 'x2' => $this->x($plot['right']), 'y1' => $y, 'y2' => $y];
+ }
+
+ /**
+ * The centre of a category (bars) or the position of a point (lines)
+ *
+ * @param array{left: float, right: float, top: float, bottom: float} $plot
+ */
+ protected function categoryPosition(int $i, array $plot): float
+ {
+ $count = count($this->rows);
+
+ if ($this->variant === 'bar') {
+ [$start, $band] = $this->band($i, $plot['left'], $plot['right']);
+
+ return $start + $band / 2;
+ }
+
+ if ($count <= 1) {
+ return ($plot['left'] + $plot['right']) / 2;
+ }
+
+ return $plot['left'] + $i * ($plot['right'] - $plot['left']) / ($count - 1);
+ }
+
+ /**
+ * @return array{0: float, 1: float} the band's start and size
+ */
+ protected function band(int $i, float $from, float $to): array
+ {
+ $band = ($to - $from) / max(1, count($this->rows));
+
+ return [$from + $i * $band, $band];
+ }
+
+ /**
+ * @param array{left: float, right: float, top: float, bottom: float} $plot
+ * @return list>
+ */
+ protected function series(Scale $scale, array $plot): array
+ {
+ $series = [];
+ $grouped = $this->stacked
+ ? array_values(array_unique(array_map(fn ($key) => $key === $this->previous_key ? $key : '__stack', $this->keys)))
+ : $this->keys;
+ $offsets = array_fill(0, count($this->rows), ['positive' => 0.0, 'negative' => 0.0]);
+ $color_index = 0;
+
+ foreach ($this->keys as $key) {
+ $is_previous = $key === $this->previous_key;
+ $color = $is_previous ? 'var(--stats-previous, var(--muted-foreground))' : "var(--color-{$key}, var(--chart-" . ($color_index++ % 8 + 1) . '))';
+ $label = (string) ($this->chart['config'][$key]['label'] ?? $key);
+ $stack = $this->stacked && ! $is_previous;
+ $points = [];
+ $bars = [];
+
+ foreach ($this->rows as $i => $row) {
+ $value = $row[$key] ?? null;
+ $title = $this->labels[$i] . ' · ' . $label . ': ' . Numbers::format(is_numeric($value) ? $value + 0 : null, $this->format);
+
+ if (! is_numeric($value)) {
+ $points[] = null;
+
+ continue;
+ }
+
+ $value = (float) $value;
+ $base = 0.0;
+
+ if ($stack) {
+ $side = $value >= 0 ? 'positive' : 'negative';
+ $base = $offsets[$i][$side];
+ $offsets[$i][$side] += $value;
+ }
+
+ if ($this->variant === 'bar') {
+ $bars[] = $this->bar($scale, $plot, $i, array_search($stack ? '__stack' : $key, $grouped, true), count($grouped), $base, $base + $value, $title);
+
+ continue;
+ }
+
+ $x = $this->categoryPosition($i, $plot);
+ $y = $scale->position($base + $value, $plot['bottom'], $plot['top']);
+ $y0 = $scale->position($base, $plot['bottom'], $plot['top']);
+
+ $points[] = ['x' => $this->x($x), 'y' => round($y, 2), 'y0' => round($y0, 2), 'title' => $title];
+ }
+
+ $series[] = [
+ 'key' => $key,
+ 'label' => $label,
+ 'color' => $color,
+ 'previous' => $is_previous,
+ 'dashed' => $is_previous && $this->variant !== 'bar',
+ 'bars' => $bars,
+ 'points' => array_values(array_filter($points)),
+ 'line' => $this->variant === 'bar' ? null : static::linePath($points),
+ 'area' => $this->variant === 'area' && ! $is_previous ? static::areaPath($points) : null,
+ ];
+ }
+
+ return $series;
+ }
+
+ /**
+ * @param array{left: float, right: float, top: float, bottom: float} $plot
+ * @return array
+ */
+ protected function bar(Scale $scale, array $plot, int $i, int $slot, int $slots, float $from, float $to, string $title): array
+ {
+ [$band_from, $band_to] = $this->horizontal ? [$plot['top'], $plot['bottom']] : [$plot['left'], $plot['right']];
+ [$start, $band] = $this->band($i, $band_from, $band_to);
+
+ $inner = $band * 0.8;
+ $size = $inner / max(1, $slots);
+ $offset = $start + ($band - $inner) / 2 + $slot * $size;
+ $gap = $slots > 1 ? min(2, $size * 0.1) : 0;
+
+ if ($this->horizontal) {
+ $a = $scale->position($from, $plot['left'], $plot['right']);
+ $b = $scale->position($to, $plot['left'], $plot['right']);
+ $x = min($a, $b);
+ $width = abs($b - $a);
+
+ return [
+ 'x' => round($this->rtl ? $this->width - $x - $width : $x, 2),
+ 'y' => round($offset + $gap / 2, 2),
+ 'width' => round($width, 2),
+ 'height' => round(max(0, $size - $gap), 2),
+ 'title' => $title,
+ ];
+ }
+
+ $a = $scale->position($from, $plot['bottom'], $plot['top']);
+ $b = $scale->position($to, $plot['bottom'], $plot['top']);
+ $width = max(0, $size - $gap);
+ $x = $offset + $gap / 2;
+
+ return [
+ 'x' => round($this->rtl ? $this->width - $x - $width : $x, 2),
+ 'y' => round(min($a, $b), 2),
+ 'width' => round($width, 2),
+ 'height' => round(abs($b - $a), 2),
+ 'title' => $title,
+ ];
+ }
+
+ /**
+ * A path through the points, broken where a value is missing
+ *
+ * @param list|null> $points
+ */
+ public static function linePath(array $points): ?string
+ {
+ $path = '';
+ $drawing = false;
+
+ foreach ($points as $point) {
+ if ($point === null) {
+ $drawing = false;
+
+ continue;
+ }
+
+ $path .= ($drawing ? 'L' : 'M') . $point['x'] . ' ' . $point['y'] . ' ';
+ $drawing = true;
+ }
+
+ return trim($path) ?: null;
+ }
+
+ /**
+ * The area under each unbroken run of points, down to its base
+ *
+ * @param list|null> $points
+ */
+ public static function areaPath(array $points): ?string
+ {
+ $paths = [];
+ $run = [];
+
+ foreach ([...$points, null] as $point) {
+ if ($point !== null) {
+ $run[] = $point;
+
+ continue;
+ }
+
+ if ($run !== []) {
+ $top = implode(' L', array_map(fn ($p) => $p['x'] . ' ' . $p['y'], $run));
+ $bottom = implode(' L', array_map(fn ($p) => $p['x'] . ' ' . $p['y0'], array_reverse($run)));
+ $paths[] = "M{$top} L{$bottom} Z";
+ $run = [];
+ }
+ }
+
+ return $paths === [] ? null : implode(' ', $paths);
+ }
+
+ /**
+ * Mirror an x position for right-to-left
+ */
+ protected function x(float $x): float
+ {
+ return round($this->rtl ? $this->width - $x : $x, 2);
+ }
+
+ protected function anchor(string $anchor): string
+ {
+ if (! $this->rtl) {
+ return $anchor;
+ }
+
+ return match ($anchor) {
+ 'start' => 'end',
+ 'end' => 'start',
+ default => $anchor,
+ };
+ }
+}
diff --git a/src/Views/Svg/PieGeometry.php b/src/Views/Svg/PieGeometry.php
new file mode 100644
index 0000000..a66a09e
--- /dev/null
+++ b/src/Views/Svg/PieGeometry.php
@@ -0,0 +1,78 @@
+ $values
+ * @return list
+ */
+ public static function slices(array $values, bool $donut = false, bool $rtl = false): array
+ {
+ $total = array_sum(array_map(fn ($value) => max(0, $value), $values));
+ $center = self::SIZE / 2;
+ $outer = $center - 4;
+ $inner = $donut ? $outer * 0.6 : 0;
+ $angle = 0.0;
+ $slices = [];
+
+ foreach ($values as $value) {
+ $share = $total > 0 ? max(0, $value) / $total : 0;
+ $sweep = $share * 2 * M_PI;
+
+ $slices[] = [
+ 'path' => static::arc($center, $outer, $inner, $angle, $angle + $sweep, $rtl),
+ 'share' => $share,
+ ];
+
+ $angle += $sweep;
+ }
+
+ return $slices;
+ }
+
+ protected static function arc(float $c, float $r, float $r0, float $start, float $end, bool $rtl): string
+ {
+ if ($end - $start <= 0) {
+ return '';
+ }
+
+ if ($end - $start >= 2 * M_PI - 1e-6) {
+ $end = $start + 2 * M_PI - 1e-4;
+ }
+
+ $large = $end - $start > M_PI ? 1 : 0;
+ $sweep = $rtl ? 0 : 1;
+
+ [$x1, $y1] = static::point($c, $r, $start, $rtl);
+ [$x2, $y2] = static::point($c, $r, $end, $rtl);
+
+ if ($r0 <= 0) {
+ return "M{$c} {$c} L{$x1} {$y1} A{$r} {$r} 0 {$large} {$sweep} {$x2} {$y2} Z";
+ }
+
+ [$x3, $y3] = static::point($c, $r0, $end, $rtl);
+ [$x4, $y4] = static::point($c, $r0, $start, $rtl);
+ $back = $rtl ? 1 : 0;
+
+ return "M{$x1} {$y1} A{$r} {$r} 0 {$large} {$sweep} {$x2} {$y2} L{$x3} {$y3} A{$r0} {$r0} 0 {$large} {$back} {$x4} {$y4} Z";
+ }
+
+ /**
+ * @return array{0: float, 1: float}
+ */
+ protected static function point(float $c, float $r, float $angle, bool $rtl): array
+ {
+ $x = $c + $r * sin($angle) * ($rtl ? -1 : 1);
+ $y = $c - $r * cos($angle);
+
+ return [round($x, 2), round($y, 2)];
+ }
+}
diff --git a/src/Views/Svg/Scale.php b/src/Views/Svg/Scale.php
new file mode 100644
index 0000000..87743a5
--- /dev/null
+++ b/src/Views/Svg/Scale.php
@@ -0,0 +1,62 @@
+ */
+ public array $ticks = [];
+
+ public float $min;
+
+ public float $max;
+
+ /**
+ * @param list $values
+ */
+ public function __construct(array $values, int $tick_count = 5)
+ {
+ $min = $values === [] ? 0 : min(0, min($values));
+ $max = $values === [] ? 0 : max(0, max($values));
+
+ if ($min == $max) {
+ $max = $min + 1;
+ }
+
+ $step = static::niceStep(($max - $min) / max(1, $tick_count));
+
+ $this->min = floor($min / $step) * $step;
+ $this->max = ceil($max / $step) * $step;
+
+ for ($tick = $this->min; $tick <= $this->max + $step / 2; $tick += $step) {
+ $this->ticks[] = round($tick, 10);
+ }
+ }
+
+ public static function niceStep(float $raw): float
+ {
+ $exponent = floor(log10($raw));
+ $fraction = $raw / (10 ** $exponent);
+
+ $nice = match (true) {
+ $fraction <= 1 => 1,
+ $fraction <= 2 => 2,
+ $fraction <= 2.5 => 2.5,
+ $fraction <= 5 => 5,
+ default => 10,
+ };
+
+ return $nice * (10 ** $exponent);
+ }
+
+ /**
+ * Where a value falls between the given pixel positions (bottom to top)
+ */
+ public function position(float $value, float $from, float $to): float
+ {
+ return $from + ($value - $this->min) / ($this->max - $this->min) * ($to - $from);
+ }
+}
diff --git a/src/Views/Svg/SparklineGeometry.php b/src/Views/Svg/SparklineGeometry.php
new file mode 100644
index 0000000..bc619a3
--- /dev/null
+++ b/src/Views/Svg/SparklineGeometry.php
@@ -0,0 +1,46 @@
+ $values
+ * @return array{width: int, height: int, line: ?string, area: ?string}
+ */
+ public static function make(array $values, int $height = 32, bool $rtl = false): array
+ {
+ $numbers = array_values(array_filter($values, fn ($value) => $value !== null));
+ $min = $numbers === [] ? 0 : min($numbers);
+ $max = $numbers === [] ? 0 : max($numbers);
+ $range = $max - $min ?: 1;
+ $count = count($values);
+ $points = [];
+
+ foreach ($values as $i => $value) {
+ if ($value === null) {
+ $points[] = null;
+
+ continue;
+ }
+
+ $x = $count > 1 ? $i * self::WIDTH / ($count - 1) : self::WIDTH / 2;
+ $x = $rtl ? self::WIDTH - $x : $x;
+ $y = $max == $min ? $height / 2 : 2 + ($max - $value) / $range * ($height - 4);
+
+ $points[] = ['x' => round($x, 2), 'y' => round($y, 2), 'y0' => $height];
+ }
+
+ return [
+ 'width' => self::WIDTH,
+ 'height' => $height,
+ 'line' => CartesianChart::linePath($points),
+ 'area' => CartesianChart::areaPath($points),
+ ];
+ }
+}
diff --git a/src/Views/ViewRenderer.php b/src/Views/ViewRenderer.php
new file mode 100644
index 0000000..39fdce2
--- /dev/null
+++ b/src/Views/ViewRenderer.php
@@ -0,0 +1,64 @@
+ $data
+ */
+ public function render(string $page, array $data = []): View|Responsable
+ {
+ if ($this->isInertia()) {
+ return $this->renderInertia($page, $data);
+ }
+
+ return view("stats::{$page}", $data);
+ }
+
+ public function stack(): string
+ {
+ return config('stats.views', self::BLADE);
+ }
+
+ public function isInertia(): bool
+ {
+ return $this->stack() === self::INERTIA;
+ }
+
+ /**
+ * The Inertia page name for a page: `time-series.index` → `stats::TimeSeries/Index`
+ */
+ public function inertiaPage(string $page): string
+ {
+ $segments = array_map(fn (string $segment) => str($segment)->studly()->toString(), explode('.', $page));
+
+ return 'stats::' . implode('/', $segments);
+ }
+
+ /**
+ * @param array $data
+ */
+ protected function renderInertia(string $page, array $data): Responsable
+ {
+ if (! class_exists(Inertia::class)) {
+ throw StatsException::inertiaMissing();
+ }
+
+ return Inertia::render($this->inertiaPage($page), $data);
+ }
+}
diff --git a/tests/Concerns/InteractsWithStats.php b/tests/Concerns/InteractsWithStats.php
new file mode 100644
index 0000000..93971b0
--- /dev/null
+++ b/tests/Concerns/InteractsWithStats.php
@@ -0,0 +1,55 @@
+ PaymentsCount::class,
+ 'payments_sum' => PaymentsSum::class,
+ 'payments_by_status' => PaymentsByStatusCount::class,
+ ], false);
+
+ CategoricalStats::register([
+ 'payments_by_user' => PaymentsByUser::class,
+ 'payment_amounts_by_user' => PaymentAmountsByUser::class,
+ ], false);
+ }
+
+ protected function allowViewingStats(): void
+ {
+ Gate::define('view_stats', fn ($user) => true);
+ }
+
+ /**
+ * Two users with payments on 3 and 4 July 2024; "today" is 4 July 2024
+ *
+ * @return array{0: User, 1: User}
+ */
+ protected function seedPayments(): array
+ {
+ $this->travelTo('2024-07-04 12:00:00');
+
+ $ahmed = User::factory()->create(['name' => 'އަޙްމަދު']);
+ $aisha = User::factory()->create(['name' => 'ޢާއިޝާ']);
+
+ PaymentFactory::new()->withUser($ahmed)->count(3)->create(['paid_at' => '2024-07-03 10:00:00', 'amount' => 100]);
+ PaymentFactory::new()->withUser($aisha)->count(2)->create(['paid_at' => '2024-07-04 09:00:00', 'amount' => 50, 'status' => 'pending']);
+ PaymentFactory::new()->withUser($aisha)->count(1)->create(['paid_at' => '2024-06-29 09:00:00', 'amount' => 10]);
+
+ return [$ahmed, $aisha];
+ }
+}
diff --git a/tests/Feature/Commands/GenerateCategoricalStatCommandTest.php b/tests/Feature/Commands/GenerateCategoricalStatCommandTest.php
index 4a257d2..ff3ee31 100644
--- a/tests/Feature/Commands/GenerateCategoricalStatCommandTest.php
+++ b/tests/Feature/Commands/GenerateCategoricalStatCommandTest.php
@@ -6,10 +6,11 @@
use Javaabu\Stats\Tests\TestCase;
use Javaabu\Stats\Tests\TestSupport\Models\Payment;
use Javaabu\Stats\Tests\TestSupport\Models\User;
+use PHPUnit\Framework\Attributes\Test;
class GenerateCategoricalStatCommandTest extends TestCase
{
- public function setUp(): void
+ protected function setUp(): void
{
parent::setUp();
@@ -31,7 +32,8 @@ protected function tearDown(): void
parent::tearDown();
}
- public function test_it_can_generate_a_count_categorical_stat(): void
+ #[Test]
+ public function it_can_generate_a_count_categorical_stat(): void
{
$expected_path = $this->app->path('Stats/Categorical/PaymentsByUser.php');
$expected_content = $this->getTestStubContents('Stats/Categorical/PaymentsByUser.php');
@@ -54,7 +56,8 @@ public function test_it_can_generate_a_count_categorical_stat(): void
}
}
- public function test_it_can_generate_a_sum_categorical_stat(): void
+ #[Test]
+ public function it_can_generate_a_sum_categorical_stat(): void
{
$expected_path = $this->app->path('Stats/Categorical/PaymentAmountsByUser.php');
$expected_content = $this->getTestStubContents('Stats/Categorical/PaymentAmountsByUser.php');
@@ -71,7 +74,8 @@ public function test_it_can_generate_a_sum_categorical_stat(): void
$this->assertEquals($expected_content, $this->getGeneratedFileContents($expected_path));
}
- public function test_it_registers_the_generated_categorical_stat(): void
+ #[Test]
+ public function it_registers_the_generated_categorical_stat(): void
{
$this->artisan('stats:categorical', [
'name' => 'PaymentsByUser',
@@ -88,7 +92,8 @@ public function test_it_registers_the_generated_categorical_stat(): void
$this->assertEquals($expected_content, $actual_content);
}
- public function test_it_shows_manual_registration_instructions_when_the_stat_cannot_be_registered(): void
+ #[Test]
+ public function it_shows_manual_registration_instructions_when_the_stat_cannot_be_registered(): void
{
$this->copyFile(
$this->getTestStubPath('Providers/AppServiceProvider.php'),
@@ -112,7 +117,8 @@ public function test_it_shows_manual_registration_instructions_when_the_stat_can
->assertSuccessful();
}
- public function test_it_rejects_an_invalid_categorical_stat_type(): void
+ #[Test]
+ public function it_rejects_an_invalid_categorical_stat_type(): void
{
$this->artisan('stats:categorical', [
'name' => 'PaymentsByUser',
diff --git a/tests/Feature/Commands/GenerateTimeSeriesStatCommandTest.php b/tests/Feature/Commands/GenerateTimeSeriesStatCommandTest.php
index 7ea6c9d..fa7713f 100644
--- a/tests/Feature/Commands/GenerateTimeSeriesStatCommandTest.php
+++ b/tests/Feature/Commands/GenerateTimeSeriesStatCommandTest.php
@@ -4,10 +4,11 @@
use Javaabu\Stats\Tests\TestCase;
use Javaabu\Stats\Tests\TestSupport\Models\Payment;
+use PHPUnit\Framework\Attributes\Test;
class GenerateTimeSeriesStatCommandTest extends TestCase
{
- public function setUp(): void
+ protected function setUp(): void
{
parent::setUp();
@@ -34,7 +35,8 @@ protected function tearDown(): void
parent::tearDown();
}
- public function test_it_can_generate_a_new_sum_stat_file(): void
+ #[Test]
+ public function it_can_generate_a_new_sum_stat_file(): void
{
$expected_path = $this->app->path('Stats/TimeSeries/PaymentsSum.php');
$expected_content = $this->getTestStubContents('Stats/TimeSeries/PaymentsSum.php');
@@ -48,7 +50,8 @@ public function test_it_can_generate_a_new_sum_stat_file(): void
$this->assertEquals($expected_content, $actual_content);
}
- public function test_it_can_generate_a_new_count_stat_file(): void
+ #[Test]
+ public function it_can_generate_a_new_count_stat_file(): void
{
$expected_path = $this->app->path('Stats/TimeSeries/PaymentsCount.php');
$expected_content = $this->getTestStubContents('Stats/TimeSeries/PaymentsCount.php');
@@ -62,7 +65,8 @@ public function test_it_can_generate_a_new_count_stat_file(): void
$this->assertEquals($expected_content, $actual_content);
}
- public function test_it_registers_stats_maps(): void
+ #[Test]
+ public function it_registers_stats_maps(): void
{
$expected_path = $this->app->path('Stats/TimeSeries/PaymentsCount.php');
$expected_content = $this->getTestStubContents('Stats/TimeSeries/PaymentsCount.php');
diff --git a/tests/Feature/Controllers/CategoricalStatsApiControllerTest.php b/tests/Feature/Controllers/CategoricalStatsApiControllerTest.php
index a463e98..8e49215 100644
--- a/tests/Feature/Controllers/CategoricalStatsApiControllerTest.php
+++ b/tests/Feature/Controllers/CategoricalStatsApiControllerTest.php
@@ -10,19 +10,21 @@
use Javaabu\Stats\Tests\TestSupport\Models\User;
use Javaabu\Stats\Tests\TestSupport\Stats\Categorical\PaymentsByUser;
use Javaabu\Stats\Tests\TestSupport\Stats\Categorical\PaymentsByUserWithPermission;
+use PHPUnit\Framework\Attributes\Test;
class CategoricalStatsApiControllerTest extends TestCase
{
use RefreshDatabase;
- public function setUp(): void
+ protected function setUp(): void
{
parent::setUp();
CategoricalStats::registerApiRoutes();
}
- public function test_it_can_generate_categorical_stats(): void
+ #[Test]
+ public function it_can_generate_categorical_stats(): void
{
CategoricalStats::register([
'payments_by_user' => PaymentsByUser::class,
@@ -34,7 +36,7 @@ public function test_it_can_generate_categorical_stats(): void
PaymentFactory::new()->withUser($bob)->count(3)->create(['paid_at' => '2024-07-04']);
$this->actingAs($alice)
- ->get(add_query_arg([
+ ->get($this->addQueryArg([
'metric' => 'payments_by_user',
'mode' => 'all',
'format' => 'chartjs',
@@ -54,7 +56,8 @@ public function test_it_can_generate_categorical_stats(): void
->assertJsonPath('result.stats', [2, 3]);
}
- public function test_it_can_search_the_selected_metrics_category_provider(): void
+ #[Test]
+ public function it_can_search_the_selected_metrics_category_provider(): void
{
CategoricalStats::register([
'payments_by_user' => PaymentsByUser::class,
@@ -73,7 +76,8 @@ public function test_it_can_search_the_selected_metrics_category_provider(): voi
->assertJsonPath('total', 1);
}
- public function test_category_search_uses_the_configured_page_size(): void
+ #[Test]
+ public function it_category_search_uses_the_configured_page_size(): void
{
CategoricalStats::register([
'payments_by_user' => PaymentsByUser::class,
@@ -94,7 +98,8 @@ public function test_category_search_uses_the_configured_page_size(): void
->assertJsonPath('total', 2);
}
- public function test_category_search_rejects_a_metric_hidden_from_the_current_user(): void
+ #[Test]
+ public function it_category_search_rejects_a_metric_hidden_from_the_current_user(): void
{
CategoricalStats::register([
'payments_by_user' => PaymentsByUser::class,
@@ -109,7 +114,8 @@ public function test_category_search_rejects_a_metric_hidden_from_the_current_us
->assertForbidden();
}
- public function test_it_rejects_metrics_that_do_not_support_current_filters(): void
+ #[Test]
+ public function it_rejects_metrics_that_do_not_support_current_filters(): void
{
CategoricalStats::register([
'payments_by_user' => PaymentsByUser::class,
diff --git a/tests/Feature/Controllers/CategoricalStatsControllerTest.php b/tests/Feature/Controllers/CategoricalStatsControllerTest.php
deleted file mode 100644
index 734dc50..0000000
--- a/tests/Feature/Controllers/CategoricalStatsControllerTest.php
+++ /dev/null
@@ -1,75 +0,0 @@
-app['config']->set('stats.default_layout', 'test::layouts.admin');
- CategoricalStats::registerApiRoutes('/api/stats/categorical');
- CategoricalStats::registerRoutes(middleware: ['web', 'stats.view-categorical']);
- CategoricalStats::register([
- 'payments_by_user' => PaymentsByUser::class,
- ], false);
- }
-
- public function test_it_can_display_the_categorical_stats_form(): void
- {
- $user = User::factory()->create();
-
- $this->actingAs($user)
- ->get('/stats/categorical')
- ->assertSuccessful()
- ->assertSee('name="metric"', false)
- ->assertSee('name="categorical_values[]"', false)
- ->assertSee('id="categorical-metric"', false)
- ->assertSee('id="categorical-mode"', false)
- ->assertSee('id="categorical-values"', false)
- ->assertSee('id="categorical-custom-date-range"', false)
- ->assertSee('id="categorical-generate-graph"', false)
- ->assertSee('id="categorical-download-stats"', false)
- ->assertSee("$('#categorical-generate-graph')", false)
- ->assertSee("$('#categorical-download-stats')", false)
- ->assertDontSee('id="generate-graph"', false)
- ->assertDontSee('id="btn-download-stats"', false)
- ->assertDontSee("$('#generate-graph')", false)
- ->assertDontSee("$('#btn-download-stats')", false);
- }
-
- public function test_it_can_export_categorical_stats(): void
- {
- $user = User::factory()->create(['name' => 'Alice']);
- PaymentFactory::new()->withUser($user)->count(2)->create(['paid_at' => '2024-07-03']);
- Excel::fake();
- $this->setFakeSetting('app_name', 'Test App');
-
- $this->actingAs($user)
- ->post('/stats/categorical', [
- 'metric' => 'payments_by_user',
- 'mode' => 'all',
- 'date_from' => '2024-07-01',
- 'date_to' => '2024-07-07',
- ])
- ->assertSuccessful();
-
- Excel::assertDownloaded('Test App Payments By User 20240701-20240707.csv', function (CategoricalStatsExport $export) {
- return $export->getReportTitle() == 'Payments By User'
- && $export->headings()[0] == 'User ID'
- && $export->headings()[1] == 'User';
- });
- }
-}
diff --git a/tests/Feature/Controllers/TimeSeriesStatsApiControllerTest.php b/tests/Feature/Controllers/TimeSeriesStatsApiControllerTest.php
index 1a2b873..77624a8 100644
--- a/tests/Feature/Controllers/TimeSeriesStatsApiControllerTest.php
+++ b/tests/Feature/Controllers/TimeSeriesStatsApiControllerTest.php
@@ -2,22 +2,25 @@
namespace Javaabu\Stats\Tests\Feature\Controllers;
+use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Javaabu\Stats\Tests\TestCase;
use Javaabu\Stats\Tests\TestSupport\Factories\ActivityFactory;
use Javaabu\Stats\Tests\TestSupport\Models\User;
-use Javaabu\Stats\Tests\TestSupport\MySQLRefreshDatabase;
use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\TestUserLoginsRepository;
use Javaabu\Stats\TimeSeriesStats;
+use PHPUnit\Framework\Attributes\RequiresMethod;
+use PHPUnit\Framework\Attributes\Test;
use Spatie\Activitylog\Models\Activity;
+/** Needs spatie/laravel-activitylog, which is optional */
+#[RequiresMethod(Activity::class, 'causer')]
class TimeSeriesStatsApiControllerTest extends TestCase
{
- use MySQLRefreshDatabase;
+ use RefreshDatabase;
- public function setUp(): void
+ protected function setUp(): void
{
- $this->setupMySql();
parent::setUp();
@@ -26,12 +29,13 @@ public function setUp(): void
Activity::query()->delete();
}
- public function test_it_can_generate_time_series_stats_with_filters(): void
+ #[Test]
+ public function it_can_generate_time_series_stats_with_filters(): void
{
$this->travelTo('2024-07-04');
TimeSeriesStats::register([
- 'user_logins' => TestUserLoginsRepository::class
+ 'user_logins' => TestUserLoginsRepository::class,
], false);
Gate::define('view_stats', function (User $user) {
@@ -60,7 +64,7 @@ public function test_it_can_generate_time_series_stats_with_filters(): void
$this->actingAs($user);
- $this->get(add_query_arg(
+ $this->get($this->addQueryArg(
[
'metric' => 'user_logins',
'format' => 'chartjs',
@@ -68,7 +72,7 @@ public function test_it_can_generate_time_series_stats_with_filters(): void
'mode' => 'day',
'filters' => [
'user' => $user->id,
- ]
+ ],
],
'/stats/time-series'
))
@@ -90,7 +94,7 @@ public function test_it_can_generate_time_series_stats_with_filters(): void
->assertJsonFragment([
'filters' => [
'user' => (string) $user->id,
- ]
+ ],
])
->assertJsonFragment([
'result' => [
@@ -114,17 +118,18 @@ public function test_it_can_generate_time_series_stats_with_filters(): void
2,
],
- 'compare' => null
- ]
+ 'compare' => null,
+ ],
]);
}
- public function test_it_can_generate_time_series_stats_from_api_with_preset_date_range(): void
+ #[Test]
+ public function it_can_generate_time_series_stats_from_api_with_preset_date_range(): void
{
$this->travelTo('2024-07-04');
TimeSeriesStats::register([
- 'user_logins' => TestUserLoginsRepository::class
+ 'user_logins' => TestUserLoginsRepository::class,
], false);
Gate::define('view_stats', function (User $user) {
@@ -153,19 +158,19 @@ public function test_it_can_generate_time_series_stats_from_api_with_preset_date
$this->actingAs($user);
- $this->get(add_query_arg(
- [
- 'metric' => 'user_logins',
- 'format' => 'chartjs',
- 'date_range' => 'last_7_days',
- 'date_from' => '',
- 'date_to' => '',
- 'mode' => 'day',
- 'compare' => true,
- 'compare_date_from' => '',
- 'compare_date_to' => '',
- ],
- '/stats/time-series'
+ $this->get($this->addQueryArg(
+ [
+ 'metric' => 'user_logins',
+ 'format' => 'chartjs',
+ 'date_range' => 'last_7_days',
+ 'date_from' => '',
+ 'date_to' => '',
+ 'mode' => 'day',
+ 'compare' => true,
+ 'compare_date_from' => '',
+ 'compare_date_to' => '',
+ ],
+ '/stats/time-series'
))
->assertSuccessful()
->assertJsonFragment([
@@ -185,7 +190,7 @@ public function test_it_can_generate_time_series_stats_from_api_with_preset_date
'format' => 'chartjs',
])
->assertJsonFragment([
- 'filters' => []
+ 'filters' => [],
])
->assertJsonFragment([
'result' => [
@@ -217,17 +222,18 @@ public function test_it_can_generate_time_series_stats_from_api_with_preset_date
0,
0,
0,
- ]
- ]
+ ],
+ ],
]);
}
- public function test_it_can_generate_time_series_stats_from_api_with_custom_date_range(): void
+ #[Test]
+ public function it_can_generate_time_series_stats_from_api_with_custom_date_range(): void
{
$this->travelTo('2024-07-04');
TimeSeriesStats::register([
- 'user_logins' => TestUserLoginsRepository::class
+ 'user_logins' => TestUserLoginsRepository::class,
], false);
Gate::define('view_stats', function (User $user) {
@@ -256,14 +262,14 @@ public function test_it_can_generate_time_series_stats_from_api_with_custom_date
$this->actingAs($user);
- $this->get(add_query_arg(
+ $this->get($this->addQueryArg(
[
'metric' => 'user_logins',
'format' => 'chartjs',
'date_from' => '2024-07-02',
'date_to' => '2024-07-07',
'mode' => 'day',
- 'compare' => true
+ 'compare' => true,
],
'/stats/time-series'
))
@@ -276,14 +282,14 @@ public function test_it_can_generate_time_series_stats_from_api_with_custom_date
'aggregate_field_label' => 'Logins',
'date_range' => 'custom',
'date_from' => '2024-07-02 00:00:00',
- 'date_to' => '2024-07-07 00:00:00',
+ 'date_to' => '2024-07-07 23:59:59',
'compare_date_range' => 'previous',
- 'compare_date_from' => '2024-06-26 23:59:59',
+ 'compare_date_from' => '2024-06-26 00:00:00',
'compare_date_to' => '2024-07-01 23:59:59',
'format' => 'chartjs',
])
->assertJsonFragment([
- 'filters' => []
+ 'filters' => [],
])
->assertJsonFragment([
'result' => [
@@ -312,17 +318,18 @@ public function test_it_can_generate_time_series_stats_from_api_with_custom_date
0,
0,
0,
- ]
- ]
+ ],
+ ],
]);
}
- public function test_it_can_generate_time_series_stats_from_api_with_preset_previous_date_range(): void
+ #[Test]
+ public function it_can_generate_time_series_stats_from_api_with_preset_previous_date_range(): void
{
$this->travelTo('2024-07-04');
TimeSeriesStats::register([
- 'user_logins' => TestUserLoginsRepository::class
+ 'user_logins' => TestUserLoginsRepository::class,
], false);
Gate::define('view_stats', function (User $user) {
@@ -360,13 +367,13 @@ public function test_it_can_generate_time_series_stats_from_api_with_preset_prev
$this->actingAs($user);
- $this->get(add_query_arg(
+ $this->get($this->addQueryArg(
[
'metric' => 'user_logins',
'format' => 'chartjs',
'date_range' => 'last_7_days',
'mode' => 'day',
- 'compare_date_range' => 'last_week'
+ 'compare_date_range' => 'last_week',
],
'/stats/time-series'
))
@@ -386,7 +393,7 @@ public function test_it_can_generate_time_series_stats_from_api_with_preset_prev
'format' => 'chartjs',
])
->assertJsonFragment([
- 'filters' => []
+ 'filters' => [],
])
->assertJsonFragment([
'result' => [
@@ -418,17 +425,18 @@ public function test_it_can_generate_time_series_stats_from_api_with_preset_prev
0,
0,
0,
- ]
- ]
+ ],
+ ],
]);
}
- public function test_it_can_generate_time_series_stats_from_api_with_custom_previous_date_range(): void
+ #[Test]
+ public function it_can_generate_time_series_stats_from_api_with_custom_previous_date_range(): void
{
$this->travelTo('2024-07-04');
TimeSeriesStats::register([
- 'user_logins' => TestUserLoginsRepository::class
+ 'user_logins' => TestUserLoginsRepository::class,
], false);
Gate::define('view_stats', function (User $user) {
@@ -466,14 +474,14 @@ public function test_it_can_generate_time_series_stats_from_api_with_custom_prev
$this->actingAs($user);
- $this->get(add_query_arg(
+ $this->get($this->addQueryArg(
[
'metric' => 'user_logins',
'format' => 'chartjs',
'date_range' => 'last_7_days',
'mode' => 'day',
'compare_date_from' => '2024-06-23',
- 'compare_date_to' => '2024-06-26'
+ 'compare_date_to' => '2024-06-26',
],
'/stats/time-series'
))
@@ -489,11 +497,11 @@ public function test_it_can_generate_time_series_stats_from_api_with_custom_prev
'date_to' => '2024-07-04 23:59:59',
'compare_date_range' => 'custom',
'compare_date_from' => '2024-06-23 00:00:00',
- 'compare_date_to' => '2024-06-26 00:00:00',
+ 'compare_date_to' => '2024-06-26 23:59:59',
'format' => 'chartjs',
])
->assertJsonFragment([
- 'filters' => []
+ 'filters' => [],
])
->assertJsonFragment([
'result' => [
@@ -525,8 +533,8 @@ public function test_it_can_generate_time_series_stats_from_api_with_custom_prev
0,
0,
0,
- ]
- ]
+ ],
+ ],
]);
}
}
diff --git a/tests/Feature/Controllers/TimeSeriesStatsControllerTest.php b/tests/Feature/Controllers/TimeSeriesStatsControllerTest.php
deleted file mode 100644
index 64eb67a..0000000
--- a/tests/Feature/Controllers/TimeSeriesStatsControllerTest.php
+++ /dev/null
@@ -1,134 +0,0 @@
-setupMySql();
-
- parent::setUp();
-
- $this->app['config']->set('stats.default_layout', 'test::layouts.admin');
-
- TimeSeriesStats::registerApiRoute('/api/stats/time-series');
- TimeSeriesStats::registerRoutes(middleware: ['web', 'stats.view-time-series']);
-
- Activity::query()->delete();
- }
-
- public function test_it_can_display_the_time_series_stats_generation_form(): void
- {
- $this->withoutExceptionHandling();
-
- $this->travelTo('2024-07-04');
-
- TimeSeriesStats::register([
- 'user_logins' => TestUserLoginsRepository::class
- ], false);
-
- Gate::define('view_stats', function (User $user) {
- return true;
- });
-
- // yesterday
- ActivityFactory::new()
- ->login()
- ->withUser()
- ->count(5)
- ->create([
- 'created_at' => '2024-07-03',
- ]);
-
- $user = User::factory()->create();
-
- // today
- ActivityFactory::new()
- ->login()
- ->withUser($user)
- ->count(2)
- ->create([
- 'created_at' => '2024-07-04',
- ]);
-
- $this->actingAs($user);
-
- $this->get('/stats/time-series')
- ->assertSuccessful()
- ->assertSee('name="metric"', false);
- }
-
- public function test_it_can_export_time_series_stats_with_preset_date_range(): void
- {
- $this->withoutExceptionHandling();
-
- $this->travelTo('2024-07-04');
-
- TimeSeriesStats::register([
- 'user_logins' => TestUserLoginsRepository::class
- ], false);
-
- Gate::define('view_stats', function (User $user) {
- return true;
- });
-
- // yesterday
- ActivityFactory::new()
- ->login()
- ->withUser()
- ->count(5)
- ->create([
- 'created_at' => '2024-07-03',
- ]);
-
- // today
- ActivityFactory::new()
- ->login()
- ->withUser()
- ->count(2)
- ->create([
- 'created_at' => '2024-07-04',
- ]);
-
- $user = User::factory()->create();
-
- Excel::fake();
-
- $this->setFakeSetting('app_name', 'Test App');
-
- $this->actingAs($user);
-
- $this->post(
- '/stats/time-series',
- [
- 'metric' => 'user_logins',
- 'date_range' => 'last_7_days',
- 'date_from' => '',
- 'date_to' => '',
- 'mode' => 'day',
- 'compare' => true,
- 'compare_date_from' => '',
- 'compare_date_to' => '',
- ]
- )
- ->assertSuccessful();
-
- Excel::assertDownloaded('Test App Test User Logins 20240628-20240704 20240621-20240627.csv', function(TimeSeriesStatsExport $export) {
- return $export->getReportTitle() == 'Test User Logins';
- });
- }
-}
diff --git a/tests/Feature/Formatters/ChartjsStatsFormatterTest.php b/tests/Feature/Formatters/ChartjsStatsFormatterTest.php
index 0625d58..5da8910 100644
--- a/tests/Feature/Formatters/ChartjsStatsFormatterTest.php
+++ b/tests/Feature/Formatters/ChartjsStatsFormatterTest.php
@@ -2,19 +2,25 @@
namespace Javaabu\Stats\Tests\Feature\Formatters;
+use Illuminate\Foundation\Testing\RefreshDatabase;
use Javaabu\Stats\Enums\PresetDateRanges;
use Javaabu\Stats\Enums\TimeSeriesModes;
use Javaabu\Stats\Formatters\TimeSeries\ChartjsStatsFormatter;
use Javaabu\Stats\Tests\TestCase;
use Javaabu\Stats\Tests\TestSupport\Factories\ActivityFactory;
-use Javaabu\Stats\Tests\TestSupport\MySQLRefreshDatabase;
use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\UserLogoutsRepository;
+use PHPUnit\Framework\Attributes\RequiresMethod;
+use PHPUnit\Framework\Attributes\Test;
+use Spatie\Activitylog\Models\Activity;
+/** Needs spatie/laravel-activitylog, which is optional */
+#[RequiresMethod(Activity::class, 'causer')]
class ChartjsStatsFormatterTest extends TestCase
{
- use MySQLRefreshDatabase;
+ use RefreshDatabase;
- public function test_it_can_format_with_the_chartjs_formatter(): void
+ #[Test]
+ public function it_can_format_with_the_chartjs_formatter(): void
{
$this->travelTo('2024-07-04');
@@ -40,7 +46,7 @@ public function test_it_can_format_with_the_chartjs_formatter(): void
$stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS);
$compare = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS->getPreviousDateRange());
- $formatter = new ChartjsStatsFormatter();
+ $formatter = new ChartjsStatsFormatter;
$data = $formatter->format(TimeSeriesModes::DAY, $stat, $compare);
@@ -84,7 +90,8 @@ public function test_it_can_format_with_the_chartjs_formatter(): void
], $compare_data);
}
- public function test_it_can_format_with_the_chartjs_formatter_for_weekly_stats(): void
+ #[Test]
+ public function it_can_format_with_the_chartjs_formatter_for_weekly_stats(): void
{
$this->travelTo('2024-07-22');
@@ -110,7 +117,7 @@ public function test_it_can_format_with_the_chartjs_formatter_for_weekly_stats()
$stat = new UserLogoutsRepository(PresetDateRanges::THIS_WEEK);
$compare = new UserLogoutsRepository(PresetDateRanges::THIS_WEEK->getPreviousDateRange());
- $formatter = new ChartjsStatsFormatter();
+ $formatter = new ChartjsStatsFormatter;
$data = $formatter->format(TimeSeriesModes::WEEK, $stat, $compare);
diff --git a/tests/Feature/Formatters/CombinedStatsFormatterTest.php b/tests/Feature/Formatters/CombinedStatsFormatterTest.php
index ce701a5..38cfe7d 100644
--- a/tests/Feature/Formatters/CombinedStatsFormatterTest.php
+++ b/tests/Feature/Formatters/CombinedStatsFormatterTest.php
@@ -9,12 +9,18 @@
use Javaabu\Stats\Tests\TestCase;
use Javaabu\Stats\Tests\TestSupport\Factories\ActivityFactory;
use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\UserLogoutsRepository;
+use PHPUnit\Framework\Attributes\RequiresMethod;
+use PHPUnit\Framework\Attributes\Test;
+use Spatie\Activitylog\Models\Activity;
+/** Needs spatie/laravel-activitylog, which is optional */
+#[RequiresMethod(Activity::class, 'causer')]
class CombinedStatsFormatterTest extends TestCase
{
use RefreshDatabase;
- public function test_it_can_format_with_the_combined_formatter(): void
+ #[Test]
+ public function it_can_format_with_the_combined_formatter(): void
{
$this->travelTo('2024-07-04');
@@ -40,7 +46,7 @@ public function test_it_can_format_with_the_combined_formatter(): void
$stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS);
$compare = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS->getPreviousDateRange());
- $formatter = new CombinedStatsFormatter();
+ $formatter = new CombinedStatsFormatter;
$data = $formatter->format(TimeSeriesModes::DAY, $stat, $compare);
diff --git a/tests/Feature/Formatters/DefaultStatsFormatterTest.php b/tests/Feature/Formatters/DefaultStatsFormatterTest.php
index e060536..c5f5eb7 100644
--- a/tests/Feature/Formatters/DefaultStatsFormatterTest.php
+++ b/tests/Feature/Formatters/DefaultStatsFormatterTest.php
@@ -9,12 +9,18 @@
use Javaabu\Stats\Tests\TestCase;
use Javaabu\Stats\Tests\TestSupport\Factories\ActivityFactory;
use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\UserLogoutsRepository;
+use PHPUnit\Framework\Attributes\RequiresMethod;
+use PHPUnit\Framework\Attributes\Test;
+use Spatie\Activitylog\Models\Activity;
+/** Needs spatie/laravel-activitylog, which is optional */
+#[RequiresMethod(Activity::class, 'causer')]
class DefaultStatsFormatterTest extends TestCase
{
use RefreshDatabase;
- public function test_it_can_format_with_the_default_formatter(): void
+ #[Test]
+ public function it_can_format_with_the_default_formatter(): void
{
$this->travelTo('2024-07-04');
@@ -36,11 +42,10 @@ public function test_it_can_format_with_the_default_formatter(): void
'created_at' => '2024-07-04',
]);
-
// create the stat
$stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS);
- $formatter = new DefaultStatsFormatter();
+ $formatter = new DefaultStatsFormatter;
$data = $formatter->format(TimeSeriesModes::DAY, $stat);
diff --git a/tests/Feature/Formatters/FlotStatsFormatterTest.php b/tests/Feature/Formatters/FlotStatsFormatterTest.php
index f2c0d32..f3dcfd4 100644
--- a/tests/Feature/Formatters/FlotStatsFormatterTest.php
+++ b/tests/Feature/Formatters/FlotStatsFormatterTest.php
@@ -9,12 +9,18 @@
use Javaabu\Stats\Tests\TestCase;
use Javaabu\Stats\Tests\TestSupport\Factories\ActivityFactory;
use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\UserLogoutsRepository;
+use PHPUnit\Framework\Attributes\RequiresMethod;
+use PHPUnit\Framework\Attributes\Test;
+use Spatie\Activitylog\Models\Activity;
+/** Needs spatie/laravel-activitylog, which is optional */
+#[RequiresMethod(Activity::class, 'causer')]
class FlotStatsFormatterTest extends TestCase
{
use RefreshDatabase;
- public function test_it_can_format_with_the_flot_formatter(): void
+ #[Test]
+ public function it_can_format_with_the_flot_formatter(): void
{
$this->travelTo('2024-07-04');
@@ -40,7 +46,7 @@ public function test_it_can_format_with_the_flot_formatter(): void
$stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS);
$compare = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS->getPreviousDateRange());
- $formatter = new FlotStatsFormatter();
+ $formatter = new FlotStatsFormatter;
$data = $formatter->format(TimeSeriesModes::DAY, $stat, $compare);
diff --git a/tests/Feature/Formatters/SparklineChartsStatsFormatterTest.php b/tests/Feature/Formatters/SparklineChartsStatsFormatterTest.php
index cfc960b..85bf514 100644
--- a/tests/Feature/Formatters/SparklineChartsStatsFormatterTest.php
+++ b/tests/Feature/Formatters/SparklineChartsStatsFormatterTest.php
@@ -5,17 +5,22 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Javaabu\Stats\Enums\PresetDateRanges;
use Javaabu\Stats\Enums\TimeSeriesModes;
-use Javaabu\Stats\Formatters\TimeSeries\FlotStatsFormatter;
use Javaabu\Stats\Formatters\TimeSeries\SparklineChartsStatsFormatter;
use Javaabu\Stats\Tests\TestCase;
use Javaabu\Stats\Tests\TestSupport\Factories\ActivityFactory;
use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\UserLogoutsRepository;
+use PHPUnit\Framework\Attributes\RequiresMethod;
+use PHPUnit\Framework\Attributes\Test;
+use Spatie\Activitylog\Models\Activity;
+/** Needs spatie/laravel-activitylog, which is optional */
+#[RequiresMethod(Activity::class, 'causer')]
class SparklineChartsStatsFormatterTest extends TestCase
{
use RefreshDatabase;
- public function test_it_can_format_with_the_sparkline_formatter(): void
+ #[Test]
+ public function it_can_format_with_the_sparkline_formatter(): void
{
$this->travelTo('2024-07-04');
@@ -41,7 +46,7 @@ public function test_it_can_format_with_the_sparkline_formatter(): void
$stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS);
$compare = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS->getPreviousDateRange());
- $formatter = new SparklineChartsStatsFormatter();
+ $formatter = new SparklineChartsStatsFormatter;
$data = $formatter->format(TimeSeriesModes::DAY, $stat, $compare);
diff --git a/tests/Feature/Middleware/AbortIfCannotViewAnyTimeSeriesStatsTest.php b/tests/Feature/Middleware/AbortIfCannotViewAnyTimeSeriesStatsTest.php
index f3c998e..e62b802 100644
--- a/tests/Feature/Middleware/AbortIfCannotViewAnyTimeSeriesStatsTest.php
+++ b/tests/Feature/Middleware/AbortIfCannotViewAnyTimeSeriesStatsTest.php
@@ -2,32 +2,37 @@
namespace Javaabu\Stats\Tests\Feature\Middleware;
+use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Javaabu\Stats\Tests\TestCase;
use Javaabu\Stats\Tests\TestSupport\Models\User;
-use Javaabu\Stats\Tests\TestSupport\MySQLRefreshDatabase;
use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\UserLogoutsWithPermission;
use Javaabu\Stats\TimeSeriesStats;
+use PHPUnit\Framework\Attributes\RequiresMethod;
+use PHPUnit\Framework\Attributes\Test;
+use Spatie\Activitylog\Models\Activity;
+/** Needs spatie/laravel-activitylog, which is optional */
+#[RequiresMethod(Activity::class, 'causer')]
class AbortIfCannotViewAnyTimeSeriesStatsTest extends TestCase
{
- use MySQLRefreshDatabase;
+ use RefreshDatabase;
- public function setUp(): void
+ protected function setUp(): void
{
- $this->setupMySql();
parent::setUp();
TimeSeriesStats::registerApiRoute();
}
- public function test_it_aborts_if_the_user_cannot_view_any_time_series_stats(): void
+ #[Test]
+ public function it_aborts_if_the_user_cannot_view_any_time_series_stats(): void
{
$user = User::factory()->make();
TimeSeriesStats::register([
- 'user_logouts_with_permission' => UserLogoutsWithPermission::class
+ 'user_logouts_with_permission' => UserLogoutsWithPermission::class,
], false);
Gate::define('view_stats', function (User $user) {
@@ -39,26 +44,27 @@ public function test_it_aborts_if_the_user_cannot_view_any_time_series_stats():
$this->actingAs($user);
- $this->get(add_query_arg(
- [
- 'metric' => 'user_logouts_with_permission',
- 'format' => 'chartjs',
- 'date_range' => 'today',
- 'mode' => 'hour'
- ],
- '/stats/time-series'
+ $this->get($this->addQueryArg(
+ [
+ 'metric' => 'user_logouts_with_permission',
+ 'format' => 'chartjs',
+ 'date_range' => 'today',
+ 'mode' => 'hour',
+ ],
+ '/stats/time-series'
))
->assertForbidden();
}
- public function test_it_allows_the_api_request_if_the_user_can_view_any_time_series_stats(): void
+ #[Test]
+ public function it_allows_the_api_request_if_the_user_can_view_any_time_series_stats(): void
{
$this->withoutExceptionHandling();
$user = User::factory()->make();
TimeSeriesStats::register([
- 'user_logouts_with_permission' => UserLogoutsWithPermission::class
+ 'user_logouts_with_permission' => UserLogoutsWithPermission::class,
], false);
Gate::define('view_stats', function (User $user) {
@@ -70,12 +76,12 @@ public function test_it_allows_the_api_request_if_the_user_can_view_any_time_ser
$this->actingAs($user);
- $this->get(add_query_arg(
+ $this->get($this->addQueryArg(
[
'metric' => 'user_logouts_with_permission',
'format' => 'chartjs',
'date_range' => 'today',
- 'mode' => 'hour'
+ 'mode' => 'hour',
],
'/stats/time-series'
))
diff --git a/tests/Feature/Pages/CategoricalStatsPageTest.php b/tests/Feature/Pages/CategoricalStatsPageTest.php
new file mode 100644
index 0000000..4a2b3b2
--- /dev/null
+++ b/tests/Feature/Pages/CategoricalStatsPageTest.php
@@ -0,0 +1,112 @@
+group(fn () => Route::stats());
+
+ $this->registerTestStats();
+ $this->allowViewingStats();
+ }
+
+ #[Test]
+ public function it_shows_the_categorical_form_and_a_bar_chart(): void
+ {
+ [$ahmed] = $this->seedPayments();
+
+ $this->actingAs($ahmed)
+ ->get('/stats/categorical?metric=payments_by_user&mode=non_empty&date_range=custom&date_from=2024-07-01&date_to=2024-07-04')
+ ->assertOk()
+ ->assertSee('data-stats-page="categorical"', false)
+ ->assertSee('name="categorical_values[]"', false)
+ ->assertSee('filter-field="metric"', false)
+ ->assertSee('/stats/categorical/categories', false)
+ ->assertSee('data-chart-variant="bar"', false)
+ ->assertSee('އަޙްމަދު · Payments By User: 3', false)
+ ->assertSee('ޢާއިޝާ · Payments By User: 2', false);
+ }
+
+ #[Test]
+ public function it_draws_a_pie_of_one_stat(): void
+ {
+ [$ahmed] = $this->seedPayments();
+
+ $this->actingAs($ahmed)
+ ->get('/stats/categorical?metric=payments_by_user&mode=non_empty&date_range=custom&date_from=2024-07-01&date_to=2024-07-04&variant=pie')
+ ->assertOk()
+ ->assertSee('data-chart-variant="donut"', false)
+ ->assertSee('Donut chart with 2 slices. Total 5. Largest: އަޙްމަދު, 60%.');
+ }
+
+ #[Test]
+ public function it_compares_the_categories_with_the_previous_period(): void
+ {
+ [$ahmed] = $this->seedPayments();
+
+ $this->actingAs($ahmed)
+ ->get('/stats/categorical?metric=payments_by_user&mode=all&date_range=custom&date_from=2024-07-03&date_to=2024-07-04&compare_to=previous')
+ ->assertOk()
+ ->assertSee('data-key="previous"', false)
+ ->assertSee('data-slot="period-current-total">5<', false);
+ }
+
+ #[Test]
+ public function it_shows_only_the_chosen_categories(): void
+ {
+ [$ahmed, $aisha] = $this->seedPayments();
+
+ $this->actingAs($ahmed)
+ ->get("/stats/categorical?metric=payments_by_user&mode=specific&categorical_values[]={$aisha->id}&date_range=custom&date_from=2024-07-01&date_to=2024-07-04")
+ ->assertOk()
+ ->assertSee('ޢާއިޝާ · Payments By User: 2', false)
+ ->assertDontSee('އަޙްމަދު · Payments By User', false);
+ }
+
+ #[Test]
+ public function it_searches_the_categories_of_a_stat(): void
+ {
+ [$ahmed] = $this->seedPayments();
+
+ $this->actingAs($ahmed)
+ ->getJson($this->addQueryArg(['filter' => ['metric' => 'payments_by_user', 'search' => 'ޢާއި']], '/stats/categorical/categories'))
+ ->assertOk()
+ ->assertJsonPath('data.0.label', 'ޢާއިޝާ')
+ ->assertJsonCount(1, 'data');
+ }
+
+ #[Test]
+ public function it_downloads_the_categorical_stat_as_csv(): void
+ {
+ [$ahmed] = $this->seedPayments();
+ config()->set('app.name', 'Test App');
+
+ $response = $this->actingAs($ahmed)
+ ->post('/stats/categorical', [
+ 'metric' => 'payments_by_user',
+ 'mode' => 'all',
+ 'date_from' => '2024-07-01',
+ 'date_to' => '2024-07-07',
+ ])
+ ->assertOk()
+ ->assertDownload('Test App Payments By User 20240701-20240707.csv');
+
+ $csv = $response->streamedContent();
+
+ $this->assertStringContainsString("\"User ID\",User,Count\n", $csv);
+ $this->assertStringContainsString("{$ahmed->id},އަޙްމަދު,3", $csv);
+ }
+}
diff --git a/tests/Feature/Pages/TimeSeriesStatsPageTest.php b/tests/Feature/Pages/TimeSeriesStatsPageTest.php
new file mode 100644
index 0000000..7a57f61
--- /dev/null
+++ b/tests/Feature/Pages/TimeSeriesStatsPageTest.php
@@ -0,0 +1,171 @@
+group(fn () => Route::stats());
+
+ $this->registerTestStats();
+ $this->allowViewingStats();
+ }
+
+ #[Test]
+ public function it_shows_the_form_and_a_chart_of_the_first_stat_by_default(): void
+ {
+ [$ahmed] = $this->seedPayments();
+
+ $this->actingAs($ahmed)
+ ->get('/stats/time-series')
+ ->assertOk()
+ ->assertSee('data-stats-page="time-series"', false)
+ ->assertSee('name="metric"', false)
+ ->assertSee('name="mode"', false)
+ ->assertSee('name="date_range"', false)
+ ->assertSee('name="compare_to"', false)
+ ->assertSee('data-slot="chart"', false)
+ ->assertSee('
', $html);
+ }
+
+ #[Test]
+ public function it_draws_a_chart_from_an_array(): void
+ {
+ $html = $this->renderBlade('', ['chart' => [
+ 'type' => 'category',
+ 'title' => 'ރަށްތައް',
+ 'valueFormat' => 'currency',
+ 'data' => [['category' => 'މާލެ', 'amount' => 1250.5], ['category' => 'އައްޑޫ', 'amount' => -20]],
+ 'config' => ['amount' => ['label' => 'ފައިސާ']],
+ 'categoryKey' => 'category',
+ 'variant' => 'bar',
+ ]]);
+
+ $this->assertStringContainsString('ރަށްތައް', $html);
+ $this->assertStringContainsString('MVR 1,250.50', $html);
+ $this->assertStringContainsString('class="stats-chart__zero"', $html);
+ }
+}
diff --git a/tests/TestSupport/Providers/TestServiceProvider.php b/tests/TestSupport/Providers/TestServiceProvider.php
index 5cec9d7..7dd397a 100644
--- a/tests/TestSupport/Providers/TestServiceProvider.php
+++ b/tests/TestSupport/Providers/TestServiceProvider.php
@@ -16,6 +16,9 @@ public function boot()
]);
$this->loadViewsFrom(__DIR__ . '/../views', 'test');
+
+ // The Inertia root view, `app`
+ $this->app['view']->addLocation(__DIR__ . '/../views');
}
/**
diff --git a/tests/TestSupport/views/app.blade.php b/tests/TestSupport/views/app.blade.php
new file mode 100644
index 0000000..dd10ca1
--- /dev/null
+++ b/tests/TestSupport/views/app.blade.php
@@ -0,0 +1,5 @@
+
+
+@inertiaHead
+@inertia
+
diff --git a/tests/js/StatsChart.test.tsx b/tests/js/StatsChart.test.tsx
new file mode 100644
index 0000000..df3eebc
--- /dev/null
+++ b/tests/js/StatsChart.test.tsx
@@ -0,0 +1,138 @@
+import { render, screen } from "@testing-library/react"
+import { describe, expect, it } from "vitest"
+import { StatsChart, StatsTile, type StatsChartData, type StatTileData } from "@javaabu/stats"
+import categorical from "./fixtures/categorical-props.json"
+import timeSeries from "./fixtures/time-series-props.json"
+
+const base = { id: "chart", description: null, valueFormat: "number" as const, height: null }
+
+describe("StatsChart", () => {
+ it("draws a time series chart from PHP with its comparison", () => {
+ const chart = timeSeries.chart as StatsChartData
+
+ const { container } = render()
+
+ expect(screen.getByText("Payments")).toBeInTheDocument()
+ expect(container.querySelector('[data-slot="period-totals"]')).not.toBeNull()
+ expect(screen.getByRole("button", { name: /table/i })).toBeInTheDocument()
+ })
+
+ it("draws a pie from PHP", () => {
+ const chart = categorical.chart as StatsChartData
+
+ const { container } = render()
+
+ expect(screen.getByText("Payments By User")).toBeInTheDocument()
+ expect(container.querySelector('[data-slot="chart-summary"]')).toHaveTextContent(
+ "Donut chart with 2 slices, total 5. Largest: އަޙްމަދު",
+ )
+ })
+
+ it("draws a category chart with horizontal bars", () => {
+ const chart: StatsChartData = {
+ ...base,
+ type: "category",
+ title: "ރަށްތަކުގެ ފައިސާ",
+ data: [
+ { category: "މާލެ", categoryId: 1, payments: 12 },
+ { category: "ހުޅުމާލެ", categoryId: 2, payments: 7 },
+ ],
+ config: { payments: { label: "ފައިސާ" } },
+ categoryKey: "category",
+ categoryLabel: "ރަށް",
+ aggregateLabel: "Count",
+ variant: "bar",
+ orientation: "horizontal",
+ stacked: false,
+ comparison: null,
+ }
+
+ const { container } = render()
+
+ expect(screen.getByText("ރަށްތަކުގެ ފައިސާ")).toBeInTheDocument()
+ expect(container.querySelector('[data-orientation="horizontal"]')).not.toBeNull()
+ })
+
+ it("draws a line category chart", () => {
+ const chart: StatsChartData = {
+ ...base,
+ type: "category",
+ title: "Lines",
+ data: [{ category: "A", categoryId: 1, a: 1 }],
+ config: { a: { label: "A" } },
+ categoryKey: "category",
+ categoryLabel: null,
+ aggregateLabel: null,
+ variant: "line",
+ orientation: null,
+ stacked: false,
+ comparison: null,
+ }
+
+ render()
+
+ expect(screen.getByText("Lines")).toBeInTheDocument()
+ })
+
+ it("draws a sparkline with an accessible summary", () => {
+ const chart: StatsChartData = {
+ ...base,
+ type: "sparkline",
+ title: "Payments",
+ data: [0, 0, 3, 2],
+ label: "ފައިސާ",
+ variant: "area",
+ color: "var(--chart-2)",
+ mode: "day",
+ }
+
+ render()
+
+ expect(screen.getByRole("img", { name: /ފައިސާ/ })).toBeInTheDocument()
+ })
+
+ it("passes the donut's centre label, or none", () => {
+ const pie = categorical.chart as Extract
+
+ const { rerender, container } = render(
+ ,
+ )
+ expect(container.querySelector('[data-slot="chart"]')).not.toBeNull()
+
+ rerender()
+ expect(container.querySelector('[data-slot="chart-summary"]')).toHaveTextContent("Pie chart")
+ })
+})
+
+describe("StatsTile", () => {
+ const tile: StatTileData = {
+ ...base,
+ type: "stat-tile",
+ title: "ފައިސާ",
+ label: "ފައިސާ",
+ value: 5,
+ previousValue: 1,
+ deltaContext: "vs previous period",
+ positiveIsGood: true,
+ sparkline: [0, 0, 3, 2],
+ icon: null,
+ href: null,
+ footer: "4 days",
+ period: null,
+ }
+
+ it("shows the value, the change and the footer", () => {
+ render()
+
+ expect(screen.getByRole("group", { name: "ފައިސާ" })).toBeInTheDocument()
+ expect(screen.getByText("5")).toBeInTheDocument()
+ expect(screen.getByText("vs previous period")).toBeInTheDocument()
+ expect(screen.getByText("4 days")).toBeInTheDocument()
+ })
+
+ it("is a link when it has an href", () => {
+ render()
+
+ expect(screen.getByRole("link")).toHaveAttribute("href", "/payments")
+ })
+})
diff --git a/tests/js/StatsPage.test.tsx b/tests/js/StatsPage.test.tsx
new file mode 100644
index 0000000..ab74c4f
--- /dev/null
+++ b/tests/js/StatsPage.test.tsx
@@ -0,0 +1,51 @@
+import { render, screen } from "@testing-library/react"
+import { describe, expect, it } from "vitest"
+import { CategoricalStatsPage, TimeSeriesStatsPage, type StatsPageProps } from "@javaabu/stats"
+import categorical from "./fixtures/categorical-props.json"
+import timeSeries from "./fixtures/time-series-props.json"
+
+describe("TimeSeriesStatsPage", () => {
+ it("renders the stencil form, the chart and the CSV download", () => {
+ const { container } = render()
+
+ expect(screen.getByRole("heading", { name: "Time Series Stats" })).toBeInTheDocument()
+ expect(container.querySelector('[data-stats-page="time-series"]')).not.toBeNull()
+ expect(container.querySelector('input[name="metric"], select[name="metric"]')).not.toBeNull()
+
+ const download = container.querySelector('[data-slot="stats-export"]') as HTMLFormElement
+ expect(download).toHaveAttribute("method", "post")
+ expect(download.querySelector('input[name="_token"]')).not.toBeNull()
+ expect(download.querySelector('input[name="metric"]')).toHaveValue("payments_count")
+ expect(screen.getByRole("button", { name: "Download CSV" })).toBeInTheDocument()
+ })
+
+ it("explains invalid input instead of drawing a chart", () => {
+ const props = {
+ ...(timeSeries as unknown as StatsPageProps),
+ chart: null,
+ export: null,
+ errors: { metric: "The selected stat is invalid." },
+ }
+
+ render()
+
+ expect(screen.getByText("Fix the errors in the form to see the stat.")).toBeInTheDocument()
+ })
+
+ it("asks for a stat when there is no chart yet", () => {
+ const props = { ...(timeSeries as unknown as StatsPageProps), chart: null, export: null, errors: {} }
+
+ render()
+
+ expect(screen.getByText("Choose a stat and a date range, then select Generate.")).toBeInTheDocument()
+ })
+})
+
+describe("CategoricalStatsPage", () => {
+ it("renders the categorical page", () => {
+ const { container } = render()
+
+ expect(container.querySelector('[data-stats-page="categorical"]')).not.toBeNull()
+ expect(screen.getByRole("heading", { name: "Categorical Stats" })).toBeInTheDocument()
+ })
+})
diff --git a/tests/js/fixtures/categorical-props.json b/tests/js/fixtures/categorical-props.json
new file mode 100644
index 0000000..e50888b
--- /dev/null
+++ b/tests/js/fixtures/categorical-props.json
@@ -0,0 +1,389 @@
+{
+ "form": {
+ "stencil": 1,
+ "id": "categorical-stats-form",
+ "action": "http://localhost/stats/categorical",
+ "method": "GET",
+ "operation": "edit",
+ "submit": "Generate",
+ "columns": {
+ "default": 1,
+ "md": 2,
+ "lg": 4
+ },
+ "state": {
+ "metric": "payments_by_user",
+ "mode": "non_empty",
+ "date_range": "custom",
+ "compare_to": null,
+ "date_from": "2024-07-01",
+ "date_to": "2024-07-04",
+ "compare_date_from": null,
+ "compare_date_to": null,
+ "categorical_values": null,
+ "with": null,
+ "variant": "pie"
+ },
+ "live": null,
+ "components": [
+ {
+ "type": "select",
+ "key": "metric",
+ "name": "metric",
+ "component": "Select2",
+ "props": {
+ "label": "Stat",
+ "required": true,
+ "options": [
+ {
+ "value": "payments_by_user",
+ "label": "Payments By User"
+ },
+ {
+ "value": "payment_amounts_by_user",
+ "label": "Payment Amounts By User"
+ }
+ ],
+ "clearable": false
+ }
+ },
+ {
+ "type": "select",
+ "key": "mode",
+ "name": "mode",
+ "component": "Select",
+ "props": {
+ "label": "Categories",
+ "required": true,
+ "options": [
+ {
+ "value": "non_empty",
+ "label": "Categories with data"
+ },
+ {
+ "value": "all",
+ "label": "All categories"
+ },
+ {
+ "value": "specific",
+ "label": "Specific categories"
+ }
+ ]
+ }
+ },
+ {
+ "type": "select",
+ "key": "date_range",
+ "name": "date_range",
+ "component": "Select",
+ "props": {
+ "label": "Date range",
+ "required": true,
+ "options": [
+ {
+ "value": "today",
+ "label": "Today"
+ },
+ {
+ "value": "yesterday",
+ "label": "Yesterday"
+ },
+ {
+ "value": "this_week",
+ "label": "This week"
+ },
+ {
+ "value": "last_week",
+ "label": "Last week"
+ },
+ {
+ "value": "this_month",
+ "label": "This month"
+ },
+ {
+ "value": "last_month",
+ "label": "Last month"
+ },
+ {
+ "value": "this_year",
+ "label": "This year"
+ },
+ {
+ "value": "last_year",
+ "label": "Last year"
+ },
+ {
+ "value": "last_7_days",
+ "label": "Last 7 days"
+ },
+ {
+ "value": "last_14_days",
+ "label": "Last 14 days"
+ },
+ {
+ "value": "last_30_days",
+ "label": "Last 30 days"
+ },
+ {
+ "value": "last_5_years",
+ "label": "Last 5 years"
+ },
+ {
+ "value": "last_10_years",
+ "label": "Last 10 years"
+ },
+ {
+ "value": "lifetime",
+ "label": "Lifetime"
+ },
+ {
+ "value": "custom",
+ "label": "Custom range"
+ }
+ ]
+ }
+ },
+ {
+ "type": "select",
+ "key": "compare_to",
+ "name": "compare_to",
+ "component": "Select",
+ "props": {
+ "label": "Compare to",
+ "placeholder": "No comparison",
+ "showPlaceholder": true,
+ "options": [
+ {
+ "value": "previous",
+ "label": "Previous period"
+ },
+ {
+ "value": "custom",
+ "label": "Custom period"
+ }
+ ]
+ }
+ },
+ {
+ "type": "group",
+ "key": "group-4",
+ "props": {
+ "columns": {
+ "default": 1,
+ "md": 2
+ }
+ },
+ "children": [
+ {
+ "type": "date",
+ "key": "date_from",
+ "name": "date_from",
+ "component": "Date",
+ "props": {
+ "label": "From",
+ "required": true
+ }
+ },
+ {
+ "type": "date",
+ "key": "date_to",
+ "name": "date_to",
+ "component": "Date",
+ "props": {
+ "label": "To",
+ "required": true
+ }
+ }
+ ],
+ "conditions": [
+ {
+ "effect": "visible",
+ "field": "date_range",
+ "selector": "#date_range",
+ "values": [
+ "custom"
+ ],
+ "checkbox": false
+ }
+ ],
+ "span": {
+ "md": 2
+ }
+ },
+ {
+ "type": "group",
+ "key": "group-5",
+ "props": {
+ "columns": {
+ "default": 1,
+ "md": 2
+ }
+ },
+ "children": [
+ {
+ "type": "date",
+ "key": "compare_date_from",
+ "name": "compare_date_from",
+ "component": "Date",
+ "props": {
+ "label": "Compare from",
+ "required": true
+ }
+ },
+ {
+ "type": "date",
+ "key": "compare_date_to",
+ "name": "compare_date_to",
+ "component": "Date",
+ "props": {
+ "label": "Compare to date",
+ "required": true
+ }
+ }
+ ],
+ "conditions": [
+ {
+ "effect": "visible",
+ "field": "compare_to",
+ "selector": "#compare_to",
+ "values": [
+ "custom"
+ ],
+ "checkbox": false
+ }
+ ],
+ "span": {
+ "md": 2
+ }
+ },
+ {
+ "type": "select",
+ "key": "categorical_values",
+ "name": "categorical_values[]",
+ "component": "Select2",
+ "props": {
+ "label": "Choose categories",
+ "required": true,
+ "multiple": true,
+ "isAjax": true,
+ "ajaxUrl": "http://localhost/stats/categorical/categories",
+ "nameField": "label",
+ "idField": "id",
+ "parent": "#metric",
+ "filterField": "metric"
+ },
+ "conditions": [
+ {
+ "effect": "visible",
+ "field": "mode",
+ "selector": "#mode",
+ "values": [
+ "specific"
+ ],
+ "checkbox": false
+ }
+ ],
+ "span": {
+ "md": 2
+ }
+ },
+ {
+ "type": "select",
+ "key": "with",
+ "name": "with[]",
+ "component": "Select2",
+ "props": {
+ "label": "Plot with",
+ "help": "Other stats to show on the same chart.",
+ "options": [
+ {
+ "value": "payments_by_user",
+ "label": "Payments By User"
+ },
+ {
+ "value": "payment_amounts_by_user",
+ "label": "Payment Amounts By User"
+ }
+ ],
+ "multiple": true
+ }
+ },
+ {
+ "type": "select",
+ "key": "variant",
+ "name": "variant",
+ "component": "Select",
+ "props": {
+ "label": "Chart",
+ "options": [
+ {
+ "value": "bar",
+ "label": "Bar"
+ },
+ {
+ "value": "horizontal",
+ "label": "Horizontal bar"
+ },
+ {
+ "value": "line",
+ "label": "Line"
+ },
+ {
+ "value": "pie",
+ "label": "Pie"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "chart": {
+ "type": "pie",
+ "id": "stats-chart-9ba06c22",
+ "title": "Payments By User",
+ "description": null,
+ "valueFormat": "number",
+ "height": null,
+ "data": [
+ {
+ "name": "އަޙްމަދު",
+ "value": 3,
+ "categoryId": 1
+ },
+ {
+ "name": "ޢާއިޝާ",
+ "value": 2,
+ "categoryId": 2
+ }
+ ],
+ "nameKey": "name",
+ "valueKey": "value",
+ "variant": "donut",
+ "centerLabel": null,
+ "nameLabel": "User",
+ "aggregateLabel": "Count",
+ "total": 5
+ },
+ "export": {
+ "url": "http://localhost/stats/categorical",
+ "params": {
+ "metric": "payments_by_user",
+ "mode": "non_empty",
+ "date_range": "custom",
+ "variant": "pie",
+ "filters": null,
+ "date_from": "2024-07-01",
+ "date_to": "2024-07-04"
+ },
+ "token": "xIMenxVLxNz8BwXd3eFxUzro2DAtcXL2swhiJVFi"
+ },
+ "strings": {
+ "title": "Categorical Stats",
+ "export": "Download CSV",
+ "empty": "Choose a stat and a date range, then select Generate.",
+ "invalid": "Fix the errors in the form to see the stat."
+ },
+ "layout": "test::layouts.admin",
+ "layout_section": "content",
+ "errors": []
+}
diff --git a/tests/js/fixtures/time-series-props.json b/tests/js/fixtures/time-series-props.json
new file mode 100644
index 0000000..0ef210f
--- /dev/null
+++ b/tests/js/fixtures/time-series-props.json
@@ -0,0 +1,447 @@
+{
+ "form": {
+ "stencil": 1,
+ "id": "time-series-stats-form",
+ "action": "http://localhost/stats/time-series",
+ "method": "GET",
+ "operation": "edit",
+ "submit": "Generate",
+ "columns": {
+ "default": 1,
+ "md": 2,
+ "lg": 4
+ },
+ "state": {
+ "metric": "payments_count",
+ "mode": "day",
+ "date_range": "custom",
+ "compare_to": "previous",
+ "date_from": "2024-07-01",
+ "date_to": "2024-07-04",
+ "compare_date_from": null,
+ "compare_date_to": null,
+ "with": null,
+ "variant": "line",
+ "status": null
+ },
+ "live": null,
+ "components": [
+ {
+ "type": "select",
+ "key": "metric",
+ "name": "metric",
+ "component": "Select2",
+ "props": {
+ "label": "Stat",
+ "required": true,
+ "options": [
+ {
+ "value": "payments_count",
+ "label": "Payments"
+ },
+ {
+ "value": "payments_sum",
+ "label": "Payments Sum"
+ },
+ {
+ "value": "payments_by_status",
+ "label": "ފައިސާ ދެއްކުން"
+ }
+ ],
+ "clearable": false
+ }
+ },
+ {
+ "type": "select",
+ "key": "mode",
+ "name": "mode",
+ "component": "Select",
+ "props": {
+ "label": "Group by",
+ "required": true,
+ "options": [
+ {
+ "value": "hour",
+ "label": "Hour"
+ },
+ {
+ "value": "day",
+ "label": "Day"
+ },
+ {
+ "value": "week",
+ "label": "Week"
+ },
+ {
+ "value": "month",
+ "label": "Month"
+ },
+ {
+ "value": "year",
+ "label": "Year"
+ }
+ ]
+ }
+ },
+ {
+ "type": "select",
+ "key": "date_range",
+ "name": "date_range",
+ "component": "Select",
+ "props": {
+ "label": "Date range",
+ "required": true,
+ "options": [
+ {
+ "value": "today",
+ "label": "Today"
+ },
+ {
+ "value": "yesterday",
+ "label": "Yesterday"
+ },
+ {
+ "value": "this_week",
+ "label": "This week"
+ },
+ {
+ "value": "last_week",
+ "label": "Last week"
+ },
+ {
+ "value": "this_month",
+ "label": "This month"
+ },
+ {
+ "value": "last_month",
+ "label": "Last month"
+ },
+ {
+ "value": "this_year",
+ "label": "This year"
+ },
+ {
+ "value": "last_year",
+ "label": "Last year"
+ },
+ {
+ "value": "last_7_days",
+ "label": "Last 7 days"
+ },
+ {
+ "value": "last_14_days",
+ "label": "Last 14 days"
+ },
+ {
+ "value": "last_30_days",
+ "label": "Last 30 days"
+ },
+ {
+ "value": "last_5_years",
+ "label": "Last 5 years"
+ },
+ {
+ "value": "last_10_years",
+ "label": "Last 10 years"
+ },
+ {
+ "value": "lifetime",
+ "label": "Lifetime"
+ },
+ {
+ "value": "custom",
+ "label": "Custom range"
+ }
+ ]
+ }
+ },
+ {
+ "type": "select",
+ "key": "compare_to",
+ "name": "compare_to",
+ "component": "Select",
+ "props": {
+ "label": "Compare to",
+ "placeholder": "No comparison",
+ "showPlaceholder": true,
+ "options": [
+ {
+ "value": "previous",
+ "label": "Previous period"
+ },
+ {
+ "value": "custom",
+ "label": "Custom period"
+ }
+ ]
+ }
+ },
+ {
+ "type": "group",
+ "key": "group-4",
+ "props": {
+ "columns": {
+ "default": 1,
+ "md": 2
+ }
+ },
+ "children": [
+ {
+ "type": "date",
+ "key": "date_from",
+ "name": "date_from",
+ "component": "Date",
+ "props": {
+ "label": "From",
+ "required": true
+ }
+ },
+ {
+ "type": "date",
+ "key": "date_to",
+ "name": "date_to",
+ "component": "Date",
+ "props": {
+ "label": "To",
+ "required": true
+ }
+ }
+ ],
+ "conditions": [
+ {
+ "effect": "visible",
+ "field": "date_range",
+ "selector": "#date_range",
+ "values": [
+ "custom"
+ ],
+ "checkbox": false
+ }
+ ],
+ "span": {
+ "md": 2
+ }
+ },
+ {
+ "type": "group",
+ "key": "group-5",
+ "props": {
+ "columns": {
+ "default": 1,
+ "md": 2
+ }
+ },
+ "children": [
+ {
+ "type": "date",
+ "key": "compare_date_from",
+ "name": "compare_date_from",
+ "component": "Date",
+ "props": {
+ "label": "Compare from",
+ "required": true
+ }
+ },
+ {
+ "type": "date",
+ "key": "compare_date_to",
+ "name": "compare_date_to",
+ "component": "Date",
+ "props": {
+ "label": "Compare to date",
+ "required": true
+ }
+ }
+ ],
+ "conditions": [
+ {
+ "effect": "visible",
+ "field": "compare_to",
+ "selector": "#compare_to",
+ "values": [
+ "custom"
+ ],
+ "checkbox": false
+ }
+ ],
+ "span": {
+ "md": 2
+ }
+ },
+ {
+ "type": "select",
+ "key": "with",
+ "name": "with[]",
+ "component": "Select2",
+ "props": {
+ "label": "Plot with",
+ "help": "Other stats to show on the same chart.",
+ "options": [
+ {
+ "value": "payments_count",
+ "label": "Payments"
+ },
+ {
+ "value": "payments_sum",
+ "label": "Payments Sum"
+ },
+ {
+ "value": "payments_by_status",
+ "label": "ފައިސާ ދެއްކުން"
+ }
+ ],
+ "multiple": true
+ },
+ "span": {
+ "md": 2
+ }
+ },
+ {
+ "type": "select",
+ "key": "variant",
+ "name": "variant",
+ "component": "Select",
+ "props": {
+ "label": "Chart",
+ "options": [
+ {
+ "value": "line",
+ "label": "Line"
+ },
+ {
+ "value": "area",
+ "label": "Area"
+ },
+ {
+ "value": "bar",
+ "label": "Bar"
+ }
+ ]
+ }
+ },
+ {
+ "type": "group",
+ "key": "group-8",
+ "children": [
+ {
+ "type": "select",
+ "key": "status",
+ "name": "status",
+ "component": "Select",
+ "props": {
+ "label": "ހާލަތު",
+ "placeholder": "ހުރިހާ",
+ "showPlaceholder": true,
+ "options": [
+ {
+ "value": "pending",
+ "label": "Awaiting Payment"
+ },
+ {
+ "value": "paid",
+ "label": "Paid"
+ }
+ ],
+ "excludeSyncField": true
+ }
+ }
+ ],
+ "conditions": [
+ {
+ "effect": "visible",
+ "field": "metric",
+ "selector": "#metric",
+ "values": [
+ "payments_by_status"
+ ],
+ "checkbox": false
+ }
+ ]
+ }
+ ]
+ },
+ "chart": {
+ "type": "time-series",
+ "id": "stats-chart-9f7a7206",
+ "title": "Payments",
+ "description": null,
+ "valueFormat": "number",
+ "height": null,
+ "data": [
+ {
+ "date": "2024-07-01",
+ "payments_count": 0,
+ "previous": 0
+ },
+ {
+ "date": "2024-07-02",
+ "payments_count": 0,
+ "previous": 0
+ },
+ {
+ "date": "2024-07-03",
+ "payments_count": 3,
+ "previous": 1
+ },
+ {
+ "date": "2024-07-04",
+ "payments_count": 2,
+ "previous": 0
+ }
+ ],
+ "labels": [
+ "1 ޖުލައި 24",
+ "2 ޖުލައި 24",
+ "3 ޖުލައި 24",
+ "4 ޖުލައި 24"
+ ],
+ "config": {
+ "payments_count": {
+ "label": "1 ޖުލައި 2024 – 4 ޖުލައި 2024"
+ },
+ "previous": {
+ "label": "27 ޖޫން 2024 – 30 ޖޫން 2024"
+ }
+ },
+ "totals": {
+ "payments_count": 5,
+ "previous": 1
+ },
+ "dateKey": "date",
+ "mode": "day",
+ "interval": "day",
+ "variant": "line",
+ "stacked": false,
+ "brush": false,
+ "comparison": {
+ "previousKey": "previous",
+ "currentLabel": "1 ޖުލައި 2024 – 4 ޖުލައި 2024",
+ "previousLabel": "27 ޖޫން 2024 – 30 ޖޫން 2024",
+ "positiveIsGood": true
+ },
+ "aggregateLabel": "Count"
+ },
+ "export": {
+ "url": "http://localhost/stats/time-series",
+ "params": {
+ "metric": "payments_count",
+ "mode": "day",
+ "date_range": "custom",
+ "variant": "line",
+ "filters": null,
+ "date_from": "2024-07-01",
+ "date_to": "2024-07-04",
+ "compare_to": "previous"
+ },
+ "token": "swJthBXHU8miNDYgLDnm0qz7RYFhBAalrRwUYmFc"
+ },
+ "strings": {
+ "title": "Time Series Stats",
+ "export": "Download CSV",
+ "empty": "Choose a stat and a date range, then select Generate.",
+ "invalid": "Fix the errors in the form to see the stat."
+ },
+ "layout": "test::layouts.admin",
+ "layout_section": "content",
+ "errors": []
+}
diff --git a/tests/js/setup.ts b/tests/js/setup.ts
index 618d44a..b5dd8ce 100644
--- a/tests/js/setup.ts
+++ b/tests/js/setup.ts
@@ -1,5 +1,34 @@
import "@testing-library/jest-dom/vitest"
import { cleanup } from "@testing-library/react"
-import { afterEach } from "vitest"
+import { afterEach, vi } from "vitest"
afterEach(() => cleanup())
+
+// jsdom gaps that Onu's charts (Recharts) and Radix rely on
+if (!window.matchMedia) {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ })
+}
+
+class ResizeObserverStub {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver
+
+Element.prototype.scrollIntoView ??= vi.fn()
+Element.prototype.hasPointerCapture ??= vi.fn(() => false)
+Element.prototype.setPointerCapture ??= vi.fn()
+Element.prototype.releasePointerCapture ??= vi.fn()
diff --git a/vitest.config.ts b/vitest.config.ts
index a36ae25..a7b4e05 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -13,7 +13,6 @@ export default defineConfig({
environment: "jsdom",
include: ["tests/js/**/*.test.{ts,tsx}"],
setupFiles: ["tests/js/setup.ts"],
- passWithNoTests: true,
coverage: {
provider: "v8",
include: ["resources/js/**/*.{ts,tsx}"],
From ba13690757ee2e68d9b203a7de0e8e9f7aec8aba Mon Sep 17 00:00:00 2001
From: Naffah Abdulla Rasheed
Date: Thu, 5 Nov 2026 20:18:37 +0500
Subject: [PATCH 04/11] Dhivehi translations for every string, and tests for
translations and logical CSS
Refs #10
Co-Authored-By: Claude Opus 5.5
---
lang/dv.json | 89 +++++++++++++++++++++++++++++++++
lang/en.json | 89 +++++++++++++++++++++++++++++++++
tests/Unit/RtlTest.php | 26 ++++++++++
tests/Unit/TranslationsTest.php | 78 +++++++++++++++++++++++++++++
4 files changed, 282 insertions(+)
create mode 100644 lang/dv.json
create mode 100644 lang/en.json
create mode 100644 tests/Unit/RtlTest.php
create mode 100644 tests/Unit/TranslationsTest.php
diff --git a/lang/dv.json b/lang/dv.json
new file mode 100644
index 0000000..9ab7f6c
--- /dev/null
+++ b/lang/dv.json
@@ -0,0 +1,89 @@
+{
+ ":category ID": ":category އައިޑީ",
+ ":chart with :count slices. Total :total. Largest: :name, :share.": ":count ބައި ހިމެނޭ :chart. ޖުމްލަ :total. އެންމެ ބޮޑު ބައި: :name، :share.",
+ ":chart with :series series and :points data points.": ":series ސީރީޒް އަދި :points ޑޭޓާ ޕޮއިންޓް ހިމެނޭ :chart.",
+ ":label: :count values, from :first to :last. Lowest :min, highest :max.": ":label: :count އަދަދު، :first އިން :last އަށް. އެންމެ ދަށް :min، އެންމެ މަތި :max.",
+ ":series: lowest :min, highest :max.": ":series: އެންމެ ދަށް :min، އެންމެ މަތި :max.",
+ "All categories": "ހުރިހާ ބައިތަކެއް",
+ "Area": "އޭރިއާ",
+ "Area chart": "އޭރިއާ ޗާޓް",
+ "Bar": "ބާ",
+ "Bar chart": "ބާ ޗާޓް",
+ "Categorical Stats": "ބައިތަކަށް ބަހާލި ތަފާސް ހިސާބު",
+ "Categories": "ބައިތައް",
+ "Categories with data": "ޑޭޓާ ހުރި ބައިތައް",
+ "Category": "ބައި",
+ "Category ID": "ބައިގެ އައިޑީ",
+ "Change": "ބަދަލު",
+ "Chart": "ޗާޓް",
+ "Choose a stat and a date range, then select Generate.": "ތަފާސް ހިސާބަކާއި ތާރީޚުތައް ހޮވާފައި \"ދައްކާ\" އަށް ފިއްތަވާ.",
+ "Choose categories": "ބައިތައް ހޮވާ",
+ "Compare from": "އަޅާކިޔާ މުއްދަތު ފެށޭ ތާރީޚު",
+ "Compare to": "އަޅާކިޔާނީ",
+ "Compare to date": "އަޅާކިޔާ މުއްދަތު ނިމޭ ތާރީޚު",
+ "Comparison Period - :date_from - :date_to": "އަޅާކިޔާ މުއްދަތު - :date_from - :date_to",
+ "Count": "އަދަދު",
+ "Custom period": "އެހެން މުއްދަތެއް",
+ "Custom range": "އެހެން ތާރީޚުތަކެއް",
+ "Date": "ތާރީޚު",
+ "Date Range": "ތާރީޚުތައް",
+ "Date range": "ތާރީޚުތައް",
+ "Day": "ދުވަސް",
+ "Donut chart": "ޑޯނަޓް ޗާޓް",
+ "Down :percent": ":percent ދަށްވެއްޖެ",
+ "Download CSV": "CSV ޑައުންލޯޑް ކުރައްވާ",
+ "Fix the errors in the form to see the stat.": "ތަފާސް ހިސާބު ފެނުމަށް ފޯމުގައިވާ ކުށްތައް ރަނގަޅުކުރައްވާ.",
+ "From": "ފެށޭ ތާރީޚު",
+ "From :from to :to.": ":from އިން :to އަށް.",
+ "Generate": "ދައްކާ",
+ "Group by": "ބަހާލާނީ",
+ "Horizontal bar": "ބޯޅަ ބާ",
+ "Hour": "ގަޑިއިރު",
+ "Last 10 years": "ފާއިތުވި 10 އަހަރު",
+ "Last 14 days": "ފާއިތުވި 14 ދުވަސް",
+ "Last 30 days": "ފާއިތުވި 30 ދުވަސް",
+ "Last 5 years": "ފާއިތުވި 5 އަހަރު",
+ "Last 7 days": "ފާއިތުވި 7 ދުވަސް",
+ "Last month": "ފާއިތުވި މަސް",
+ "Last week": "ފާއިތުވި ހަފްތާ",
+ "Last year": "ފާއިތުވި އަހަރު",
+ "Lifetime": "ހުރިހާ ދުވަހެއް",
+ "Line": "ލައިން",
+ "Line chart": "ލައިން ޗާޓް",
+ "Month": "މަސް",
+ "No change": "ބަދަލެއް ނުވޭ",
+ "No comparison": "އަޅާ ނުކިޔާ",
+ "No data": "ޑޭޓާ ނެތް",
+ "No data to show": "ދައްކާނެ ޑޭޓާއެއް ނެތް",
+ "Other": "އެހެނިހެން",
+ "Other stats to show on the same chart.": "މި ޗާޓުގައި ދައްކާނެ އެހެން ތަފާސް ހިސާބުތައް.",
+ "Pie": "ޕައި",
+ "Pie chart": "ޕައި ޗާޓް",
+ "Plot with": "އެކުގައި ދައްކާ",
+ "Previous period": "ކުރީގެ މުއްދަތު",
+ "Primary Period - :date_from - :date_to": "މައި މުއްދަތު - :date_from - :date_to",
+ "Share": "ހިއްސާ",
+ "Specific categories": "ހޮވާ ބައިތައް",
+ "Stat": "ތަފާސް ހިސާބު",
+ "Stats": "ތަފާސް ހިސާބު",
+ "Sum": "ޖުމްލަ",
+ "This month": "މިމަސް",
+ "This period": "މި މުއްދަތު",
+ "This week": "މިހަފްތާ",
+ "This year": "މިއަހަރު",
+ "Time Series Stats": "ވަގުތާއެކު ތަފާސް ހިސާބު",
+ "To": "ނިމޭ ތާރީޚު",
+ "Today": "މިއަދު",
+ "Total": "ޖުމްލަ",
+ "Up :percent": ":percent މައްޗަށް",
+ "User Logins": "ޔޫޒަރުންގެ ލޮގިން",
+ "User Signups": "ޔޫޒަރުންގެ ސައިން އަޕް",
+ "Value": "އަދަދު",
+ "View as table": "ޖަދުވަލެއްގެ ގޮތުގައި ބަލާ",
+ "View stats": "ތަފާސް ހިސާބު ބެލުން",
+ "Week": "ހަފްތާ",
+ "Week :week, :year": ":year ގެ :week ވަނަ ހަފްތާ",
+ "Year": "އަހަރު",
+ "Yesterday": "އިއްޔެ",
+ "vs previous period": "ކުރީގެ މުއްދަތާ އަޅާކިޔާއިރު"
+}
diff --git a/lang/en.json b/lang/en.json
new file mode 100644
index 0000000..9a1448c
--- /dev/null
+++ b/lang/en.json
@@ -0,0 +1,89 @@
+{
+ ":category ID": ":category ID",
+ ":chart with :count slices. Total :total. Largest: :name, :share.": ":chart with :count slices. Total :total. Largest: :name, :share.",
+ ":chart with :series series and :points data points.": ":chart with :series series and :points data points.",
+ ":label: :count values, from :first to :last. Lowest :min, highest :max.": ":label: :count values, from :first to :last. Lowest :min, highest :max.",
+ ":series: lowest :min, highest :max.": ":series: lowest :min, highest :max.",
+ "All categories": "All categories",
+ "Area": "Area",
+ "Area chart": "Area chart",
+ "Bar": "Bar",
+ "Bar chart": "Bar chart",
+ "Categorical Stats": "Categorical Stats",
+ "Categories": "Categories",
+ "Categories with data": "Categories with data",
+ "Category": "Category",
+ "Category ID": "Category ID",
+ "Change": "Change",
+ "Chart": "Chart",
+ "Choose a stat and a date range, then select Generate.": "Choose a stat and a date range, then select Generate.",
+ "Choose categories": "Choose categories",
+ "Compare from": "Compare from",
+ "Compare to": "Compare to",
+ "Compare to date": "Compare to date",
+ "Comparison Period - :date_from - :date_to": "Comparison Period - :date_from - :date_to",
+ "Count": "Count",
+ "Custom period": "Custom period",
+ "Custom range": "Custom range",
+ "Date": "Date",
+ "Date Range": "Date Range",
+ "Date range": "Date range",
+ "Day": "Day",
+ "Donut chart": "Donut chart",
+ "Down :percent": "Down :percent",
+ "Download CSV": "Download CSV",
+ "Fix the errors in the form to see the stat.": "Fix the errors in the form to see the stat.",
+ "From": "From",
+ "From :from to :to.": "From :from to :to.",
+ "Generate": "Generate",
+ "Group by": "Group by",
+ "Horizontal bar": "Horizontal bar",
+ "Hour": "Hour",
+ "Last 10 years": "Last 10 years",
+ "Last 14 days": "Last 14 days",
+ "Last 30 days": "Last 30 days",
+ "Last 5 years": "Last 5 years",
+ "Last 7 days": "Last 7 days",
+ "Last month": "Last month",
+ "Last week": "Last week",
+ "Last year": "Last year",
+ "Lifetime": "Lifetime",
+ "Line": "Line",
+ "Line chart": "Line chart",
+ "Month": "Month",
+ "No change": "No change",
+ "No comparison": "No comparison",
+ "No data": "No data",
+ "No data to show": "No data to show",
+ "Other": "Other",
+ "Other stats to show on the same chart.": "Other stats to show on the same chart.",
+ "Pie": "Pie",
+ "Pie chart": "Pie chart",
+ "Plot with": "Plot with",
+ "Previous period": "Previous period",
+ "Primary Period - :date_from - :date_to": "Primary Period - :date_from - :date_to",
+ "Share": "Share",
+ "Specific categories": "Specific categories",
+ "Stat": "Stat",
+ "Stats": "Stats",
+ "Sum": "Sum",
+ "This month": "This month",
+ "This period": "This period",
+ "This week": "This week",
+ "This year": "This year",
+ "Time Series Stats": "Time Series Stats",
+ "To": "To",
+ "Today": "Today",
+ "Total": "Total",
+ "Up :percent": "Up :percent",
+ "User Logins": "User Logins",
+ "User Signups": "User Signups",
+ "Value": "Value",
+ "View as table": "View as table",
+ "View stats": "View stats",
+ "Week": "Week",
+ "Week :week, :year": "Week :week, :year",
+ "Year": "Year",
+ "Yesterday": "Yesterday",
+ "vs previous period": "vs previous period"
+}
diff --git a/tests/Unit/RtlTest.php b/tests/Unit/RtlTest.php
new file mode 100644
index 0000000..62cd084
--- /dev/null
+++ b/tests/Unit/RtlTest.php
@@ -0,0 +1,26 @@
+assertDoesNotMatchRegularExpression($physical, $file->getContents(), "{$file->getRelativePathname()} uses physical left/right CSS");
+ }
+ }
+}
diff --git a/tests/Unit/TranslationsTest.php b/tests/Unit/TranslationsTest.php
new file mode 100644
index 0000000..8522d12
--- /dev/null
+++ b/tests/Unit/TranslationsTest.php
@@ -0,0 +1,78 @@
+
+ */
+ protected function usedKeys(): array
+ {
+ $keys = ['View stats', 'Stats', 'Primary Period - :date_from - :date_to', 'Comparison Period - :date_from - :date_to'];
+
+ $files = [...File::allFiles(__DIR__ . '/../../src'), ...File::allFiles(__DIR__ . '/../../resources/views')];
+
+ foreach ($files as $file) {
+ preg_match_all("/__\\(\\s*'((?:[^'\\\\]|\\\\.)*)'/", $file->getContents(), $matches);
+
+ foreach ($matches[1] as $key) {
+ $keys[] = str_replace("\\'", "'", $key);
+ }
+ }
+
+ return array_values(array_unique($keys));
+ }
+
+ /**
+ * @return array
+ */
+ protected function translations(string $locale): array
+ {
+ return json_decode(File::get(__DIR__ . "/../../lang/{$locale}.json"), true, flags: JSON_THROW_ON_ERROR);
+ }
+
+ #[Test]
+ public function it_has_english_and_dhivehi_for_every_string(): void
+ {
+ $en = $this->translations('en');
+ $dv = $this->translations('dv');
+
+ foreach ($this->usedKeys() as $key) {
+ $this->assertArrayHasKey($key, $en, "lang/en.json is missing [{$key}]");
+ $this->assertArrayHasKey($key, $dv, "lang/dv.json is missing [{$key}]");
+ }
+
+ $this->assertEquals(array_keys($en), array_keys($dv));
+ }
+
+ #[Test]
+ public function it_keeps_the_placeholders_in_dhivehi(): void
+ {
+ foreach ($this->translations('dv') as $key => $value) {
+ preg_match_all('/:[a-z_]+/', $key, $expected);
+ preg_match_all('/:[a-z_]+/', $value, $actual);
+
+ sort($expected[0]);
+ sort($actual[0]);
+
+ $this->assertEquals($expected[0], $actual[0], "The Dhivehi of [{$key}] has different placeholders");
+ $this->assertMatchesRegularExpression('/\p{Thaana}/u', $value, "[{$key}] isn't in Thaana");
+ }
+ }
+
+ #[Test]
+ public function it_translates_the_stats_strings_in_dhivehi(): void
+ {
+ app()->setLocale('dv');
+
+ $this->assertEquals('ފާއިތުވި 7 ދުވަސް', __('Last 7 days'));
+ $this->assertEquals('ޖަދުވަލެއްގެ ގޮތުގައި ބަލާ', __('View as table'));
+ }
+}
From bf6feb4965e867cc31f7191971fe2080913d4a72 Mon Sep 17 00:00:00 2001
From: Naffah Abdulla Rasheed
Date: Thu, 5 Nov 2026 20:23:30 +0500
Subject: [PATCH 05/11] Laravel Boost guideline and javaabu-stats skill,
AGENTS.md, upgrading guide, Dhivehi review list, README and changelog
Refs #8, #9, #10
Co-Authored-By: Claude Opus 5.5
---
.gitattributes | 20 +-
AGENTS.md | 47 +++++
CHANGELOG.md | 32 +++
CLAUDE.md | 1 +
README.md | 49 ++++-
docs/advanced-usage/dhivehi-and-rtl.md | 70 +++++++
docs/upgrading.md | 153 +++++++++++++++
resources/boost/guidelines/core.blade.php | 10 +
resources/boost/skills/javaabu-stats/SKILL.md | 183 ++++++++++++++++++
.../skills/javaabu-stats/references/api.md | 68 +++++++
.../references/common-mistakes.md | 22 +++
tests/Feature/Pages/InertiaPagesTest.php | 2 +-
tests/js/StatsChart.test.tsx | 2 +-
tests/js/StatsPage.test.tsx | 10 +-
tests/js/fixtures/categorical-props.json | 2 +-
tests/js/fixtures/time-series-props.json | 88 ++++-----
16 files changed, 697 insertions(+), 62 deletions(-)
create mode 100644 AGENTS.md
create mode 100644 CLAUDE.md
create mode 100644 docs/advanced-usage/dhivehi-and-rtl.md
create mode 100644 docs/upgrading.md
create mode 100644 resources/boost/guidelines/core.blade.php
create mode 100644 resources/boost/skills/javaabu-stats/SKILL.md
create mode 100644 resources/boost/skills/javaabu-stats/references/api.md
create mode 100644 resources/boost/skills/javaabu-stats/references/common-mistakes.md
diff --git a/.gitattributes b/.gitattributes
index bb6265e..eea060b 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,11 +1,23 @@
# Path-based git attributes
# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html
-# Ignore all test and documentation with "export-ignore".
+# Keep development files out of the Composer package. docs, llms.txt,
+# resources/boost and the prebuilt dist/ ship with it.
/.gitattributes export-ignore
/.gitignore export-ignore
-/.travis.yml export-ignore
+/.github export-ignore
+/.editorconfig export-ignore
+/.prettierrc.json export-ignore
+/.prettierignore export-ignore
/phpunit.xml.dist export-ignore
-/.scrutinizer.yml export-ignore
+/pint.json export-ignore
/tests export-ignore
-/.editorconfig export-ignore
+/AGENTS.md export-ignore
+/CLAUDE.md export-ignore
+/node_modules export-ignore
+/package.json export-ignore
+/pnpm-lock.yaml export-ignore
+/tsconfig.json export-ignore
+/vite.config.ts export-ignore
+/vitest.config.ts export-ignore
+/eslint.config.js export-ignore
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..ace0423
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,47 @@
+# stats: notes for AI coding agents
+
+`javaabu/stats` turns stat classes (time series and categorical repositories) into charts, pages, a JSON API and CSV exports. Stats are registered by metric in `TimeSeriesStats` / `CategoricalStats`; chart builders (`src/Charts`) describe a chart and serialise to the props of Onu's chart components; Blade draws the same data as SVG (`src/Views`), React with Onu (`resources/js`, shipped through javaabu/manifest as `@javaabu/stats`). Apps extend the repository base classes, the controllers and the policy, and v1 apps keep their stat classes, so treat the repository contracts and base classes, `TimeSeriesStats`/`CategoricalStats`, `StatsFilter`, the chart builders and their array shape (`resources/js/types.ts`), the Blade components, `Route::stats()`/`Route::statsApi()`, the request parameters, the config keys and the page props as public contracts.
+
+## Layout
+
+| Path | What |
+| --- | --- |
+| `src/TimeSeriesStats.php`, `src/CategoricalStats.php` | Registries of metrics and formatters; defaults; deprecated v1 route helpers |
+| `src/Repositories`, `src/Concerns`, `src/Contracts`, `src/CategoryProviders` | Stat base classes, date ranges and filters, category providers |
+| `src/Enums` | `TimeSeriesModes` (date SQL per driver, step keys, display), `PresetDateRanges`, `CategoricalModes` |
+| `src/Filters` | `StatsFilter` (exact, scope, closure, `from()` for javaabu/filters → `ModelFilter`) |
+| `src/Charts` | `TimeSeriesChart`, `CategoryChart`, `PieChart`, `Sparkline`, `StatTile` and their concerns |
+| `src/Cache`, `src/Jobs`, `src/Commands/WarmStatsCacheCommand.php`, `src/StatsManager.php`, `src/Facades/Stats.php` | Versioned cache, warming, the `Stats` facade |
+| `src/Forms` | The stencil forms of the stats pages and `CategorySelect` |
+| `src/Http`, `src/Routing`, `src/Policies`, `src/Views/ViewRenderer.php` | Page and API controllers, requests (`ResolvesStats` holds the parameter contract), route macros, policy, Blade/Inertia switch |
+| `src/Views/Components`, `src/Views/Svg`, `resources/views`, `resources/css/charts.css` | Blade chart components, SVG geometry, views, chart styles (prebuilt into `dist/`) |
+| `src/Exports`, `src/Formatters`, `src/Generators`, `stubs` | CSV exports, v1 formatters, generator commands and stubs |
+| `resources/js` | React: `StatsChart`, `StatsTile`, `StatsPage`, the Inertia pages, types |
+| `lang/en.json`, `lang/dv.json` | Translations; English strings are the keys |
+| `docs`, `llms.txt`, `resources/boost` | Docusaurus docs, the index for AI assistants, the Laravel Boost guideline and skill |
+| `tests` | Testbench tests (SQLite by default; `DB_CONNECTION=mysql|mariadb|pgsql` runs them on that database); `tests/js` are Vitest tests of the React side, on fixtures generated from PHP |
+
+## Rules
+
+- Follow Javaabu codestyle ([boost-javaabu-guidelines](https://github.com/Javaabu/boost-javaabu-guidelines)): snake_case variables and properties, camelCase methods, early returns, `#[Test]` and `it_…` test names. Pint enforces formatting. The enums use the package's own `HasLabels`, not javaabu/helpers' `IsEnum`: never depend on javaabu/helpers or javaabu/settings ^1 (CI fails).
+- Date SQL must work on MySQL, MariaDB, PostgreSQL and SQLite. Add a driver in `TimeSeriesModes::getSql()` and run the suite on each database.
+- Keep the chart array shape and `resources/js/types.ts` in step, and keep the Blade SVG and the React charts showing the same thing. When the page props change, regenerate the fixtures: `UPDATE_FIXTURES=1 vendor/bin/phpunit --filter InertiaPagesTest`.
+- Anything a chart depends on goes into its `cacheKey()`.
+- Nothing may assume the `web` guard or `App\Models\User`. Stats authorize through the gate (`canView()` → `StatsPolicy`).
+- Inertia and spatie/laravel-activitylog are optional: feature-detect them, keep them in `suggest`, and mark tests that need them with `#[RequiresMethod]`.
+- Every user-facing string goes through `__()` with an English key, and gets a line in `lang/en.json` and `lang/dv.json` (`TranslationsTest` checks). Use Thaana in tests of user-facing behaviour. List uncertain translations in `docs/advanced-usage/dhivehi-and-rtl.md`. Views, CSS and React use logical properties only (`RtlTest` checks).
+- After changing `resources/css/charts.css`, run `pnpm build` and commit `dist/` (CI checks).
+- Every change needs tests, docs (linked from `llms.txt`), the Boost skill if it changes how the package is used, and a line in `CHANGELOG.md`.
+- Work on the `next-gen` branch; don't commit to `main`.
+
+## Commands
+
+```bash
+composer install
+composer lint # vendor/bin/pint --test
+composer format # vendor/bin/pint
+composer test # vendor/bin/phpunit (SQLite)
+DB_CONNECTION=pgsql DB_DATABASE=stats_test DB_USERNAME=postgres vendor/bin/phpunit
+pnpm install && pnpm check # lint, format, typecheck and Vitest (needs vendor/ for stencil)
+pnpm build # dist/ for apps without Vite
+```
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8869e77..27365c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,38 @@
All notable changes will be documented in this file
+## 2.0.0 - unreleased
+
+See [Upgrading from v1](docs/upgrading.md) for the breaking changes.
+
+### Added
+
+- Chart builders: `TimeSeriesChart`, `CategoryChart`, `PieChart`, `Sparkline` and `StatTile`, with several stats or several periods on one plot and comparisons with the previous period.
+- Blade chart components (``, `time-series-chart`, `category-chart`, `pie-chart`, `sparkline`, `stat-tile`, `stat-tiles`) drawn as accessible SVG with Onu's tokens.
+- React (`@javaabu/stats` through javaabu/manifest): `StatsChart`, `StatsTile`, `StatsPage` and the Inertia pages `stats::TimeSeries/Index` and `stats::Categorical/Index`, on Onu's chart components.
+- Cached charts with TTLs for current and past periods, `Stats::forget()`, `Stats::flush()`, the `InvalidatesStats` model trait, `Stats::warm()`, `php artisan stats:warm` and the `WarmStatsCache` job.
+- Stats forms defined with javaabu/stencil; `StatsFilter::from()` uses javaabu/filters filters as stats filters with fields in the form.
+- `Route::stats()` and `Route::statsApi()` with prefix, name, guard, middleware and controllers; controllers in `config('stats.controllers')`; a `filters()` hook for pages of one model.
+- `view_stats` registered with javaabu/permissions and an overridable `StatsPolicy`.
+- Date grouping on MySQL, MariaDB, PostgreSQL and SQLite.
+- `compare_to` and `date_range=custom` request parameters, `with[]` for plotting other stats.
+- Dhivehi translations and right-to-left charts.
+- Docs for people and agents, `llms.txt`, a Laravel Boost guideline and the `javaabu-stats` skill.
+
+### Changed
+
+- PHP 8.4+, Laravel 12 or 13, javaabu/forms v2.
+- CSV exports without maatwebsite/excel; file names use `config('app.name')`.
+- Week keys are the date the week starts on, month keys `YYYY-MM`; hourly keys now match the SQL.
+- A plain `date_to` covers the whole day.
+- Built-in stats use `config('stats.user_model')` or the default guard's user model.
+- Category search is case-insensitive on every database.
+
+### Removed
+
+- The material-admin views, `` and ``, and the `framework`, `default_layout`, `scripts_stack`, `time_series_stats_view` and `categorical_stats_view` config keys.
+- The dependencies on javaabu/helpers, javaabu/settings and maatwebsite/excel.
+
## 1.0.0 - 202X-XX-XX
- initial release
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..43c994c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/README.md b/README.md
index 35cfeb3..96ce92a 100644
--- a/README.md
+++ b/README.md
@@ -5,11 +5,48 @@
[](https://packagist.org/packages/javaabu/stats)
[](LICENSE.md)
-Time series and categorical stats for Laravel: define a stat once, then chart it in Blade or Inertia React with Onu, compare periods, cache dashboards and export CSV.
+Time series and categorical stats for Laravel. Define a stat once, then chart it in Blade or Inertia React with [Onu](https://github.com/Javaabu/onu)'s charts.
+
+- Count, sum or any aggregate of a model per hour, day, week, month or year, or per category, on MySQL, MariaDB, PostgreSQL or SQLite.
+- Line, area, bar, pie and donut charts, sparklines and KPI tiles: SVG in Blade, Onu's components in React, the same tokens in both.
+- Several stats or several periods on one plot, and comparisons with the previous period.
+- Cached dashboards with sensible TTLs, background warming and invalidation.
+- Stats pages with forms defined in [javaabu/stencil](https://github.com/Javaabu/stencil), filters from [javaabu/filters](https://github.com/Javaabu/filters), a JSON API and CSV exports.
+- A policy and the `view_stats` permission through [javaabu/permissions](https://github.com/Javaabu/permissions), for any guard.
+- Dhivehi and right-to-left.
+
+```php
+use Javaabu\Stats\Charts\StatTile;
+use Javaabu\Stats\Charts\TimeSeriesChart;
+use Javaabu\Stats\Enums\PresetDateRanges;
+use Javaabu\Stats\TimeSeriesStats;
+
+TimeSeriesStats::register(['payments_count' => PaymentsCount::class]);
+
+$tile = StatTile::make('payments_count')->range(PresetDateRanges::THIS_MONTH)->compare()->sparkline();
+$chart = TimeSeriesChart::make('payments_count')->range(PresetDateRanges::LAST_30_DAYS)->compare()->area();
+```
+
+```blade
+
+
+```
+
+```tsx
+import { StatsChart } from "@javaabu/stats"
+
+
+```
+
+```php
+Route::stats(['prefix' => 'admin/stats', 'name' => 'admin.stats', 'guard' => 'web_admin']);
+```
+
+Coming from v1? Your stat classes keep working; see [Upgrading](docs/upgrading.md).
## Documentation
-You'll find the documentation on [https://docs.javaabu.com/stats](https://docs.javaabu.com/stats).
+You'll find the documentation on [https://docs.javaabu.com/stats](https://docs.javaabu.com/stats), and in [`docs/`](docs/intro.md).
Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving this package? Feel free to create an [issue](../../issues) on GitHub, we'll try to address it as soon as possible.
@@ -17,10 +54,9 @@ If you've found a bug regarding security please mail [info@javaabu.com](mailto:i
## Testing
-You can run the tests with
-
-``` bash
-./vendor/bin/phpunit
+```bash
+composer test # PHP, on SQLite; DB_CONNECTION=mysql|mariadb|pgsql for another database
+pnpm check # React: lint, format, typecheck, Vitest
```
## Changelog
@@ -39,6 +75,7 @@ If you discover any security related issues, please email [info@javaabu.com](mai
- [Javaabu Pvt. Ltd.](https://github.com/javaabu)
- [Arushad Ahmed (@dash8x)](http://arushad.com)
+- [Athfan Khaleel (@athphane)](https://github.com/athphane)
- [All Contributors](../../contributors)
## License
diff --git a/docs/advanced-usage/dhivehi-and-rtl.md b/docs/advanced-usage/dhivehi-and-rtl.md
new file mode 100644
index 0000000..ca7617e
--- /dev/null
+++ b/docs/advanced-usage/dhivehi-and-rtl.md
@@ -0,0 +1,70 @@
+---
+title: Dhivehi and RTL
+sidebar_position: 9
+---
+
+# Dhivehi and right-to-left
+
+Everything the package shows is translated, and the pages and charts work right to left.
+
+## Translations
+
+Every string the package shows goes through `__()` with an English key, and the package ships `lang/en.json` and `lang/dv.json`. Set the app's locale to `dv` and the pages, charts, form labels, preset date ranges, modes, accessible summaries and the `view_stats` permission description are in Dhivehi.
+
+To change a translation, publish the files and edit them:
+
+```bash
+php artisan vendor:publish --tag=stats-translations
+```
+
+They are copied to `lang/vendor/stats`. Your app's own `lang/dv.json` also wins over the package's for any key it has.
+
+Names of your stats come from your stat classes (`getName()`), and category names from your category providers, so translate them there:
+
+```php
+public function getName(): string
+{
+ return __('Payments');
+}
+```
+
+The React charts translate their own strings (the "View as table" button, "Up 12%", the summaries) through Onu's `dv` locale. Strings the pages need from PHP (titles, the download button, messages) come in the page's `strings` prop, already translated.
+
+## Dates and numbers
+
+- Chart steps are formatted in the app's locale with the formats in `config('stats.date_formats')`, so months are named in Dhivehi (`ޖުލައި 2024`). Weeks read "Week 29, 2024" (`:year ގެ :week ވަނަ ހަފްތާ`), numbered with `config('stats.date_locale')` and `config('stats.week_starts_on_sunday')`.
+- Period names use the presets' labels (`ފާއިތުވި 7 ދުވަސް`) or the dates in the app's locale.
+- Numbers use Latin digits in Dhivehi, as Onu does. Currency is shown as `ރ. 1,250.00` in Dhivehi and `MVR 1,250.00` otherwise.
+- The CSV exports have headings in the app's locale and steps as ISO dates (`2024-07-03`), so spreadsheets and other tools can read them; category and stat names are written as they are. Files start with a UTF-8 byte order mark so Excel shows Thaana correctly.
+
+## Right to left
+
+- **Blade charts** are drawn mirrored when the app's locale is in `config('stats.rtl_locales')` (`dv`, `ar`, `fa`, `he` and `ur` by default): time runs right to left, so the newest step is on the left, the value axis is on the right, horizontal bars grow to the left, and pies run counter-clockwise from 12 o'clock. This matches Onu's React charts. Pass `:rtl="true"` or `:rtl="false"` to `` to choose for one chart.
+- **React charts** follow the page's `dir`, as every Onu component does.
+- **Styles** use logical CSS only (`margin-inline-start`, `inset-block-start`, `ms-`, `pe-`), and percentages are wrapped in `dir="ltr"` so the sign stays before the number. A test fails the build if a view, the stylesheet or a React file uses `left` or `right`.
+- Set `dir="rtl"` and `lang="dv"` on your layout's ``.
+
+## Thaana input
+
+The stats forms have no free-text fields: stats, modes, ranges and categories are chosen from lists, and dates from date pickers. The category search box searches category names in Thaana (`ޢާއި` finds `ޢާއިޝާ`). Filters from javaabu/filters keep their own Thaana support (`TextFilter` has `->thaana()`).
+
+## Translations to review
+
+These Dhivehi strings are the best choice I could make, but a Dhivehi speaker should check them, mostly technical terms with no settled Dhivehi word:
+
+| English | Dhivehi | Note |
+| --- | --- | --- |
+| Category, Categories | ބައި, ބައިތައް | "ބާވަތް" may read better for some stats |
+| Line, Area, Bar, Pie (chart types) | ލައިން, އޭރިއާ, ބާ, ޕައި | Transliterated |
+| Line chart, Area chart, Bar chart, Pie chart, Donut chart | ލައިން ޗާޓް, އޭރިއާ ޗާޓް, ބާ ޗާޓް, ޕައި ޗާޓް, ޑޯނަޓް ޗާޓް | Transliterated |
+| Horizontal bar | ބޯޅަ ބާ | |
+| Time Series Stats | ވަގުތާއެކު ތަފާސް ހިސާބު | |
+| Categorical Stats | ބައިތަކަށް ބަހާލި ތަފާސް ހިސާބު | |
+| Generate | ދައްކާ | Literally "show" |
+| Group by | ބަހާލާނީ | |
+| Plot with | އެކުގައި ދައްކާ | |
+| Share (of a pie) | ހިއްސާ | |
+| Lifetime | ހުރިހާ ދުވަހެއް | |
+| User Logins, User Signups | ޔޫޒަރުންގެ ލޮގިން, ޔޫޒަރުންގެ ސައިން އަޕް | |
+| :chart with :series series and :points data points. | :series ސީރީޒް އަދި :points ޑޭޓާ ޕޮއިންޓް ހިމެނޭ :chart. | Screen reader summary |
+| Week :week, :year | :year ގެ :week ވަނަ ހަފްތާ | |
diff --git a/docs/upgrading.md b/docs/upgrading.md
new file mode 100644
index 0000000..f0af8c2
--- /dev/null
+++ b/docs/upgrading.md
@@ -0,0 +1,153 @@
+---
+title: Upgrading from v1
+sidebar_position: 1.9
+---
+
+# Upgrading from v1 to v2
+
+v2 keeps the way you define stats: `TimeSeriesStats::register()`, `CategoricalStats::register()`, the `CountStatsRepository` / `SumStatsRepository` families, `StatsFilter`, the date ranges, the modes, the category providers and the formatters all work as before. Most of the changes are around them: the front end, the dependencies, the database support and the routes.
+
+Work through the sections below in order. Each one says what changed, why, and what to do.
+
+## Requirements
+
+| | v1 | v2 |
+| --- | --- | --- |
+| PHP | 8.1+ | 8.4+ |
+| Laravel | 9–13 | 12 or 13 |
+| javaabu/forms | ^1.68 | ^2.0 |
+| Front end | jQuery, Select2, Chart.js / Flot, material-admin views | Blade with SVG charts, or Inertia React with [Onu](https://github.com/Javaabu/onu)'s charts |
+
+```bash
+composer require javaabu/stats:^2.0
+```
+
+While the next-gen packages are unreleased, add the VCS repositories listed in [Installation](./installation-and-setup.md) and require `^2.0@dev`.
+
+## Removed dependencies
+
+v2 no longer requires:
+
+- **javaabu/helpers.** It pins javaabu/settings ^1, which can't be installed next to settings v2. The enums now have their own `getLabel()` and `getLabels()` (`Javaabu\Stats\Enums\Concerns\HasLabels`) instead of `IsEnum` and `NativeEnumsTrait`. The controllers extend `Illuminate\Routing\Controller`.
+- **javaabu/settings.** Exports used `get_setting('app_name')` for the file name; they now use `config('app.name')`.
+- **maatwebsite/excel.** Exports are plain CSV, streamed with a UTF-8 byte order mark so spreadsheet apps show Thaana. `TimeSeriesStatsExport` and `CategoricalStatsExport` keep `array()`, `headings()`, `map()`, `getReportTitle()` and `formattedDateRange()`, and gain `download($file_name)`, `toCsv()` and `lines()`. They no longer implement Laravel Excel's concerns, so `Excel::download(new TimeSeriesStatsExport(...))` becomes `(new TimeSeriesStatsExport(...))->download($file_name)`, and `Excel::fake()` in your tests becomes an assertion on the response (`assertDownload()`, `streamedContent()`).
+
+If your app used any of these packages itself, require them in your app's `composer.json`.
+
+## Views and front end
+
+The `material-admin-26` views, the `stats.framework` config, and the `` and `` components are gone, with the jQuery, Select2 and Chart.js scripts they needed.
+
+In their place:
+
+- **Pages.** `Route::stats()` registers a time series page and a categorical page. They are plain GET forms (defined with javaabu/stencil) that the server answers with the chart, so the Blade pages need no JavaScript of their own. `config('stats.views')` picks Blade or Inertia React. See [Stats pages](./basic-usage/stats-pages.md).
+- **Charts anywhere.** `TimeSeriesChart`, `CategoryChart`, `PieChart`, `Sparkline` and `StatTile` describe a chart in PHP. Blade draws it as SVG (``), and React draws it with Onu (``). See [Charts](./basic-usage/charts.md).
+
+| v1 | v2 |
+| --- | --- |
+| `config('stats.framework')` | Removed. Blade uses javaabu/forms v2 components, which follow `config('forms.framework')` |
+| `config('stats.default_layout')` | `config('stats.layout')` and `config('stats.layout_section')` |
+| `config('stats.scripts_stack')` | Removed: the pages need no scripts; javaabu/manifest adds the chart styles |
+| `config('stats.time_series_stats_view')`, `categorical_stats_view` | Removed. Publish the views (`stats-views` tag) or extend the controller |
+| `` on a model's page | A page for the model: extend `TimeSeriesStatsController` and return the filters from `filters()`; or a chart with `->filters([...])`. See [Stats pages](./basic-usage/stats-pages.md#stats-for-one-model) |
+| `` | The same, with `CategoricalStatsController` or `CategoryChart` |
+| `resources/views/vendor/stats/material-admin-26/...` | Delete them; publish the new views if you need to change them |
+
+If you had published the v1 views, delete `resources/views/vendor/stats/material-admin-26`.
+
+## Routes
+
+v1 registered routes with static helpers. v2 has route macros that take a prefix, a name, a guard and middleware, so the pages work for any guard:
+
+```php
+// v1
+TimeSeriesStats::registerRoutes();
+TimeSeriesStats::registerApiRoute();
+CategoricalStats::registerRoutes();
+CategoricalStats::registerApiRoutes();
+
+// v2
+Route::stats(['prefix' => 'admin/stats', 'name' => 'admin.stats', 'guard' => 'web_admin']);
+Route::statsApi(['middleware' => ['api', 'auth:api']]);
+```
+
+The v1 helpers still work and are deprecated. They now use the controllers in `config('stats.controllers')`. The `stats.view-time-series` and `stats.view-categorical` middleware still exist, but the v2 routes don't need them: the controllers check the stats themselves.
+
+## Authorization
+
+In v1 a stat's `canView()` checked `$user->can('view_stats')`. In v2 it asks the gate (`Gate::forUser($user)->allows('view', $stat)`), which uses `StatsPolicy`, which checks `view_stats`. The result is the same unless you change the policy.
+
+- `view_stats` is now registered with javaabu/permissions (group "Stats"). Run your permission sync so the permission and its Dhivehi description exist. Turn this off with `stats.register_permissions`.
+- Replace the policy with `config('stats.policy')`, `config('permissions.policies')` or `Gate::policy(TimeSeriesStatsRepository::class, ...)`.
+- Stats that override `canView()` keep working as they are.
+
+## Built-in stats
+
+v1 registered `user_signups` (and `user_logins` with spatie/laravel-activitylog) whenever `App\Models\User` existed. v2 registers them when `config('stats.default_stats')` is on (the default) for the model in `config('stats.user_model')`, or, when that's null, the model of the default guard's user provider. Their names changed from "Admin User Signups" and "Admin User Logins" to "User Signups" and "User Logins". `TimeSeriesStats::excludeDefaultStats()` still turns them off.
+
+## Databases
+
+v1's date grouping only worked on MySQL. v2 groups dates on MySQL, MariaDB, PostgreSQL and SQLite, and throws a `StatsException` on other drivers. To keep the results the same everywhere, a few keys changed:
+
+| Mode | v1 key | v2 key |
+| --- | --- | --- |
+| `hour` | `2024-07-16 11:00` (and `2024-07-16 11:51` in PHP, so hourly steps didn't match) | `2024-07-16 11:00` in both |
+| `day` | `2024-07-16` | `2024-07-16` |
+| `week` | `YEARWEEK()`, e.g. `202429` | The date the week starts on, e.g. `2024-07-14` |
+| `month` | `2024, 07` | `2024-07` |
+| `year` | `2024` | `2024` |
+
+This changes the `default` formatter's output (the raw results) and anything that read the `week` or `month` columns of `results()`. The other formatters, the charts and the exports format the steps for display and don't change.
+
+If you overrode `getSql()` on `TimeSeriesModes` usage or `getDateSqlForTimeMode()` in a stat, note that `TimeSeriesModes::getSql($field, $driver)` now takes the database driver.
+
+Steps are counted from the start of the first step to the start of the last one, so a range that starts mid-week now includes the whole of both weeks it touches.
+
+## Dates in requests
+
+A `date_to` (or `compare_date_to`) without a time now covers the whole day: `2024-07-07` means up to `2024-07-07 23:59:59`, not midnight at the start of it. Likewise a `date_from` without a time starts at midnight. Dates with a time are used as given. As a result, the previous period of a custom range is now the same number of whole days before it.
+
+`ExactDateRange::getPreviousDateRange()` now ends one second before the range starts and has the same length, ignoring microseconds.
+
+The requests also accept:
+
+- `date_range=custom` with `date_from` and `date_to`;
+- `compare_to=previous` or `compare_to=custom` with `compare_date_from` and `compare_date_to`. `compare=1` and `compare_date_range` still work.
+
+## Enums
+
+| v1 | v2 |
+| --- | --- |
+| `implements IsEnum`, `use NativeEnumsTrait` | `use HasLabels` |
+| `TimeSeriesModes::getLabels()` returned Hourly, Day, Week, Month, Year | Hour, Day, Week, Month, Year, translated |
+| `PresetDateRanges` labels from the value | Explicit, translated labels ("Last 7 days") |
+| `CategoricalModes` had no labels | "Categories with data", "All categories", "Specific categories" |
+| — | `options()` on each enum: `[['value' => ..., 'label' => ...], ...]` |
+| — | `TimeSeriesModes::startOfStep()`, `displayDate()`, `toIsoDate()`, `chartInterval()` |
+
+## Category search
+
+The categories endpoint and the default search of model category providers now use `whereLike()`, so search is case-insensitive on PostgreSQL too. The endpoint also accepts `search` besides `filter[search]`, and `per_page=-1` for a single page of up to 100.
+
+## Exports
+
+Export file names keep their shape: `{App Name} {Stat name} {20240701-20240707} [{comparison}].csv`. The app name now comes from `config('app.name')` instead of the `app_name` setting. The `$filters` argument of `exportStats()` and `exportCategoricalStats()` is now merged with the request's `filters[...]` and the values of filters with form fields; v1 used only the argument for time series exports.
+
+## Class names that moved or went away
+
+| v1 | v2 |
+| --- | --- |
+| `Javaabu\Stats\Views\Components\TimeSeries`, `Categorical`, `Component` | Removed; see Views |
+| `Javaabu\Helpers\Http\Controllers\Controller` as the base of the controllers | `Illuminate\Routing\Controller` |
+| `Illuminate\Testing\Exceptions\InvalidArgumentException` from `ExactDateRange` | `InvalidArgumentException` |
+| `MySQLRefreshDatabase` in the package's tests | Removed; the tests run on any database in `DB_CONNECTION` |
+
+## Checklist
+
+1. Update PHP, Laravel and javaabu/forms; remove javaabu/helpers, javaabu/settings and maatwebsite/excel from your app if nothing else uses them.
+2. Replace the route helpers with `Route::stats()` and `Route::statsApi()`.
+3. Delete published v1 views; set `stats.layout`.
+4. Replace `` and `` with a model page or charts.
+5. Sync permissions.
+6. Check code that reads `week` or `month` keys from `results()` or the `default` format.
+7. Replace `Excel::fake()` in tests of exports.
diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php
new file mode 100644
index 0000000..1459271
--- /dev/null
+++ b/resources/boost/guidelines/core.blade.php
@@ -0,0 +1,10 @@
+{{--
+ javaabu/stats guidelines for AI code assistants
+
+ Maintained by: Javaabu (https://javaabu.com)
+--}}
+
+## javaabu/stats
+
+- This project builds stats with `javaabu/stats`. Define a stat as a class (`CountStatsRepository`, `SumStatsRepository`, `CountCategoricalStatsRepository`, `SumCategoricalStatsRepository`), register it with `TimeSeriesStats::register()` / `CategoricalStats::register()` (or `config('stats.time_series_stats')`), and show it with the chart builders (`TimeSeriesChart`, `CategoryChart`, `PieChart`, `Sparkline`, `StatTile`), drawn as SVG by `` in Blade and by `` from `@javaabu/stats` in React. Don't write raw `GROUP BY DATE(...)` queries, Chart.js code, or your own dashboard caching.
+- Always activate the `javaabu-stats` skill when adding or changing a stat, a chart, a dashboard or stat tile, the stats pages or their routes, stats filters, stats caching, or tests of any of these.
diff --git a/resources/boost/skills/javaabu-stats/SKILL.md b/resources/boost/skills/javaabu-stats/SKILL.md
new file mode 100644
index 0000000..b067184
--- /dev/null
+++ b/resources/boost/skills/javaabu-stats/SKILL.md
@@ -0,0 +1,183 @@
+---
+name: javaabu-stats
+description: Define, chart, cache and test stats in Laravel with javaabu/stats. Use whenever the project depends on javaabu/stats and the task adds or changes a time series or categorical stat (counts or sums of a model over time or by category), registers metrics, draws a chart, sparkline, pie or KPI/stat tile in Blade or Inertia React, builds a dashboard, compares periods or plots several stats together, adds stats filters (javaabu/filters), sets up Route::stats() / Route::statsApi() pages and the JSON API, exports stats to CSV, caches or warms dashboard stats, changes who may view stats, makes a stats page for one model, upgrades from stats v1, or tests stats.
+license: MIT
+metadata:
+ author: Javaabu
+---
+
+# Stats with javaabu/stats
+
+A stat is a class that knows its query, date column and aggregate. You register it under a metric name, then show it with a chart builder. The package groups dates on MySQL, MariaDB, PostgreSQL and SQLite, draws the charts with Onu's tokens (SVG in Blade, Onu's components in React), caches them, and gives you stats pages, a JSON API and CSV exports. The docs ship with the package: `vendor/javaabu/stats/llms.txt` lists every page in `vendor/javaabu/stats/docs/`.
+
+| Task | Read |
+| --- | --- |
+| Define, register, chart and cache stats | This file |
+| Every class, option and route | [references/api.md](references/api.md) |
+| Mistakes to avoid, upgrading from v1 | [references/common-mistakes.md](references/common-mistakes.md) |
+
+## 1. Define a stat
+
+```bash
+php artisan stats:time-series PaymentsCount Payment # count
+php artisan stats:time-series PaymentsSum Payment --type=sum
+php artisan stats:categorical PaymentsByUser Payment User user_id # model, category model, category id column
+```
+
+Or by hand:
+
+```php
+options(PaymentStatus::class)), // a field in the stats form: status=paid
+ ];
+ }
+}
+```
+
+Sums extend `SumStatsRepository` and add `getFieldToSum()`. Categorical stats extend `CountCategoricalStatsRepository` / `SumCategoricalStatsRepository` and add `categoryProvider()` (a model class, an enum class, an array, a query or a `CategoryProvider`), `getCategoryField()` and `getDateField()`.
+
+## 2. Register it
+
+```php
+// AppServiceProvider::boot()
+TimeSeriesStats::register(['payments_count' => PaymentsCount::class]);
+CategoricalStats::register(['payments_by_status' => PaymentsByStatus::class]);
+```
+
+Or in `config/stats.php`: `'time_series_stats' => [...]`, `'categorical_stats' => [...]`.
+
+## 3. Chart it
+
+```php
+use Javaabu\Stats\Charts\{CategoryChart, PieChart, Sparkline, StatTile, TimeSeriesChart};
+use Javaabu\Stats\Enums\{PresetDateRanges, TimeSeriesModes};
+
+$tile = StatTile::make('payments_count')->range(PresetDateRanges::THIS_MONTH)->compare()->sparkline()->icon('credit-card');
+
+$trend = TimeSeriesChart::make('payments_count')
+ ->mode(TimeSeriesModes::DAY)
+ ->range(PresetDateRanges::LAST_30_DAYS)
+ ->compare() // against the previous 30 days
+ ->area();
+
+$both = TimeSeriesChart::make()->stats(['payments_count', 'refunds_count'])->bar(); // several stats
+$years = TimeSeriesChart::make('payments_count')->mode('month')
+ ->periods([PresetDateRanges::THIS_YEAR, PresetDateRanges::LAST_YEAR]); // several periods
+
+$by_status = PieChart::make('payments_by_status')->range(PresetDateRanges::THIS_YEAR)->donut()->top(5);
+$by_island = CategoryChart::make('payments_by_island')->horizontal()->top(10);
+```
+
+Blade:
+
+```blade
+
+
+
+
+
+```
+
+Inertia React (pass the chart as a prop; it's `JsonSerializable`):
+
+```tsx
+import { StatsChart, StatsTile, type StatsChartData, type StatTileData } from "@javaabu/stats"
+
+export default function Dashboard({ trend, tile }: { trend: StatsChartData; tile: StatTileData }) {
+ return (
+ <>
+
+
+ >
+ )
+}
+```
+
+`@javaabu/stats` comes from the Composer package through javaabu/manifest's Vite plugin; the app installs `@javaabu/onu` and `recharts`.
+
+## 4. Cache dashboards
+
+Charts cache their data by default: `stats.cache.ttl.current` (10 minutes) for periods that include today, `stats.cache.ttl.past` (a day) for periods that ended. `->cache(minutes: 5)` or `->withoutCache()` per chart.
+
+```php
+// AppServiceProvider::boot(): charts to keep warm
+Stats::warm(fn () => [
+ StatTile::make('payments_count')->range(PresetDateRanges::THIS_MONTH)->compare(),
+ TimeSeriesChart::make('payments_count')->range(PresetDateRanges::LAST_30_DAYS),
+]);
+
+// routes/console.php
+Schedule::job(new WarmStatsCache)->everyFiveMinutes();
+```
+
+Invalidate with `Stats::forget('payments_count')`, `Stats::flush()`, or the `InvalidatesStats` trait on the model (`protected array $invalidates_stats = ['payments_count'];`). Build the dashboard's charts the same way as the warmed ones so the cache keys match.
+
+## 5. Stats pages, API and permissions
+
+```php
+// routes/web.php, in the admin group
+Route::stats(['prefix' => 'admin/stats', 'name' => 'admin.stats', 'guard' => 'web_admin']);
+
+// routes/api.php
+Route::statsApi(['middleware' => ['auth:api']]);
+```
+
+The pages (time series and categorical) are GET forms defined with stencil; `config('stats.views')` picks `blade` or `inertia`. Viewing a stat needs `view_stats` (registered with javaabu/permissions; run `php artisan permissions:sync`). For one model's stats, extend `TimeSeriesStatsController` and return the fixed filters from `filters(Request $request)`.
+
+## 6. Test
+
+```php
+#[Test]
+public function it_shows_this_months_payments_on_the_dashboard(): void
+{
+ $this->travelTo('2024-07-04');
+ Payment::factory()->count(3)->create(['paid_at' => '2024-07-03']);
+
+ $tile = StatTile::make('payments_count')->range(PresetDateRanges::THIS_MONTH)->withoutCache()->toArray();
+
+ $this->assertEquals(3, $tile['value']);
+}
+```
+
+Use `->withoutCache()` (or `config()->set('stats.cache.enabled', false)`) in tests that change data after a chart is built, travel to a fixed date, and assert on `toArray()` or the rendered Blade.
diff --git a/resources/boost/skills/javaabu-stats/references/api.md b/resources/boost/skills/javaabu-stats/references/api.md
new file mode 100644
index 0000000..71cf67e
--- /dev/null
+++ b/resources/boost/skills/javaabu-stats/references/api.md
@@ -0,0 +1,68 @@
+# javaabu/stats API
+
+## Stat base classes
+
+| Class | You implement |
+| --- | --- |
+| `Repositories\TimeSeries\CountStatsRepository` | `query()`, `getTable()`, `getAggregateFieldName()`; optional `getDateFieldName()` (default `created_at`), `getName()`, `allowedFilters()`, `canView()` |
+| `Repositories\TimeSeries\SumStatsRepository` | the same plus `getFieldToSum()` |
+| `Repositories\TimeSeries\AggregateStatsRepository` | the same plus `getAggregateSql()` (`'avg(amount) as average'`) |
+| `Repositories\TimeSeries\SignupsRepository`, `LoginsRepository` | `userModelClass()` |
+| `Repositories\Categorical\CountCategoricalStatsRepository` | `query()`, `categoryProvider()`, `getCategoryField()`, `getDateField()`, `getAggregateFieldName()` |
+| `Repositories\Categorical\SumCategoricalStatsRepository` | the same plus `getFieldToSum()` |
+
+Using a stat directly: `TimeSeriesStats::createFromMetric('payments_count', PresetDateRanges::THIS_YEAR, ['user' => 1])` then `->results(TimeSeriesModes::MONTH)`, `->total()`, `->format('chartjs', TimeSeriesModes::DAY)`.
+
+## Filters
+
+| Filter | Value from | Applies |
+| --- | --- | --- |
+| `StatsFilter::exact('user', 'user_id')` | `filters[user]` | `where user_id = ?` |
+| `StatsFilter::scope('island')` | `filters[island]` | `->island($value)` |
+| `StatsFilter::closure('status', fn (Builder $query, $value) => ...)` | `filters[status]` | the closure |
+| `StatsFilter::from(SelectFilter::make('status')->options(...))` | `status` (its own query keys), or `filters[status]` | the javaabu/filters filter; its fields appear in the stats form |
+
+Names that can't be used for `StatsFilter::from()`: `metric`, `with`, `mode`, `variant`, `format`, `filters`, `categorical_values`, `date_range`, `date_from`, `date_to`, `compare`, `compare_to`, `compare_date_range`, `compare_date_from`, `compare_date_to`, `page`, `per_page`, `search`.
+
+## Charts (`Javaabu\Stats\Charts`)
+
+Every chart: `make(?string $metric = null, ?string $label = null)`, `title()`, `description()`, `id()`, `range(DateRange|string)`, `filters(array)`, `valueFormat('number'|'integer'|'compact'|'percent'|'currency')`, `height(int)`, `cache(int|bool, minutes: ?int)`, `withoutCache()`, `data()`, `refresh()`, `isWarm()`, `toArray()`, `render()`; also `Htmlable` and `JsonSerializable`.
+
+| Chart | Also | Onu component |
+| --- | --- | --- |
+| `TimeSeriesChart` | `stat($metric, $label, $filters)`, `stats([...])`, `mode()`, `compare(true|DateRange)`, `periods([...])`, `line()`, `area()`, `bar()`, `stacked()`, `brush()`, `positiveIsGood(false)` | `TimeSeriesChart` |
+| `CategoryChart` | `stat()`, `stats()`, `categoryMode()`, `categories([ids])`, `top(n)`, `compare()`, `bar()`, `line()`, `area()`, `horizontal()`, `stacked()`, `positiveIsGood()` | `CategoryChart` |
+| `PieChart` | `stat()`, `categoryMode()`, `categories()`, `top(n)` (rest as "Other"), `donut()`, `centerLabel($value, $label)` / `centerLabel(false)`, `keepEmpty()` | `PieChart` |
+| `Sparkline` | `mode()`, `area()`, `color('var(--chart-2)')` | `Sparkline` |
+| `StatTile` | `label()`, `compare()`, `sparkline(mode)`, `icon()`, `href()`, `footer()`, `deltaContext()`, `positiveIsGood()` | `StatTile` |
+
+`Stats::timeSeries()`, `Stats::category()`, `Stats::pie()`, `Stats::sparkline()`, `Stats::tile()` are shortcuts.
+
+## Blade components
+
+`` (any chart or its array; `:rtl` to force a direction), ``, ``, ``, ``, ``, ``. They require the `stats::charts` stylesheet through javaabu/manifest.
+
+## React (`@javaabu/stats`)
+
+`StatsChart`, `StatsTile`, `StatsPage`, `StatsExportButton`, `TimeSeriesStatsPage`, `CategoricalStatsPage`; types `StatsChartData`, `TimeSeriesChartData`, `CategoryChartData`, `PieChartData`, `SparklineData`, `StatTileData`, `StatsPageProps`. Inertia pages: `stats::TimeSeries/Index`, `stats::Categorical/Index`.
+
+## Cache
+
+`Stats::forget('metric' | [...])`, `Stats::flush()`, `Stats::warm(Chart|iterable|Closure)`, `Stats::warmNow()`, `php artisan stats:warm [--queue] [--flush]`, `Javaabu\Stats\Jobs\WarmStatsCache`, `Javaabu\Stats\Concerns\InvalidatesStats` (`$invalidates_stats`). Config: `stats.cache.enabled`, `store`, `prefix`, `ttl.current`, `ttl.past`, `pages`, `warm.connection`, `warm.queue`.
+
+## Routes
+
+| Macro | Options | Routes |
+| --- | --- | --- |
+| `Route::stats()` | `prefix` (`stats`), `name` (`stats`), `guard`, `middleware` (default `auth` or `auth:{guard}`), `time_series`, `categorical`, `controllers` | `{name}.time-series.index` (GET), `.time-series.export` (POST CSV), `.categorical.index`, `.categorical.export`, `.categorical.categories` |
+| `Route::statsApi()` | the same, name `api.stats` | `{name}.time-series.index`, `.categorical.index`, `.categorical.categories` (JSON) |
+
+Request parameters: `metric`, `with[]`, `mode`, `date_range` (preset or `custom`), `date_from`, `date_to`, `compare_to` (`previous`/`custom`), `compare` (bool), `compare_date_range`, `compare_date_from`, `compare_date_to`, `format`, `filters[...]`, filter fields, `categorical_values[]`, `variant`.
+
+## Authorization
+
+`view_stats` (group "Stats"), `StatsPolicy` (`view`, `export`) for `TimeSeriesStatsRepository` and `CategoricalStatsRepository`; `$stat->canView($user)` asks the gate. Config: `stats.policy`, `stats.register_permissions`.
+
+## Config keys
+
+`time_series_stats`, `categorical_stats`, `default_stats`, `user_model`, `week_starts_on_sunday`, `date_locale`, `date_formats`, `default_time_series_mode`, `default_categorical_mode`, `default_date_range`, `categorical_items_per_page`, `cache.*`, `views`, `layout`, `layout_section`, `rtl_locales`, `controllers.{time_series,categorical,time_series_api,categorical_api,categories}`, `policy`, `register_permissions`.
diff --git a/resources/boost/skills/javaabu-stats/references/common-mistakes.md b/resources/boost/skills/javaabu-stats/references/common-mistakes.md
new file mode 100644
index 0000000..7cf964e
--- /dev/null
+++ b/resources/boost/skills/javaabu-stats/references/common-mistakes.md
@@ -0,0 +1,22 @@
+# Common mistakes
+
+- **Writing your own date grouping.** Don't `selectRaw('DATE(created_at) ...')` or `YEARWEEK()`: extend a stat class; the package groups dates for MySQL, MariaDB, PostgreSQL and SQLite and fills empty steps with zeros.
+- **Unregistered metrics.** Charts take a registered metric (`'payments_count'`) or the stat's class name. A typo throws `StatsException::unknownMetric`.
+- **Building charts in JavaScript.** Don't fetch the JSON API and draw with Chart.js. Build the chart in PHP and pass it to `` or ``.
+- **Charts that never refresh in tests.** Charts are cached. In tests that create data after building a chart, use `->withoutCache()` or `config()->set('stats.cache.enabled', false)`.
+- **Dashboard and warmed charts that differ.** The cache key is the chart's spec (metrics, range, mode, filters, options, locale). Build them in one place (a method or a closure registered with `Stats::warm()`) so the dashboard reads what was warmed.
+- **Forgetting to invalidate.** Add `InvalidatesStats` to the models stats are computed from, or call `Stats::forget('metric')` after bulk imports, or the dashboard shows old numbers until the TTL ends.
+- **`filters[...]` for a form filter.** Filters made with `StatsFilter::from()` read their own query keys (`status=paid`); `filters[...]` is for fixed filters such as the model of a model-specific page.
+- **Naming a form filter after a stats parameter** (`mode`, `metric`, `date_from`, …): it throws. Pick another name and set `->column()` on the javaabu/filters filter.
+- **Assuming the `web` guard.** Pass `guard` to `Route::stats()` for admin portals; type users as `Authorizable`/`Model`, not `App\Models\User`.
+- **Checking `view_stats` yourself.** Use `$stat->canView($user)` or the gate (`can('view', $stat)`), so an app's policy override applies.
+- **Left/right CSS in published views.** Use logical utilities (`ms-`, `pe-`, `start-`); the charts mirror in Dhivehi.
+
+## Upgrading from v1
+
+- Replace `TimeSeriesStats::registerRoutes()` / `registerApiRoute()` and `CategoricalStats::registerRoutes()` / `registerApiRoutes()` with `Route::stats()` and `Route::statsApi()` (the old ones still work, deprecated).
+- The material-admin views and `` / `` are gone. Use the pages, a model page (extend the controller's `filters()`), or charts.
+- Week keys are now the week's start date (`2024-07-14`), month keys `2024-07`; code reading the raw `default` format must change.
+- Exports are CSV without maatwebsite/excel: `(new TimeSeriesStatsExport($mode, $stats))->download('file.csv')`; tests assert the download instead of `Excel::fake()`.
+- A plain `date_to` covers the whole day.
+- See `docs/upgrading.md` for the full list.
diff --git a/tests/Feature/Pages/InertiaPagesTest.php b/tests/Feature/Pages/InertiaPagesTest.php
index 2171e00..19da8de 100644
--- a/tests/Feature/Pages/InertiaPagesTest.php
+++ b/tests/Feature/Pages/InertiaPagesTest.php
@@ -64,7 +64,7 @@ public function it_renders_the_time_series_page_with_the_form_and_the_chart(): v
->where('chart.comparison.previousKey', 'previous')
->where('export.url', url('stats/time-series'))
->has('export.token')
- ->where('strings.title', 'Time Series Stats')
+ ->where('strings.title', 'ވަގުތާއެކު ތަފާސް ހިސާބު')
->etc());
$this->writeFixture($response, 'time-series-props.json');
diff --git a/tests/js/StatsChart.test.tsx b/tests/js/StatsChart.test.tsx
index df3eebc..5ddd61c 100644
--- a/tests/js/StatsChart.test.tsx
+++ b/tests/js/StatsChart.test.tsx
@@ -12,7 +12,7 @@ describe("StatsChart", () => {
const { container } = render()
- expect(screen.getByText("Payments")).toBeInTheDocument()
+ expect(screen.getByText(chart.title)).toBeInTheDocument()
expect(container.querySelector('[data-slot="period-totals"]')).not.toBeNull()
expect(screen.getByRole("button", { name: /table/i })).toBeInTheDocument()
})
diff --git a/tests/js/StatsPage.test.tsx b/tests/js/StatsPage.test.tsx
index ab74c4f..db8e41f 100644
--- a/tests/js/StatsPage.test.tsx
+++ b/tests/js/StatsPage.test.tsx
@@ -8,7 +8,7 @@ describe("TimeSeriesStatsPage", () => {
it("renders the stencil form, the chart and the CSV download", () => {
const { container } = render()
- expect(screen.getByRole("heading", { name: "Time Series Stats" })).toBeInTheDocument()
+ expect(screen.getByRole("heading", { name: timeSeries.strings.title })).toBeInTheDocument()
expect(container.querySelector('[data-stats-page="time-series"]')).not.toBeNull()
expect(container.querySelector('input[name="metric"], select[name="metric"]')).not.toBeNull()
@@ -16,7 +16,7 @@ describe("TimeSeriesStatsPage", () => {
expect(download).toHaveAttribute("method", "post")
expect(download.querySelector('input[name="_token"]')).not.toBeNull()
expect(download.querySelector('input[name="metric"]')).toHaveValue("payments_count")
- expect(screen.getByRole("button", { name: "Download CSV" })).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: timeSeries.strings.export })).toBeInTheDocument()
})
it("explains invalid input instead of drawing a chart", () => {
@@ -29,7 +29,7 @@ describe("TimeSeriesStatsPage", () => {
render()
- expect(screen.getByText("Fix the errors in the form to see the stat.")).toBeInTheDocument()
+ expect(screen.getByText(timeSeries.strings.invalid)).toBeInTheDocument()
})
it("asks for a stat when there is no chart yet", () => {
@@ -37,7 +37,7 @@ describe("TimeSeriesStatsPage", () => {
render()
- expect(screen.getByText("Choose a stat and a date range, then select Generate.")).toBeInTheDocument()
+ expect(screen.getByText(timeSeries.strings.empty)).toBeInTheDocument()
})
})
@@ -46,6 +46,6 @@ describe("CategoricalStatsPage", () => {
const { container } = render()
expect(container.querySelector('[data-stats-page="categorical"]')).not.toBeNull()
- expect(screen.getByRole("heading", { name: "Categorical Stats" })).toBeInTheDocument()
+ expect(screen.getByRole("heading", { name: categorical.strings.title })).toBeInTheDocument()
})
})
diff --git a/tests/js/fixtures/categorical-props.json b/tests/js/fixtures/categorical-props.json
index e50888b..17b61a0 100644
--- a/tests/js/fixtures/categorical-props.json
+++ b/tests/js/fixtures/categorical-props.json
@@ -375,7 +375,7 @@
"date_from": "2024-07-01",
"date_to": "2024-07-04"
},
- "token": "xIMenxVLxNz8BwXd3eFxUzro2DAtcXL2swhiJVFi"
+ "token": "VkBhvZXNMoFgbLKZ85pgmsVazhhQwXga8T2v4Vau"
},
"strings": {
"title": "Categorical Stats",
diff --git a/tests/js/fixtures/time-series-props.json b/tests/js/fixtures/time-series-props.json
index 0ef210f..725b23f 100644
--- a/tests/js/fixtures/time-series-props.json
+++ b/tests/js/fixtures/time-series-props.json
@@ -5,7 +5,7 @@
"action": "http://localhost/stats/time-series",
"method": "GET",
"operation": "edit",
- "submit": "Generate",
+ "submit": "ދައްކާ",
"columns": {
"default": 1,
"md": 2,
@@ -32,7 +32,7 @@
"name": "metric",
"component": "Select2",
"props": {
- "label": "Stat",
+ "label": "ތަފާސް ހިސާބު",
"required": true,
"options": [
{
@@ -57,28 +57,28 @@
"name": "mode",
"component": "Select",
"props": {
- "label": "Group by",
+ "label": "ބަހާލާނީ",
"required": true,
"options": [
{
"value": "hour",
- "label": "Hour"
+ "label": "ގަޑިއިރު"
},
{
"value": "day",
- "label": "Day"
+ "label": "ދުވަސް"
},
{
"value": "week",
- "label": "Week"
+ "label": "ހަފްތާ"
},
{
"value": "month",
- "label": "Month"
+ "label": "މަސް"
},
{
"value": "year",
- "label": "Year"
+ "label": "އަހަރު"
}
]
}
@@ -89,68 +89,68 @@
"name": "date_range",
"component": "Select",
"props": {
- "label": "Date range",
+ "label": "ތާރީޚުތައް",
"required": true,
"options": [
{
"value": "today",
- "label": "Today"
+ "label": "މިއަދު"
},
{
"value": "yesterday",
- "label": "Yesterday"
+ "label": "އިއްޔެ"
},
{
"value": "this_week",
- "label": "This week"
+ "label": "މިހަފްތާ"
},
{
"value": "last_week",
- "label": "Last week"
+ "label": "ފާއިތުވި ހަފްތާ"
},
{
"value": "this_month",
- "label": "This month"
+ "label": "މިމަސް"
},
{
"value": "last_month",
- "label": "Last month"
+ "label": "ފާއިތުވި މަސް"
},
{
"value": "this_year",
- "label": "This year"
+ "label": "މިއަހަރު"
},
{
"value": "last_year",
- "label": "Last year"
+ "label": "ފާއިތުވި އަހަރު"
},
{
"value": "last_7_days",
- "label": "Last 7 days"
+ "label": "ފާއިތުވި 7 ދުވަސް"
},
{
"value": "last_14_days",
- "label": "Last 14 days"
+ "label": "ފާއިތުވި 14 ދުވަސް"
},
{
"value": "last_30_days",
- "label": "Last 30 days"
+ "label": "ފާއިތުވި 30 ދުވަސް"
},
{
"value": "last_5_years",
- "label": "Last 5 years"
+ "label": "ފާއިތުވި 5 އަހަރު"
},
{
"value": "last_10_years",
- "label": "Last 10 years"
+ "label": "ފާއިތުވި 10 އަހަރު"
},
{
"value": "lifetime",
- "label": "Lifetime"
+ "label": "ހުރިހާ ދުވަހެއް"
},
{
"value": "custom",
- "label": "Custom range"
+ "label": "އެހެން ތާރީޚުތަކެއް"
}
]
}
@@ -161,17 +161,17 @@
"name": "compare_to",
"component": "Select",
"props": {
- "label": "Compare to",
- "placeholder": "No comparison",
+ "label": "އަޅާކިޔާނީ",
+ "placeholder": "އަޅާ ނުކިޔާ",
"showPlaceholder": true,
"options": [
{
"value": "previous",
- "label": "Previous period"
+ "label": "ކުރީގެ މުއްދަތު"
},
{
"value": "custom",
- "label": "Custom period"
+ "label": "އެހެން މުއްދަތެއް"
}
]
}
@@ -192,7 +192,7 @@
"name": "date_from",
"component": "Date",
"props": {
- "label": "From",
+ "label": "ފެށޭ ތާރީޚު",
"required": true
}
},
@@ -202,7 +202,7 @@
"name": "date_to",
"component": "Date",
"props": {
- "label": "To",
+ "label": "ނިމޭ ތާރީޚު",
"required": true
}
}
@@ -238,7 +238,7 @@
"name": "compare_date_from",
"component": "Date",
"props": {
- "label": "Compare from",
+ "label": "އަޅާކިޔާ މުއްދަތު ފެށޭ ތާރީޚު",
"required": true
}
},
@@ -248,7 +248,7 @@
"name": "compare_date_to",
"component": "Date",
"props": {
- "label": "Compare to date",
+ "label": "އަޅާކިޔާ މުއްދަތު ނިމޭ ތާރީޚު",
"required": true
}
}
@@ -274,8 +274,8 @@
"name": "with[]",
"component": "Select2",
"props": {
- "label": "Plot with",
- "help": "Other stats to show on the same chart.",
+ "label": "އެކުގައި ދައްކާ",
+ "help": "މި ޗާޓުގައި ދައްކާނެ އެހެން ތަފާސް ހިސާބުތައް.",
"options": [
{
"value": "payments_count",
@@ -302,19 +302,19 @@
"name": "variant",
"component": "Select",
"props": {
- "label": "Chart",
+ "label": "ޗާޓް",
"options": [
{
"value": "line",
- "label": "Line"
+ "label": "ލައިން"
},
{
"value": "area",
- "label": "Area"
+ "label": "އޭރިއާ"
},
{
"value": "bar",
- "label": "Bar"
+ "label": "ބާ"
}
]
}
@@ -419,7 +419,7 @@
"previousLabel": "27 ޖޫން 2024 – 30 ޖޫން 2024",
"positiveIsGood": true
},
- "aggregateLabel": "Count"
+ "aggregateLabel": "އަދަދު"
},
"export": {
"url": "http://localhost/stats/time-series",
@@ -433,13 +433,13 @@
"date_to": "2024-07-04",
"compare_to": "previous"
},
- "token": "swJthBXHU8miNDYgLDnm0qz7RYFhBAalrRwUYmFc"
+ "token": "NPYJWzMyREosPhGV06lHFYHntOWGJefAyVJ3YrLv"
},
"strings": {
- "title": "Time Series Stats",
- "export": "Download CSV",
- "empty": "Choose a stat and a date range, then select Generate.",
- "invalid": "Fix the errors in the form to see the stat."
+ "title": "ވަގުތާއެކު ތަފާސް ހިސާބު",
+ "export": "CSV ޑައުންލޯޑް ކުރައްވާ",
+ "empty": "ތަފާސް ހިސާބަކާއި ތާރީޚުތައް ހޮވާފައި \"ދައްކާ\" އަށް ފިއްތަވާ.",
+ "invalid": "ތަފާސް ހިސާބު ފެނުމަށް ފޯމުގައިވާ ކުށްތައް ރަނގަޅުކުރައްވާ."
},
"layout": "test::layouts.admin",
"layout_section": "content",
From 56d8ecdf51775c854c3d5817a1833051aef04f29 Mon Sep 17 00:00:00 2001
From: Naffah Abdulla Rasheed
Date: Thu, 5 Nov 2026 23:33:22 +0500
Subject: [PATCH 06/11] Fix exports, route parameters, falsy filters, export
ability, paging and chart recomputation
- Exports read form filter values from the request body.
- Pages pass the current route's parameters to their export and category URLs,
so a prefix like users/{user}/stats works.
- Filter values 0, '0' and false are applied; only missing values are skipped.
- Downloads check the policy's export ability (the stat's canView() by default).
- Category search paging links keep filter[metric].
- stats:time-series refuses unknown types.
- Charts compute again when an option changes after reading them; a tile's link,
icon and texts aren't part of its cache key. React tiles show the period.
Refs #2, #5, #7
Co-Authored-By: Claude Opus 5.5
---
resources/js/components/StatsChart.tsx | 3 +-
src/Charts/Chart.php | 19 ++-
src/Charts/StatTile.php | 29 ++--
.../GenerateTimeSeriesStatCommand.php | 8 +-
src/Concerns/ExportsCategoricalStats.php | 5 +
src/Concerns/ExportsTimeSeriesStats.php | 5 +
src/Concerns/HasFilters.php | 9 +-
.../Api/CategoryProviderApiController.php | 2 +-
.../CategoricalStatsController.php | 4 +-
.../Controllers/TimeSeriesStatsController.php | 2 +-
src/Http/Requests/Concerns/ResolvesStats.php | 2 +-
src/Policies/StatsPolicy.php | 8 +-
tests/Feature/Http/RegressionsTest.php | 137 ++++++++++++++++++
13 files changed, 211 insertions(+), 22 deletions(-)
create mode 100644 tests/Feature/Http/RegressionsTest.php
diff --git a/resources/js/components/StatsChart.tsx b/resources/js/components/StatsChart.tsx
index 9bc1ce0..0ca3fbe 100644
--- a/resources/js/components/StatsChart.tsx
+++ b/resources/js/components/StatsChart.tsx
@@ -118,7 +118,8 @@ export function StatsTile({ tile, ...props }: StatsTileProps) {
...(tile.deltaContext ? { deltaContext: tile.deltaContext } : {}),
...(tile.sparkline ? { sparkline: tile.sparkline } : {}),
...(tile.icon ? { icon: tile.icon } : {}),
- ...(tile.footer ? { footer: tile.footer } : {}),
+ // Without a change to show, the period goes in the footer, as in Blade
+ ...(tile.footer || (tile.previousValue === null && tile.period) ? { footer: tile.footer ?? tile.period } : {}),
...props,
} as ComponentProps
diff --git a/src/Charts/Chart.php b/src/Charts/Chart.php
index af9ee73..6f4aa9d 100644
--- a/src/Charts/Chart.php
+++ b/src/Charts/Chart.php
@@ -58,6 +58,8 @@ abstract class Chart implements Arrayable, Htmlable, JsonSerializable
/** @var array|null */
protected ?array $computed = null;
+ protected ?string $computed_for = null;
+
/**
* The chart's type, as the React and Blade renderers know it
*/
@@ -223,10 +225,14 @@ public function isCached(): bool
*/
public function data(): array
{
- if ($this->computed !== null) {
+ $signature = $this->signature();
+
+ if ($this->computed !== null && $this->computed_for === $signature) {
return $this->computed;
}
+ $this->computed_for = $signature;
+
if (! $this->cache_enabled) {
return $this->computed = $this->compute();
}
@@ -247,6 +253,8 @@ public function data(): array
*/
public function refresh(): array
{
+ $this->computed_for = $this->signature();
+
if (! $this->cache_enabled) {
return $this->computed = $this->compute();
}
@@ -260,6 +268,15 @@ public function refresh(): array
);
}
+ /**
+ * What the computed data was computed for, so changing any option after
+ * reading the data computes it again
+ */
+ protected function signature(): string
+ {
+ return md5(serialize([$this->fullCacheKey(), $this->metrics(), $this->cache_enabled, $this->cache_ttl]));
+ }
+
/**
* Whether the chart's data is in the cache
*/
diff --git a/src/Charts/StatTile.php b/src/Charts/StatTile.php
index 3a00bd4..a47ad00 100644
--- a/src/Charts/StatTile.php
+++ b/src/Charts/StatTile.php
@@ -163,7 +163,7 @@ protected function compute(): array
$series = $this->series[0] ?? null;
if (! $series) {
- return ['value' => null];
+ return ['value' => null, 'previousValue' => null, 'sparkline' => null, 'period' => null];
}
$stat = $this->makeStat($series['metric'], $this->getRange(), $series['filters']);
@@ -176,15 +176,9 @@ protected function compute(): array
}
return [
- 'label' => $this->getTitle(),
'value' => $stat->total(),
'previousValue' => $previous,
- 'deltaContext' => $compare_range ? ($this->delta_context ?? __('vs previous period')) : null,
- 'positiveIsGood' => $this->positive_is_good,
'sparkline' => $sparkline,
- 'icon' => $this->icon,
- 'href' => $this->href,
- 'footer' => $this->footer,
'period' => TimeSeriesSteps::rangeLabel($this->getRange(), $stat->getDateFrom(), $stat->getDateTo()),
];
}
@@ -198,11 +192,26 @@ protected function cacheKey(): array
'range' => static::rangeKey($this->getRange()),
'compare' => $compare ? static::rangeKey($compare) : null,
'sparkline' => $this->has_sparkline ? ($this->sparkline_mode ?? TimeSeriesStats::defaultMode())->value : null,
- 'title' => $this->title,
+ ];
+ }
+
+ /**
+ * The cached numbers, with the tile's label, link, icon and texts, which
+ * aren't part of the cache key
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ $compare = $this->getCompareRange() !== null;
+
+ return [
+ ...parent::toArray(),
+ 'label' => $this->getTitle(),
+ 'deltaContext' => $compare ? ($this->delta_context ?? __('vs previous period')) : null,
+ 'positiveIsGood' => $this->positive_is_good,
'icon' => $this->icon,
'href' => $this->href,
- 'positive_is_good' => $this->positive_is_good,
- 'delta_context' => $this->delta_context,
'footer' => $this->footer,
];
}
diff --git a/src/Commands/GenerateTimeSeriesStatCommand.php b/src/Commands/GenerateTimeSeriesStatCommand.php
index d49a38e..4ec4f44 100644
--- a/src/Commands/GenerateTimeSeriesStatCommand.php
+++ b/src/Commands/GenerateTimeSeriesStatCommand.php
@@ -44,7 +44,13 @@ public function handle(): int
$model_class = (string) $this->argument('model');
$force = (bool) $this->option('force');
$path = (string) $this->option('path');
- $type = (string) $this->option('type');
+ $type = strtolower((string) $this->option('type'));
+
+ if (! in_array($type, ['count', 'sum'], true)) {
+ $this->error("Invalid time series stat type [{$type}]. Expected count or sum.");
+
+ return Command::FAILURE;
+ }
// create generator
$generator = $this->getGenerator($type, $name, $model_class);
diff --git a/src/Concerns/ExportsCategoricalStats.php b/src/Concerns/ExportsCategoricalStats.php
index 0c3c591..8215c32 100644
--- a/src/Concerns/ExportsCategoricalStats.php
+++ b/src/Concerns/ExportsCategoricalStats.php
@@ -2,6 +2,7 @@
namespace Javaabu\Stats\Concerns;
+use Illuminate\Support\Facades\Gate;
use Javaabu\Stats\CategoricalStats;
use Javaabu\Stats\Exports\CategoricalStatsExport;
use Javaabu\Stats\Http\Requests\CategoricalStatsRequest;
@@ -30,6 +31,10 @@ public function exportCategoricalStats(CategoricalStatsRequest $request, array $
$compare?->setFilters(array_merge($compare->getFilters(), $filters));
}
+ $user = $request->statsUser();
+
+ abort_unless($user && Gate::forUser($user)->allows('export', $stats), 403);
+
$exporter = new CategoricalStatsExport($stats, $compare, $request->mode(), $request->categoricalValues());
return $exporter->download(ExportFileName::make($exporter->getReportTitle(), $stats, $compare, $export_title));
diff --git a/src/Concerns/ExportsTimeSeriesStats.php b/src/Concerns/ExportsTimeSeriesStats.php
index b265329..203bf24 100644
--- a/src/Concerns/ExportsTimeSeriesStats.php
+++ b/src/Concerns/ExportsTimeSeriesStats.php
@@ -2,6 +2,7 @@
namespace Javaabu\Stats\Concerns;
+use Illuminate\Support\Facades\Gate;
use Javaabu\Stats\Exports\TimeSeriesStatsExport;
use Javaabu\Stats\Http\Requests\TimeSeriesStatsRequest;
use Javaabu\Stats\Support\ExportFileName;
@@ -30,6 +31,10 @@ public function exportStats(TimeSeriesStatsRequest $request, array $filters = []
$compare?->setFilters(array_merge($compare->getFilters(), $filters));
}
+ $user = $request->statsUser();
+
+ abort_unless($user && Gate::forUser($user)->allows('export', $stats), 403);
+
$exporter = new TimeSeriesStatsExport($request->mode(), $stats, $compare);
return $exporter->download(ExportFileName::make($exporter->getReportTitle(), $stats, $compare, $export_title));
diff --git a/src/Concerns/HasFilters.php b/src/Concerns/HasFilters.php
index 8857b14..4f88ecb 100644
--- a/src/Concerns/HasFilters.php
+++ b/src/Concerns/HasFilters.php
@@ -89,9 +89,14 @@ public function applyFilters(Builder $query): Builder
{
collect($this->allowedFilters())
->each(function (Filter $filter) use ($query) {
- if ($value = $this->getFilter($filter->getName())) {
- $filter->apply($query, $value, $this);
+ $value = $this->getFilter($filter->getName());
+
+ // 0, '0' and false are values; only missing ones are skipped
+ if ($value === null || $value === '' || $value === []) {
+ return;
}
+
+ $filter->apply($query, $value, $this);
});
return $query;
diff --git a/src/Http/Controllers/Api/CategoryProviderApiController.php b/src/Http/Controllers/Api/CategoryProviderApiController.php
index 623ba41..46b4351 100644
--- a/src/Http/Controllers/Api/CategoryProviderApiController.php
+++ b/src/Http/Controllers/Api/CategoryProviderApiController.php
@@ -42,6 +42,6 @@ public function index(Request $request): JsonResponse
$per_page = $per_page < 1 ? 100 : $per_page;
$provider = CategoricalStats::createFromMetric($metric, filters: $filters)->getCategoryProvider();
- return new JsonResponse($provider->searchCategoricalStatsItems($search, $per_page));
+ return new JsonResponse($provider->searchCategoricalStatsItems($search, $per_page)->appends($request->query()));
}
}
diff --git a/src/Http/Controllers/CategoricalStatsController.php b/src/Http/Controllers/CategoricalStatsController.php
index eba7d2f..910eb04 100644
--- a/src/Http/Controllers/CategoricalStatsController.php
+++ b/src/Http/Controllers/CategoricalStatsController.php
@@ -65,7 +65,7 @@ public function index(Request $request): View|Responsable
->with([
'metrics' => $metrics,
'filters' => $filters,
- 'categories_url' => route(StatsRoutes::namePrefix($request) . '.categorical.categories'),
+ 'categories_url' => route(StatsRoutes::namePrefix($request) . '.categorical.categories', $request->route()?->parameters() ?? []),
])
->model($stats_request->all())
->action($request->url());
@@ -124,7 +124,7 @@ protected function viewData(Request $request, CategoricalStatsForm $form, ?Chart
'form' => $inertia ? $form->render('json') : $form,
'chart' => $chart?->toArray(),
'export' => $chart ? [
- 'url' => route(StatsRoutes::namePrefix($request) . '.categorical.export'),
+ 'url' => route(StatsRoutes::namePrefix($request) . '.categorical.export', $request->route()?->parameters() ?? []),
'params' => $stats_request->all(),
'token' => $inertia ? csrf_token() : null,
] : null,
diff --git a/src/Http/Controllers/TimeSeriesStatsController.php b/src/Http/Controllers/TimeSeriesStatsController.php
index ef89ba1..eef3458 100644
--- a/src/Http/Controllers/TimeSeriesStatsController.php
+++ b/src/Http/Controllers/TimeSeriesStatsController.php
@@ -106,7 +106,7 @@ protected function viewData(Request $request, TimeSeriesStatsForm $form, ?TimeSe
'form' => $inertia ? $form->render('json') : $form,
'chart' => $chart?->toArray(),
'export' => $chart ? [
- 'url' => route(StatsRoutes::namePrefix($request) . '.time-series.export'),
+ 'url' => route(StatsRoutes::namePrefix($request) . '.time-series.export', $request->route()?->parameters() ?? []),
'params' => $stats_request->all(),
'token' => $inertia ? csrf_token() : null,
] : null,
diff --git a/src/Http/Requests/Concerns/ResolvesStats.php b/src/Http/Requests/Concerns/ResolvesStats.php
index b39f82c..e185ae4 100644
--- a/src/Http/Requests/Concerns/ResolvesStats.php
+++ b/src/Http/Requests/Concerns/ResolvesStats.php
@@ -66,7 +66,7 @@ public function fixedFilters(): array
public function filtersFor(InteractsWithFilters $stat): array
{
$filters = $this->fixedFilters();
- $input = $this->query();
+ $input = $this->all();
foreach ($stat->allowedFilters() as $filter) {
if (! $filter instanceof HasFields || array_key_exists($filter->getName(), $filters)) {
diff --git a/src/Policies/StatsPolicy.php b/src/Policies/StatsPolicy.php
index 57ba9f2..bfd74ab 100644
--- a/src/Policies/StatsPolicy.php
+++ b/src/Policies/StatsPolicy.php
@@ -12,7 +12,7 @@
* | Ability | Permission |
* | -------- | ----------------------- |
* | view | view_stats |
- * | export | what view needs |
+ * | export | the stat's canView() |
*
* A stat can still decide for itself by overriding `canView()`. Replace the
* policy with `config('stats.policy')`, `config('permissions.policies')` or
@@ -27,8 +27,12 @@ public function view(Authorizable $user, TimeSeriesStatsRepository|CategoricalSt
return $user->can(self::VIEW);
}
+ /**
+ * Whoever can see a stat can download it. The stat's own canView() decides,
+ * so stats that override it keep working; restrict exports in your policy.
+ */
public function export(Authorizable $user, TimeSeriesStatsRepository|CategoricalStatsRepository $stat): bool
{
- return $this->view($user, $stat);
+ return $stat->canView($user);
}
}
diff --git a/tests/Feature/Http/RegressionsTest.php b/tests/Feature/Http/RegressionsTest.php
new file mode 100644
index 0000000..2a86adc
--- /dev/null
+++ b/tests/Feature/Http/RegressionsTest.php
@@ -0,0 +1,137 @@
+registerTestStats();
+ $this->allowViewingStats();
+ }
+
+ #[Test]
+ public function it_applies_form_filters_posted_to_the_export(): void
+ {
+ [$ahmed] = $this->seedPayments();
+ Route::middleware('web')->group(fn () => Route::stats());
+
+ $csv = $this->actingAs($ahmed)
+ ->post('/stats/time-series', [
+ 'metric' => 'payments_by_status',
+ 'mode' => 'day',
+ 'date_from' => '2024-07-03',
+ 'date_to' => '2024-07-04',
+ 'status' => 'pending',
+ ])
+ ->assertOk()
+ ->streamedContent();
+
+ $this->assertStringContainsString('2024-07-03,0', $csv);
+ $this->assertStringContainsString('2024-07-04,2', $csv);
+ }
+
+ #[Test]
+ public function it_works_with_a_route_parameter_in_the_prefix(): void
+ {
+ [$ahmed, $aisha] = $this->seedPayments();
+ Route::middleware('web')->group(fn () => Route::stats([
+ 'prefix' => 'users/{owner}/stats',
+ 'name' => 'users.stats',
+ 'controllers' => ['time_series' => IslandStatsController::class],
+ ]));
+
+ $this->actingAs($ahmed)
+ ->get("/users/{$aisha->id}/stats/time-series?owner={$aisha->id}&metric=payments_count&mode=day&date_range=custom&date_from=2024-07-03&date_to=2024-07-04")
+ ->assertOk()
+ ->assertSee(url("users/{$aisha->id}/stats/time-series"), false)
+ ->assertSee('4 Jul 24 · Payments: 2', false);
+ }
+
+ #[Test]
+ public function it_filters_by_zero_and_false(): void
+ {
+ $this->seedPayments();
+
+ $stat = new PaymentsCount(new ExactDateRange('2024-07-01', '2024-07-04 23:59:59'), ['user' => 0]);
+
+ $this->assertEquals(0, $stat->total());
+ }
+
+ #[Test]
+ public function it_checks_the_export_ability(): void
+ {
+ [$ahmed] = $this->seedPayments();
+ Route::middleware('web')->group(fn () => Route::stats());
+ Gate::before(fn ($user, string $ability) => $ability === 'export' ? false : null);
+
+ $this->actingAs($ahmed)
+ ->post('/stats/time-series', ['metric' => 'payments_count', 'mode' => 'day', 'date_from' => '2024-07-03', 'date_to' => '2024-07-04'])
+ ->assertForbidden();
+ }
+
+ #[Test]
+ public function it_keeps_the_metric_in_category_paging_links(): void
+ {
+ [$ahmed] = $this->seedPayments();
+ Route::middleware('web')->group(fn () => Route::stats());
+
+ $this->actingAs($ahmed)
+ ->getJson('/stats/categorical/categories?filter[metric]=payments_by_user&per_page=1')
+ ->assertOk()
+ ->assertJsonPath('next_page_url', fn (string $url) => str_contains(urldecode($url), 'filter[metric]=payments_by_user'));
+ }
+
+ #[Test]
+ public function it_refuses_unknown_time_series_types(): void
+ {
+ $this->artisan('stats:time-series', ['name' => 'Foo', 'model' => 'Payment', '--type' => 'foo'])
+ ->expectsOutputToContain('Invalid time series stat type [foo]')
+ ->assertFailed();
+ }
+
+ #[Test]
+ public function it_computes_again_after_an_option_changes(): void
+ {
+ $this->seedPayments();
+
+ $chart = TimeSeriesChart::make('payments_count')->range(new ExactDateRange('2024-07-01', '2024-07-04 23:59:59'))->withoutCache();
+ $this->assertNull($chart->toArray()['comparison']);
+
+ $chart->compare()->mode('week');
+ $this->assertEquals('previous', $chart->toArray()['comparison']['previousKey']);
+ $this->assertEquals('week', $chart->toArray()['mode']);
+ }
+
+ #[Test]
+ public function it_keeps_a_tiles_link_and_texts_out_of_its_cache_key(): void
+ {
+ $this->seedPayments();
+
+ StatTile::make('payments_count')->range(PresetDateRanges::THIS_MONTH)->href('http://cli.test/payments')->toArray();
+
+ $tile = StatTile::make('payments_count')->range(PresetDateRanges::THIS_MONTH)->href('https://app.test/payments')->icon('credit-card');
+
+ $this->assertTrue($tile->isWarm());
+ $this->assertEquals('https://app.test/payments', $tile->toArray()['href']);
+ $this->assertEquals('credit-card', $tile->toArray()['icon']);
+ }
+}
From 8f5af426e2de0ef820773eed123ef80e5630b9d5 Mon Sep 17 00:00:00 2001
From: Naffah Abdulla Rasheed
Date: Thu, 5 Nov 2026 23:33:22 +0500
Subject: [PATCH 07/11] Docs for people and agents: Docusaurus pages, llms.txt
Closes #8
Co-Authored-By: Claude Opus 5.5
---
docs/advanced-usage/_category_.json | 11 +
docs/advanced-usage/api-reference.md | 255 +++++++++++
docs/advanced-usage/artisan-commands.md | 109 +++++
docs/advanced-usage/caching-and-warming.md | 175 ++++++++
docs/advanced-usage/configuration.md | 104 +++++
docs/advanced-usage/databases.md | 70 +++
docs/advanced-usage/formatters.md | 196 +++++++++
docs/advanced-usage/json-api.md | 198 +++++++++
.../overriding-controllers-and-views.md | 125 ++++++
.../permissions-and-policies.md | 126 ++++++
docs/ai-assistants.md | 36 ++
docs/basic-usage/_category_.json | 11 +
docs/basic-usage/categorical-stats.md | 206 +++++++++
docs/basic-usage/category-providers.md | 306 +++++++++++++
docs/basic-usage/charts.md | 405 ++++++++++++++++++
docs/basic-usage/dashboards.md | 312 ++++++++++++++
docs/basic-usage/filters.md | 148 +++++++
docs/basic-usage/stats-pages.md | 297 +++++++++++++
docs/basic-usage/time-series-stats.md | 356 +++++++++++++++
docs/categorical/_category_.json | 11 -
docs/categorical/category-providers.md | 403 -----------------
.../categorical/defining-categorical-stats.md | 191 ---------
.../filters-and-model-specific-stats.md | 134 ------
docs/categorical/getting-started.md | 109 -----
docs/changelog.md | 4 +-
docs/contributing-to-docs.md | 4 +-
docs/installation-and-setup.md | 343 +++++++++------
docs/intro.md | 68 ++-
docs/metadata.json | 4 +-
docs/questions-and-security.md | 2 +-
docs/requirements.md | 29 +-
docs/time-series/_category_.json | 11 -
docs/time-series/basic-concepts.md | 101 -----
.../time-series/defining-time-series-stats.md | 271 ------------
docs/time-series/filters.md | 103 -----
docs/time-series/formatters.md | 310 --------------
docs/time-series/getting-started.md | 99 -----
docs/time-series/model-specific-stats.md | 116 -----
docs/time-series/predefined-stats.md | 61 ---
docs/upgrading.md | 5 +
llms.txt | 48 +++
41 files changed, 3819 insertions(+), 2054 deletions(-)
create mode 100644 docs/advanced-usage/_category_.json
create mode 100644 docs/advanced-usage/api-reference.md
create mode 100644 docs/advanced-usage/artisan-commands.md
create mode 100644 docs/advanced-usage/caching-and-warming.md
create mode 100644 docs/advanced-usage/configuration.md
create mode 100644 docs/advanced-usage/databases.md
create mode 100644 docs/advanced-usage/formatters.md
create mode 100644 docs/advanced-usage/json-api.md
create mode 100644 docs/advanced-usage/overriding-controllers-and-views.md
create mode 100644 docs/advanced-usage/permissions-and-policies.md
create mode 100644 docs/ai-assistants.md
create mode 100644 docs/basic-usage/_category_.json
create mode 100644 docs/basic-usage/categorical-stats.md
create mode 100644 docs/basic-usage/category-providers.md
create mode 100644 docs/basic-usage/charts.md
create mode 100644 docs/basic-usage/dashboards.md
create mode 100644 docs/basic-usage/filters.md
create mode 100644 docs/basic-usage/stats-pages.md
create mode 100644 docs/basic-usage/time-series-stats.md
delete mode 100644 docs/categorical/_category_.json
delete mode 100644 docs/categorical/category-providers.md
delete mode 100644 docs/categorical/defining-categorical-stats.md
delete mode 100644 docs/categorical/filters-and-model-specific-stats.md
delete mode 100644 docs/categorical/getting-started.md
delete mode 100644 docs/time-series/_category_.json
delete mode 100644 docs/time-series/basic-concepts.md
delete mode 100644 docs/time-series/defining-time-series-stats.md
delete mode 100644 docs/time-series/filters.md
delete mode 100644 docs/time-series/formatters.md
delete mode 100644 docs/time-series/getting-started.md
delete mode 100644 docs/time-series/model-specific-stats.md
delete mode 100644 docs/time-series/predefined-stats.md
create mode 100644 llms.txt
diff --git a/docs/advanced-usage/_category_.json b/docs/advanced-usage/_category_.json
new file mode 100644
index 0000000..825e129
--- /dev/null
+++ b/docs/advanced-usage/_category_.json
@@ -0,0 +1,11 @@
+{
+ "position": 1.4,
+ "label": "Advanced Usage",
+ "collapsible": true,
+ "collapsed": false,
+ "link": {
+ "type": "generated-index",
+ "slug": "/stats/_categories/advanced-usage",
+ "description": "Caching, configuration, permissions, overrides, the JSON API, formatters, databases, commands and every class."
+ }
+}
diff --git a/docs/advanced-usage/api-reference.md b/docs/advanced-usage/api-reference.md
new file mode 100644
index 0000000..f7f3174
--- /dev/null
+++ b/docs/advanced-usage/api-reference.md
@@ -0,0 +1,255 @@
+---
+title: API reference
+sidebar_position: 10
+---
+
+Everything is in the `Javaabu\Stats` namespace unless a full name is given.
+
+# `TimeSeriesStats` and `CategoricalStats`
+
+Static registries of stats and formatters. Both have:
+
+| Method | Returns |
+| --- | --- |
+| `register(array $stats, bool $merge = true)` | Register stats as metric => class. `false` replaces the registered ones |
+| `statsMap()` | Classes keyed by metric |
+| `getClassNameForMetric(string $metric)` | The class (the metric itself when not registered) |
+| `getMetricForStat(string $stat)` | The metric of a class |
+| `createFromMetric(string $metric, DateRange $date_range = PresetDateRanges::THIS_YEAR, array $filters = [])` | A stat instance |
+| `getMetricName(string $metric)` | The stat's name |
+| `getMetricNames(array\|string $filters = [], ?Authorizable $user = null)` | Names keyed by metric, of the stats the user can see that allow the filters |
+| `allowedMetrics(array\|string $filters = [], ?Authorizable $user = null)` | The same, as a list of metrics |
+| `metricsThatAllowFilters(array\|string $filters, ?Authorizable $user = null, StatListReturnType $return_type = StatListReturnType::METRIC_AND_NAME)` | Either, or classes keyed by metric |
+| `canViewAny(?Authorizable $user = null)` | Whether the user can see any stat |
+| `defaultMode()` | `TimeSeriesModes` or `CategoricalModes`, from the config |
+| `defaultDateRange()` | The default `PresetDateRanges` |
+| `registerFormatters(array $formatters, bool $merge = true)` | Register formatters as name => class |
+| `formattersMap()`, `allowedFormats()` | Formatter classes by name, and the names |
+| `createFromFormat(string $format)` | A formatter instance |
+| `getClassNameForFormat(string $name)`, `getNameForFormatter(string $formatter)` | Look formatters up |
+
+`TimeSeriesStats` also has:
+
+| Method | Returns |
+| --- | --- |
+| `registerDefaultStats()` | Register `user_signups` and `user_logins` |
+| `excludeDefaultStats()`, `shouldExcludeDefaultStats()` | Keep the built-in stats out |
+| `dateLocale()` | The Carbon locale used for weeks (`en_GB@Stats`) |
+| `firstDayOfWeek()` | `Carbon::SUNDAY` or `Carbon::MONDAY` |
+| `weekMode()` | The MySQL `WEEK()` mode matching the first day (v1) |
+| `registerRoutes()`, `registerApiRoute()` | Deprecated v1 routes. Use `Route::stats()` and `Route::statsApi()` |
+
+`CategoricalStats` also has `categoricalItemsPerPage()` and the deprecated `registerRoutes()` and `registerApiRoutes()`.
+
+# `Facades\Stats` (`StatsManager`)
+
+| Method | Returns |
+| --- | --- |
+| `timeSeries(?string $metric = null, ?string $label = null)` | `TimeSeriesChart` |
+| `category(?string $metric = null, ?string $label = null)` | `CategoryChart` |
+| `pie(?string $metric = null, ?string $label = null)` | `PieChart` |
+| `sparkline(?string $metric = null, ?string $label = null)` | `Sparkline` |
+| `tile(?string $metric = null, ?string $label = null)` | `StatTile` |
+| `cache()` | The `StatsCache` |
+| `forget(string\|array $metrics = '*')` | Clear the cached charts of the metrics (all for `*` or `[]`) |
+| `flush()` | Clear every cached chart |
+| `warm(Chart\|iterable\|Closure $charts)` | Register charts to warm |
+| `warmables()` | The registered charts |
+| `warmNow(?Closure $after = null)` | Compute and cache them; returns how many |
+| `clearWarmables()` | Forget the registered charts |
+
+# Charts
+
+## `Charts\Chart` (every chart)
+
+| Method | What |
+| --- | --- |
+| `title(?string)`, `description(?string)`, `id(?string)` | Texts and the HTML id |
+| `range(DateRange\|string)`, `filters(array)` | The date range and fixed filters |
+| `valueFormat(string)`, `height(?int)` | Display |
+| `cache(int\|bool $seconds = true, ?int $minutes = null)`, `withoutCache()` | Caching |
+| `data()`, `refresh()`, `isWarm()`, `isCached()` | The computed data and its cache |
+| `toArray()`, `jsonSerialize()`, `render()`, `toHtml()` | Output |
+| `type()`, `metrics()` | What it is |
+| `getTitle()`, `getId()`, `getRange()`, `getFilters()`, `getValueFormat()`, `getHeight()` | Getters |
+
+Constants: `FORMAT_NUMBER`, `FORMAT_INTEGER`, `FORMAT_COMPACT`, `FORMAT_PERCENT`, `FORMAT_CURRENCY`.
+
+From `Charts\Concerns\HasStats` (every chart): `stat(string $metric, ?string $label = null, array $filters = [])`, `stats(array $metrics)`, `getSeries()`.
+
+From `Charts\Concerns\HasCategories` (`CategoryChart`, `PieChart`): `categoryMode(CategoricalModes|string)`, `getCategoryMode()`, `categories(array $ids)`, `top(?int $limit)`, `makeStat(string $metric, DateRange $range, array $filters = [])`.
+
+## The builders
+
+All have `static make(?string $metric = null, ?string $label = null)`.
+
+| Class | Methods | Constants |
+| --- | --- | --- |
+| `Charts\TimeSeriesChart` | `mode()`, `getMode()`, `periods(array)`, `compare(DateRange\|string\|bool = true)`, `getCompareRange()`, `positiveIsGood(bool = true)`, `variant(string)`, `line()`, `area()`, `bar()`, `stacked(bool = true)`, `brush(bool = true)`, `makeStat(string $metric, DateRange $range, array $filters = [])` | `LINE`, `AREA`, `BAR` |
+| `Charts\CategoryChart` | `compare()`, `getCompareRange()`, `positiveIsGood()`, `variant()`, `bar()`, `line()`, `area()`, `horizontal(bool = true)`, `stacked(bool = true)` | `BAR`, `LINE`, `AREA` |
+| `Charts\PieChart` | `donut(bool = true)`, `centerLabel(string\|false\|null $value, ?string $label = null)`, `keepEmpty(bool = true)` | |
+| `Charts\Sparkline` | `mode()`, `getMode()`, `area(bool = true)`, `color(?string)` | |
+| `Charts\StatTile` | `label(?string)`, `compare()`, `getCompareRange()`, `sparkline(TimeSeriesModes\|string\|bool = true)`, `icon(?string)`, `href(?string)`, `positiveIsGood()`, `deltaContext(?string)`, `footer(?string)` | |
+
+See [Charts](../basic-usage/charts.md) for what each does.
+
+# Stats
+
+## Contracts
+
+`Contracts\TimeSeriesStatsRepository` and `Contracts\CategoricalStatsRepository` extend `Contracts\InteractsWithDateRange` and `Contracts\InteractsWithFilters`.
+
+| `InteractsWithDateRange` | `InteractsWithFilters` |
+| --- | --- |
+| `getDateFrom()`, `getDateTo()`, `getDateRange()` | `allowedFilters()` |
+| `setDateFrom()`, `setDateTo()`, `setDateRange(DateRange)` | `getFilters()`, `getFilter(string $filter, $default = null)`, `setFilters(array)` |
+| `formattedDateRange(string $format, string $separator)` | `isAllowedFilter(string)` |
+| `getDateField()`, `getMinDate()`, `getMaxDate()` | `ensureAllFiltersAllowed(array $filters, bool $throw_on_fail = false)` |
+| `interval(TimeSeriesModes)` | `applyFilters(Builder)`, `filteredQuery()` |
+| `applyDateFilters(Builder)`, `filteredQueryWithoutDateFilters()` | |
+
+`TimeSeriesStatsRepository` adds `query()`, `hour()`, `day()`, `week()`, `month()`, `year()`, `results(TimeSeriesModes)`, `total()`, `format(string $format, TimeSeriesModes $mode = TimeSeriesModes::DAY)`, `getCurrentMode()`, `getAggregateFieldName()`, `getAggregateFieldLabel()`, `getName()`, `canView(?Authorizable)` and `metric()`.
+
+`CategoricalStatsRepository` adds `query()`, `categoryProvider()`, `getCategoryProvider()`, `getCategoryField()`, `getCategoryFieldAlias()`, `getCategoryNameFieldAlias()`, `getCategoryTitle()`, `getCategoryIdTitle()`, `getAggregateFieldName()`, `getAggregateFieldLabel()`, `getName()`, `canView()`, `metric()`, `results()`, `resultsToArray(Collection)`, `resolveCategoryNames(CategoricalModes $mode, array $categorical_values = [])` and `format(string $format, ?CategoricalStatsRepository $compare = null, CategoricalModes $mode = CategoricalModes::NON_EMPTY, array $categorical_values = [])`.
+
+## Base classes
+
+| Class | You implement |
+| --- | --- |
+| `Repositories\TimeSeries\AbstractTimeSeriesStatsRepository` | Everything about the queries |
+| `Repositories\TimeSeries\AggregateStatsRepository` | `query()`, `getTable()`, `getAggregateSql()`, `getAggregateFieldName()`. Has `getDateFieldName()`, `getDateField()`, `getDateSqlForTimeMode(TimeSeriesModes)`, `groupedQuery(TimeSeriesModes)` |
+| `Repositories\TimeSeries\CountStatsRepository` | `query()`, `getTable()`, `getAggregateFieldName()` |
+| `Repositories\TimeSeries\SumStatsRepository` | The same, and `getFieldToSum()` |
+| `Repositories\TimeSeries\SignupsRepository` | `userModelClass()` |
+| `Repositories\TimeSeries\LoginsRepository` | `userModelClass()`. Needs spatie/laravel-activitylog |
+| `Repositories\TimeSeries\ActivityLogStatsRepository` | `eventDescription()`, `getAggregateFieldName()` |
+| `Repositories\TimeSeries\UserSignupsRepository`, `UserLoginsRepository` | Nothing: the built-in stats. `UserSignupsRepository::defaultUserModel()` gives their model |
+| `Repositories\Categorical\AbstractCategoricalStatsRepository` | `query()`, `categoryProvider()`, `getCategoryField()`, `getDateField()`, `getAggregateSql()`, `getAggregateFieldName()`. Has `groupByCategory()` |
+| `Repositories\Categorical\CountCategoricalStatsRepository` | The same, without `getAggregateSql()` |
+| `Repositories\Categorical\SumCategoricalStatsRepository` | The same, and `getFieldToSum()` |
+
+The traits `Concerns\HasDateRange` and `Concerns\HasFilters` implement the two contracts above.
+
+# Filters
+
+| Class | What |
+| --- | --- |
+| `Filters\StatsFilter` | `exact(string $name, string $internal_name = '')`, `scope(string $name, string $internal_name = '')`, `closure(string $name, Closure $callback)`, `from(\Javaabu\Filters\Filter $filter)` |
+| `Filters\ExactFilter`, `Filters\ScopeFilter`, `Filters\ClosureFilter` | The filters those make. `getName()`, `getInternalName()`, `apply(Builder $query, $value, InteractsWithFilters $stat)` |
+| `Filters\AbstractFilter` | A base for filters with a name and an internal name |
+| `Filters\ModelFilter` | A javaabu/filters filter: `getFilter()`, `getFields()`, `getQueryKeys()`, `resolveValue(array $input)`. `RESERVED` lists the names it can't use |
+| `Contracts\Filter` | `getName()`, `apply()` |
+| `Contracts\HasFields` | `getFields()`, `getQueryKeys()`, `resolveValue(array $input)` |
+
+# Category providers
+
+| Class | What |
+| --- | --- |
+| `Contracts\CategoryProvider` | `getCategoricalStatsItems(?array $ids = null)`, `searchCategoricalStatsItems(?string $search = null, int $per_page = 15)`, `sortCategoricalStatsItemsBy(string $field, string $direction = 'asc')`, `sortCategoricalStatsItemsUsing(Closure)`, `getCategoricalStatsCategoryTitle()`, `getCategoricalStatsCategoryIdTitle()` |
+| `CategoryProviders\EloquentCategoryProvider` | `new EloquentCategoryProvider(Builder\|Model\|string $source, string $id_field = '', string $label_field = '', string $sort_field = '', string $sort_direction = 'asc', ?Closure $sort_query = null)` |
+| `CategoryProviders\EnumCategoryProvider` | `new EnumCategoryProvider(string $enum, string $sort_field = '', string $sort_direction = 'asc', ?Closure $sort_callback = null)` |
+| `CategoryProviders\ArrayCategoryProvider` | `new ArrayCategoryProvider(iterable $categories, string $sort_field = '', string $sort_direction = 'asc', ?Closure $sort_callback = null)` |
+| `Concerns\IsCategoricalStatsProvider` | Makes a model a provider. Adds `getCategoricalStatsIdField()`, `getCategoricalStatsLabelField()`, `getCategoricalStatsSearchField()` |
+| `Concerns\HasCategoricalStatsItems` | Makes an in-memory provider: implement `categoricalStatsItems(): iterable` |
+| `Support\CategoryProviderFactory` | `make(mixed $provider)`: wraps a provider source |
+
+# Enums
+
+| Enum | Cases | Methods |
+| --- | --- | --- |
+| `Enums\TimeSeriesModes` | `HOUR`, `DAY`, `WEEK`, `MONTH`, `YEAR` | `getLabel()`, `getSql(string $date_field, string $driver = 'mysql')`, `interval(Carbon, Carbon)`, `startOfStep(Carbon)`, `increment(Carbon)`, `formatDate(Carbon, bool $for_display = true)`, `displayDate(Carbon, ?string $locale = null)`, `toIsoDate(Carbon)`, `getDateFormat()`, `getInternalDateFormat()`, `chartInterval()` |
+| `Enums\CategoricalModes` | `NON_EMPTY`, `ALL`, `SPECIFIC` | `getLabel()`, `getDescription()`, `resolveCategoryNames(...)` |
+| `Enums\PresetDateRanges` | `TODAY`, `YESTERDAY`, `THIS_WEEK`, `LAST_WEEK`, `THIS_MONTH`, `LAST_MONTH`, `THIS_YEAR`, `LAST_YEAR`, `LAST_7_DAYS`, `LAST_14_DAYS`, `LAST_30_DAYS`, `LAST_5_YEARS`, `LAST_10_YEARS`, `LIFETIME` | `getDateFrom()`, `getDateTo()`, `getName()`, `getLabel()`, `getPreviousDateRange()` |
+| `Enums\StatListReturnType` | `METRIC`, `METRIC_AND_NAME`, `METRIC_AND_CLASS` | |
+
+`TimeSeriesModes`, `CategoricalModes` and `PresetDateRanges` have `getLabels()` (labels keyed by value) and `options()` (a list of `value` and `label`) from `Enums\Concerns\HasLabels`.
+
+# Support
+
+| Class | What |
+| --- | --- |
+| `Contracts\DateRange` | `getDateFrom()`, `getDateTo()`, `getName()`, `getPreviousDateRange()` |
+| `Support\ExactDateRange` | `new ExactDateRange($date_from, $date_to)`. Its name is `custom` |
+| `Support\TimeSeriesSteps` | `for(TimeSeriesStatsRepository $stats, TimeSeriesModes $mode)`: every step with `date`, `key`, `value`. `rangeLabel(DateRange $range, ?Carbon $from = null, ?Carbon $to = null)` |
+| `Support\Numbers` | `format(int\|float\|null $value, string $format = 'number', ?string $locale = null)`, `tick()`, `compact()`, `currency()`, `currencySymbol()` |
+| `Support\ExportFileName` | `make(string $title, $stats, $compare = null, string $prefix = '')` |
+
+# Cache
+
+| Class | What |
+| --- | --- |
+| `Cache\StatsCache` | `remember()`, `refresh()`, `has()`, `forget()`, `flush()`, `ttlFor()`, `key()`, `version()`, `enabled()`, `store()`, `prefix()`. `ALL` is `*`. See [Caching and warming](./caching-and-warming.md#the-cache-service) |
+| `Concerns\InvalidatesStats` | Model trait. `$invalidates_stats`, `statsToInvalidate()` |
+| `Jobs\WarmStatsCache` | Queued, unique for 600 seconds. Runs `Stats::warmNow()` |
+
+# HTTP
+
+| Class | What |
+| --- | --- |
+| `Http\Controllers\TimeSeriesStatsController`, `CategoricalStatsController` | `index()`, `export()`, and the hooks `filters()`, `chart()`, `viewData()` |
+| `Http\Controllers\Api\TimeSeriesStatsApiController`, `CategoricalStatsApiController` | `index()` |
+| `Http\Controllers\Api\CategoryProviderApiController` | `index()` |
+| `Http\Controllers\Concerns\RendersStatsPage` | `views()`, `statsUser()`, `pageRequest()`, `shareErrors()`, `errorMessages()`, `attributeNames()`, `pageStrings()` |
+| `Http\Requests\TimeSeriesStatsRequest`, `CategoricalStatsRequest` | `metric()`, `otherMetrics()`, `mode()`, `stats(?string $metric = null)`, `compareStats(?string $metric = null)`, `rules()`, `bodyParameters()`. Categorical: `categoricalValues()` |
+| `Http\Requests\Concerns\ResolvesStats` | `statsUser()`, `fixedFilters()`, `filtersFor(InteractsWithFilters)`, `hasCustomDateRange()`, `dateRange(?DateRange $default = null)`, `compareDateRangeName()`, `compareDateRange(?DateRange $date_range = null)`. Constants `CUSTOM_RANGE` (`custom`), `COMPARE_PREVIOUS` (`previous`), `COMPARE_CUSTOM` (`custom`) |
+| `Http\Middleware\AbortIfCannotViewAnyTimeSeriesStats`, `AbortIfCannotViewAnyCategoricalStats` | Aliased `stats.view-time-series` and `stats.view-categorical` |
+| `Concerns\ExportsTimeSeriesStats` | `exportStats(TimeSeriesStatsRequest $request, array $filters = [], string $export_title = '')` |
+| `Concerns\ExportsCategoricalStats` | `exportCategoricalStats(CategoricalStatsRequest $request, array $filters = [], string $export_title = '')` |
+| `Routing\StatsRoutes` | `register(Router, array $options = [])`, `registerApi(Router, array $options = [])`, `guard(Request)`, `namePrefix(Request, string $default = 'stats')`. `GUARD_ACTION` |
+| `Policies\StatsPolicy` | `view()`, `export()`. `VIEW` is `view_stats` |
+
+Route macros: `Route::stats(array $options = [])` and `Route::statsApi(array $options = [])`. See [Stats pages](../basic-usage/stats-pages.md#routes) and [JSON API](./json-api.md#routes).
+
+# Forms
+
+| Class | What |
+| --- | --- |
+| `Forms\StatsForm` | The shared stencil form. Context: `metrics`, `filters` |
+| `Forms\TimeSeriesStatsForm` | The time series form |
+| `Forms\CategoricalStatsForm` | The categorical form. Context: `categories_url`. `PIE` is `pie` |
+| `Forms\Fields\CategorySelect` | A searchable multiple select loaded over AJAX. `dependsOn(string\|Closure $parent_selector, string\|Closure $filter_field)` |
+
+# Exports
+
+| Class | What |
+| --- | --- |
+| `Exports\CsvExport` | `array()`, `headings()`, `map($row)`, `getReportTitle()`, `formattedDateRange()`, `preamble()`, `lines()`, `toCsv()`, `download(string $file_name)` |
+| `Exports\TimeSeriesStatsExport` | `new TimeSeriesStatsExport(TimeSeriesModes $mode, TimeSeriesStatsRepository $stats, ?TimeSeriesStatsRepository $compare = null)`. Also `metric()` |
+| `Exports\CategoricalStatsExport` | `new CategoricalStatsExport(CategoricalStatsRepository $stats, ?CategoricalStatsRepository $compare = null, CategoricalModes $mode = CategoricalModes::NON_EMPTY, array $categorical_values = [])` |
+
+```php
+$export = new TimeSeriesStatsExport(TimeSeriesModes::DAY, $stat);
+
+$csv = $export->toCsv();
+return $export->download('payments.csv');
+```
+
+# Views
+
+| Class | What |
+| --- | --- |
+| `Views\ViewRenderer` | `render(string $page, array $data = [])`, `stack()`, `isInertia()`, `inertiaPage(string $page)`. `BLADE`, `INERTIA` |
+| `Views\Components\Chart` | `` |
+| `Views\Components\TimeSeriesChart`, `CategoryChart`, `PieChart`, `Sparkline` | The same component under other names |
+| `Views\Components\StatTile` | `` |
+| `stats::components.stat-tiles` | `` |
+
+# Exceptions
+
+| Class | Thrown when |
+| --- | --- |
+| `Exceptions\StatsException` | `unsupportedDriver()`: grouping a time series stat on another database. `unknownMetric()`: a chart's metric isn't registered. `reservedFilterName()`: a `StatsFilter::from()` filter uses a reserved name. `inertiaMissing()`: `stats.views` is `inertia` without inertia-laravel |
+| `Exceptions\InvalidFiltersException` | A stat gets a filter it doesn't allow. `$unknown_filters`, `$allowed_filters` |
+
+# JavaScript (`@javaabu/stats`)
+
+| Export | What |
+| --- | --- |
+| `StatsChart` | `{ chart: StatsChartData, className?: string }` |
+| `StatsTile` | `{ tile: StatTileData }` and Onu `StatTile` props except `label` and `value` |
+| `StatsPage` | `StatsPageProps & { page: string }` |
+| `StatsExportButton` | `{ download: StatsExport, label: string }` |
+| `TimeSeriesStatsPage`, `CategoricalStatsPage` | The Inertia pages |
+| Types | `StatsChartData`, `StatsChartBase`, `TimeSeriesChartData`, `CategoryChartData`, `PieChartData`, `SparklineData`, `StatTileData`, `StatsValueFormat`, `StatsSeriesConfig`, `StatsComparison`, `StatsExport`, `StatsPageStrings`, `StatsPageProps` |
+
+Inertia pages: `stats::TimeSeries/Index` and `stats::Categorical/Index`.
diff --git a/docs/advanced-usage/artisan-commands.md b/docs/advanced-usage/artisan-commands.md
new file mode 100644
index 0000000..59f0db5
--- /dev/null
+++ b/docs/advanced-usage/artisan-commands.md
@@ -0,0 +1,109 @@
+---
+title: Artisan commands
+sidebar_position: 8
+---
+
+| Command | What |
+| --- | --- |
+| `stats:time-series` | Generate a time series stat and register it |
+| `stats:categorical` | Generate a categorical stat and register it |
+| `stats:warm` | Compute the charts registered with `Stats::warm()` and cache them |
+
+# stats:time-series
+
+```bash
+php artisan stats:time-series {name} {model} [--type=count|sum] [--path=] [--force]
+```
+
+| Argument or option | What |
+| --- | --- |
+| `name` | The class name: `PaymentsCount` |
+| `model` | The model: a class (`App\Models\Payment`), a name in `App\Models` (`Payment`), or a morph alias (`payment`) |
+| `--type`, `-t` | `count` (the default) or `sum` |
+| `--path`, `-p` | Where to put the file. Default: `app/Stats/TimeSeries` |
+| `--force`, `-f` | Overwrite the file if it exists |
+
+```bash
+php artisan stats:time-series PaymentsCount Payment
+php artisan stats:time-series PaymentAmounts Payment --type=sum
+```
+
+It writes `app/Stats/TimeSeries/PaymentsCount.php`, a `CountStatsRepository` (or `SumStatsRepository`) with the model's query and table, an empty `allowedFilters()`, and the aggregate field `count` (or `total`). For a sum, fill in `getFieldToSum()`.
+
+Then it registers the stat in `app/Providers/AppServiceProvider.php`, with the class name in snake case as the metric (`payments_count`). For that to work, the provider must already have a `TimeSeriesStats::register([` call to add to:
+
+```php
+use Javaabu\Stats\TimeSeriesStats;
+
+public function boot(): void
+{
+ TimeSeriesStats::register([
+ ]);
+}
+```
+
+The generated class uses `created_at`. Add `getDateFieldName()` for another date column, and `getName()` for a better name.
+
+# stats:categorical
+
+```bash
+php artisan stats:categorical {name} {model} {category_model} {category_id_field} [--type=count|sum] [--path=] [--force]
+```
+
+| Argument or option | What |
+| --- | --- |
+| `name` | The class name: `PaymentsByIsland` |
+| `model` | The model the stat counts or sums: a class, a name in `App\Models`, or a morph alias |
+| `category_model` | The model whose records are the categories |
+| `category_id_field` | The column on `model` that holds the category's id: `island_id`. A bare column is qualified with the table (`payments.island_id`); pass a qualified one for a join or alias |
+| `--type`, `-t` | `count` (the default) or `sum` |
+| `--path`, `-p` | Where to put the file. Default: `app/Stats/Categorical` |
+| `--force`, `-f` | Overwrite the file if it exists |
+
+```bash
+php artisan stats:categorical PaymentsByIsland Payment Island island_id
+php artisan stats:categorical PaymentAmountsByIsland Payment Island island_id --type=sum
+```
+
+It writes a `CountCategoricalStatsRepository` (or `SumCategoricalStatsRepository`) with the query, `categoryProvider()` returning the category model, the category field, the date field (`{table}.created_at`) and the aggregate field. `getCategoryFieldAlias()` is the category model's morph alias when it has one in the morph map, else `category`. For a sum, fill in `getFieldToSum()`.
+
+It registers the stat in an existing `CategoricalStats::register([` call in `AppServiceProvider`, checks that it worked, and otherwise prints the lines to add yourself.
+
+A `--type` other than `count` or `sum` fails with an error.
+
+## Your own stubs
+
+```bash
+php artisan vendor:publish --tag=stats-stubs
+```
+
+The generators use the files in `stubs/vendor/stats` when they exist:
+
+| Stub | Used for |
+| --- | --- |
+| `Stats/TimeSeries/CountStat.stub` | `stats:time-series` |
+| `Stats/TimeSeries/SumStat.stub` | `stats:time-series --type=sum` |
+| `Stats/Categorical/CountStat.stub` | `stats:categorical` |
+| `Stats/Categorical/SumStat.stub` | `stats:categorical --type=sum` |
+
+# stats:warm
+
+```bash
+php artisan stats:warm [--queue] [--flush]
+```
+
+| Option | What |
+| --- | --- |
+| `--queue` | Dispatch the `WarmStatsCache` job instead of warming now |
+| `--flush` | Clear every cached stat first |
+
+Without `--queue`, it lists each chart it warmed (its title and type) and the count. Schedule it:
+
+```php
+// routes/console.php
+use Illuminate\Support\Facades\Schedule;
+
+Schedule::command('stats:warm')->everyFiveMinutes();
+```
+
+See [Caching and warming](./caching-and-warming.md).
diff --git a/docs/advanced-usage/caching-and-warming.md b/docs/advanced-usage/caching-and-warming.md
new file mode 100644
index 0000000..017be52
--- /dev/null
+++ b/docs/advanced-usage/caching-and-warming.md
@@ -0,0 +1,175 @@
+---
+title: Caching and warming
+sidebar_position: 1
+---
+
+Stats are aggregate queries over whole tables, so a dashboard of ten charts can be slow. The package caches what [charts](../basic-usage/charts.md) compute, clears it when the data changes, and can compute it ahead of time.
+
+# What is cached
+
+| | Cached |
+| --- | --- |
+| Charts (`TimeSeriesChart`, `CategoryChart`, `PieChart`, `Sparkline`, `StatTile`) | Yes, by default |
+| The [stats pages](../basic-usage/stats-pages.md) | No, unless `stats.cache.pages` is `true` |
+| The [JSON API](./json-api.md), and stats used directly (`$stat->total()`, `$stat->results()`) | No |
+
+A chart's data is cached under a key made from everything that changes it: the chart type, its stats and their labels and filters, the chart's filters, the dates (presets by day), the mode, the options, the locale, and a version per metric. Two charts built the same way share one entry.
+
+# How long
+
+| Chart's period | Kept for | Config |
+| --- | --- | --- |
+| Ended before today | 1 day | `stats.cache.ttl.past` (seconds) |
+| Includes today | 10 minutes | `stats.cache.ttl.current` (seconds) |
+
+A chart can set its own time, or turn caching off:
+
+```php
+TimeSeriesChart::make('payments_count')->cache(300); // 5 minutes
+TimeSeriesChart::make('payments_count')->cache(minutes: 30);
+TimeSeriesChart::make('payments_count')->cache(false); // or ->withoutCache(), or ->cache(0)
+```
+
+Turn it off everywhere with `STATS_CACHE=false`, for example in `phpunit.xml`. Use another store than the default with `STATS_CACHE_STORE=redis`. Any store works; tags aren't needed.
+
+# Clear it when data changes
+
+```php
+use Javaabu\Stats\Facades\Stats;
+
+Stats::forget('payments_count'); // charts that use this metric
+Stats::forget(['payments_count', 'payments_by_island']);
+Stats::forget(); // every chart, the same as Stats::flush()
+Stats::flush();
+```
+
+Clearing bumps a version stored in the cache, so the old entries are no longer read; they expire on their own.
+
+## From the model
+
+Add `InvalidatesStats` to the models stats are computed from. Saving, deleting or restoring one clears the stats:
+
+```php
+where(...)->update([...])`), imports, or writes by other apps. Call `Stats::forget()` after those.
+
+On a busy table, every save clearing the stats means the cache rarely helps. Leave the trait off there and let the 10-minute TTL do the work, or warm the charts.
+
+# Warm the cache
+
+Warming computes charts ahead of time and puts them in the cache, so nobody waits for them. Register the charts once, in a service provider's `boot()`:
+
+```php
+use App\Stats\Dashboard;
+use Javaabu\Stats\Charts\StatTile;
+use Javaabu\Stats\Enums\PresetDateRanges;
+use Javaabu\Stats\Facades\Stats;
+
+Stats::warm(fn () => Dashboard::all());
+
+Stats::warm([
+ StatTile::make('payments_count')->range(PresetDateRanges::THIS_MONTH)->compare(),
+]);
+```
+
+`warm()` takes a chart, a list of charts, or a closure that returns them. A closure is better: the charts are built when they're warmed, so relative ranges such as "this month" are current and the labels are translated then. The closure is called through the container, so it can type-hint dependencies.
+
+Then warm them on a schedule:
+
+```php
+// routes/console.php
+use Illuminate\Support\Facades\Schedule;
+use Javaabu\Stats\Jobs\WarmStatsCache;
+
+Schedule::command('stats:warm')->everyFiveMinutes();
+
+// or on a queue
+Schedule::job(new WarmStatsCache)->everyFiveMinutes();
+```
+
+| Command | What |
+| --- | --- |
+| `php artisan stats:warm` | Compute every registered chart now, and list them |
+| `php artisan stats:warm --queue` | Dispatch the `WarmStatsCache` job instead |
+| `php artisan stats:warm --flush` | Clear every cached stat first |
+
+The `WarmStatsCache` job runs on `stats.cache.warm.connection` and `stats.cache.warm.queue` (the defaults when `null`). It is unique for 10 minutes, so a slow warm-up isn't queued twice.
+
+Warming recomputes and stores every chart even when it's still cached, so warm a little more often than `ttl.current` and visitors always hit the cache.
+
+## Make sure the page uses the warmed data
+
+The page finds the warmed entry only when it builds the same chart: the same stats, labels, filters, range, mode and options, in the same locale. Build the charts in one place and use it from both the controller and `Stats::warm()`, as in [Dashboards](../basic-usage/dashboards.md#a-complete-dashboard).
+
+The command runs in the app's default locale. For a site in Dhivehi and English, warm both:
+
+```php
+// routes/console.php
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Schedule;
+use Javaabu\Stats\Facades\Stats;
+
+Schedule::call(function () {
+ foreach (['dv', 'en'] as $locale) {
+ App::setLocale($locale);
+ Stats::warmNow();
+ }
+})->everyFiveMinutes();
+```
+
+## From code
+
+| Method | What |
+| --- | --- |
+| `Stats::warm(Chart\|iterable\|Closure $charts)` | Register charts to warm |
+| `Stats::warmables()` | The registered charts, with closures resolved |
+| `Stats::warmNow(?Closure $after = null)` | Compute and cache them now; returns how many. `$after` is called with each chart |
+| `Stats::clearWarmables()` | Forget the registered charts (for tests) |
+| `$chart->refresh()` | Compute one chart and cache it |
+| `$chart->isWarm()` | Whether a chart's data is cached |
+
+# The cache service
+
+`Stats::cache()` returns the `Javaabu\Stats\Cache\StatsCache` singleton, for caching your own stat results the same way:
+
+```php
+use Javaabu\Stats\Facades\Stats;
+
+$top_islands = Stats::cache()->remember(
+ ['report' => 'top_islands', 'month' => now()->format('Y-m')], // what the value depends on
+ ['payments_by_island'], // the metrics, for Stats::forget()
+ fn () => $this->topIslands(),
+ ttl: null, // null: by the end date
+ ends_at: now()->endOfMonth(),
+);
+```
+
+| Method | What |
+| --- | --- |
+| `remember(array $key, array $metrics, Closure $callback, ?int $ttl = null, ?CarbonInterface $ends_at = null)` | Get it, or compute and store it |
+| `refresh(...)` | Compute and store it, whatever is cached. Same arguments |
+| `has(array $key, array $metrics)` | Whether it's cached |
+| `forget(string\|array $metrics = '*')`, `flush()` | Clear |
+| `ttlFor(?CarbonInterface $ends_at = null)` | `ttl.past` for a period that ended before today, else `ttl.current` |
+| `key(array $key, array $metrics)` | The cache key |
+| `version(string $metric)` | A metric's current version |
+| `enabled()`, `store()`, `prefix()` | From `stats.cache` |
diff --git a/docs/advanced-usage/configuration.md b/docs/advanced-usage/configuration.md
new file mode 100644
index 0000000..e9bd551
--- /dev/null
+++ b/docs/advanced-usage/configuration.md
@@ -0,0 +1,104 @@
+---
+title: Configuration
+sidebar_position: 2
+---
+
+```bash
+php artisan vendor:publish --tag=stats-config
+```
+
+# Stats
+
+| Key | Default | What |
+| --- | --- | --- |
+| `time_series_stats` | `[]` | Time series stats to register, as metric => class. Registered before the app's service providers boot, so `TimeSeriesStats::register()` calls come after them |
+| `categorical_stats` | `[]` | Categorical stats to register, as metric => class |
+| `default_stats` | `true` | Register `user_signups` (and `user_logins`, with spatie/laravel-activitylog). See [Time series stats](../basic-usage/time-series-stats.md#built-in-stats) |
+| `user_model` | `null` | The user model of the built-in stats. `null`: the model of the default guard's user provider |
+
+```php
+'time_series_stats' => [
+ 'payments_count' => App\Stats\TimeSeries\PaymentsCount::class,
+],
+
+'categorical_stats' => [
+ 'payments_by_island' => App\Stats\Categorical\PaymentsByIsland::class,
+],
+```
+
+# Dates
+
+| Key | Default | What |
+| --- | --- | --- |
+| `week_starts_on_sunday` | `true` | `true`: weeks start on Sunday; `false`: Monday. Used to group by week, for `this_week` and `last_week`, and for the steps of week charts |
+| `date_locale` | `en_GB` | The locale used to work out weeks (their numbers and the year they belong to) |
+| `date_formats` | See below | How each step is shown in chart tables and labels, and by the `chartjs` formatter. Carbon `isoFormat` (moment.js) formats |
+
+```php
+'date_formats' => [
+ 'hour' => 'D MMM YY hh:mm A',
+ 'day' => 'D MMM YY',
+ 'week' => 'gggg - \W\e\e\k w',
+ 'month' => 'YYYY MMMM',
+ 'year' => 'YYYY',
+],
+```
+
+Charts label weeks "Week 29, 2024" (a translated string), so `date_formats.week` only affects the formatters. Other steps are formatted in the app's locale, so month names are in Dhivehi when the locale is `dv`.
+
+# Defaults
+
+| Key | Default | What |
+| --- | --- | --- |
+| `default_time_series_mode` | `TimeSeriesModes::DAY` | The grouping of charts, sparklines and the time series page when none is chosen. An enum case or its value |
+| `default_categorical_mode` | `CategoricalModes::NON_EMPTY` | Which categories charts and the categorical page list when none is chosen |
+| `default_date_range` | `PresetDateRanges::LAST_7_DAYS` | The range of charts and pages when none is chosen |
+| `categorical_items_per_page` | `15` | The page size of the category search |
+
+# Cache
+
+| Key | Default | What |
+| --- | --- | --- |
+| `cache.enabled` | `env('STATS_CACHE', true)` | Cache chart data |
+| `cache.store` | `env('STATS_CACHE_STORE')` | The cache store. `null`: the app's default store |
+| `cache.prefix` | `javaabu_stats` | The prefix of the cache keys |
+| `cache.ttl.current` | `600` | Seconds to keep data of a period that includes today |
+| `cache.ttl.past` | `86400` | Seconds to keep data of a period that ended before today |
+| `cache.pages` | `false` | Cache the stats pages' charts too. Off, they show live data |
+| `cache.warm.connection` | `null` | The queue connection of the `WarmStatsCache` job. `null`: the default |
+| `cache.warm.queue` | `null` | Its queue. `null`: the default |
+
+See [Caching and warming](./caching-and-warming.md).
+
+# Views
+
+| Key | Default | What |
+| --- | --- | --- |
+| `views` | `env('STATS_VIEWS', 'blade')` | How the stats pages render: `blade` or `inertia` |
+| `layout` | `layouts.app` | The Blade layout the pages extend |
+| `layout_section` | `content` | The section of the layout they fill |
+| `rtl_locales` | `['dv', 'ar', 'fa', 'he', 'ur']` | Locales whose Blade charts run right to left |
+
+With `inertia` and no `inertiajs/inertia-laravel`, the pages throw a `StatsException`.
+
+# Controllers and authorization
+
+| Key | Default | What |
+| --- | --- | --- |
+| `controllers.time_series` | `TimeSeriesStatsController` | The time series page |
+| `controllers.categorical` | `CategoricalStatsController` | The categorical page |
+| `controllers.time_series_api` | `Api\TimeSeriesStatsApiController` | The time series JSON endpoint |
+| `controllers.categorical_api` | `Api\CategoricalStatsApiController` | The categorical JSON endpoint |
+| `controllers.categories` | `Api\CategoryProviderApiController` | The category search |
+| `policy` | `StatsPolicy` | The policy of every stat |
+| `register_permissions` | `true` | Register `view_stats` with javaabu/permissions. Turn it off to register your own |
+
+The controller classes are in `Javaabu\Stats\Http\Controllers`, the policy in `Javaabu\Stats\Policies`. See [Overriding controllers and views](./overriding-controllers-and-views.md) and [Permissions and policies](./permissions-and-policies.md).
+
+# Environment variables
+
+| Variable | Key |
+| --- | --- |
+| `STATS_CACHE` | `cache.enabled` |
+| `STATS_CACHE_STORE` | `cache.store` |
+| `STATS_VIEWS` | `views` |
diff --git a/docs/advanced-usage/databases.md b/docs/advanced-usage/databases.md
new file mode 100644
index 0000000..13ccdd4
--- /dev/null
+++ b/docs/advanced-usage/databases.md
@@ -0,0 +1,70 @@
+---
+title: Databases
+sidebar_position: 7
+---
+
+Time series stats group their date column into hours, days, weeks, months or years with SQL. The package writes that SQL for each database it supports, so the same stat gives the same results on all of them:
+
+| Driver | Database | Tested with |
+| --- | --- | --- |
+| `mysql` | MySQL | 8.4 |
+| `mariadb` | MariaDB | 11 |
+| `pgsql` | PostgreSQL | 17 |
+| `sqlite` | SQLite | The version PHP ships |
+
+The driver is read from the connection of the stat's query, so stats on different connections work side by side.
+
+Categorical stats use a plain `GROUP BY` on the category field, and work on any database Laravel supports.
+
+# Step keys
+
+Every database returns the same key for a step:
+
+| Mode | Key | Example |
+| --- | --- | --- |
+| `hour` | `YYYY-MM-DD HH:00` | `2024-07-04 09:00` |
+| `day` | `YYYY-MM-DD` | `2024-07-04` |
+| `week` | The date the week starts on, `YYYY-MM-DD` | `2024-06-30` |
+| `month` | `YYYY-MM` | `2024-07` |
+| `year` | `YYYY` | `2024` |
+
+Weeks are keyed by their first day rather than a week number, so they don't depend on a database's week-numbering rules. The first day is Sunday, or Monday with `'week_starts_on_sunday' => false`. Change it before any data is cached, or clear the cache after (`php artisan stats:warm --flush`, or `Stats::flush()`).
+
+Dates are grouped and compared as they are stored; the package doesn't convert time zones. Store dates in the app's time zone (`config('app.timezone')`), which is what Carbon uses for the date ranges.
+
+# Other databases
+
+Any other driver (`sqlsrv`, say) throws `Javaabu\Stats\Exceptions\StatsException` when a time series stat is grouped:
+
+> Time series stats don't support the [sqlsrv] database driver. Use MySQL, MariaDB, PostgreSQL or SQLite, or override getDateSqlForTimeMode() in your stat.
+
+Override `getDateSqlForTimeMode()` to give the SQL yourself. It must return the step's key in the formats above:
+
+```php
+getDateField();
+
+ return match ($mode) {
+ TimeSeriesModes::HOUR => "FORMAT({$date}, 'yyyy-MM-dd HH:00')",
+ TimeSeriesModes::DAY => "FORMAT({$date}, 'yyyy-MM-dd')",
+ TimeSeriesModes::WEEK => "FORMAT(DATEADD(day, -((DATEPART(weekday, {$date}) + @@DATEFIRST - 1) % 7), {$date}), 'yyyy-MM-dd')",
+ TimeSeriesModes::MONTH => "FORMAT({$date}, 'yyyy-MM')",
+ TimeSeriesModes::YEAR => "FORMAT({$date}, 'yyyy')",
+ };
+ }
+}
+```
+
+The week expression above starts weeks on Sunday; adjust it to match `week_starts_on_sunday`. It is an example: test it on your database, and compare it with the package's own expressions in `TimeSeriesModes::getSql()`.
+
+`TimeSeriesModes::getSql(string $date_field, string $driver = 'mysql')` gives the package's SQL for any date expression and supported driver.
diff --git a/docs/advanced-usage/formatters.md b/docs/advanced-usage/formatters.md
new file mode 100644
index 0000000..65a8d2a
--- /dev/null
+++ b/docs/advanced-usage/formatters.md
@@ -0,0 +1,196 @@
+---
+title: Formatters
+sidebar_position: 6
+---
+
+A formatter turns a stat's results into an array for a consumer: a JavaScript chart library, a CSV file, an API client. The [chart builders](../basic-usage/charts.md) don't use them; the [JSON API](./json-api.md) (`format=`), CSV exports and your own code do.
+
+```php
+use Javaabu\Stats\Enums\PresetDateRanges;
+use Javaabu\Stats\Enums\TimeSeriesModes;
+use Javaabu\Stats\TimeSeriesStats;
+
+$stat = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS);
+$compare = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS->getPreviousDateRange());
+
+$stat->format('chartjs', TimeSeriesModes::DAY); // without a comparison
+
+TimeSeriesStats::createFromFormat('chartjs')->format(TimeSeriesModes::DAY, $stat, $compare);
+```
+
+# Time series formats
+
+`format(TimeSeriesModes $mode, TimeSeriesStatsRepository $stats, ?TimeSeriesStatsRepository $compare = null): array`. The examples are for 28 June to 4 July 2024, compared with 21 to 27 June, with 5 logins on 3 July and 2 on 4 July.
+
+## default
+
+The results as they are: only steps with data. No gaps filled.
+
+```php
+[
+ 'stats' => [
+ ['logins' => 5, 'day' => '2024-07-03'],
+ ['logins' => 2, 'day' => '2024-07-04'],
+ ],
+ 'compare' => [], // null without a comparison
+]
+```
+
+## chartjs
+
+For [Chart.js](https://www.chartjs.org): labels, and a value per step. Labels of the two periods are joined with `#`. Gaps are filled with `0`.
+
+```php
+[
+ 'labels' => ['28 Jun 24#21 Jun 24', '29 Jun 24#22 Jun 24', '30 Jun 24#23 Jun 24', '1 Jul 24#24 Jun 24', '2 Jul 24#25 Jun 24', '3 Jul 24#26 Jun 24', '4 Jul 24#27 Jun 24'],
+ 'stats' => [0, 0, 0, 0, 0, 5, 2],
+ 'compare' => [0, 0, 0, 0, 0, 0, 0], // null without a comparison
+]
+```
+
+The labels use `stats.date_formats`.
+
+## sparkline
+
+A value per step, gaps filled. Ignores the comparison.
+
+```php
+[0, 0, 0, 0, 0, 5, 2]
+```
+
+## flot
+
+For [Flot](https://www.flotcharts.org): `[index, value]` per step, gaps filled. Ignores the comparison.
+
+```php
+[[0, 0], [1, 0], [2, 0], [3, 0], [4, 0], [5, 5], [6, 2]]
+```
+
+## combined
+
+A row per step, gaps filled. With a comparison, each step's row is followed by the comparison period's row, and both have the period's dates. The CSV export uses it.
+
+```php
+[
+ ['logins' => 0, 'day' => '2024-06-28', 'date_range' => '2024-06-28 00:00 - 2024-07-04 23:59'],
+ ['logins' => 0, 'day' => '2024-06-21', 'date_range' => '2024-06-21 00:00 - 2024-06-27 23:59'],
+ // ...
+ ['logins' => 2, 'day' => '2024-07-04', 'date_range' => '2024-06-28 00:00 - 2024-07-04 23:59'],
+ ['logins' => 0, 'day' => '2024-06-27', 'date_range' => '2024-06-21 00:00 - 2024-06-27 23:59'],
+]
+```
+
+Without a comparison, the rows have no `date_range`.
+
+# Categorical formats
+
+`format(CategoricalStatsRepository $stats, ?CategoricalStatsRepository $compare = null, CategoricalModes $mode = CategoricalModes::NON_EMPTY, array $categorical_values = []): array`. The categories follow the mode, in the provider's order, with `0` for categories without data.
+
+```php
+use Javaabu\Stats\CategoricalStats;
+use Javaabu\Stats\Enums\CategoricalModes;
+
+$stat = CategoricalStats::createFromMetric('payments_by_island', PresetDateRanges::THIS_MONTH);
+$compare = CategoricalStats::createFromMetric('payments_by_island', PresetDateRanges::LAST_MONTH);
+
+$stat->format('combined', $compare, CategoricalModes::ALL);
+CategoricalStats::createFromFormat('chartjs')->format($stat, $compare, CategoricalModes::SPECIFIC, [12, 15]);
+```
+
+## default
+
+```php
+[
+ 'stats' => [
+ ['island' => 12, 'island_name' => 'Male', 'payments' => 40],
+ ['island' => 15, 'island_name' => 'Hulhumale', 'payments' => 7],
+ ],
+ 'compare' => [
+ ['island' => 12, 'island_name' => 'Male', 'payments' => 31],
+ ['island' => 15, 'island_name' => 'Hulhumale', 'payments' => 0],
+ ], // null without a comparison
+]
+```
+
+The keys are the stat's `getCategoryFieldAlias()`, `getCategoryNameFieldAlias()` and `getAggregateFieldName()`.
+
+## combined
+
+A row per category, with the comparison's value in `compare_{aggregate field}`. The CSV export uses it.
+
+```php
+[
+ ['island' => 12, 'island_name' => 'Male', 'payments' => 40, 'compare_payments' => 31],
+ ['island' => 15, 'island_name' => 'Hulhumale', 'payments' => 7, 'compare_payments' => 0],
+]
+```
+
+`compare_payments` is `null` without a comparison.
+
+## chartjs
+
+```php
+[
+ 'labels' => ['Male', 'Hulhumale'],
+ 'stats' => [40, 7],
+ 'compare' => [31, 0], // null without a comparison
+]
+```
+
+# Your own formatter
+
+Extend `AbstractTimeSeriesStatsFormatter` (or `AbstractCategoricalStatsFormatter`) and implement `format()`:
+
+```php
+ $stats->getName(),
+ 'data' => array_map(fn (array $step) => ['x' => $step['key'], 'y' => $step['value']], TimeSeriesSteps::for($stats, $mode)),
+ ]];
+
+ if ($compare) {
+ $series[] = [
+ 'name' => $compare->formattedDateRange('YYYY-MM-DD'),
+ 'data' => array_map(fn (array $step) => ['x' => $step['key'], 'y' => $step['value']], TimeSeriesSteps::for($compare, $mode)),
+ ];
+ }
+
+ return ['series' => $series];
+ }
+}
+```
+
+`TimeSeriesSteps::for($stat, $mode)` walks the range one step at a time and gives each step's `date` (Carbon), `key` (as in results) and `value` (`0` without data).
+
+Register it in a service provider's `boot()`:
+
+```php
+use App\Stats\Formatters\ApexChartsFormatter;
+use Javaabu\Stats\TimeSeriesStats;
+
+TimeSeriesStats::registerFormatters([
+ 'apexcharts' => ApexChartsFormatter::class,
+]);
+```
+
+It can now be used by name: `$stat->format('apexcharts')`, and `format=apexcharts` in the JSON API. Register categorical formatters with `CategoricalStats::registerFormatters()`. Pass `false` as the second argument to replace the registered formatters instead of adding to them.
+
+| Method | On `TimeSeriesStats` and `CategoricalStats` |
+| --- | --- |
+| `registerFormatters(array $formatters, bool $merge = true)` | Register formatters by name |
+| `formattersMap()` | Classes keyed by name |
+| `allowedFormats()` | The names |
+| `createFromFormat(string $format)` | A formatter instance |
+| `getClassNameForFormat(string $name)`, `getNameForFormatter(string $class)` | Look up either way |
diff --git a/docs/advanced-usage/json-api.md b/docs/advanced-usage/json-api.md
new file mode 100644
index 0000000..fd91366
--- /dev/null
+++ b/docs/advanced-usage/json-api.md
@@ -0,0 +1,198 @@
+---
+title: JSON API
+sidebar_position: 5
+---
+
+The JSON API gives the numbers of any registered stat, for a mobile app, a front end of your own or another service. The [stats pages](../basic-usage/stats-pages.md) don't need it.
+
+# Routes
+
+```php
+// routes/api.php
+Route::statsApi(['guard' => 'sanctum']);
+```
+
+| Route | Name | What |
+| --- | --- | --- |
+| `GET {prefix}/time-series` | `{name}.time-series.index` | A time series stat |
+| `GET {prefix}/categorical` | `{name}.categorical.index` | A categorical stat |
+| `GET {prefix}/categorical/categories` | `{name}.categorical.categories` | Search a categorical stat's categories |
+
+`Route::statsApi()` takes the same options as `Route::stats()`:
+
+| Option | Default | What |
+| --- | --- | --- |
+| `prefix` | `stats` | The URL prefix, under your API file's own prefix (`/api/stats/...` in `routes/api.php`) |
+| `name` | `api.stats` | The route name prefix |
+| `guard` | `null` (the default guard) | The guard users authenticate with. Permissions are checked for its user |
+| `middleware` | `['auth']`, or `["auth:{guard}"]` with a guard | The route middleware. Replaces the default, so include an `auth` middleware |
+| `time_series` | `true` | Register the time series endpoint |
+| `categorical` | `true` | Register the categorical endpoint and the category search |
+| `controllers` | From `stats.controllers` | `time_series_api`, `categorical_api`, `categories` |
+
+Users see only the stats they may view: asking for a stat the user can't see, or invalid input, gets `422` with the errors.
+
+# Time series
+
+```text
+GET /api/stats/time-series?metric=payments_count&mode=day&date_range=custom&date_from=2024-07-03&date_to=2024-07-04&compare_to=previous
+```
+
+| Parameter | Required | What |
+| --- | --- | --- |
+| `metric` | Yes | A metric the user can see, that allows the fixed filters |
+| `mode` | Yes | `hour`, `day`, `week`, `month` or `year` |
+| `date_range` | Or the dates | A preset (`last_30_days`, see [Date ranges](../basic-usage/time-series-stats.md#date-ranges)), or `custom` |
+| `date_from`, `date_to` | When `date_range` is `custom` or missing | The range. A date without a time covers the whole day; `date_to` can't be before `date_from` |
+| `compare_to` | No | `previous` for the period before, `custom` for `compare_date_from` to `compare_date_to` |
+| `compare_date_from`, `compare_date_to` | With `compare_to=custom` | The comparison period. Giving both compares even without `compare_to` |
+| `compare_date_range` | No | A preset to compare with. Wins over `compare_to` |
+| `compare` | No | `1` compares with the previous period (v1) |
+| `format` | No | A registered [formatter](./formatters.md): `default` (the default), `chartjs`, `sparkline`, `flot`, `combined` |
+| `filters[name]` | No | Fixed filters, by name |
+| `{filter}` | No | The values of `StatsFilter::from()` filters, by their own query parameters (`status=paid`) |
+
+```json
+{
+ "metric": "payments_count",
+ "metric_name": "Payments",
+ "mode": "day",
+ "aggregate_field": "payments",
+ "aggregate_field_label": "Payments",
+ "date_range_title": "Primary Period - Jul 3, 2024 - Jul 4, 2024",
+ "date_range": "custom",
+ "date_from": "2024-07-03 00:00:00",
+ "date_to": "2024-07-04 23:59:59",
+ "compare_date_range_title": "Comparison Period - Jul 1, 2024 - Jul 2, 2024",
+ "compare_date_range": "previous",
+ "compare_date_from": "2024-07-01 00:00:00",
+ "compare_date_to": "2024-07-02 23:59:59",
+ "format": "default",
+ "result": {
+ "stats": [
+ { "payments": 3, "day": "2024-07-03" },
+ { "payments": 2, "day": "2024-07-04" }
+ ],
+ "compare": []
+ },
+ "filters": []
+}
+```
+
+| Field | What |
+| --- | --- |
+| `metric`, `metric_name` | The metric and the stat's name |
+| `mode` | The grouping |
+| `aggregate_field`, `aggregate_field_label` | The value's key in `result`, and its label |
+| `date_range` | The preset's value, or `custom` |
+| `date_from`, `date_to` | The resolved range |
+| `date_range_title` | "Primary Period - Jul 3, 2024 - Jul 4, 2024", translated |
+| `compare_date_range` | `previous`, `custom`, a preset's value, or `null` without a comparison |
+| `compare_date_from`, `compare_date_to`, `compare_date_range_title` | The comparison period, or `null` |
+| `format` | The formatter used |
+| `result` | The formatter's output |
+| `filters` | The filters applied, by name |
+
+The `default` format only has steps with data. With `format=chartjs`, every step is there:
+
+```json
+"result": {
+ "labels": ["3 Jul 24#1 Jul 24", "4 Jul 24#2 Jul 24"],
+ "stats": [3, 2],
+ "compare": [0, 0]
+}
+```
+
+`with[]` is accepted (and validated) but the API returns the main stat only. Ask once per metric.
+
+# Categorical
+
+```text
+GET /api/stats/categorical?metric=payments_by_island&mode=non_empty&date_range=this_month&format=combined
+```
+
+The same parameters as the time series endpoint, except:
+
+| Parameter | Required | What |
+| --- | --- | --- |
+| `mode` | Yes | `non_empty`, `all` or `specific`. See [Categorical stats](../basic-usage/categorical-stats.md#which-categories) |
+| `categorical_values[]` | When `mode` is `specific` | Category ids |
+| `format` | No | `default` (the default), `combined` or `chartjs` |
+
+```json
+{
+ "metric": "payments_by_island",
+ "metric_name": "Payments by island",
+ "mode": "non_empty",
+ "aggregate_field": "payments",
+ "aggregate_field_label": "Payments",
+ "date_range_title": "Primary Period - Jul 1, 2024 - Jul 31, 2024",
+ "date_range": "this_month",
+ "date_from": "2024-07-01 00:00:00",
+ "date_to": "2024-07-31 23:59:59",
+ "compare_date_range_title": null,
+ "compare_date_range": null,
+ "compare_date_from": null,
+ "compare_date_to": null,
+ "format": "default",
+ "result": {
+ "stats": [
+ { "island": 12, "island_name": "Male", "payments": 40 },
+ { "island": 15, "island_name": "Hulhumale", "payments": 7 }
+ ],
+ "compare": null
+ },
+ "filters": []
+}
+```
+
+# Category search
+
+```text
+GET /api/stats/categorical/categories?filter[metric]=payments_by_island&filter[search]=hulhu&per_page=20
+```
+
+| Parameter | Required | What |
+| --- | --- | --- |
+| `filter[metric]` | Yes | A categorical metric the user can see |
+| `filter[search]`, or `search` | No | The search text |
+| `page` | No | The page |
+| `per_page` | No | 1 to 100. Default: `stats.categorical_items_per_page` (15). `0` or `-1` gives 100 |
+| `filters[name]` | No | Fixed filters. The metric must allow them |
+
+The answer is a Laravel paginator; each item in `data` has `id` and `label`:
+
+```json
+{
+ "current_page": 1,
+ "data": [
+ { "id": 15, "label": "Hulhumale" }
+ ],
+ "per_page": 20,
+ "total": 1,
+ "last_page": 1
+}
+```
+
+# Errors
+
+| Status | When |
+| --- | --- |
+| `401` | Not logged in on the guard |
+| `403` | Category search: the user can't see the metric, or it doesn't allow the filters |
+| `422` | Invalid input, including a metric the user can't see or that doesn't allow the filters. `errors` has a message per field |
+
+```json
+{
+ "message": "The selected metric is invalid. (and 2 more errors)",
+ "errors": {
+ "metric": ["The selected metric is invalid."],
+ "date_from": ["The date from field is required when date range is not present."],
+ "date_to": ["The date to field is required when date range is not present."]
+ }
+}
+```
+
+# Documenting the API
+
+The requests (`TimeSeriesStatsRequest`, `CategoricalStatsRequest`) have `bodyParameters()` describing every parameter, and the controllers have doc blocks in a "Stats" group, which API documentation generators such as Scribe read.
diff --git a/docs/advanced-usage/overriding-controllers-and-views.md b/docs/advanced-usage/overriding-controllers-and-views.md
new file mode 100644
index 0000000..1ffe6ee
--- /dev/null
+++ b/docs/advanced-usage/overriding-controllers-and-views.md
@@ -0,0 +1,125 @@
+---
+title: Overriding controllers and views
+sidebar_position: 4
+---
+
+# The controllers
+
+Extend a controller and point the config at it, or pass it to one set of routes:
+
+```php
+// config/stats.php
+'controllers' => [
+ 'time_series' => App\Http\Controllers\StatsController::class,
+ 'categorical' => Javaabu\Stats\Http\Controllers\CategoricalStatsController::class,
+ 'time_series_api' => Javaabu\Stats\Http\Controllers\Api\TimeSeriesStatsApiController::class,
+ 'categorical_api' => Javaabu\Stats\Http\Controllers\Api\CategoricalStatsApiController::class,
+ 'categories' => Javaabu\Stats\Http\Controllers\Api\CategoryProviderApiController::class,
+],
+```
+
+```php
+// routes/web.php
+Route::stats(['controllers' => ['time_series' => App\Http\Controllers\StatsController::class]]);
+
+// routes/api.php
+Route::statsApi(['controllers' => ['time_series_api' => App\Http\Controllers\Api\StatsController::class]]);
+```
+
+`Route::stats()` takes `time_series`, `categorical` and `categories`; `Route::statsApi()` takes `time_series_api`, `categorical_api` and `categories`.
+
+## The page controllers
+
+`TimeSeriesStatsController` and `CategoricalStatsController` have these methods to override:
+
+| Method | What |
+| --- | --- |
+| `filters(Request $request): array` | Fixed filters for every stat on the page and in the download. Empty by default. See [Stats for one model](../basic-usage/stats-pages.md#stats-for-one-model) |
+| `index(Request $request)` | The page |
+| `chart($request)` | The chart for valid input: a `TimeSeriesChart`, or a `CategoryChart` / `PieChart` |
+| `viewData(Request $request, $form, ?$chart, $stats_request, array $metrics): array` | What the Blade view or the Inertia page gets |
+| `export($request)` | The CSV download |
+
+Change the chart, for example to always show the brush and format money:
+
+```php
+brush();
+
+ if (str_ends_with($request->metric(), '_amounts')) {
+ $chart->valueFormat(Chart::FORMAT_CURRENCY);
+ }
+
+ return $chart;
+ }
+}
+```
+
+The controllers find their own routes by name: they link to `{name}.time-series.export`, `{name}.categorical.export` and `{name}.categorical.categories`, where `{name}` is the prefix of the current route's name. Register controllers through `Route::stats()`, or keep that naming if you register the routes yourself.
+
+## The API controllers
+
+`TimeSeriesStatsApiController` and `CategoricalStatsApiController` have `index()` (see [JSON API](./json-api.md)); `CategoryProviderApiController` has `index()` for the category search.
+
+# The Blade views
+
+```bash
+php artisan vendor:publish --tag=stats-views
+```
+
+They are copied to `resources/views/vendor/stats`:
+
+| View | What |
+| --- | --- |
+| `time-series/index.blade.php`, `categorical/index.blade.php` | The pages: they extend the layout and include the partial |
+| `partials/page.blade.php` | The page body: title, form, chart, download button, messages |
+| `components/charts/cartesian.blade.php` | Time series and category charts |
+| `components/charts/pie.blade.php` | Pie and donut charts |
+| `components/charts/sparkline.blade.php` | Sparklines |
+| `components/charts/stat-tile.blade.php` | Stat tiles |
+| `components/charts/header.blade.php`, `table.blade.php`, `delta.blade.php` | The chart title, the "View as table" disclosure and the change badge |
+| `components/stat-tiles.blade.php` | The tile grid (``) |
+
+Copy only the files you change; Laravel uses the package's for the rest. The page variables are listed in [Stats pages](../basic-usage/stats-pages.md#blade). To only change the layout, set `stats.layout` and `stats.layout_section` instead.
+
+The chart styles are in `resources/css/charts.css` (the `stats::charts` asset). They use `stats-` classes and CSS variables (`--stats-foreground`, `--stats-muted`, `--stats-border`, `--stats-card`, `--stats-radius`, `--stats-positive`, `--stats-negative`, `--stats-previous`) that default to Onu's tokens. Override the variables in your CSS to restyle charts, or replace the file through javaabu/manifest by putting your copy at `resources/vendor/stats/css/charts.css` (your app must build with manifest's Vite plugin).
+
+# The React pages
+
+Copy `vendor/javaabu/stats/resources/js/Pages/TimeSeries/Index.tsx` to `resources/vendor/stats/js/Pages/TimeSeries/Index.tsx` (or `Categorical/Index.tsx`) and change it: manifest's `resolvePage()` and Vite plugin use the app's copy first. manifest copies it for you:
+
+```bash
+php artisan manifest:override stats js/Pages/TimeSeries/Index.tsx
+```
+
+Any other file under `resources/js` can be overridden the same way, such as `components/StatsChart.tsx`. Restart `npm run dev` after adding an override.
+
+Build on the package's pieces, imported as `@javaabu/stats` or by path (`@javaabu/stats/components/StatsPage`). See [Stats pages](../basic-usage/stats-pages.md#inertia-react) for an example.
+
+# The translations
+
+```bash
+php artisan vendor:publish --tag=stats-translations
+```
+
+They are copied to `lang/vendor/stats`. Your app's own `lang/dv.json` also wins for any key it has. See [Dhivehi and RTL](./dhivehi-and-rtl.md).
+
+# The generator stubs
+
+```bash
+php artisan vendor:publish --tag=stats-stubs
+```
+
+They are copied to `stubs/vendor/stats`, and `stats:time-series` and `stats:categorical` use your copies. See [Artisan commands](./artisan-commands.md).
diff --git a/docs/advanced-usage/permissions-and-policies.md b/docs/advanced-usage/permissions-and-policies.md
new file mode 100644
index 0000000..70a2176
--- /dev/null
+++ b/docs/advanced-usage/permissions-and-policies.md
@@ -0,0 +1,126 @@
+---
+title: Permissions and policies
+sidebar_position: 3
+---
+
+# The permission
+
+The package registers one permission with [javaabu/permissions](https://github.com/Javaabu/permissions), in a `stats` group labelled "Stats":
+
+| Permission | Lets a user |
+| --- | --- |
+| `view_stats` | See every stat that doesn't decide otherwise: on the stats pages, in the JSON API and in CSV downloads |
+
+Create it with `php artisan permissions:sync`, for every guard in `config('permissions.guards')`, and give it to roles as usual. The constant is `Javaabu\Stats\Policies\StatsPolicy::VIEW`.
+
+Turn registration off with `'register_permissions' => false` to register it (or something else) yourself.
+
+# The policy
+
+`Javaabu\Stats\Policies\StatsPolicy` is the policy of every stat: it is registered for the `TimeSeriesStatsRepository` and `CategoricalStatsRepository` contracts, so it covers every stat class.
+
+| Ability | Allowed with |
+| --- | --- |
+| `view` | `view_stats` |
+| `export` | The stat's `canView()`. The package's downloads check it; restrict downloads by overriding it in your policy |
+
+A stat's `canView(?Authorizable $user = null)` asks the policy's `view`, and is `false` without a user. The stats pages, the API, the category search, the downloads, the forms' stat lists and `getMetricNames()` / `allowedMetrics()` all go through `canView()`.
+
+```php
+use Javaabu\Stats\TimeSeriesStats;
+
+TimeSeriesStats::createFromMetric('payments_count')->canView($user);
+$user->can('view', TimeSeriesStats::createFromMetric('payments_count'));
+TimeSeriesStats::canViewAny($user);
+```
+
+Replace the policy, in order of precedence:
+
+```php
+// config/permissions.php
+'policies' => [
+ Javaabu\Stats\Contracts\TimeSeriesStatsRepository::class => App\Policies\StatsPolicy::class,
+ Javaabu\Stats\Contracts\CategoricalStatsRepository::class => App\Policies\StatsPolicy::class,
+],
+
+// or in a service provider
+Gate::policy(TimeSeriesStatsRepository::class, App\Policies\StatsPolicy::class);
+
+// or config/stats.php
+'policy' => App\Policies\StatsPolicy::class,
+```
+
+```php
+metric(), 'payment')) {
+ return $user->can('view_payment_stats');
+ }
+
+ return parent::view($user, $stat);
+ }
+}
+```
+
+# A permission per stat
+
+Or let a stat decide for itself, by overriding `canView()`:
+
+```php
+use Illuminate\Contracts\Auth\Access\Authorizable;
+
+class PaymentsCount extends CountStatsRepository
+{
+ public function canView(?Authorizable $user = null): bool
+ {
+ return $user?->can('view_payment_stats') ?? false;
+ }
+}
+```
+
+Register `view_payment_stats` with javaabu/permissions in your app, so `permissions:sync` creates it:
+
+```php
+use Javaabu\Permissions\Permissions;
+
+Permissions::register('stats', [
+ 'view_payment_stats' => 'View payment stats',
+]);
+```
+
+# Charts and dashboards
+
+Chart builders don't check permissions: they draw whatever you give them. Authorize the page that shows them, and leave out charts of stats the user can't see. See [Dashboards](../basic-usage/dashboards.md#the-controller).
+
+# Guards and user models
+
+Nothing assumes the `web` guard or `App\Models\User`. Pass the guard to the routes, and permissions are checked for that guard's user:
+
+```php
+Route::stats(['guard' => 'web_admin', 'prefix' => 'admin/stats', 'name' => 'admin.stats']);
+Route::statsApi(['guard' => 'sanctum']);
+```
+
+The user only needs to be `Authorizable` (every Eloquent user model is).
+
+# Middleware
+
+Two middleware aliases abort with `403` when the user can't see any stat. The v1 route helpers use them; the new routes check inside the controllers instead. Use them on your own routes:
+
+| Alias | Class |
+| --- | --- |
+| `stats.view-time-series` | `AbortIfCannotViewAnyTimeSeriesStats` |
+| `stats.view-categorical` | `AbortIfCannotViewAnyCategoricalStats` |
+
+Both take guards as parameters: `stats.view-time-series:web_admin`.
diff --git a/docs/ai-assistants.md b/docs/ai-assistants.md
new file mode 100644
index 0000000..1d3a66c
--- /dev/null
+++ b/docs/ai-assistants.md
@@ -0,0 +1,36 @@
+---
+title: AI assistants
+sidebar_position: 1.45
+---
+
+The docs are plain Markdown, written for people and AI coding assistants alike. The package ships them, so they always match the version you have installed.
+
+| File | What |
+| --- | --- |
+| `vendor/javaabu/stats/llms.txt` | The rules for using the package, and every page |
+| `vendor/javaabu/stats/docs/` | The pages |
+| `vendor/javaabu/stats/resources/boost/` | The Laravel Boost guideline and the `javaabu-stats` skill |
+
+# Laravel Boost
+
+[Laravel Boost](https://github.com/laravel/boost) finds the guideline and the skill by itself in Composer packages. Run this after installing or updating the package:
+
+```bash
+php artisan boost:update
+```
+
+Choose `javaabu/stats` if Boost asks which third-party packages to use.
+
+The guideline tells the assistant that the project builds stats with this package, and to use the `javaabu-stats` skill when it adds or changes a stat, a chart, a dashboard, the stats pages, stats filters, caching or their tests. The skill covers defining and registering stats, the chart builders in Blade and React, dashboards, caching, the pages and the JSON API, with references for every class and the common mistakes.
+
+# Other assistants
+
+Point your assistant at the docs from the project's instructions file (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules`, `.github/copilot-instructions.md`):
+
+```md
+## Stats
+
+Stats, charts and dashboards use javaabu/stats. Before adding a stat or a chart, read vendor/javaabu/stats/llms.txt.
+```
+
+Or copy `vendor/javaabu/stats/resources/boost/skills/javaabu-stats` into your assistant's skills folder, such as `.claude/skills/`.
diff --git a/docs/basic-usage/_category_.json b/docs/basic-usage/_category_.json
new file mode 100644
index 0000000..f2e0cf3
--- /dev/null
+++ b/docs/basic-usage/_category_.json
@@ -0,0 +1,11 @@
+{
+ "position": 1.3,
+ "label": "Basic Usage",
+ "collapsible": true,
+ "collapsed": false,
+ "link": {
+ "type": "generated-index",
+ "slug": "/stats/_categories/basic-usage",
+ "description": "Define stats, filter them, chart them, build dashboards and use the stats pages."
+ }
+}
diff --git a/docs/basic-usage/categorical-stats.md b/docs/basic-usage/categorical-stats.md
new file mode 100644
index 0000000..6eb059d
--- /dev/null
+++ b/docs/basic-usage/categorical-stats.md
@@ -0,0 +1,206 @@
+---
+title: Categorical stats
+sidebar_position: 2
+---
+
+A categorical stat groups records by a category and counts or sums each group: payments by status, applications by island, revenue by customer. The categories and their labels come from a [category provider](./category-providers.md): a model, an enum, an array or your own class.
+
+# Define a stat
+
+Extend a base class in `Javaabu\Stats\Repositories\Categorical`. Or generate it: `php artisan stats:categorical PaymentsByIsland Payment Island island_id` (see [Artisan commands](../advanced-usage/artisan-commands.md)).
+
+## Count
+
+```php
+where('status', 'paid');
+ }
+
+ public function categoryProvider(): mixed
+ {
+ return Island::class;
+ }
+
+ public function getCategoryField(): string
+ {
+ return 'payments.island_id';
+ }
+
+ public function getCategoryFieldAlias(): string
+ {
+ return 'island';
+ }
+
+ public function getDateField(): string
+ {
+ return 'payments.paid_at';
+ }
+
+ public function getAggregateFieldName(): string
+ {
+ return 'payments';
+ }
+
+ public function getName(): string
+ {
+ return __('Payments by island');
+ }
+}
+```
+
+## Sum
+
+Extend `SumCategoricalStatsRepository` and return the column from `getFieldToSum()`:
+
+```php
+getAggregateFieldName();
+}
+```
+
+## What to implement
+
+| Method | Required | What |
+| --- | --- | --- |
+| `query(): Builder` | Yes | The base query. Joins, scopes and `where`s are fine |
+| `categoryProvider(): mixed` | Yes | Where the categories come from: a model class, model or query, an enum class, an array or collection, or a `CategoryProvider`. See [Category providers](./category-providers.md) |
+| `getCategoryField(): string` | Yes | The column whose values are the category ids (`payments.island_id`). It can be a trusted SQL expression, such as `COALESCE(payments.island_id, 0)` or a `CASE`; it is used to select, group and order. Never build it from user input |
+| `getDateField(): string` | Yes, in practice | The qualified date column the date range applies to. Default: `created_at`, which is ambiguous once the query has a join |
+| `getAggregateFieldName(): string` | Yes | The name of the value in results. Also the default label, headlined and translated |
+| `getFieldToSum(): string` | `SumCategoricalStatsRepository` | The column to sum |
+| `getAggregateSql(): string` | `AbstractCategoricalStatsRepository` | The aggregate expression |
+| `getCategoryFieldAlias(): string` | No | The name of the category id in results and formatted output. Default: `category` |
+| `getCategoryNameFieldAlias(): string` | No | The name of the category label in formatted output. Default: `{category field alias}_name` |
+| `getCategoryTitle(): string`, `getCategoryIdTitle(): string` | No | The CSV column titles. Default: the provider's (`Island`, `Island ID`) |
+| `getName(): string` | No | The stat's name. Default: the class name, headlined, without `Repository` and (for counts) `Count` |
+| `getAggregateFieldLabel(): string` | No | The label of the value. Default: the aggregate field name, headlined and translated |
+| `allowedFilters(): array` | No | See [Filters](./filters.md) |
+| `canView(?Authorizable $user = null): bool` | No | Who may see the stat. Default: the stats policy's `view` (`view_stats`) |
+
+Categorical stats run a plain `GROUP BY`, so they work on any database Laravel supports.
+
+# Register it
+
+```php
+// config/stats.php
+'categorical_stats' => [
+ 'payments_by_island' => App\Stats\Categorical\PaymentsByIsland::class,
+ 'payment_amounts_by_status' => App\Stats\Categorical\PaymentAmountsByStatus::class,
+],
+```
+
+or in a service provider's `boot()`:
+
+```php
+use App\Stats\Categorical\PaymentsByIsland;
+use Javaabu\Stats\CategoricalStats;
+
+CategoricalStats::register([
+ 'payments_by_island' => PaymentsByIsland::class,
+]);
+```
+
+`CategoricalStats` has the same lookups as `TimeSeriesStats`: `statsMap()`, `getClassNameForMetric()`, `getMetricForStat()`, `getMetricName()`, `getMetricNames()`, `allowedMetrics()`, `metricsThatAllowFilters()` and `canViewAny()`. See [Time series stats](./time-series-stats.md#register-it).
+
+# Which categories
+
+A categorical mode (`Javaabu\Stats\Enums\CategoricalModes`) decides which categories are listed:
+
+| Mode | Value | Lists |
+| --- | --- | --- |
+| `NON_EMPTY` | `non_empty` | Categories with data in the period, or in the comparison period. The default (`stats.default_categorical_mode`) |
+| `ALL` | `all` | Every category the provider has, with `0` for those without data |
+| `SPECIFIC` | `specific` | Only the category ids you give |
+
+# Use a stat in PHP
+
+```php
+use Javaabu\Stats\CategoricalStats;
+use Javaabu\Stats\Enums\CategoricalModes;
+use Javaabu\Stats\Enums\PresetDateRanges;
+
+$stat = CategoricalStats::createFromMetric('payments_by_island', PresetDateRanges::THIS_MONTH);
+
+$rows = $stat->results(); // a Collection of rows: ['island' => 12, 'payments' => 40]
+$values = $stat->resultsToArray($rows); // [12 => 40, 15 => 7], values keyed by category id
+$names = $stat->resolveCategoryNames(CategoricalModes::SPECIFIC, [12, 15]); // [12 => 'Male', 15 => 'Hulhumale']
+
+$compare = CategoricalStats::createFromMetric('payments_by_island', PresetDateRanges::LAST_MONTH);
+
+$formatted = $stat->format('combined', $compare, CategoricalModes::ALL);
+// [['island' => 12, 'island_name' => 'Male', 'payments' => 40, 'compare_payments' => 31], ...]
+
+$formatted = $stat->format('default', mode: CategoricalModes::SPECIFIC, categorical_values: [12]);
+```
+
+`format(string $format, ?CategoricalStatsRepository $compare = null, CategoricalModes $mode = CategoricalModes::NON_EMPTY, array $categorical_values = [])` takes a formatter's name: `default`, `combined` or `chartjs`, or one you register. See [Formatters](../advanced-usage/formatters.md).
+
+Date ranges, filters and the date methods work as for [time series stats](./time-series-stats.md#date-ranges). `getCategoryProvider()` returns the resolved `CategoryProvider`.
+
+To draw a categorical stat, use `CategoryChart` or `PieChart`; see [Charts](./charts.md).
diff --git a/docs/basic-usage/category-providers.md b/docs/basic-usage/category-providers.md
new file mode 100644
index 0000000..54e78a4
--- /dev/null
+++ b/docs/basic-usage/category-providers.md
@@ -0,0 +1,306 @@
+---
+title: Category providers
+sidebar_position: 3
+---
+
+A category provider turns the values in a categorical stat's `getCategoryField()` into labels for charts, CSV files and the category picker. Every provider gives items with an `id` and a `label`:
+
+```php
+[
+ ['id' => 12, 'label' => 'Male'],
+ ['id' => 15, 'label' => 'Hulhumale'],
+]
+```
+
+Return the source from the stat's `categoryProvider()`. The package wraps it in the right provider:
+
+| You return | Provider |
+| --- | --- |
+| A model class, a model or an Eloquent query | `EloquentCategoryProvider` |
+| An enum class | `EnumCategoryProvider` |
+| An array or a collection | `ArrayCategoryProvider` |
+| A `CategoryProvider` instance, or the class of one | Used as it is (a class is made with `new`) |
+
+Anything else throws an `InvalidArgumentException`.
+
+# Models
+
+Return the model class when the category id is the model's primary key:
+
+```php
+use App\Models\Island;
+
+public function categoryProvider(): mixed
+{
+ return Island::class;
+}
+```
+
+By default the provider:
+
+- uses the primary key as the `id`,
+- uses `admin_link_name` as the label when the model has that accessor, else the `name` attribute,
+- searches the label column with `LIKE` (`name` when the label is `admin_link_name`),
+- orders by the primary key,
+- titles the CSV columns from the model's morph alias, or its class name: `Island` and `Island ID`.
+
+## Limit the categories
+
+Return a query to limit which models can be categories. The same query is used for labels and for the category search:
+
+```php
+public function categoryProvider(): mixed
+{
+ return Island::query()
+ ->where('is_inhabited', true)
+ ->where('atoll_id', auth()->user()->atoll_id);
+}
+```
+
+## Labels, search, order and titles on the model
+
+The provider looks for these methods and scopes on the model, so the model decides how it appears in every stat:
+
+| Model method or scope | What |
+| --- | --- |
+| `getCategoricalStatsLabelField(): string` | The attribute used as the label |
+| `getCategoricalStatsSearchField(): string` | The column the fallback `LIKE` search uses |
+| `getCategoricalStatsSortField(): string` | The column to order by |
+| `getCategoricalStatsSortDirection(): string` | `asc` or `desc`. Default: `asc` |
+| `getCategoricalStatsCategoryTitle(): string` | The CSV title of the category name column |
+| `getCategoricalStatsCategoryIdTitle(): string` | The CSV title of the category id column |
+| `scopeCategoricalStatsSearch(Builder $query, string $search): Builder` | The search. Preferred over the others |
+| `scopeSearch(Builder $query, string $search): Builder` | Used when there is no `categoricalStatsSearch` scope |
+| `scopeCategoricalStatsSort(Builder $query): Builder` | Custom order. Preferred over the sort field methods |
+
+```php
+where(function (Builder $query) use ($search) {
+ $query->where('name', 'like', "%{$search}%")
+ ->orWhere('name_dv', 'like', "%{$search}%");
+ });
+ }
+}
+```
+
+The label is, in order: `getCategoricalStatsLabelField()`, the `admin_link_name` accessor, the `name` attribute.
+
+The search is, in order: the `categoricalStatsSearch` scope, the `search` scope, a `LIKE` on `getCategoricalStatsSearchField()` (or the label column).
+
+Without the title methods, the category title is the model's morph alias or class name, headlined and translated, and the id title is `:category ID`.
+
+## Order
+
+The order is chosen in this sequence:
+
+1. A callback given to `sortCategoricalStatsItemsUsing()`. It gets the query; use `reorder()` in it to replace an existing order.
+2. A field given to `sortCategoricalStatsItemsBy()`. It replaces any existing order.
+3. `orderBy` clauses on the query you return from `categoryProvider()`.
+4. The model's `categoricalStatsSort` scope.
+5. The model's `getCategoricalStatsSortField()` and `getCategoricalStatsSortDirection()`.
+6. The primary key.
+
+For one stat, order through the query or a configured provider:
+
+```php
+use Javaabu\Stats\CategoryProviders\EloquentCategoryProvider;
+
+public function categoryProvider(): mixed
+{
+ return (new EloquentCategoryProvider(Island::class))
+ ->sortCategoricalStatsItemsBy('name', 'asc');
+}
+```
+
+```php
+return Island::query()->orderByDesc('population')->orderBy('name');
+```
+
+```php
+return (new EloquentCategoryProvider(Island::class))
+ ->sortCategoricalStatsItemsUsing(fn (Builder $query) => $query->orderByRaw('code is null')->orderBy('code'));
+```
+
+`EloquentCategoryProvider`'s constructor also takes the columns: `new EloquentCategoryProvider(Island::class, id_field: 'code', label_field: 'name_dv', sort_field: 'name_dv', sort_direction: 'asc')`, and a `sort_query` closure.
+
+## The provider trait
+
+A model can be its own provider with `IsCategoricalStatsProvider`:
+
+```php
+use Javaabu\Stats\Concerns\IsCategoricalStatsProvider;
+use Javaabu\Stats\Contracts\CategoryProvider;
+
+class Island extends Model implements CategoryProvider
+{
+ use IsCategoricalStatsProvider;
+}
+```
+
+Return a model instance from the stat to use the trait's sort methods: `return (new Island)->sortCategoricalStatsItemsBy('name');`.
+
+| Method | Default |
+| --- | --- |
+| `getCategoricalStatsIdField(): string` | The primary key |
+| `getCategoricalStatsLabelField(): string` | `admin_link_name` when that accessor exists, else `name` |
+| `getCategoricalStatsSearchField(): string` | `name` |
+| `getCategoricalStatsCategoryTitle(): string` | The morph alias or class name |
+| `getCategoricalStatsCategoryIdTitle(): string` | `:category ID` |
+| `getCategoricalStatsItems(?array $ids = null): Collection` | The models (those ids only, when given), mapped to items |
+| `searchCategoricalStatsItems(?string $search = null, int $per_page = 15): LengthAwarePaginator` | Searched, ordered and paginated items |
+
+The optional sort hooks (`getCategoricalStatsSortField()`, `getCategoricalStatsSortDirection()`, `scopeCategoricalStatsSort()`) work as above.
+
+# Enums
+
+Return the enum class:
+
+```php
+use App\Enums\PaymentStatus;
+
+public function categoryProvider(): mixed
+{
+ return PaymentStatus::class;
+}
+```
+
+A backed enum's values are the ids; a pure enum's case names are. The label is the case's `getLabel()`, else `label()`, else the headlined case name. The category title is the enum's class name, headlined.
+
+Wrap the enum to order it:
+
+```php
+use Javaabu\Stats\CategoryProviders\EnumCategoryProvider;
+
+return (new EnumCategoryProvider(PaymentStatus::class))
+ ->sortCategoricalStatsItemsBy('label', 'asc');
+```
+
+# Arrays and collections
+
+Return labels keyed by id when the categories are fixed:
+
+```php
+public function categoryProvider(): mixed
+{
+ return [
+ 'card' => 'ކާޑު',
+ 'cash' => 'ނަގުދު',
+ 'transfer' => 'ޓްރާންސްފަރ',
+ ];
+}
+```
+
+or a list of items:
+
+```php
+return collect([
+ ['id' => 'card', 'label' => 'ކާޑު'],
+ ['id' => 'cash', 'label' => 'ނަގުދު'],
+]);
+```
+
+An item can be an array (`id`, and `label` or `name`), an object (`id` or `value`, and `label`, `name` or `value`), or a label keyed by its id.
+
+Order with `ArrayCategoryProvider`, by a field or with a comparison callback:
+
+```php
+use Javaabu\Stats\CategoryProviders\ArrayCategoryProvider;
+
+return (new ArrayCategoryProvider($channels))->sortCategoricalStatsItemsBy('label', 'desc');
+
+return (new ArrayCategoryProvider($channels))
+ ->sortCategoricalStatsItemsUsing(fn (array $left, array $right) => mb_strlen($left['label']) <=> mb_strlen($right['label']));
+```
+
+Arrays, collections and enums are searched and paginated in memory. Use a model or your own provider for large sets.
+
+Their CSV titles are `Category` and `Category ID`. Override `getCategoryTitle()` and `getCategoryIdTitle()` on the stat to change them.
+
+# Your own provider
+
+Implement `Javaabu\Stats\Contracts\CategoryProvider` for categories from anywhere else. For an in-memory source, `HasCategoricalStatsItems` does the normalising, searching, sorting and paging; you only list the items:
+
+```php
+title('ފައިސާ ދެއްކުން')
+ ->range(PresetDateRanges::LAST_30_DAYS)
+ ->compare()
+ ->area();
+
+ return view('payments.stats', ['payments' => $payments]);
+}
+```
+
+**Blade**
+
+```blade
+
+
+{{-- or echo it --}}
+{{ $payments }}
+```
+
+**Inertia React**
+
+```php
+use Inertia\Inertia;
+use Inertia\Response;
+
+public function index(): Response
+{
+ return Inertia::render('Payments/Stats', [
+ 'payments' => TimeSeriesChart::make('payments_count')->title('ފައިސާ ދެއްކުން')->range(PresetDateRanges::LAST_30_DAYS)->compare()->area()->toArray(),
+ ]);
+}
+```
+
+```tsx
+// resources/js/pages/Payments/Stats.tsx
+import { StatsChart, type TimeSeriesChartData } from "@javaabu/stats"
+
+export default function Stats({ payments }: { payments: TimeSeriesChartData }) {
+ return
+}
+```
+
+The data is computed when the chart is turned into an array (`toArray()`, `json_encode()`, rendering), and cached; see [Caching](#caching). A chart of a metric that isn't registered throws `Javaabu\Stats\Exceptions\StatsException` at that point.
+
+Charts don't check permissions. Check them in the controller, as for anything else on the page: `abort_unless($request->user()->can('view_stats'), 403)`, or a stat's own `canView()`.
+
+## Blade components
+
+| Component | Takes |
+| --- | --- |
+| `` | Any chart, or its array |
+| `` | The same as `chart` |
+| `` | The same as `chart` |
+| `` | The same as `chart` |
+| `` | The same as `chart` |
+| `` | A `StatTile` (or `:chart="$tile"`) |
+| `…` | A responsive grid of tiles. `columns`: up to 2, 3 or 4 per row (default 4) |
+
+Every chart component also takes `:rtl="true|false"`. By default charts run right to left when the app's locale is in `stats.rtl_locales`. Other attributes (`class`, `id`) go on the chart's root element.
+
+A Blade chart is a `` with a title, the SVG, a legend when there is more than one series, an accessible summary, and a "View as table" disclosure with the numbers. With a comparison, the totals of both periods and the change are shown above it. Series take their colours from `--chart-1` to `--chart-8` in order; set `--color-{series key}` (for example `--color-payments_count`) to give one series its own colour.
+
+## React components
+
+Import them from `@javaabu/stats` (javaabu/manifest resolves the alias; run `php artisan manifest:types` for TypeScript):
+
+| Export | What |
+| --- | --- |
+| `StatsChart` | Props: `chart` (any chart's array) and `className`. Picks Onu's `TimeSeriesChart`, `CategoryChart`, `PieChart`, `Sparkline` or `StatTile` by the chart's type |
+| `StatsTile` | Props: `tile` (a `StatTile`'s array), plus any of Onu's `StatTile` props except `label` and `value` (`loading`, `tip`, `chart`, `className`). With `href`, the whole tile is a link |
+| Types | `StatsChartData` (the union), `TimeSeriesChartData`, `CategoryChartData`, `PieChartData`, `SparklineData`, `StatTileData`, `StatsValueFormat`, `StatsComparison`, `StatsSeriesConfig` |
+
+Lay tiles out with Onu's `StatTiles`:
+
+```tsx
+import { StatTiles } from "@javaabu/onu/charts"
+import { StatsTile, type StatTileData } from "@javaabu/stats"
+
+export function Tiles({ tiles }: { tiles: StatTileData[] }) {
+ return (
+
+ {tiles.map((tile) => (
+
+ ))}
+
+ )
+}
+```
+
+# Options of every chart
+
+| Method | Default | What |
+| --- | --- | --- |
+| `stat(string $metric, ?string $label = null, array $filters = [])` | | Add a stat, with its own label and filters |
+| `stats(array $metrics)` | | Add several: `['payments_count', 'refunds_count']`, or labels keyed by metric |
+| `title(?string $title)` | The first stat's name | The heading |
+| `description(?string $description)` | None | A line under the heading |
+| `id(?string $id)` | A hash of the chart | The HTML `id` |
+| `range(DateRange\|string $range)` | `stats.default_date_range` | A `PresetDateRanges` case or value (`'last_30_days'`), or an `ExactDateRange` |
+| `filters(array $filters)` | `[]` | Filters for every stat of the chart |
+| `valueFormat(string $format)` | `number` | How values are shown. See [Value formats](#value-formats) |
+| `height(?int $height)` | Onu's, or 300 in Blade (32 for a Blade sparkline) | Height in pixels |
+| `cache(int\|bool $seconds = true, ?int $minutes = null)` | On, with the default TTLs | Cache for that long: `cache(300)`, `cache(minutes: 5)`. `cache(false)` or `cache(0)` turns it off |
+| `withoutCache()` | | The same as `cache(false)` |
+
+And to read it:
+
+| Method | What |
+| --- | --- |
+| `toArray()`, `jsonSerialize()` | The chart as the props of `` and the Blade components |
+| `data()` | The computed part only, from the cache when it's there |
+| `refresh()` | Compute again and put it in the cache |
+| `isWarm()` | Whether the data is in the cache |
+| `render()`, `toHtml()` | The Blade component's HTML |
+| `type()` | `time-series`, `category`, `pie`, `sparkline` or `stat-tile` |
+| `metrics()` | The metrics it uses |
+| `getTitle()`, `getId()`, `getRange()`, `getFilters()`, `getValueFormat()`, `getHeight()`, `isCached()`, `getSeries()` | The options |
+
+# TimeSeriesChart
+
+```php
+use Javaabu\Stats\Charts\TimeSeriesChart;
+use Javaabu\Stats\Enums\PresetDateRanges;
+use Javaabu\Stats\Enums\TimeSeriesModes;
+
+TimeSeriesChart::make('payments_count')
+ ->title('ފައިސާ ދެއްކުން')
+ ->range(PresetDateRanges::LAST_30_DAYS)
+ ->mode(TimeSeriesModes::DAY)
+ ->line();
+```
+
+| Method | Default | What |
+| --- | --- | --- |
+| `mode(TimeSeriesModes\|string $mode)` | `stats.default_time_series_mode` | Group by `hour`, `day`, `week`, `month` or `year` |
+| `variant(string $variant)`, `line()`, `area()`, `bar()` | `line` | How series are drawn. Constants: `TimeSeriesChart::LINE`, `AREA`, `BAR` |
+| `stacked(bool $stacked = true)` | `false` | Stack the series (areas or bars). Ignored with a comparison |
+| `brush(bool $brush = true)` | `false` | A range slider to zoom (React only) |
+| `compare(DateRange\|string\|bool $with = true)` | `false` | Compare the first stat with the previous period, or with the period given |
+| `positiveIsGood(bool $positive_is_good = true)` | `true` | Whether a rise is good (green) in the comparison. `false` for refunds, complaints, waiting times |
+| `periods(array $periods)` | | Plot the first stat over each period |
+| `getMode()`, `getCompareRange()` | | |
+
+Every step of the range is there, with `0` where there is no data.
+
+## Several stats on one plot
+
+```php
+TimeSeriesChart::make()
+ ->stats([
+ 'payments_count' => 'ފައިސާ ދެއްކުން',
+ 'refunds_count' => 'ރީފަންޑު',
+ ])
+ ->range(PresetDateRanges::THIS_MONTH)
+ ->bar()
+ ->stacked();
+```
+
+The same stat can be plotted twice with different filters:
+
+```php
+TimeSeriesChart::make()
+ ->stat('payments_count', 'މާލެ', ['island' => 12])
+ ->stat('payments_count', 'ހުޅުމާލެ', ['island' => 15])
+ ->range(PresetDateRanges::LAST_30_DAYS);
+```
+
+Each series gets a key from its metric (`payments_count`, then `payments_count_2`), used in the data rows and in `--color-{key}`.
+
+## Compare with the previous period
+
+```php
+TimeSeriesChart::make('payments_count')
+ ->range(PresetDateRanges::THIS_MONTH)
+ ->compare(); // last month, the same length
+
+TimeSeriesChart::make('payments_count')
+ ->range(PresetDateRanges::THIS_MONTH)
+ ->compare(PresetDateRanges::THIS_MONTH->getPreviousDateRange());
+
+TimeSeriesChart::make('complaints_count')
+ ->range(new ExactDateRange('2024-07-01', '2024-07-31 23:59:59'))
+ ->compare(new ExactDateRange('2023-07-01', '2023-07-31 23:59:59'))
+ ->positiveIsGood(false);
+```
+
+The previous period is drawn muted (dashed, for lines), step for step against the current one, and the chart shows both totals and the change. The series are labelled with the periods: a preset's label ("This month"), or the dates. Only the first stat is compared.
+
+## Several periods
+
+Plot one stat over several periods, each period a series, step by step on the first period's dates:
+
+```php
+TimeSeriesChart::make('payments_count')
+ ->mode(TimeSeriesModes::MONTH)
+ ->periods([PresetDateRanges::THIS_YEAR, PresetDateRanges::LAST_YEAR])
+ ->title('މިއަހަރާއި ފާއިތުވި އަހަރު');
+```
+
+The series are keyed `period_1`, `period_2`, … and labelled with the periods. With `periods()`, only the first stat is used, and `range()` and `compare()` are ignored.
+
+## The data
+
+`toArray()` gives the props of Onu's `TimeSeriesChart`:
+
+```php
+[
+ 'type' => 'time-series',
+ 'id' => 'stats-chart-1a2b3c4d',
+ 'title' => 'ފައިސާ ދެއްކުން',
+ 'description' => null,
+ 'valueFormat' => 'number',
+ 'height' => null,
+ 'data' => [
+ ['date' => '2024-07-03', 'payments_count' => 3, 'previous' => 0],
+ ['date' => '2024-07-04', 'payments_count' => 2, 'previous' => 1],
+ ],
+ 'labels' => ['3 Jul 24', '4 Jul 24'], // stats.date_formats, in the app's locale
+ 'config' => ['payments_count' => ['label' => 'This month'], 'previous' => ['label' => '1 Jun 2024 – 30 Jun 2024']],
+ 'totals' => ['payments_count' => 5, 'previous' => 1],
+ 'dateKey' => 'date',
+ 'mode' => 'day',
+ 'interval' => 'day', // null for years
+ 'variant' => 'line',
+ 'stacked' => false,
+ 'brush' => false,
+ 'comparison' => ['previousKey' => 'previous', 'currentLabel' => 'This month', 'previousLabel' => '1 Jun 2024 – 30 Jun 2024', 'positiveIsGood' => true],
+ 'aggregateLabel' => 'Payments',
+]
+```
+
+# CategoryChart
+
+```php
+use Javaabu\Stats\Charts\CategoryChart;
+use Javaabu\Stats\Enums\CategoricalModes;
+
+CategoryChart::make('payments_by_island')
+ ->title('ރަށްތަކުން ފައިސާ ދެއްކުން')
+ ->range(PresetDateRanges::THIS_YEAR)
+ ->horizontal()
+ ->top(10);
+```
+
+| Method | Default | What |
+| --- | --- | --- |
+| `variant(string $variant)`, `bar()`, `line()`, `area()` | `bar` | Constants: `CategoryChart::BAR`, `LINE`, `AREA` |
+| `horizontal(bool $horizontal = true)` | `false` | Bars along the side, for long category names. Bars only |
+| `stacked(bool $stacked = true)` | `false` | Stack the series. Ignored with a comparison |
+| `compare(DateRange\|string\|bool $with = true)` | `false` | Compare the first stat with the previous period, or the period given |
+| `positiveIsGood(bool $positive_is_good = true)` | `true` | |
+| `categoryMode(CategoricalModes\|string $mode)` | `stats.default_categorical_mode` | `non_empty`, `all` or `specific`. See [Categorical stats](./categorical-stats.md#which-categories) |
+| `categories(array $ids)` | | Only these categories (sets the mode to `specific`) |
+| `top(?int $limit)` | | Only the categories with the highest values of the first stat, highest first |
+| `getCategoryMode()`, `getCompareRange()` | | |
+
+Several categorical stats side by side, with the categories of the first:
+
+```php
+CategoryChart::make()
+ ->stats(['payments_by_island' => 'ފައިސާ', 'refunds_by_island' => 'ރީފަންޑު'])
+ ->range(PresetDateRanges::THIS_YEAR);
+```
+
+Each row of `data` is a category: `['category' => 'Male', 'categoryId' => 12, 'payments_by_island' => 40, 'previous' => 31]`. The array also has `config`, `categoryKey`, `categoryLabel` (the provider's title), `aggregateLabel`, `variant`, `orientation` (`null` unless bars), `stacked` and `comparison`.
+
+# PieChart
+
+```php
+use Javaabu\Stats\Charts\PieChart;
+
+PieChart::make('payments_by_status')
+ ->range(PresetDateRanges::THIS_MONTH)
+ ->donut()
+ ->centerLabel('MVR 4.2M', 'ބަޖެޓް');
+```
+
+| Method | Default | What |
+| --- | --- | --- |
+| `donut(bool $donut = true)` | `false` | A ring instead of a pie |
+| `centerLabel(string\|false\|null $value, ?string $label = null)` | The total | The donut's centre. `false` for none |
+| `keepEmpty(bool $keep = true)` | `false` | Keep categories without a value as empty slices |
+| `top(?int $limit)` | | The largest slices, and the rest added up as one "Other" slice |
+| `categoryMode()`, `categories()` | | As for `CategoryChart` |
+
+Slices are largest first. Only the first stat is drawn. `data` is a list of `['name' => 'Paid', 'value' => 40, 'categoryId' => 'paid']` (the "Other" slice has `categoryId` `null`); the array also has `nameKey`, `valueKey`, `variant` (`pie` or `donut`), `centerLabel` (`null` for the total, `false` for none), `nameLabel`, `aggregateLabel` and `total`.
+
+# Sparkline
+
+```php
+use Javaabu\Stats\Charts\Sparkline;
+
+Sparkline::make('payments_count', 'ފައިސާ ދެއްކުން')
+ ->range(PresetDateRanges::LAST_30_DAYS)
+ ->mode(TimeSeriesModes::DAY)
+ ->area()
+ ->color('var(--chart-3)');
+```
+
+| Method | Default | What |
+| --- | --- | --- |
+| `mode(TimeSeriesModes\|string $mode)` | `stats.default_time_series_mode` | One value per step |
+| `area(bool $area = true)` | A line | Fill under the line |
+| `color(?string $color)` | `var(--chart-1)` | Any CSS colour or token |
+
+`data` is a list of values, one per step. `label` (the stat's label, or the title) names the sparkline for screen readers.
+
+# StatTile
+
+```php
+use Javaabu\Stats\Charts\StatTile;
+
+StatTile::make('payments_count')
+ ->label('ފައިސާ ދެއްކުން')
+ ->range(PresetDateRanges::THIS_MONTH)
+ ->compare()
+ ->sparkline(TimeSeriesModes::DAY)
+ ->icon('credit-card')
+ ->href(route('payments.index'));
+```
+
+| Method | Default | What |
+| --- | --- | --- |
+| `label(?string $label)` | The stat's label, or its name | The tile's label (the same as `title()`) |
+| `compare(DateRange\|string\|bool $with = true)` | `false` | Show the change from the previous period, or the period given |
+| `deltaContext(?string $context)` | "vs previous period" | The text after the change |
+| `positiveIsGood(bool $positive_is_good = true)` | `true` | Whether a rise is good (green) |
+| `sparkline(TimeSeriesModes\|string\|bool $mode = true)` | None | A sparkline of the range, by the mode given or the default mode |
+| `icon(?string $icon)` | None | An Onu semantic icon name: `credit-card`, `file-text`, `users` |
+| `href(?string $href)` | None | Make the whole tile a link |
+| `footer(?string $footer)` | None | A line at the bottom |
+
+The value is the stat's `total()` for the range. When there is no change to show (no comparison, or nothing in the previous period), the Blade tile shows the period under the value instead.
+
+The array: `label`, `value`, `previousValue`, `deltaContext`, `positiveIsGood`, `sparkline` (a list of values or `null`), `icon`, `href`, `footer` and `period`.
+
+# Value formats
+
+`valueFormat()` takes the same names as Onu's charts. Constants are on `Javaabu\Stats\Charts\Chart`.
+
+| Format | Constant | Shows |
+| --- | --- | --- |
+| `number` | `Chart::FORMAT_NUMBER` | `12,345.6` (axis ticks are compact: `12.3K`) |
+| `integer` | `Chart::FORMAT_INTEGER` | `12,346` |
+| `compact` | `Chart::FORMAT_COMPACT` | `12.3K` |
+| `percent` | `Chart::FORMAT_PERCENT` | `0.256` as `25.6%` |
+| `currency` | `Chart::FORMAT_CURRENCY` | `MVR 1,250.00`, or `ރ. 1,250.00` in Dhivehi |
+
+```php
+StatTile::make('payment_amounts')->valueFormat(Chart::FORMAT_CURRENCY);
+```
+
+Numbers use Latin digits in Dhivehi, as Onu shows them. See [Dhivehi and RTL](../advanced-usage/dhivehi-and-rtl.md).
+
+# Caching
+
+Chart data is cached by default, keyed by everything that changes it (the stats, filters, dates, mode, options and locale). Data for a period that ended before today keeps for a day; data that includes today keeps for 10 minutes. Clear it when the data changes:
+
+```php
+Stats::forget('payments_count'); // charts that use the metric
+Stats::flush(); // every chart
+```
+
+or add the `InvalidatesStats` trait to the model. See [Caching and warming](../advanced-usage/caching-and-warming.md).
diff --git a/docs/basic-usage/dashboards.md b/docs/basic-usage/dashboards.md
new file mode 100644
index 0000000..5cac31e
--- /dev/null
+++ b/docs/basic-usage/dashboards.md
@@ -0,0 +1,312 @@
+---
+title: Dashboards
+sidebar_position: 6
+---
+
+A dashboard is a page of [charts](./charts.md): a row of stat tiles, a chart or two, maybe sparklines in a table. Build the charts in PHP, draw them in Blade or React, and keep them in the cache so the page loads fast.
+
+# Stat tiles
+
+A `StatTile` shows a time series stat's total for a range, the change from the previous period and an optional sparkline:
+
+```php
+use Javaabu\Stats\Charts\Chart;
+use Javaabu\Stats\Charts\StatTile;
+use Javaabu\Stats\Enums\PresetDateRanges;
+use Javaabu\Stats\Enums\TimeSeriesModes;
+
+$tiles = [
+ StatTile::make('payments_count')->label('ފައިސާ ދެއްކުން')->range(PresetDateRanges::THIS_MONTH)->compare()->sparkline(TimeSeriesModes::DAY)->icon('credit-card'),
+ StatTile::make('payment_amounts')->label('ޖުމްލަ ފައިސާ')->range(PresetDateRanges::THIS_MONTH)->compare()->valueFormat(Chart::FORMAT_CURRENCY)->icon('banknote'),
+ StatTile::make('refunds_count')->label('ރީފަންޑު')->range(PresetDateRanges::THIS_MONTH)->compare()->positiveIsGood(false)->icon('rotate-ccw'),
+];
+```
+
+**Blade**: `` lays them out in a grid of up to `columns` per row (2, 3 or 4; default 4) that falls back to fewer columns in narrow containers:
+
+```blade
+
+ @foreach($tiles as $tile)
+
+ @endforeach
+
+```
+
+**React**: Onu's `StatTiles` does the same:
+
+```tsx
+import { StatTiles } from "@javaabu/onu/charts"
+import { StatsTile, type StatTileData } from "@javaabu/stats"
+
+
+ {tiles.map((tile) => (
+
+ ))}
+
+```
+
+Tiles take time series stats. For a categorical figure on a dashboard, use a `CategoryChart` or `PieChart`. Every tile option is in [Charts](./charts.md#stattile).
+
+# Sparklines
+
+A `Sparkline` is a stat's trend without axes, for a table row or a card:
+
+```php
+use Javaabu\Stats\Charts\Sparkline;
+
+$rows = Island::query()->orderBy('name')->get()->map(fn (Island $island) => [
+ 'island' => $island,
+ 'trend' => Sparkline::make('payments_count', $island->name)
+ ->filters(['island' => $island->id])
+ ->range(PresetDateRanges::LAST_30_DAYS)
+ ->mode(TimeSeriesModes::DAY)
+ ->area(),
+]);
+```
+
+```blade
+@foreach($rows as $row)
+
+
{{ $row['island']->name }}
+
+
+@endforeach
+```
+
+```tsx
+
+
+
+```
+
+Each sparkline runs its own query (and is cached on its own). Keep tables of sparklines short, or warm them.
+
+# A complete dashboard
+
+Put the dashboard's charts in one class, so the controller and the cache warmer build exactly the same charts:
+
+```php
+
+ */
+ public static function tiles(): array
+ {
+ return [
+ StatTile::make('payments_count')
+ ->label('ފައިސާ ދެއްކުން')
+ ->range(PresetDateRanges::THIS_MONTH)
+ ->compare()
+ ->sparkline(TimeSeriesModes::DAY)
+ ->icon('credit-card'),
+ StatTile::make('payment_amounts')
+ ->label('ޖުމްލަ ފައިސާ')
+ ->range(PresetDateRanges::THIS_MONTH)
+ ->compare()
+ ->valueFormat(Chart::FORMAT_CURRENCY)
+ ->icon('banknote'),
+ StatTile::make('user_signups')
+ ->label('އައު މެމްބަރުން')
+ ->range(PresetDateRanges::THIS_MONTH)
+ ->compare()
+ ->icon('users'),
+ StatTile::make('refunds_count')
+ ->label('ރީފަންޑު')
+ ->range(PresetDateRanges::THIS_MONTH)
+ ->compare()
+ ->positiveIsGood(false)
+ ->icon('rotate-ccw'),
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public static function charts(): array
+ {
+ return [
+ 'payments' => TimeSeriesChart::make('payments_count')
+ ->title('ފައިސާ ދެއްކުން')
+ ->description('ފާއިތުވި 30 ދުވަސް')
+ ->range(PresetDateRanges::LAST_30_DAYS)
+ ->mode(TimeSeriesModes::DAY)
+ ->compare()
+ ->area(),
+ 'by_island' => CategoryChart::make('payments_by_island')
+ ->title('ރަށްތަކުން ފައިސާ ދެއްކުން')
+ ->range(PresetDateRanges::THIS_MONTH)
+ ->horizontal()
+ ->top(10),
+ 'by_status' => PieChart::make('payments_by_status')
+ ->title('ހާލަތު')
+ ->range(PresetDateRanges::THIS_MONTH)
+ ->donut(),
+ ];
+ }
+
+ /**
+ * Every chart, for warming the cache
+ *
+ * @return list
+ */
+ public static function all(): array
+ {
+ return [...static::tiles(), ...array_values(static::charts())];
+ }
+}
+```
+
+## The controller
+
+Charts don't check permissions, so the controller does:
+
+```php
+user()->can('view_stats'), 403);
+
+ return view('admin.dashboard', [
+ 'tiles' => Dashboard::tiles(),
+ 'charts' => Dashboard::charts(),
+ ]);
+ }
+}
+```
+
+When stats have permissions of their own (a `canView()` override), keep only the charts the user may see:
+
+```php
+use Javaabu\Stats\Charts\Chart;
+use Javaabu\Stats\TimeSeriesStats;
+
+$allowed = TimeSeriesStats::allowedMetrics([], $request->user());
+
+$tiles = array_values(array_filter(
+ Dashboard::tiles(),
+ fn (Chart $tile) => array_diff($tile->metrics(), $allowed) === [],
+));
+```
+
+Use `CategoricalStats::allowedMetrics()` for categorical charts.
+
+## Blade
+
+```blade
+{{-- resources/views/admin/dashboard.blade.php --}}
+@extends('layouts.admin')
+
+@section('content')
+
+ @foreach($tiles as $tile)
+
+ @endforeach
+
+
+
+
+
+ )
+}
+```
+
+# Keep it fast
+
+Every chart's data is cached: for 10 minutes when its period includes today, for a day when it doesn't. The first visitor after that pays for the queries. To take that away, warm the cache ahead of time:
+
+```php
+// AppServiceProvider::boot()
+use App\Stats\Dashboard;
+use Javaabu\Stats\Facades\Stats;
+
+Stats::warm(fn () => Dashboard::all());
+```
+
+```php
+// routes/console.php
+use Illuminate\Support\Facades\Schedule;
+
+Schedule::command('stats:warm')->everyFiveMinutes();
+```
+
+Warming only helps when the page builds the same charts: the cache key covers the stats, filters, dates, options (including a tile's label, icon and link) and the locale. Build them in one place, as above.
+
+And clear a stat's charts when its data changes, with the `InvalidatesStats` trait on the model or `Stats::forget('payments_count')`. See [Caching and warming](../advanced-usage/caching-and-warming.md).
diff --git a/docs/basic-usage/filters.md b/docs/basic-usage/filters.md
new file mode 100644
index 0000000..868fbaf
--- /dev/null
+++ b/docs/basic-usage/filters.md
@@ -0,0 +1,148 @@
+---
+title: Filters
+sidebar_position: 4
+---
+
+Filters let one stat answer narrower questions: payments of one customer, applications from one island, payments with a status. A stat lists the filters it accepts in `allowedFilters()`; you pass values by the filter's name.
+
+```php
+use Javaabu\Filters\SelectFilter;
+use Javaabu\Stats\Filters\StatsFilter;
+
+public function allowedFilters(): array
+{
+ return [
+ StatsFilter::exact('island', 'payments.island_id'),
+ StatsFilter::scope('paid_by'),
+ StatsFilter::from(SelectFilter::make('status')->label('ހާލަތު')->options(PaymentStatus::class)),
+ ];
+}
+```
+
+Time series and categorical stats take filters the same way.
+
+# Filter types
+
+## exact
+
+`StatsFilter::exact(string $name, string $internal_name = '')` adds `where {internal_name} = {value}`. The internal name is the column; it defaults to the filter's name.
+
+```php
+StatsFilter::exact('island', 'payments.island_id'); // ?filters[island]=12
+StatsFilter::exact('user_id'); // the column is user_id
+```
+
+## scope
+
+`StatsFilter::scope(string $name, string $internal_name = '')` calls a query scope with the value. The scope defaults to the filter's name in camel case: `paid_by` calls `paidBy($value)`, that is `scopePaidBy()`.
+
+```php
+StatsFilter::scope('paid_by'); // $query->paidBy($value)
+StatsFilter::scope('customer', 'ownedBy'); // $query->ownedBy($value)
+```
+
+## closure
+
+`StatsFilter::closure(string $name, Closure $callback)` calls your closure. It gets `$query`, `$value` and `$stat` by name (and anything else from the container), and returns the query:
+
+```php
+use Illuminate\Database\Eloquent\Builder;
+use Javaabu\Stats\Contracts\InteractsWithFilters;
+
+StatsFilter::closure('atoll', function (Builder $query, $value, InteractsWithFilters $stat) {
+ return $query->whereHas('island', fn (Builder $query) => $query->where('atoll_id', $value));
+});
+```
+
+## javaabu/filters
+
+`StatsFilter::from(\Javaabu\Filters\Filter $filter)` uses a [javaabu/filters](https://github.com/Javaabu/filters) filter, the same ones your index pages use. It filters the query the way the filter does, and it adds the filter's fields to the [stats pages](./stats-pages.md)' form, shown while a stat that has the filter is selected.
+
+```php
+use Javaabu\Filters\DateRangeFilter;
+use Javaabu\Filters\SelectFilter;
+
+public function allowedFilters(): array
+{
+ return [
+ StatsFilter::from(SelectFilter::make('status')->label('ހާލަތު')->options(PaymentStatus::class)),
+ StatsFilter::from(SelectFilter::make('island_id')->label('ރަށް')->relationship('island', 'name')->searchable()),
+ StatsFilter::from(DateRangeFilter::make('due_at')->label(__('Due'))),
+ ];
+}
+```
+
+It returns a `Javaabu\Stats\Filters\ModelFilter`. Values pass through the filter's own cleaning first, so `' paid '` becomes `paid` and empty values are ignored.
+
+# Pass filter values
+
+By name, wherever a stat is made:
+
+```php
+use Javaabu\Stats\Charts\TimeSeriesChart;
+use Javaabu\Stats\Enums\PresetDateRanges;
+use Javaabu\Stats\TimeSeriesStats;
+
+$stat = TimeSeriesStats::createFromMetric('payments_count', PresetDateRanges::THIS_MONTH, ['island' => 12, 'status' => 'paid']);
+
+$chart = TimeSeriesChart::make('payments_count')->filters(['island' => 12]);
+```
+
+A filter the stat doesn't allow throws `Javaabu\Stats\Exceptions\InvalidFiltersException`, which names the filters it does allow. A filter whose value is missing (`null`, `''` or `[]`) isn't applied; `0`, `'0'` and `false` are values and are applied.
+
+# In a URL
+
+The stats pages and the JSON API read filter values from the query string in two ways:
+
+| Form | For | Example |
+| --- | --- | --- |
+| `filters[name]=value` | Fixed filters: any filter type, by name. The pages send them back as hidden fields, so they stay fixed while people change the rest | `?filters[island]=12` |
+| `name=value` | Filters made with `StatsFilter::from()`, by their own query parameters, as on an index page | `?status=paid`, `?due_at_from=2024-07-01&due_at_to=2024-07-31` |
+
+A value in `filters[...]` wins over the same filter's flat parameters.
+
+Only stats that allow every fixed filter are offered. `?filters[island]=12` lists the stats with an `island` filter, and leaves out the rest. The same goes for your own lists:
+
+```php
+TimeSeriesStats::getMetricNames(['island' => 12], auth()->user()); // names keyed by metric
+TimeSeriesStats::allowedMetrics(['island'], auth()->user()); // metrics; filter names are enough
+```
+
+## Reserved names
+
+The stats forms use these query parameters, so a `StatsFilter::from()` filter can't be named after one, or read one (`ModelFilter::RESERVED`):
+
+`metric`, `with`, `mode`, `variant`, `format`, `filters`, `categorical_values`, `date_range`, `date_from`, `date_to`, `compare`, `compare_to`, `compare_date_range`, `compare_date_from`, `compare_date_to`, `page`, `per_page`, `search`
+
+Making one throws a `Javaabu\Stats\Exceptions\StatsException`. Watch for defaults: `TextFilter::make()` is named `search`, and `DateRangeFilter::make('date')` reads `date_from` and `date_to`. Name them: `TextFilter::make('reference')`, `DateRangeFilter::make('paid_on')`.
+
+# Your own filter type
+
+Implement `Javaabu\Stats\Contracts\Filter`:
+
+```php
+where('amount', '>=', $value);
+ }
+}
+```
+
+To give it fields in the stats form, also implement `Javaabu\Stats\Contracts\HasFields`: `getFields()` returns stencil fields named after its query parameters, `getQueryKeys()` lists those parameters, and `resolveValue(array $input)` turns the query string into the filter's value, or `null` when it doesn't apply. Extending `Javaabu\Stats\Filters\AbstractFilter` gives you the name and internal name.
+
+For a stats page of one model (every stat filtered to one customer, say), see [Stats pages](./stats-pages.md#stats-for-one-model).
diff --git a/docs/basic-usage/stats-pages.md b/docs/basic-usage/stats-pages.md
new file mode 100644
index 0000000..e5dda21
--- /dev/null
+++ b/docs/basic-usage/stats-pages.md
@@ -0,0 +1,297 @@
+---
+title: Stats pages
+sidebar_position: 7
+---
+
+`Route::stats()` gives you two pages where people explore every registered stat: pick a stat, a date range and filters, see the chart, and download it as CSV. One page is for time series stats, the other for categorical stats. They render in Blade or Inertia React.
+
+# Routes
+
+```php
+// routes/web.php
+Route::stats();
+```
+
+| Route | Name | What |
+| --- | --- | --- |
+| `GET /stats/time-series` | `stats.time-series.index` | The time series page |
+| `POST /stats/time-series` | `stats.time-series.export` | Its CSV download |
+| `GET /stats/categorical` | `stats.categorical.index` | The categorical page |
+| `POST /stats/categorical` | `stats.categorical.export` | Its CSV download |
+| `GET /stats/categorical/categories` | `stats.categorical.categories` | The category search behind the "Choose categories" field |
+
+`Route::stats()` takes options:
+
+| Option | Default | What |
+| --- | --- | --- |
+| `prefix` | `stats` | The URL prefix |
+| `name` | `stats` | The route name prefix (`admin.stats` gives `admin.stats.time-series.index`) |
+| `guard` | `null` (the default guard) | The guard users log in with. Permissions are checked for its user |
+| `middleware` | `['auth']`, or `["auth:{guard}"]` with a guard | The route middleware. Replaces the default, so include an `auth` middleware |
+| `time_series` | `true` | Register the time series page |
+| `categorical` | `true` | Register the categorical page and the category search |
+| `controllers` | From `stats.controllers` | Controller classes for this set of routes: `time_series`, `categorical`, `categories`. See [Overriding controllers and views](../advanced-usage/overriding-controllers-and-views.md) |
+
+```php
+Route::stats([
+ 'guard' => 'web_admin',
+ 'prefix' => 'admin/stats',
+ 'name' => 'admin.stats',
+ 'categorical' => false,
+]);
+```
+
+The routes need sessions and CSRF protection for the download, so register them in the `web` middleware group (`routes/web.php` is).
+
+Link to a page with the stat already chosen:
+
+```php
+route('stats.time-series.index', ['metric' => 'payments_count', 'mode' => 'month', 'date_range' => 'this_year']);
+```
+
+# What the pages do
+
+- Users must be logged in, and see only the stats they may view (`view_stats` by default). With none, the page answers `403`.
+- The form is a plain `GET` form, so every chart has a URL you can bookmark or share.
+- With no parameters, the page shows the first stat for the default date range (`stats.default_date_range`) and mode.
+- Invalid parameters don't redirect: the page shows the form with its errors and no chart.
+- The chart has the same options as the [chart builders](./charts.md). The pages show live data; set `stats.cache.pages` to `true` to cache them like other charts.
+- A "Download CSV" button posts the same parameters to the export route.
+
+## The time series page
+
+The chart is a `TimeSeriesChart` of the chosen stat, plus any stats in "Plot with", compared with another period when asked.
+
+## The categorical page
+
+The chart is a `CategoryChart` (bars, horizontal bars or a line), or a donut `PieChart` when "Pie" is chosen for a single stat without a comparison.
+
+# Query parameters
+
+Both pages:
+
+| Parameter | Values | What |
+| --- | --- | --- |
+| `metric` | A metric the user can see | The stat. Default: the first one |
+| `with[]` | Metrics | Other stats to plot on the same chart |
+| `date_range` | A preset value (`last_30_days`), or `custom` | Default: `stats.default_date_range` |
+| `date_from`, `date_to` | Dates | A custom range, when `date_range` is `custom` (or missing). A date without a time covers the whole day |
+| `compare_to` | `previous` or `custom` | Compare with the previous period, or with `compare_date_from` to `compare_date_to` |
+| `compare_date_from`, `compare_date_to` | Dates | The custom comparison period |
+| `compare_date_range` | A preset value | Compare with a preset period |
+| `compare` | `1` | Compare with the previous period (the v1 parameter) |
+| `filters[name]` | Any | Fixed filters. See [Filters](./filters.md#in-a-url) |
+| `{filter}` | Any | The values of `StatsFilter::from()` filters, by their own parameters (`status=paid`) |
+
+The time series page:
+
+| Parameter | Values | Default |
+| --- | --- | --- |
+| `mode` | `hour`, `day`, `week`, `month`, `year` | `stats.default_time_series_mode` |
+| `variant` | `line`, `area`, `bar` | `line` |
+
+The categorical page:
+
+| Parameter | Values | Default |
+| --- | --- | --- |
+| `mode` | `non_empty`, `all`, `specific` | `stats.default_categorical_mode` |
+| `categorical_values[]` | Category ids | Required when `mode` is `specific` |
+| `variant` | `bar`, `horizontal`, `line`, `pie` | `bar` |
+
+```text
+/stats/time-series?metric=payments_count&with[]=refunds_count&mode=week&date_range=this_year&compare_to=previous&status=paid
+/stats/categorical?metric=payments_by_island&mode=specific&categorical_values[]=12&categorical_values[]=15&date_range=custom&date_from=2024-07-01&date_to=2024-07-31
+```
+
+# The form
+
+The forms are [javaabu/stencil](https://github.com/Javaabu/stencil) forms, `Javaabu\Stats\Forms\TimeSeriesStatsForm` and `CategoricalStatsForm`, rendered by javaabu/forms in Blade and by `` in React. They are `GET` forms with a "Generate" button, in two columns on medium screens and four on large ones.
+
+| Field | Page | What |
+| --- | --- | --- |
+| Stat (`metric`) | Both | A searchable select of the stats the user can see |
+| Group by (`mode`) | Time series | Hour, day, week, month or year |
+| Categories (`mode`) | Categorical | Categories with data, all categories, or specific categories |
+| Date range (`date_range`) | Both | The presets, and "Custom range" |
+| Compare to (`compare_to`) | Both | No comparison, the previous period, or a custom period |
+| From, To (`date_from`, `date_to`) | Both | Shown for a custom range |
+| Compare from, Compare to date (`compare_date_from`, `compare_date_to`) | Both | Shown for a custom comparison |
+| Choose categories (`categorical_values`) | Categorical | A searchable multiple select of the chosen stat's categories, loaded from the category search. Shown for specific categories |
+| Plot with (`with`) | Both | Other stats to show on the same chart |
+| Chart (`variant`) | Both | The chart type |
+| Filter fields | Both | The fields of each `StatsFilter::from()` filter, shown while a stat that has it is chosen |
+| `filters[...]` | Both | Hidden fields that keep the fixed filters |
+
+Use the forms in your own pages:
+
+```php
+use Javaabu\Stats\Forms\TimeSeriesStatsForm;
+use Javaabu\Stats\TimeSeriesStats;
+
+$form = TimeSeriesStatsForm::make()
+ ->with([
+ 'metrics' => TimeSeriesStats::getMetricNames([], $request->user()), // the stats to offer
+ 'filters' => [], // fixed filters
+ ])
+ ->model($request->query())
+ ->action(route('reports.payments'));
+```
+
+`CategoricalStatsForm` also takes `categories_url` in `with()`: the URL of the category search (`route('stats.categorical.categories')`).
+
+# Blade
+
+With `'views' => 'blade'` (the default), the pages are `stats::time-series.index` and `stats::categorical.index`. They extend `config('stats.layout')` (`layouts.app`) and fill `config('stats.layout_section')` (`content`), and include `stats::partials.page`.
+
+They get these variables, if you [publish and change them](../advanced-usage/overriding-controllers-and-views.md):
+
+| Variable | What |
+| --- | --- |
+| `$form` | The stencil form (echo it: `{{ $form }}`) |
+| `$chart` | The chart's array, or `null` when the input is invalid |
+| `$export` | `url`, `params` (the page's parameters) and `token` (`null` in Blade; the view adds `@csrf`), or `null` with no chart |
+| `$strings` | `title`, `export`, `empty`, `invalid`, translated |
+| `$layout`, `$layout_section` | From the config |
+
+The pages need no JavaScript of their own. javaabu/forms' components bring theirs (for the searchable selects and date pickers) through javaabu/manifest.
+
+# Inertia React
+
+With `'views' => 'inertia'`, the pages are `stats::TimeSeries/Index` and `stats::Categorical/Index`. See [Installation](../installation-and-setup.md#inertia-react) for the Vite and `resolvePage()` setup. The props are the variables above, with:
+
+- `form` as stencil's JSON schema,
+- `export.token` as the CSRF token, for the download form,
+- `errors` with the first error of each invalid field.
+
+The pieces are exported as `@javaabu/stats`, to build your own page:
+
+| Export | What |
+| --- | --- |
+| `TimeSeriesStatsPage`, `CategoricalStatsPage` | The two pages |
+| `StatsPage` | Their body: the title, ``, the chart and the download button, or a message. Props: `StatsPageProps` and `page` |
+| `StatsExportButton` | The download form. Props: `download` (the `export` prop) and `label` |
+| `StatsPageProps`, `StatsExport`, `StatsPageStrings` | The prop types |
+
+```tsx
+// resources/vendor/stats/js/Pages/TimeSeries/Index.tsx
+import type { ReactNode } from "react"
+import { StatsPage } from "@javaabu/stats/components/StatsPage"
+import type { StatsPageProps } from "@javaabu/stats/types"
+import ReportsLayout from "@/layouts/reports"
+
+export default function Index(props: StatsPageProps) {
+ return
+}
+
+Index.layout = (page: ReactNode) => {page}
+```
+
+A file at `resources/vendor/stats/js/Pages/...` replaces the package's page; see [Overriding controllers and views](../advanced-usage/overriding-controllers-and-views.md#the-react-pages).
+
+# CSV export
+
+The "Download CSV" button posts the page's parameters to the export route, which answers with a CSV file:
+
+- named `{App name} {Stat name} {20240701-20240731} [{comparison dates}].csv`,
+- starting with a UTF-8 byte order mark, so spreadsheet apps show Thaana correctly,
+- with a few `#` comment lines at the top: the stat's name and the dates,
+- of the chosen stat only (not the "Plot with" stats).
+
+Time series: a row per step, with the step, the comparison period's dates when compared (a row per step of each period), and the value.
+
+```text
+# ----------------------------------------
+# Payments
+# 20240703-20240704
+# ----------------------------------------
+
+Day,Payments
+2024-07-03,3
+2024-07-04,2
+```
+
+Categorical: a row per category, with the category id, the name, and the value, or a value per period when compared.
+
+Invalid input redirects back with the errors, like any form request. The download needs the same permission as the page.
+
+To offer the download from your own controller, use the traits the package's controllers use:
+
+```php
+use Javaabu\Stats\Concerns\ExportsTimeSeriesStats;
+use Javaabu\Stats\Http\Requests\TimeSeriesStatsRequest;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+
+class PaymentReportsController extends Controller
+{
+ use ExportsTimeSeriesStats;
+
+ public function export(TimeSeriesStatsRequest $request): StreamedResponse
+ {
+ return $this->exportStats($request, filters: ['island' => 12], export_title: 'Male');
+ }
+}
+```
+
+`exportStats()` and `exportCategoricalStats()` (from `ExportsCategoricalStats`, with a `CategoricalStatsRequest`) take the request, extra fixed filters, and a prefix for the file name (default: the app's name). They answer `403` when the user can't see the stat with those filters.
+
+# Stats for one model
+
+A page of one model's stats (an island's payments, a customer's orders) is the stats page with a fixed filter. Extend the controller and return the filter from `filters()`:
+
+```php
+findOrFail($request->route('island'));
+
+ Gate::authorize('view', $island);
+
+ return ['island' => $island->id];
+ }
+}
+```
+
+`filters()` is called for the page and for the download. Its filters are added to every stat, the page offers only the stats that allow them, and they're sent back as hidden `filters[...]` fields, so people can't remove them.
+
+Register the routes with the model's id in the prefix. The pages pass the current route's parameters on to their download and category-search URLs, so nothing else is needed:
+
+```php
+// routes/web.php
+use App\Http\Controllers\Admin\IslandStatsController;
+
+Route::stats([
+ 'prefix' => 'islands/{island}/stats',
+ 'name' => 'islands.stats',
+ 'categorical' => false,
+ 'controllers' => ['time_series' => IslandStatsController::class],
+]);
+```
+
+Link to it with `route('islands.stats.time-series.index', $island)`. For a categorical page, extend `CategoricalStatsController` the same way and pass it as `controllers.categorical`.
+
+The stats need the filter:
+
+```php
+public function allowedFilters(): array
+{
+ return [
+ StatsFilter::exact('island', 'payments.island_id'),
+ ];
+}
+```
+
+For a few fixed charts of one model, on its own page, you don't need the stats pages: use the [chart builders](./charts.md) with `->filters(['island' => $island->id])`.
+
+# The v1 route helpers
+
+`TimeSeriesStats::registerRoutes()`, `TimeSeriesStats::registerApiRoute()`, `CategoricalStats::registerRoutes()` and `CategoricalStats::registerApiRoutes()` still work, with their v1 URLs, names and middleware, and are deprecated. Use `Route::stats()` and `Route::statsApi()`. See [Upgrading](../upgrading.md).
diff --git a/docs/basic-usage/time-series-stats.md b/docs/basic-usage/time-series-stats.md
new file mode 100644
index 0000000..ba1e51f
--- /dev/null
+++ b/docs/basic-usage/time-series-stats.md
@@ -0,0 +1,356 @@
+---
+title: Time series stats
+sidebar_position: 1
+---
+
+A time series stat is a value that changes over time: payments per day, sign-ups per week, revenue per month. The package groups it by one of five modes (`Javaabu\Stats\Enums\TimeSeriesModes`):
+
+| Mode | Value | Step key in results |
+| --- | --- | --- |
+| `HOUR` | `hour` | `2024-07-04 09:00` |
+| `DAY` | `day` | `2024-07-04` |
+| `WEEK` | `week` | The date the week starts on: `2024-06-30` |
+| `MONTH` | `month` | `2024-07` |
+| `YEAR` | `year` | `2024` |
+
+# Define a stat
+
+Extend one of the base classes in `Javaabu\Stats\Repositories\TimeSeries`. Each needs a query, the table and a name for the value. Or generate the class: `php artisan stats:time-series PaymentsCount Payment` (see [Artisan commands](../advanced-usage/artisan-commands.md)).
+
+## Count
+
+`CountStatsRepository` counts rows:
+
+```php
+where('status', 'paid');
+ }
+
+ public function getTable(): string
+ {
+ return 'payments';
+ }
+
+ public function getDateFieldName(): string
+ {
+ return 'paid_at';
+ }
+
+ public function getAggregateFieldName(): string
+ {
+ return 'payments';
+ }
+
+ public function getName(): string
+ {
+ return __('ފައިސާ ދެއްކުން');
+ }
+}
+```
+
+## Sum
+
+`SumStatsRepository` adds up a column. Return it from `getFieldToSum()`:
+
+```php
+where('status', 'paid');
+ }
+
+ public function getTable(): string
+ {
+ return 'payments';
+ }
+
+ public function getDateFieldName(): string
+ {
+ return 'paid_at';
+ }
+
+ public function getFieldToSum(): string
+ {
+ return 'payments.amount';
+ }
+
+ public function getAggregateFieldName(): string
+ {
+ return 'amount';
+ }
+}
+```
+
+## Any other aggregate
+
+`AggregateStatsRepository` takes the aggregate SQL. Alias it as the aggregate field name:
+
+```php
+where('status', 'paid');
+ }
+
+ public function getTable(): string
+ {
+ return 'payments';
+ }
+
+ public function getAggregateSql(): string
+ {
+ return 'avg(payments.amount) as ' . $this->getAggregateFieldName();
+ }
+
+ public function getAggregateFieldName(): string
+ {
+ return 'average_amount';
+ }
+}
+```
+
+The SQL comes from your code, never from the request. It runs grouped by the step (`GROUP BY`) and once more without grouping for the total.
+
+## What to implement
+
+| Method | Required | What |
+| --- | --- | --- |
+| `query(): Builder` | Yes | The base query. Scopes, joins and `where`s are fine; the package adds the date range, the filters, the select and the grouping |
+| `getTable(): string` | Yes | The main table, used to qualify the date column |
+| `getAggregateFieldName(): string` | Yes | The name of the value in results (`payments`). Also the default label, headlined and translated (`Payments`) |
+| `getFieldToSum(): string` | `SumStatsRepository` | The column to sum |
+| `getAggregateSql(): string` | `AggregateStatsRepository` | The aggregate expression, aliased as the aggregate field name |
+| `getDateFieldName(): string` | No | The date column. Default: `created_at` |
+| `getDateField(): string` | No | The qualified date column. Default: `{table}.{date field name}`. Override it for a joined table or an expression |
+| `getName(): string` | No | The stat's name in selects, titles and CSV files. Default: the class name, headlined, without `Repository` and (for counts) `Count`: `PaymentsCount` is "Payments" |
+| `getAggregateFieldLabel(): string` | No | The label of the value in tables and CSV headings. Default: the aggregate field name, headlined and translated |
+| `allowedFilters(): array` | No | Filters the stat accepts. See [Filters](./filters.md) |
+| `canView(?Authorizable $user = null): bool` | No | Who may see the stat. Default: the `view` ability of the stats policy (`view_stats`). See [Permissions and policies](../advanced-usage/permissions-and-policies.md) |
+| `getDateSqlForTimeMode(TimeSeriesModes $mode): string` | No | The SQL that groups the date column. See [Databases](../advanced-usage/databases.md) |
+
+Restrict who sees one stat:
+
+```php
+use Illuminate\Contracts\Auth\Access\Authorizable;
+
+public function canView(?Authorizable $user = null): bool
+{
+ return $user?->can('view_payment_stats') ?? false;
+}
+```
+
+# Register it
+
+Give each stat a metric name. The metric is what pages, charts, the API and the cache use.
+
+```php
+// config/stats.php
+'time_series_stats' => [
+ 'payments_count' => App\Stats\TimeSeries\PaymentsCount::class,
+ 'payment_amounts' => App\Stats\TimeSeries\PaymentAmountsSum::class,
+],
+```
+
+or in a service provider's `boot()`:
+
+```php
+use App\Stats\TimeSeries\PaymentAmountsSum;
+use App\Stats\TimeSeries\PaymentsCount;
+use Javaabu\Stats\TimeSeriesStats;
+
+TimeSeriesStats::register([
+ 'payments_count' => PaymentsCount::class,
+ 'payment_amounts' => PaymentAmountsSum::class,
+]);
+```
+
+`register()` adds to what is registered. Pass `false` as the second argument to replace everything instead: `TimeSeriesStats::register([...], false)`. The stats pages list the stats in the order they were registered.
+
+Look stats up with:
+
+| Method | Returns |
+| --- | --- |
+| `TimeSeriesStats::statsMap()` | Classes keyed by metric |
+| `TimeSeriesStats::getClassNameForMetric('payments_count')` | The class |
+| `TimeSeriesStats::getMetricForStat(PaymentsCount::class)` | The metric |
+| `TimeSeriesStats::getMetricName('payments_count')` | The stat's name |
+| `TimeSeriesStats::getMetricNames(array $filters = [], ?Authorizable $user = null)` | Names keyed by metric, for the stats the user can see that allow the filters |
+| `TimeSeriesStats::allowedMetrics(array $filters = [], ?Authorizable $user = null)` | The same, as a list of metrics |
+| `TimeSeriesStats::metricsThatAllowFilters(array $filters, ?Authorizable $user = null, StatListReturnType $return_type = StatListReturnType::METRIC_AND_NAME)` | Either of the above, or classes keyed by metric with `StatListReturnType::METRIC_AND_CLASS` |
+| `TimeSeriesStats::canViewAny(?Authorizable $user = null)` | Whether the user can see any stat |
+
+`$stat->metric()` gives a stat instance's metric.
+
+# Built-in stats
+
+When `stats.default_stats` is on (the default), two stats are registered for your users:
+
+| Metric | Class | What |
+| --- | --- | --- |
+| `user_signups` | `UserSignupsRepository` | Users created, by `created_at` |
+| `user_logins` | `UserLoginsRepository` | Logins, from [spatie/laravel-activitylog](https://github.com/spatie/laravel-activitylog): activities with the description `login` caused by a user. Only when activitylog is installed |
+
+The user model is `config('stats.user_model')`, or the model of the default guard's user provider when that is `null`. Nothing is registered when neither gives a class.
+
+```php
+// config/stats.php
+'default_stats' => true,
+'user_model' => App\Models\Admin::class,
+```
+
+To leave them out, set `'default_stats' => false`, or call `TimeSeriesStats::excludeDefaultStats()` in a service provider's `register()`. To register them at a point of your choosing (to change their order), exclude them and call `TimeSeriesStats::registerDefaultStats()` in `boot()` after your own.
+
+`user_logins` accepts a filter named after the user model's morph class (`user` with a morph map, otherwise the class name), for one user's logins.
+
+## Sign-ups and logins of other models
+
+Extend `SignupsRepository` or `LoginsRepository` and return the model:
+
+```php
+ 5]); // a stat that allows an island filter
+$stat = new PaymentsCount(PresetDateRanges::THIS_YEAR); // the date range defaults to THIS_YEAR
+
+$total = $stat->total(); // 1250 (int or float)
+$rows = $stat->results(TimeSeriesModes::DAY); // a Collection of rows: ['payments' => 3, 'day' => '2024-07-03']
+$filled = $stat->format('combined', TimeSeriesModes::DAY); // every day, with 0 for days without data
+```
+
+`results()` only has steps with data. To get every step, use a formatter (see [Formatters](../advanced-usage/formatters.md)) or a chart (see [Charts](./charts.md)), which fill the gaps with zero.
+
+Filters that the stat doesn't allow throw `Javaabu\Stats\Exceptions\InvalidFiltersException`.
+
+## Date ranges
+
+A date range is a `PresetDateRanges` case or an `ExactDateRange`. Both implement `Javaabu\Stats\Contracts\DateRange`.
+
+| Preset | Value | Covers |
+| --- | --- | --- |
+| `TODAY` | `today` | Today |
+| `YESTERDAY` | `yesterday` | Yesterday |
+| `THIS_WEEK` | `this_week` | This week, starting on Sunday or Monday (`stats.week_starts_on_sunday`) |
+| `LAST_WEEK` | `last_week` | Last week |
+| `THIS_MONTH` | `this_month` | This month |
+| `LAST_MONTH` | `last_month` | Last month |
+| `THIS_YEAR` | `this_year` | This year |
+| `LAST_YEAR` | `last_year` | Last year |
+| `LAST_7_DAYS` | `last_7_days` | Today and the 6 days before |
+| `LAST_14_DAYS` | `last_14_days` | Today and the 13 days before |
+| `LAST_30_DAYS` | `last_30_days` | Today and the 29 days before |
+| `LAST_5_YEARS` | `last_5_years` | This year and the 4 before |
+| `LAST_10_YEARS` | `last_10_years` | This year and the 9 before |
+| `LIFETIME` | `lifetime` | From the stat's first record to its last |
+
+```php
+$range = PresetDateRanges::from('last_30_days');
+$range->getDateFrom(); // Carbon
+$range->getDateTo();
+$range->getLabel(); // "Last 30 days", translated
+$range->getPreviousDateRange(); // the 30 days before, as an ExactDateRange
+PresetDateRanges::getLabels(); // labels keyed by value, for selects
+
+$range = new ExactDateRange('2024-07-01', '2024-07-31 23:59:59');
+$range->getPreviousDateRange(); // the same length, ending the second before 1 July
+```
+
+An `ExactDateRange` takes anything Carbon parses. A date without a time starts at midnight, so give the end date a time (or `Carbon::parse(...)->endOfDay()`) to include the whole day. Its end can't be before its start.
+
+## The stat's dates
+
+| Method | What |
+| --- | --- |
+| `getDateRange()` | The `DateRange` it was made with |
+| `getDateFrom()`, `getDateTo()` | The resolved start and end |
+| `setDateRange(DateRange $range)`, `setDateFrom($date)`, `setDateTo($date)` | Change them |
+| `formattedDateRange(string $format = 'YYYY-MM-DD HH:mm', string $separator = ' - ')` | `2024-07-01 00:00 - 2024-07-31 23:59` |
+| `interval(TimeSeriesModes $mode)` | The number of steps after the first |
+| `getMinDate()`, `getMaxDate()` | The earliest and latest date in the query |
+
+## The stat's queries
+
+| Method | What |
+| --- | --- |
+| `query()` | Your base query |
+| `filteredQuery()` | With the filters and the date range |
+| `filteredQueryWithoutDateFilters()` | With the filters only |
+| `hour()`, `day()`, `week()`, `month()`, `year()` | The grouped query for a mode |
+| `groupedQuery(TimeSeriesModes $mode)` | The same, for any mode |
diff --git a/docs/categorical/_category_.json b/docs/categorical/_category_.json
deleted file mode 100644
index 0286ea3..0000000
--- a/docs/categorical/_category_.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "position": 1.4,
- "label": "Categorical Stats",
- "collapsible": true,
- "collapsed": false,
- "link": {
- "type": "generated-index",
- "slug": "/stats/_categories/categorical",
- "description": "How to define and use categorical statistics"
- }
-}
diff --git a/docs/categorical/category-providers.md b/docs/categorical/category-providers.md
deleted file mode 100644
index 72aab1b..0000000
--- a/docs/categorical/category-providers.md
+++ /dev/null
@@ -1,403 +0,0 @@
----
-title: Category providers
-sidebar_position: 3
----
-
-A category provider maps the value stored in `getCategoryField()` to a label displayed in graphs, exports, and the category selector.
-
-Each item has an `id` and `label`:
-
-```php
-[
- ['id' => 1, 'label' => 'Retail'],
- ['id' => 2, 'label' => 'Wholesale'],
-]
-```
-
-Choose a provider based on where your categories are stored.
-
-## Export column titles
-
-Providers supply the default titles for the category ID and category name columns in CSV exports. A custom provider can override:
-
-```php
-public function getCategoricalStatsCategoryTitle(): string
-{
- return __('Customer');
-}
-
-public function getCategoricalStatsCategoryIdTitle(): string
-{
- return __('Customer Number');
-}
-```
-
-Eloquent providers derive the category title from the model's morph class when available, otherwise from the model class name. Enum providers use the enum class name. Array, collection, and other in-memory providers default to `Category` and `Category ID`.
-
-The stat repository may override these titles for an individual metric with `getCategoryTitle()` and `getCategoryIdTitle()`.
-
-## Eloquent model providers
-
-Return a model class when the category ID is the model's primary key:
-
-```php
-use App\Models\Customer;
-
-public function categoryProvider(): mixed
-{
- return Customer::class;
-}
-```
-
-By default, the provider:
-
-- Uses the model's primary key as `id`.
-- Uses `admin_link_name` as the label when that accessor exists.
-- Otherwise uses the `name` attribute as the label.
-- Orders paginated searches by the primary key when no order is configured.
-- Derives export column titles from the model's morph class or class name.
-
-### Customize labels and search
-
-Add these methods to the category model when its fields differ from the defaults:
-
-```php
-public function getCategoricalStatsLabelField(): string
-{
- return 'display_name';
-}
-
-public function getCategoricalStatsSearchField(): string
-{
- return 'name';
-}
-```
-
-For multi-column or relationship searches, define a `categoricalStatsSearch` scope:
-
-```php
-use Illuminate\Database\Eloquent\Builder;
-
-public function scopeCategoricalStatsSearch(Builder $query, string $search): Builder
-{
- return $query->where(function (Builder $query) use ($search) {
- $query->where('first_name', 'like', "%{$search}%")
- ->orWhere('last_name', 'like', "%{$search}%")
- ->orWhere('email', 'like', "%{$search}%");
- });
-}
-```
-
-If `categoricalStatsSearch` is not defined, the provider uses an existing `search` scope. If neither scope exists, it applies a `LIKE` condition to `getCategoricalStatsSearchField()`.
-
-### Model customization reference
-
-When `categoryProvider()` returns an Eloquent model class, model instance, or query, the provider detects the following methods and scopes on the model:
-
-| Model method or scope | Purpose |
-| --- | --- |
-| `getCategoricalStatsLabelField(): string` | Returns the model attribute used as the item `label`. |
-| `getCategoricalStatsSearchField(): string` | Returns the database column used by the fallback `LIKE` search. |
-| `getCategoricalStatsSortField(): string` | Returns the database column used to order category items. |
-| `getCategoricalStatsSortDirection(): string` | Returns `asc` or `desc`; defaults to `asc` when omitted. |
-| `getCategoricalStatsCategoryTitle(): string` | Returns the category-name column title used by exports. |
-| `getCategoricalStatsCategoryIdTitle(): string` | Returns the category-ID column title used by exports. |
-| `scopeCategoricalStatsSearch(Builder $query, string $search): Builder` | Defines the preferred Eloquent search query. |
-| `scopeSearch(Builder $query, string $search): Builder` | Used as a fallback when `categoricalStatsSearch` is not defined. |
-| `scopeCategoricalStatsSort(Builder $query): Builder` | Defines custom ordering and takes precedence over the model sort-field methods. |
-
-For example:
-
-```php
-use Illuminate\Database\Eloquent\Builder;
-use Illuminate\Database\Eloquent\Model;
-
-class Customer extends Model
-{
- public function getCategoricalStatsLabelField(): string
- {
- return 'display_name';
- }
-
- public function getCategoricalStatsSearchField(): string
- {
- return 'legal_name';
- }
-
- public function getCategoricalStatsSortField(): string
- {
- return 'display_name';
- }
-
- public function getCategoricalStatsSortDirection(): string
- {
- return 'asc';
- }
-
- public function getCategoricalStatsCategoryTitle(): string
- {
- return __('Customer');
- }
-
- public function getCategoricalStatsCategoryIdTitle(): string
- {
- return __('Customer Number');
- }
-
- public function scopeCategoricalStatsSearch(Builder $query, string $search): Builder
- {
- return $query->where(function (Builder $query) use ($search) {
- $query->where('display_name', 'like', "%{$search}%")
- ->orWhere('legal_name', 'like', "%{$search}%");
- });
- }
-}
-```
-
-The label precedence for a model automatically wrapped in `EloquentCategoryProvider` is:
-
-1. `getCategoricalStatsLabelField()`.
-2. The `admin_link_name` accessor, when present.
-3. The `name` attribute.
-
-The search precedence is:
-
-1. `scopeCategoricalStatsSearch()`.
-2. `scopeSearch()`.
-3. A `LIKE` query against `getCategoricalStatsSearchField()`, or the resolved label column when no search-field method exists. When `admin_link_name` is the label accessor, the fallback search column is `name`.
-
-The title methods are optional. Without them, the category title is derived from the model's morph class or class name, and the ID title is generated by adding `ID` to that title.
-
-### Restrict the available models
-
-Return an Eloquent query to limit the categories users can select:
-
-```php
-public function categoryProvider(): mixed
-{
- return Customer::query()
- ->where('is_active', true)
- ->where('company_id', auth()->user()->company_id);
-}
-```
-
-The same query is used when resolving labels and serving paginated search results.
-
-### Configure ordering
-
-For simple model-wide ordering, add the sort-field method to the category model. The direction method is optional and defaults to `asc`:
-
-```php
-public function getCategoricalStatsSortField(): string
-{
- return 'name';
-}
-
-public function getCategoricalStatsSortDirection(): string
-{
- return 'desc';
-}
-```
-
-For custom Eloquent ordering, add the auto-discovered `categoricalStatsSort` scope:
-
-```php
-public function scopeCategoricalStatsSort(Builder $query): Builder
-{
- return $query
- ->orderByRaw('priority is null')
- ->orderBy('priority')
- ->orderBy('name');
-}
-```
-
-The scope takes precedence over `getCategoricalStatsSortField()` and `getCategoricalStatsSortDirection()`.
-
-Ordering may also be configured for an individual stat through the returned query or provider instance.
-
-For straightforward ordering, create an `EloquentCategoryProvider` and select a field and direction:
-
-```php
-use Javaabu\Stats\CategoryProviders\EloquentCategoryProvider;
-
-public function categoryProvider(): mixed
-{
- return (new EloquentCategoryProvider(Customer::class))
- ->sortCategoricalStatsItemsBy('name', 'asc');
-}
-```
-
-For multiple order clauses, return an ordered query:
-
-```php
-public function categoryProvider(): mixed
-{
- return Customer::query()
- ->orderByDesc('priority')
- ->orderBy('name');
-}
-```
-
-You can also configure the query through a callback:
-
-```php
-return (new EloquentCategoryProvider(Customer::class))
- ->sortCategoricalStatsItemsUsing(
- fn ($query) => $query->orderByRaw('priority is null')->orderBy('priority')
- );
-```
-
-Eloquent ordering configuration is selected in this order:
-
-1. A callback configured with `sortCategoricalStatsItemsUsing()`. The callback receives the query and may use `reorder()` when it should replace existing ordering.
-2. A field configured with `sortCategoricalStatsItemsBy()`. This replaces existing query ordering.
-3. Existing `orderBy` clauses on the query returned by `categoryProvider()`.
-4. `scopeCategoricalStatsSort()` on the model.
-5. `getCategoricalStatsSortField()` and `getCategoricalStatsSortDirection()` on the model.
-6. The model's primary key as the default.
-
-### Use the provider trait on a model
-
-A model can implement `CategoryProvider` directly with `IsCategoricalStatsProvider`:
-
-```php
-use Javaabu\Stats\Concerns\IsCategoricalStatsProvider;
-use Javaabu\Stats\Contracts\CategoryProvider;
-
-class Customer extends Model implements CategoryProvider
-{
- use IsCategoricalStatsProvider;
-}
-```
-
-Return a configured model instance from the stat when you want to use the trait's sorting methods:
-
-```php
-return (new Customer)
- ->sortCategoricalStatsItemsBy('name');
-```
-
-The trait adds all methods required by `CategoryProvider`. A model may override trait methods or add the optional sorting hooks when it needs more control:
-
-| Method or optional hook | Default behavior |
-| --- | --- |
-| `getCategoricalStatsIdField(): string` | Uses the model's primary key. |
-| `getCategoricalStatsLabelField(): string` | Uses `admin_link_name` when its accessor exists, otherwise `name`. |
-| `getCategoricalStatsSearchField(): string` | Uses `name`. |
-| `getCategoricalStatsSortField(): string` | Optional model-wide sort column; falls back to the primary key when omitted. |
-| `getCategoricalStatsSortDirection(): string` | Optional model-wide sort direction; defaults to `asc`. |
-| `scopeCategoricalStatsSort(Builder $query): Builder` | Optional custom ordering scope; takes precedence over the model sort-field methods. |
-| `getCategoricalStatsCategoryTitle(): string` | Derives the title from the morph class or model class name. |
-| `getCategoricalStatsCategoryIdTitle(): string` | Adds `ID` to the category title. |
-| `getCategoricalStatsItems(?array $ids = null): Collection` | Resolves and maps the requested models. |
-| `searchCategoricalStatsItems(?string $search = null, int $per_page = 15): LengthAwarePaginator` | Searches, sorts, and paginates the models. |
-
-The trait also adds the chainable `sortCategoricalStatsItemsBy()` and `sortCategoricalStatsItemsUsing()` methods. Overriding `getCategoricalStatsItems()` or `searchCategoricalStatsItems()` is only necessary when the normal Eloquent behavior is insufficient.
-
-## Enum providers
-
-Return a native enum class:
-
-```php
-public function categoryProvider(): mixed
-{
- return PaymentStatus::class;
-}
-```
-
-Backed enum values become IDs. The label is resolved from `getLabel()`, then `label()`, and finally the title-cased case name.
-
-To configure ordering, wrap the enum:
-
-```php
-use Javaabu\Stats\CategoryProviders\EnumCategoryProvider;
-
-return (new EnumCategoryProvider(PaymentStatus::class))
- ->sortCategoricalStatsItemsBy('label', 'asc');
-```
-
-## Array and collection providers
-
-Return an associative array when categories are already available in memory:
-
-```php
-public function categoryProvider(): mixed
-{
- return [
- 'card' => 'Card',
- 'cash' => 'Cash',
- ];
-}
-```
-
-Arrays and collections can also contain explicit items:
-
-```php
-return collect([
- ['id' => 'card', 'label' => 'Card'],
- ['id' => 'cash', 'label' => 'Cash'],
-]);
-```
-
-Use `ArrayCategoryProvider` for field or callback sorting:
-
-```php
-use Javaabu\Stats\CategoryProviders\ArrayCategoryProvider;
-
-return (new ArrayCategoryProvider($categories))
- ->sortCategoricalStatsItemsBy('label', 'desc');
-```
-
-A custom comparator receives two normalized items:
-
-```php
-return (new ArrayCategoryProvider($categories))
- ->sortCategoricalStatsItemsUsing(
- fn (array $left, array $right) =>
- strlen($left['label']) <=> strlen($right['label'])
- );
-```
-
-Array and collection providers search and paginate after loading their items into memory. Prefer an Eloquent or custom data-source provider for large category sets.
-
-## Custom providers
-
-Implement `CategoryProvider` when categories come from another data source. For an in-memory source, `HasCategoricalStatsItems` provides normalization, searching, sorting, and pagination:
-
-```php
-use Javaabu\Stats\Concerns\HasCategoricalStatsItems;
-use Javaabu\Stats\Contracts\CategoryProvider;
-
-class ChannelProvider implements CategoryProvider
-{
- use HasCategoricalStatsItems;
-
- protected function categoricalStatsItems(): iterable
- {
- return config('channels');
- }
-}
-```
-
-Implement the contract methods yourself when the underlying service should perform searching or pagination.
-
-## Use the category search endpoint
-
-The Select2 component calls:
-
-```text
-GET /stats/categorical/categories
- ?filter[metric]=payments_by_customer
- &filter[search]=acme
- &page=2
-```
-
-Optional parameters are:
-
-- `per_page`, from 1 to 100.
-- `filters`, for model-specific stats.
-
-The response is a standard Laravel paginator whose `data` entries contain `id` and `label`. The requested metric must be registered, visible to the authenticated user, and compatible with the supplied filters.
-
-
-
diff --git a/docs/categorical/defining-categorical-stats.md b/docs/categorical/defining-categorical-stats.md
deleted file mode 100644
index 043f1e2..0000000
--- a/docs/categorical/defining-categorical-stats.md
+++ /dev/null
@@ -1,191 +0,0 @@
----
-title: Defining categorical stats
-sidebar_position: 2
----
-
-A categorical stat repository supplies the records to aggregate, the field to group, the category labels, and the date column used by date-range filters.
-
-## Generate a stat
-
-The generator creates the repository and adds it to an existing `CategoricalStats::register()` map in `AppServiceProvider`. It verifies the generated metric-to-class mapping before reporting that registration succeeded. If the provider cannot be updated automatically, the command prints the import and registration snippet to add manually.
-
-```bash
-php artisan stats:categorical PaymentsByCustomer Payment Customer customer_id
-```
-
-The arguments are:
-
-1. `name`: the stat class name.
-2. `model`: the model used for the base query.
-3. `category_model`: the model used as the category provider.
-4. `category_id_field`: the category ID field on the base query model.
-
-The command creates a count stat by default. Use `--type=sum` for a sum stat:
-
-```bash
-php artisan stats:categorical PaymentAmountsByCustomer \
- Payment Customer customer_id \
- --type=sum
-```
-
-You may use fully qualified model classes, model names from `App\Models`, or registered morph names. An unqualified category field such as `customer_id` is generated as `payments.customer_id`. Pass an already-qualified field when the query uses a join or alias.
-
-Additional options:
-
-- `--force` or `-f` overwrites the generated file.
-- `--path` or `-p` changes the output directory.
-- `--type` or `-t` accepts `count` or `sum`.
-
-The generated date field is the base model's qualified `created_at` column. Update `getDateField()` if the stat should use another timestamp. For a sum stat, also complete `getFieldToSum()`.
-
-The generated `getCategoryFieldAlias()` returns the category model's morph alias when the model is present in Laravel's morph map. It returns `category` when no morph alias is registered. This name is used for the category ID column in grouped query results.
-
-## Define a count stat manually
-
-Extend `CountCategoricalStatsRepository`:
-
-```php
-whereNotNull('paid_at');
- }
-
- public function categoryProvider(): mixed
- {
- return Customer::class;
- }
-
- public function getCategoryField(): string
- {
- return 'payments.customer_id';
- }
-
- public function getCategoryFieldAlias(): string
- {
- return 'customer';
- }
-
- public function getDateField(): string
- {
- return 'payments.paid_at';
- }
-
- public function getAggregateFieldName(): string
- {
- return 'count';
- }
-
- public function canView(?Authorizable $user = null): bool
- {
- return $user?->can('view_payment_stats') ?? false;
- }
-}
-```
-
-`query()` may contain joins, scopes, or base constraints. `getCategoryField()` must return the database field whose values match the IDs supplied by `categoryProvider()`. It may also return a trusted SQL expression, such as `COALESCE(payments.customer_id, 0)` or a `CASE` expression; the repository uses the expression for selecting, grouping, and ordering. Do not build this value from request or other user-controlled input. `getCategoryFieldAlias()` controls the category ID attribute on grouped and formatted results and defaults to `category`.
-
-The corresponding category-name attribute defaults to `_name`. Override it independently when needed:
-
-```php
-public function getCategoryNameFieldAlias(): string
-{
- return 'customer_name';
-}
-```
-
-CSV headings default to the titles supplied by the category provider. Override them for a specific repository with:
-
-```php
-public function getCategoryTitle(): string
-{
- return __('Customer');
-}
-
-public function getCategoryIdTitle(): string
-{
- return __('Customer Number');
-}
-```
-
-`getAggregateFieldName()` is used as the result key and to generate the default translated label. Override `getAggregateFieldLabel()` when you need a custom label.
-
-## Define a sum stat
-
-Extend `SumCategoricalStatsRepository` and return the numeric field from `getFieldToSum()`:
-
-```php
-use Javaabu\Stats\Repositories\Categorical\SumCategoricalStatsRepository;
-
-class PaymentAmountsByCustomer extends SumCategoricalStatsRepository
-{
- // query(), categoryProvider(), getCategoryField(), getDateField(),
- // and canView() are the same as the count example.
-
- public function getFieldToSum(): string
- {
- return 'payments.amount';
- }
-
- public function getAggregateFieldName(): string
- {
- return 'amount';
- }
-}
-```
-
-For another SQL aggregate, extend `AbstractCategoricalStatsRepository` and implement `getAggregateSql()`.
-
-## Register the stat
-
-Register the repository using a stable metric name:
-
-```php
-use App\Stats\Categorical\PaymentsByCustomer;
-use Javaabu\Stats\CategoricalStats;
-
-CategoricalStats::register([
- 'payments_by_customer' => PaymentsByCustomer::class,
-]);
-```
-
-## Use a stat in PHP
-
-Create the repository from its metric and a date range:
-
-```php
-use Javaabu\Stats\CategoricalStats;
-use Javaabu\Stats\Enums\CategoricalModes;
-use Javaabu\Stats\Enums\PresetDateRanges;
-
-$stat = CategoricalStats::createFromMetric(
- 'payments_by_customer',
- PresetDateRanges::THIS_MONTH,
-);
-
-$rows = $stat->results();
-
-$formatted = $stat->format(
- 'default',
- mode: CategoricalModes::ALL,
-);
-```
-
-The available category modes are:
-
-- `NON_EMPTY`: categories present in the primary or comparison results.
-- `ALL`: every item from the provider, with missing values filled with zero.
-- `SPECIFIC`: only the IDs supplied in `categorical_values`.
-
-Built-in formats are `default`, `combined`, and `chartjs`. Register additional formatter classes with `CategoricalStats::registerFormatters()`.
diff --git a/docs/categorical/filters-and-model-specific-stats.md b/docs/categorical/filters-and-model-specific-stats.md
deleted file mode 100644
index 3a5bdc0..0000000
--- a/docs/categorical/filters-and-model-specific-stats.md
+++ /dev/null
@@ -1,134 +0,0 @@
----
-title: Filters and model-specific stats
-sidebar_position: 4
----
-
-Filters let one categorical repository serve both a global stats page and pages scoped to a specific model, tenant, status, or other application value.
-
-## Define allowed filters
-
-Return the filters accepted by the repository from `allowedFilters()`:
-
-```php
-use Javaabu\Stats\Filters\StatsFilter;
-
-public function allowedFilters(): array
-{
- return [
- StatsFilter::exact('customer', 'payments.customer_id'),
- StatsFilter::scope('status'),
- ];
-}
-```
-
-The keys sent by the page or API must match the filter names. Filters are applied to the primary result, comparison result, graph, and export.
-
-Only repositories that allow every supplied filter are included in the metric selector:
-
-```php
-use Javaabu\Stats\CategoricalStats;
-
-$metrics = CategoricalStats::getMetricNames(
- ['customer' => $customer->id],
- auth()->user(),
-);
-```
-
-Use `allowedMetrics()` instead when you only need a list of metric identifiers.
-
-## Add model-specific routes
-
-Create GET and POST routes for the page and export:
-
-```php
-use App\Http\Controllers\Admin\CustomersController;
-
-Route::get('customers/{customer}/categorical-stats', [CustomersController::class, 'categoricalStats'])
- ->name('admin.customers.categorical-stats');
-
-Route::post('customers/{customer}/categorical-stats', [CustomersController::class, 'categoricalStatsExport'])
- ->name('admin.customers.categorical-stats.export');
-```
-
-## Add controller actions
-
-Use `ExportsCategoricalStats` for the CSV response:
-
-```php
-use App\Models\Customer;
-use Javaabu\Stats\Concerns\ExportsCategoricalStats;
-use Javaabu\Stats\Http\Requests\CategoricalStatsRequest;
-
-class CustomersController extends Controller
-{
- use ExportsCategoricalStats;
-
- protected function categoricalStatsFilters(Customer $customer): array
- {
- return [
- 'customer' => $customer->id,
- ];
- }
-
- public function categoricalStats(Customer $customer)
- {
- $this->authorize('viewStats', $customer);
-
- return view('admin.customers.categorical-stats', [
- 'customer' => $customer,
- 'filters' => $this->categoricalStatsFilters($customer),
- ]);
- }
-
- public function categoricalStatsExport(
- Customer $customer,
- CategoricalStatsRequest $request
- ) {
- $this->authorize('viewStats', $customer);
-
- return $this->exportCategoricalStats(
- $request,
- $this->categoricalStatsFilters($customer),
- $customer->name.' Customer',
- );
- }
-}
-```
-
-The filters passed by the controller are merged with filters submitted by the component. The export helper validates that the selected metric is visible and supports the fixed filters.
-
-## Render the component
-
-```bladehtml
-
-```
-
-Supported attributes:
-
-- `url`: form action for CSV exports. Defaults to the export route registered by `CategoricalStats::registerRoutes()`.
-- `api-url`: endpoint used to generate graph data. Defaults to the graph route registered by `CategoricalStats::registerApiRoutes()`.
-- `filters`: associative array of fixed filters.
-- `metrics`: optional metric-to-label map. By default, the component loads authorized metrics compatible with `filters`.
-- `user`: user used for metric authorization. Defaults to the authenticated user.
-- `framework`: form framework passed to the base stats component.
-
-The component forwards `filters` to both graph requests and category-search requests. Changing the selected metric updates the category selector to search that metric's provider.
-
-## Supply a custom metric list
-
-Pass a metric map when the page should show only a subset:
-
-```bladehtml
-
-```
-
-Only include metrics that are registered and authorized for the selected user and filters.
-
-
diff --git a/docs/categorical/getting-started.md b/docs/categorical/getting-started.md
deleted file mode 100644
index a33d5b6..0000000
--- a/docs/categorical/getting-started.md
+++ /dev/null
@@ -1,109 +0,0 @@
----
-title: Getting started
-sidebar_position: 1
----
-
-Categorical stats group records by a category and calculate either a count or sum for each category. For example, you can display payment counts by customer, order totals by status, or signups by source.
-
-## Prerequisites
-
-The included graph view uses Chart.js v4. The category selector uses the Select2 integration provided by `javaabu/forms`. Make Chart.js available at `public/vendors/chart.js/chart.umd.js`, or publish and customize the package views if your application loads it elsewhere.
-
-## Register the routes
-
-Add the categorical API routes to `routes/api.php` inside the middleware group used by your application:
-
-```php
-use Javaabu\Stats\CategoricalStats;
-
-Route::middleware(['auth:sanctum'])->group(function () {
- CategoricalStats::registerApiRoutes();
-});
-```
-
-This registers:
-
-- `GET /stats/categorical` for graph data.
-- `GET /stats/categorical/categories` for the paginated category selector.
-
-Add the page and CSV export routes to `routes/web.php` or your admin route file:
-
-```php
-use Javaabu\Stats\CategoricalStats;
-
-Route::middleware(['auth'])->group(function () {
- CategoricalStats::registerRoutes();
-});
-```
-
-This registers:
-
-- `GET /stats/categorical` for the categorical stats page.
-- `POST /stats/categorical` for CSV exports.
-
-Pass custom URLs, route names, or middleware when the defaults do not match your application:
-
-```php
-CategoricalStats::registerApiRoutes(
- url: '/api/admin/stats/categorical',
- name: 'api.admin.stats.categorical',
- categories_name: 'api.admin.stats.categorical.categories',
- middleware: ['auth:sanctum', 'stats.view-categorical'],
-);
-
-CategoricalStats::registerRoutes(
- url: '/admin/stats/categorical',
- index_name: 'admin.stats.categorical',
- export_name: 'admin.stats.categorical.export',
- middleware: ['auth', 'can:view_stats', 'stats.view-categorical'],
-);
-```
-
-## Register a metric
-
-Register each categorical stat in a service provider, usually `AppServiceProvider::boot()`:
-
-```php
-use App\Stats\Categorical\PaymentsByCustomer;
-use Javaabu\Stats\CategoricalStats;
-
-CategoricalStats::register([
- 'payments_by_customer' => PaymentsByCustomer::class,
-]);
-```
-
-The array key is the metric sent to the graph, export, and category-search endpoints.
-
-## Render the stats component
-
-The package page renders the component automatically. To place the generator in another view, use:
-
-```bladehtml
-
-```
-
-Only metrics authorized for the selected user are displayed. See [Defining categorical stats](./defining-categorical-stats.md) for repository examples and [Filters and model-specific stats](./filters-and-model-specific-stats.md) for embedding filtered stats in another page.
-
-## Configuration
-
-Publish the package configuration when you need to change the defaults:
-
-```bash
-php artisan vendor:publish \
- --provider="Javaabu\Stats\StatsServiceProvider" \
- --tag="stats-config"
-```
-
-The categorical options in `config/stats.php` are:
-
-```php
-'default_categorical_mode' => \Javaabu\Stats\Enums\CategoricalModes::NON_EMPTY,
-'categorical_items_per_page' => 15,
-'categorical_stats_view' => 'stats::material-admin-26.categorical-stats.index',
-```
-
-`categorical_items_per_page` controls the default page size of the category selector API. The API also accepts a `per_page` value up to 100.
-
-Categorical stats share `default_date_range`, `framework`, `default_layout`, and `scripts_stack` with time-series stats.
-
-
diff --git a/docs/changelog.md b/docs/changelog.md
index 163f17c..2aae95f 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -3,4 +3,6 @@ title: Changelog
sidebar_position: 1.6
---
-All notable changes to this package are documented on [GitHub](https://github.com/Javaabu/stats/blob/main/CHANGELOG.md)
+All notable changes to this package are documented in [CHANGELOG.md](https://github.com/Javaabu/stats/blob/main/CHANGELOG.md) on GitHub. The file also ships with the package, at `vendor/javaabu/stats/CHANGELOG.md`.
+
+Moving from v1 to v2? Read [Upgrading](./upgrading.md).
diff --git a/docs/contributing-to-docs.md b/docs/contributing-to-docs.md
index f2e3809..55c90a4 100644
--- a/docs/contributing-to-docs.md
+++ b/docs/contributing-to-docs.md
@@ -8,4 +8,6 @@ Should you come across any glitches in our documentation, your discerning eye is
For your reference, each project's documentation resides snugly in the /docs folder. That's the hub where everything documentation-related unfolds. Just so you're in the loop, we employ [Docusaurus](https://docusaurus.io) to transform those markdown files into the HTML served to the public. And since we are using Docusaurus, there's a lot of special magic that you can use to make the docs even better. Here are a few resources that will be useful:
- [Markdown Front Matter](https://docusaurus.io/docs/markdown-features#front-matter)
- [Admonitions](https://docusaurus.io/docs/markdown-features/admonitions)
-- [Sidebar Metadata](https://docusaurus.io/docs/sidebar/autogenerated#autogenerated-sidebar-metadata)
\ No newline at end of file
+- [Sidebar Metadata](https://docusaurus.io/docs/sidebar/autogenerated#autogenerated-sidebar-metadata)
+
+The docs are read by people and by AI coding assistants. Keep examples complete and runnable, and check every method, option and config key against the code.
diff --git a/docs/installation-and-setup.md b/docs/installation-and-setup.md
index 6a8f396..439ea88 100644
--- a/docs/installation-and-setup.md
+++ b/docs/installation-and-setup.md
@@ -3,143 +3,236 @@ title: Installation & Setup
sidebar_position: 1.2
---
-You can install the package via composer:
-
```bash
composer require javaabu/stats
```
-# Publishing the config file
+The service provider registers itself, and so does the `Stats` facade. There is no migration: stats read your own tables.
+
+:::note Before the v2 release
-Publishing the config file is optional:
+v2 is developed on the `next-gen` branch and depends on unreleased Javaabu packages. Until it's tagged, add the repositories and require the dev version:
+
+```json
+{
+ "repositories": [
+ { "type": "vcs", "url": "https://github.com/Javaabu/stats" },
+ { "type": "vcs", "url": "https://github.com/Javaabu/filters" },
+ { "type": "vcs", "url": "https://github.com/Javaabu/forms" },
+ { "type": "vcs", "url": "https://github.com/Javaabu/manifest" },
+ { "type": "vcs", "url": "https://github.com/Javaabu/permissions" },
+ { "type": "vcs", "url": "https://github.com/Javaabu/stencil" }
+ ]
+}
+```
```bash
-php artisan vendor:publish --provider="Javaabu\Stats\StatsServiceProvider" --tag="stats-config"
+composer require javaabu/stats:^2.0@dev
```
-This is the default content of the config file:
+Composer only accepts dev versions of packages your app requires itself, so either require the other five with `@dev` too, or set `"minimum-stability": "dev"` and `"prefer-stable": true` in your `composer.json`.
+
+:::
+
+# Publish the config
+
+Publishing the config is optional. Do it to register stats in the config file or change a default:
+
+```bash
+php artisan vendor:publish --tag=stats-config
+```
+
+Every option is described in [Configuration](./advanced-usage/configuration.md).
+
+# Define a stat
```php
true,
-
-
- /*
- |--------------------------------------------------------------------------
- | The locale used for dates
- |--------------------------------------------------------------------------
- |
- | The locale used to format dates
- |
- */
-
- 'date_locale' => 'en_GB',
-
- /*
- |--------------------------------------------------------------------------
- | Date formats for different time modes
- |--------------------------------------------------------------------------
- |
- | Used to format the date when generating stats in different time series modes
- | Uses momentJS compatible formats
- |
- */
-
- 'date_formats' => [
- 'hour' => 'D MMM YY hh:mm A',
- 'day' => 'D MMM YY',
- 'week' => 'gggg - \W\e\e\k w',
- 'month' => 'YYYY MMMM',
- 'year' => 'YYYY',
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Default time series mode
- |--------------------------------------------------------------------------
- |
- | Default mode to show on the stats form
- |
- */
-
- 'default_time_series_mode' => \Javaabu\Stats\Enums\TimeSeriesModes::DAY,
-
- 'default_categorical_mode' => \Javaabu\Stats\Enums\CategoricalModes::NON_EMPTY,
-
- 'categorical_items_per_page' => 15,
-
- /*
- |--------------------------------------------------------------------------
- | Default date range
- |--------------------------------------------------------------------------
- |
- | Default date range to show on stats
- |
- */
-
- 'default_date_range' => \Javaabu\Stats\Enums\PresetDateRanges::LAST_7_DAYS,
-
- /*
- |--------------------------------------------------------------------------
- | Default CSS Framework
- |--------------------------------------------------------------------------
- |
- | This option controls the default CSS framework that will be used by the
- | package when rendering views
- |
- | Supported: "material-admin-26"
- |
- */
-
- 'framework' => 'material-admin-26',
-
- /*
- |--------------------------------------------------------------------------
- | Default Layout
- |--------------------------------------------------------------------------
- |
- | Default layout view for stats views
- |
- */
-
- 'default_layout' => 'layouts.admin',
-
- /*
- |--------------------------------------------------------------------------
- | Scripts Stack
- |--------------------------------------------------------------------------
- |
- | The name of the stack to push scripts
- |
- */
-
- 'scripts_stack' => 'scripts',
-
- /*
- |--------------------------------------------------------------------------
- | View for time series stats
- |--------------------------------------------------------------------------
- |
- | Used in the main time series stats controller
- |
- */
-
- 'time_series_stats_view' => 'stats::material-admin-26.time-series-stats.index',
-
- 'categorical_stats_view' => 'stats::material-admin-26.categorical-stats.index',
-];
+namespace App\Stats\TimeSeries;
+
+use App\Models\Payment;
+use Illuminate\Database\Eloquent\Builder;
+use Javaabu\Stats\Repositories\TimeSeries\CountStatsRepository;
+
+class PaymentsCount extends CountStatsRepository
+{
+ public function query(): Builder
+ {
+ return Payment::query();
+ }
+
+ public function getTable(): string
+ {
+ return 'payments';
+ }
+
+ public function getAggregateFieldName(): string
+ {
+ return 'payments';
+ }
+}
+```
+
+Or generate it: `php artisan stats:time-series PaymentsCount Payment`.
+
+Register it under a metric name, in the config or a service provider:
+
+```php
+// config/stats.php
+'time_series_stats' => [
+ 'payments_count' => App\Stats\TimeSeries\PaymentsCount::class,
+],
+```
+
+```php
+// AppServiceProvider::boot()
+use App\Stats\TimeSeries\PaymentsCount;
+use Javaabu\Stats\TimeSeriesStats;
+
+TimeSeriesStats::register([
+ 'payments_count' => PaymentsCount::class,
+]);
+```
+
+See [Time series stats](./basic-usage/time-series-stats.md) and [Categorical stats](./basic-usage/categorical-stats.md) for every option.
+
+# Add the stats pages
+
+Register the routes where you want them:
+
+```php
+// routes/web.php
+Route::stats(); // /stats/time-series and /stats/categorical, auth middleware, stats.* names
+
+// or, for an admin portal on its own guard
+Route::stats(['guard' => 'web_admin', 'prefix' => 'admin/stats', 'name' => 'admin.stats']);
+```
+
+The routes need the `web` middleware group (sessions and CSRF for the CSV download); `routes/web.php` already has it.
+
+Sync the permissions and give `view_stats` to a role:
+
+```bash
+php artisan permissions:sync
+```
+
+Users with `view_stats` can now open `/stats/time-series`. See [Stats pages](./basic-usage/stats-pages.md) and [Permissions and policies](./advanced-usage/permissions-and-policies.md).
+
+## Blade
+
+Blade is the default. The pages extend `layouts.app` and fill its `content` section. Change both in the config:
+```php
+'layout' => 'layouts.admin',
+'layout_section' => 'main',
+```
+
+The charts are SVG drawn on the server, so there is nothing to build. Their stylesheet (`stats::charts`) ships prebuilt; javaabu/manifest adds it to every page that shows a chart, before ``. If you place manifest's tags yourself, keep `@manifestStyles` in the layout's ``:
+
+```blade
+{{-- resources/views/layouts/app.blade.php --}}
+
+
+
+ @manifestStyles
+
+
+ @yield('content')
+ @manifestScripts
+
+
+```
+
+If your app builds with Vite and has manifest's Vite plugin, the stylesheet is built with your app instead of using the prebuilt copy.
+
+The charts use Onu's colour tokens. With javaabu/forms' default `onu` framework they are there already. With `bootstrap-6`, define `--chart-1` to `--chart-8` (and optionally `--card`, `--border`, `--muted-foreground`, `--success`, `--destructive`) in your CSS.
+
+## Inertia React
+
+```bash
+composer require inertiajs/inertia-laravel
```
+
+```php
+// config/stats.php, or STATS_VIEWS=inertia
+'views' => 'inertia',
+```
+
+The pages are `stats::TimeSeries/Index` and `stats::Categorical/Index`, shipped through javaabu/manifest. Add manifest's Vite plugin:
+
+```js
+// vite.config.js
+import react from "@vitejs/plugin-react"
+import laravel from "laravel-vite-plugin"
+import { defineConfig } from "vite"
+import manifest from "./vendor/javaabu/manifest/dist/vite.js"
+
+export default defineConfig({
+ plugins: [laravel({ input: ["resources/css/app.css", "resources/js/app.tsx"], refresh: true }), react(), manifest()],
+})
+```
+
+Resolve pages with `resolvePage()`, and give the package's pages your layout:
+
+```tsx
+// resources/js/app.tsx
+import { createInertiaApp } from "@inertiajs/react"
+import { resolvePage } from "@javaabu/manifest/inertia"
+import { createRoot } from "react-dom/client"
+import AdminLayout from "@/layouts/admin"
+
+createInertiaApp({
+ resolve: async (name) => {
+ const page = await resolvePage(name, import.meta.glob("./pages/**/*.tsx"))
+ page.default.layout ??= (children: React.ReactNode) => {children}
+ return page
+ },
+ setup({ el, App, props }) {
+ createRoot(el).render()
+ },
+})
+```
+
+Install the npm packages the pages and charts use:
+
+```bash
+npm install @javaabu/onu recharts
+php artisan manifest:check
+```
+
+`manifest:check` lists anything still missing (such as `@javaabu/forms`) with the install command.
+
+The pages and Onu are styled with Tailwind CSS 4. Import Onu's stylesheet and add the package's components to your Tailwind sources:
+
+```css
+/* resources/css/app.css */
+@import "tailwindcss";
+@import "@javaabu/onu/tailwind.css";
+@source "../../vendor/javaabu/stats/resources/js";
+```
+
+Wrap your app in Onu's `ThemeProvider` and `OnuProvider` if it doesn't already; see [Onu's installation](https://github.com/Javaabu/onu/blob/main/docs/installation-and-setup.md).
+
+To use the React components in your own pages, import them as `@javaabu/stats`:
+
+```tsx
+import { StatsChart, StatsTile } from "@javaabu/stats"
+```
+
+Run `php artisan manifest:types` so TypeScript knows the alias. See [Charts](./basic-usage/charts.md).
+
+# Publishing
+
+| Tag | What |
+| --- | --- |
+| `stats-config` | `config/stats.php` |
+| `stats-views` | The Blade views, to `resources/views/vendor/stats` |
+| `stats-translations` | `lang/vendor/stats` |
+| `stats-stubs` | The generator stubs, to `stubs/vendor/stats` |
+
+```bash
+php artisan vendor:publish --tag=stats-config
+```
+
+See [Overriding controllers and views](./advanced-usage/overriding-controllers-and-views.md) and [Dhivehi and RTL](./advanced-usage/dhivehi-and-rtl.md).
diff --git a/docs/intro.md b/docs/intro.md
index 4ada207..f3682a0 100644
--- a/docs/intro.md
+++ b/docs/intro.md
@@ -5,4 +5,70 @@ sidebar_position: 1.0
# Stats
-[Stats](https://github.com/Javaabu/stats) Simplifies stats generation for Laravel. This package allows you to easily define time series statistics and visualize them through an interactive graph.
+[javaabu/stats](https://github.com/Javaabu/stats) turns your Eloquent data into statistics: how many payments came in each day, how much was paid per island, how this month compares with the last one.
+
+- **Defined once.** A stat is a small class: a query, a date column, and what to count or sum. Register it under a metric name (`payments_count`) and use it everywhere.
+- **Two kinds of stats.** Time series stats group a value by hour, day, week, month or year. Categorical stats group it by a category: a status, an island, a customer.
+- **Charts in PHP.** `TimeSeriesChart`, `CategoryChart`, `PieChart`, `Sparkline` and `StatTile` describe a chart in PHP. Blade draws it as SVG with no JavaScript; React draws it with [Onu](https://github.com/Javaabu/onu)'s chart components. Plot several stats together, several periods, or compare with the previous period.
+- **Stats pages for free.** `Route::stats()` adds a time series page and a categorical page: a form to pick the stat, the dates and the filters, the chart, and a CSV download. In Blade or Inertia React.
+- **Dashboards that stay fast.** Chart data is cached, invalidated when your models change, and can be computed ahead of time with `stats:warm`.
+- **Filters.** Let a stat be filtered by a column, a scope, a closure or a [javaabu/filters](https://github.com/Javaabu/filters) filter, which also adds a field to the stats form.
+- **Any SQL database.** MySQL, MariaDB, PostgreSQL and SQLite.
+- **Dhivehi.** Every string is translated, the charts work right to left, and CSV files open with Thaana intact.
+
+```php
+namespace App\Stats\TimeSeries;
+
+use App\Models\Payment;
+use Illuminate\Database\Eloquent\Builder;
+use Javaabu\Stats\Repositories\TimeSeries\CountStatsRepository;
+
+class PaymentsCount extends CountStatsRepository
+{
+ public function query(): Builder
+ {
+ return Payment::query()->whereNotNull('paid_at');
+ }
+
+ public function getTable(): string
+ {
+ return 'payments';
+ }
+
+ public function getDateFieldName(): string
+ {
+ return 'paid_at';
+ }
+
+ public function getAggregateFieldName(): string
+ {
+ return 'payments';
+ }
+
+ public function getName(): string
+ {
+ return __('ފައިސާ ދެއްކުން');
+ }
+}
+```
+
+```php
+use Javaabu\Stats\Charts\StatTile;
+use Javaabu\Stats\Charts\TimeSeriesChart;
+use Javaabu\Stats\Enums\PresetDateRanges;
+
+$tile = StatTile::make('payments_count')->range(PresetDateRanges::THIS_MONTH)->compare()->sparkline();
+$chart = TimeSeriesChart::make('payments_count')->range(PresetDateRanges::LAST_30_DAYS)->compare()->area();
+```
+
+```blade
+
+
+```
+
+```tsx
+
+
+```
+
+Start with [Installation & Setup](./installation-and-setup.md), then [Time series stats](./basic-usage/time-series-stats.md) and [Charts](./basic-usage/charts.md). Coming from v1? Read [Upgrading](./upgrading.md).
diff --git a/docs/metadata.json b/docs/metadata.json
index 16a4217..0c52fab 100644
--- a/docs/metadata.json
+++ b/docs/metadata.json
@@ -1,5 +1,5 @@
{
"name": "Stats",
- "description": "Simplifies stats generation for Laravel",
- "version": "1"
+ "description": "Time series and categorical stats for Laravel: define a stat once, then chart it in Blade or Inertia React, compare periods, cache dashboards and export CSV",
+ "version": "2"
}
diff --git a/docs/questions-and-security.md b/docs/questions-and-security.md
index c48c95f..cb63cd1 100644
--- a/docs/questions-and-security.md
+++ b/docs/questions-and-security.md
@@ -3,6 +3,6 @@ title: Questions and Security
sidebar_position: 1.5
---
-Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving this package? Feel free to create an [issue](../../issues) on GitHub, we'll try to address it as soon as possible.
+Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving this package? Feel free to create an [issue](https://github.com/Javaabu/stats/issues) on GitHub, we'll try to address it as soon as possible.
If you've found a bug regarding security please mail [info@javaabu.com](mailto:info@javaabu.com) instead of using the issue tracker.
diff --git a/docs/requirements.md b/docs/requirements.md
index 73bd6c0..abe469f 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -3,7 +3,30 @@ title: Requirements
sidebar_position: 1.1
---
-This package requires the following:
+- PHP 8.4 or 8.5
+- Laravel 12 or 13
+- MySQL, MariaDB, PostgreSQL or SQLite. Other databases need a little code; see [Databases](./advanced-usage/databases.md).
+- [javaabu/forms](https://github.com/Javaabu/forms) v2, [javaabu/stencil](https://github.com/Javaabu/stencil) v1, [javaabu/filters](https://github.com/Javaabu/filters) v1, [javaabu/permissions](https://github.com/Javaabu/permissions) v3 and [javaabu/manifest](https://github.com/Javaabu/manifest) v1, installed with this package
-- Laravel 9.0 or higher
-- PHP 8.1 or higher
+Optional:
+
+| Package | For |
+| --- | --- |
+| `inertiajs/inertia-laravel` ^2.0 | The Inertia React stats pages (`stats.views` = `inertia`). See [Stats pages](./basic-usage/stats-pages.md). |
+| `spatie/laravel-activitylog` ^4.10 | The built-in `user_logins` stat, and any stat you build on `LoginsRepository` or `ActivityLogStatsRepository`. See [Time series stats](./basic-usage/time-series-stats.md#built-in-stats). |
+
+# Front end
+
+The Blade charts and pages need no JavaScript and no build step: their stylesheet ships prebuilt and javaabu/manifest adds it to the page. The charts take their colours from [Onu](https://github.com/Javaabu/onu)'s design tokens (`--chart-1` to `--chart-8`, `--card`, `--border`, …), which javaabu/forms' Onu styles provide.
+
+The React charts and the Inertia pages are built by your app's Vite, through manifest's Vite plugin. They need:
+
+| npm package | Version |
+| --- | --- |
+| `@javaabu/onu` | ^0.2.1 |
+| `recharts` | ^3 |
+| `@javaabu/forms` | The next-gen branch (`github:Javaabu/react-forms#next-gen`) until it's released |
+| `react`, `react-dom` | ^19.0 |
+| `@inertiajs/react` | ^2.0 or ^3.0, for the Inertia pages |
+
+`php artisan manifest:check` compares these with your `package.json` and prints the install command for anything missing. Onu also needs Tailwind CSS 4 and its providers; see [Onu's installation](https://github.com/Javaabu/onu/blob/main/docs/installation-and-setup.md).
diff --git a/docs/time-series/_category_.json b/docs/time-series/_category_.json
deleted file mode 100644
index 485e31c..0000000
--- a/docs/time-series/_category_.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "position": 1.3,
- "label": "Time Series Stats",
- "collapsible": true,
- "collapsed": false,
- "link": {
- "type": "generated-index",
- "slug": "/stats/_categories/time-series",
- "description": "How to define and use time series statistics"
- }
-}
diff --git a/docs/time-series/basic-concepts.md b/docs/time-series/basic-concepts.md
deleted file mode 100644
index cdccf31..0000000
--- a/docs/time-series/basic-concepts.md
+++ /dev/null
@@ -1,101 +0,0 @@
----
-title: Basic Concepts
-sidebar_position: 2
----
-
-Time series statistics are any statistic that varies over time. Using time series stats, you can track any numerical value that changes over time within a given time period. For example, this could be daily user signups, weekly sales, etc. Currently, this package allows viewing time series stats in the following modes:
-
-- Hour
-- Day
-- Week
-- Month
-- Year
-
-# Instantiating a stat class
-
-To use a Time Series Stat Repository, you need to make a new instance of the class by providing a date range to generate the stats for.
-The data range should be either a `\Javaabu\Stats\Enums\PresetDateRanges` enum or an `\Javaabu\Stats\Support\ExactDateRange` object.
-
-For preset date ranges, the package offers the following presets:
-- `TODAY`
-- `YESTERDAY`
-- `THIS_WEEK`
-- `LAST_WEEK`
-- `THIS_MONTH`
-- `LAST_MONTH`
-- `THIS_YEAR`
-- `LAST_YEAR`
-- `LAST_7_DAYS`
-- `LAST_14_DAYS`
-- `LAST_30_DAYS`
-- `LIFETIME`
-
-For exact date ranges, you can provide your own start and end date:
-
-```php
-use \Javaabu\Stats\Support\ExactDateRange;
-
-$date_range = new ExactDateRange('2024-07-30 12:32:00', '2024-08-02 13:42:00');
-```
-
-Once you've a date range, you can instantiate a new instance of the stat:
-
-```php
-use \Javaabu\Stats\Repositories\TimeSeries\UserLoginsRepository;
-use \Javaabu\Stats\Enums\PresetDateRanges;
-
-$stat = new UserLoginsRepository(PresetDateRanges::LAST_7_DAYS);
-```
-
-You can also instantiate a stat using the `TimeSeriesStats::createFromMetric()` method. When calling this method, you have to use the registered metric name for the stat.
-
-```php
-use \Javaabu\Stats\TimeSeriesStats;
-use \Javaabu\Stats\Enums\PresetDateRanges;
-
-$stat = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS);
-```
-
-# Getting the results from a stat class
-
-Once you have instantiated a stat class, you can use the stat class to get the results in any of the available Time Series modes:
-
-```php
-use \Javaabu\Stats\Enums\TimeSeriesModes;
-
-$results = $stat->results(TimeSeriesModes::DAY); // returns a collection
-```
-
-Note that when calling the `results` method, the returned collection will not have any data for missing days (or hours, weeks, etc. depending on the mode you're using) within the given date range.
-
-# Formatting the results
-
-To have the missing days also included as `0` values, you can format your results using a formatter that fills the missing days:
-
-```php
-$formatted_results = $stat->format(
- 'combined', // which format to use
- TimeSeriesModes::DAY); // returns an array
-```
-
-# Filtering the results
-
-Some stats will also allow you to filter the results using certain allowed filter values. For example, the `UserLoginsRepository` allows filtering by a specific user. To filter the results, you can provide an array of filters when you instantiate the stat.
-
-```php
-use \Javaabu\Stats\TimeSeriesStats;
-use \Javaabu\Stats\Enums\PresetDateRanges;
-
-$stat = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS, ['user' => 2]);
-$filtered_results = $stat->results(TimeSeriesModes::DAY);
-```
-
-# Getting the total for the date range
-
-You can get the total value for your given date range by calling the `total()` method of the stat.
-
-```php
-$total = $stat->total();
-```
-
-
diff --git a/docs/time-series/defining-time-series-stats.md b/docs/time-series/defining-time-series-stats.md
deleted file mode 100644
index 2a2c2ba..0000000
--- a/docs/time-series/defining-time-series-stats.md
+++ /dev/null
@@ -1,271 +0,0 @@
----
-title: Defining time series stats
-sidebar_position: 3
----
-
-To define a time series stat, you have to provide the query for each of the available time series modes, and a query to get the total for the full date range.
-Luckily, this package makes the process easy for you by providing a set of abstract Stat Repository classes that you can extend to define your stat.
-
-# Aggregate Stats
-
-You can define an Aggregate Stat like so:
-
-```php
-getAggregateFieldName();
- }
-
- public function getTable(): string
- {
- // this is the base table for your query
- return 'payments';
- }
-
- public function getAggregateFieldName(): string
- {
- // this is the name of the aggregate field,
- // which will be also used to generate the
- // legend for the stats graph
- return 'transactions';
- }
-}
-```
-
-# Registering your stat
-
-Once your stat class is defined, you can register it so that the stat will appear in the stat graph. You can register stats, by calling the `TimeSeriesStats::register()` method in your `AppServiceProvider`'s `boot` method. When registering the stat, you should provide a unique metric name for each stat.
-
-```php
-use \Javaabu\Stats\TimeSeriesStats;
-
-class AppServiceProvider extends ServiceProvider
-{
- /**
- * Bootstrap any application services.
- */
- public function boot(): void
- {
- TimeSeriesStats::register([
- 'payments_count' => \App\Stats\TimeSeries\PaymentsCountRepository::class,
- 'payments_amount' => \App\Stats\TimeSeries\PaymentsAmountRepository::class,
- ]);
- }
-}
-```
-
-# Modifying the date field
-
-By default, the `created_at` field is used to determine the date when generating the results. You can modify this by overriding the `getDateFieldName` method of your stat class.
-
-```php
-...
-class PaymentsCountRepository extends AggregateStatsRepository
-{
- public function getDateFieldName(): string
- {
- return 'paid_at';
- }
-...
-```
-
-# Modifying the stat name
-
-By default, the name displayed for the stat in the stats page is generated using the name of the stat class. To customise this name, you can override the `getName` method of your stat class.
-
-```php
-...
-class PaymentsCountRepository extends AggregateStatsRepository
-{
- public function getName(): string
- {
- return __('Successful Payment Transactions');
- }
-...
-```
-
-# Modifying who can view the stat
-
-By default, a stat can be viewed by a user with the `view_stats` permission. To modify this, you can override the `canView` method of your stat class.
-
-```php
-...
-class PaymentsCountRepository extends AggregateStatsRepository
-{
- public function canView(?Authorizable $user = null): bool
- {
- return $user && $user->can('viewStats', Payment::class);
- }
-...
-```
-
-# Count Stats
-
-`CountStatsRepository` is an abstract stat class that extends the `AggregateStatsRepository` class. Count Stats can be used to easily display the count of some value over a given time period. You can define a count stat like so:
-
-```php
-paid();
- }
-
- public function getTable(): string
- {
- return 'payments';
- }
-
- public function getAggregateFieldName(): string
- {
- return 'transactions';
- }
-}
-```
-
-The above example will show the count of `paid` payments over time.
-
-## Using artisan to generate a count stat
-
-Instead of manually creating a count stat class, you can use the provided `stats:time-series` command to generate a count stat for a given model.
-
-```bash
-php artisan stats:time-series PaymentsCount App\Models\Payment --type=count
-```
-
-The first argument is the name of your stat class and the 2nd argument is the model you're generating the stat for. This will create a `PaymentsCount.php` file inside the `App\Stats\TimeSeries` directory. It will also register the stat in your `AppServiceProvider`. For the automatic stat registration to work, you should already have a call to `TimeSeriesStats::register` in your `AppServiceProvider`.
-
-Note that you for count stats, you can ommit the `--type=count` as `count` is the default option for the command.
-
-```bash
-php artisan stats:time-series PaymentsCount App\Models\Payment
-```
-
-Also, you can ommit the `App\Models` namespace for models in the default Models directory.
-
-```bash
-php artisan stats:time-series PaymentsCount Payment
-```
-
-Alternatively, you can use the model morph name as well.
-
-```bash
-php artisan stats:time-series PaymentsCount payment
-```
-
-# Sum Stats
-
-`SumStatsRepository` is an abstract stat class that extends the `AggregateStatsRepository` class. Sum Stats can be used to easily display the sum of some value over a given time period. You can define a sum stat like so:
-
-```php
-paid();
- }
-
- public function getTable(): string
- {
- return 'payments';
- }
-
- public function getFieldToSum(): string
- {
- return 'amount';
- }
-
- public function getAggregateFieldName(): string
- {
- return 'amount_received_mvr';
- }
-}
-```
-
-The above example will show the sum of the `amount` for `paid` payments over time.
-
-## Using artisan to generate a sum stat
-
-Instead of manually creating a sum stat class, you can use the provided `stats:time-series` command to generate a sum stat for a given model.
-
-```bash
-php artisan stats:time-series PaymentAmounts payment --type=sum
-```
-
-# Login Stats
-
-`LoginsRepository` is an abstract stat class that can be used to display login counts for a specific user type. This stat uses data from [`spatie/laravel-activitylog`](https://github.com/spatie/laravel-activitylog) to generate the results. So you will need the `spatie/laravel-activitylog` package to use this type of stats.
-
-```php
-where('name', $value);
- }),
-```
-
-The closure will get access to 3 values, which is the `$query` builder instance, the filter `$value` and the `$stat` class.
-
-# Getting stats that allow only a specific set of filters
-
-For some use cases, you might want to retrieve all stats that allows a certain filter or set of filters. For example, you might want to display all stats that has a `customer` filter. To do this, you can call the `TimeSeriesStats::metricsThatAllowFilters` method.
-
-```php
-$customer_stats = \Javaabu\Stats\TimeSeriesStats::metricsThatAllowFilters(['customer']);
-```
-
-You can also pass a `user` object to this method to get stats that are visible to that user.
-
-```php
-$customer_stats = \Javaabu\Stats\TimeSeriesStats::metricsThatAllowFilters(['customer'], auth()->user());
-```
-
-By default, `metricsThatAllowFilters` will return an associative array of stat metric names and the stat display name. If you want to instead return either the stat class names or metric names only, you can set the return type using the 3rd argument.
-
-```php
-use Javaabu\Stats\Enums\StatListReturnType;
-use \Javaabu\Stats\TimeSeriesStats;
-
-$only_metric_names = TimeSeriesStats::metricsThatAllowFilters(['customer'], auth()->user(), StatListReturnType::METRIC);
-$metric_name_and_class_name = TimeSeriesStats::metricsThatAllowFilters(['customer'], auth()->user(), StatListReturnType::METRIC_AND_CLASS);
-```
diff --git a/docs/time-series/formatters.md b/docs/time-series/formatters.md
deleted file mode 100644
index cfdc8ab..0000000
--- a/docs/time-series/formatters.md
+++ /dev/null
@@ -1,310 +0,0 @@
----
-title: Formatters
-sidebar_position: 5
----
-
-Formatters allow formatting the results from your stats classes to different formats.
-
-```php
-$formatted_results = $stat->format('default', TimeSeriesModes::DAY);
-```
-
-# Instantiating a formatter
-
-You can instantiate a formatter instance by calling the `TimeSeriesStats::createFromFormat` method with the formatter's registered name.
-
-```php
-use \Javaabu\Stats\TimeSeriesStats;
-
-$formatter = TimeSeriesStats::createFromFormat('chartjs');
-```
-
-# Using a formatter
-
-You can use a formatter outside a stat class, by calling the `format` method of the formatter.
-
-```php
-use \Javaabu\Stats\TimeSeriesStats;
-use \Javaabu\Stats\Enums\PresetDateRanges;
-use \Javaabu\Stats\Enums\TimeSeriesModes;
-
-$stat = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS);
-$comparison = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS->getPreviousDateRange());
-
-$formatted_results = $formatter->format(TimeSeriesModes::DAY, $stat, $comparison);
-```
-
-The `format` method accepts a time series mode, a stat class object and another optional stat class object for a comparison period. Note that only certain formatters support comparison stats.
-
-# Available Formats
-
-The package ships with several different formatters that can work with different JS graph libraries.
-
-## default
-
-This is the default formatter which returns an array of `stats` and `compare`. This formatter doesn't fill missing values.
-
-```php
-$formatter = TimeSeriesStats::createFromFormat('default');
-$stat = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS);
-
-$formatted_results = $formatter->format(TimeSeriesModes::DAY, $stat);
-
-// returns the following
-/*
-[
- 'stats' => [
- [
- 'logins' => 5,
- 'day' => '2024-07-03',
- ],
- [
- 'logins' => 2,
- 'day' => '2024-07-04',
- ]
- ],
-
- 'compare' => null
-]
- */
-```
-
-## chartjs
-
-This formatter produces results compatible with the [`chart.js`](https://www.chartjs.org/) JS library. This formatter will fill missing days.
-
-```php
-$formatter = TimeSeriesStats::createFromFormat('chartjs');
-$stat = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS);
-$compare = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS->->getPreviousDateRange());
-
-$formatted_results = $formatter->format(TimeSeriesModes::DAY, $stat, $compare);
-
-// returns the following
-/*
-[
- 'labels' => [
- '28 Jun 24#21 Jun 24',
- '29 Jun 24#22 Jun 24',
- '30 Jun 24#23 Jun 24',
- '1 Jul 24#24 Jun 24',
- '2 Jul 24#25 Jun 24',
- '3 Jul 24#26 Jun 24',
- '4 Jul 24#27 Jun 24',
- ],
-
- 'stats' => [
- 0,
- 0,
- 0,
- 0,
- 0,
- 5,
- 2,
- ],
-
- 'compare' => [
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- ]
-]
- */
-```
-
-## sparkline
-
-This formatter produces results compatible with the [`jquery-sparkline`](https://omnipotent.net/jquery.sparkline) JS library. This formatter will fill missing days. This formatter doesn't support comparison data.
-
-```php
-$formatter = TimeSeriesStats::createFromFormat('sparkline');
-$stat = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS);
-
-$formatted_results = $formatter->format(TimeSeriesModes::DAY, $stat);
-
-// returns the following
-/*
-[
- 0,
- 0,
- 0,
- 0,
- 0,
- 5,
- 2,
-]
- */
-```
-
-## flot
-
-This formatter produces results compatible with the [`flot`](https://www.flotcharts.org/) JS library. This formatter will fill missing days.
-
-```php
-$formatter = TimeSeriesStats::createFromFormat('flot');
-$stat = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS);
-$compare = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS->->getPreviousDateRange());
-
-$formatted_results = $formatter->format(TimeSeriesModes::DAY, $stat, $compare);
-
-// returns the following
-/*
-[
- [0, 0],
- [1, 0],
- [2, 0],
- [3, 0],
- [4, 0],
- [5, 5],
- [6, 2],
-]
- */
-```
-
-## combined
-
-This formatter combines the main stat and the comparison data into a single array. This formatter will fill missing days.
-
-```php
-$formatter = TimeSeriesStats::createFromFormat('combined');
-$stat = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS);
-$compare = TimeSeriesStats::createFromMetric('user_logins', PresetDateRanges::LAST_7_DAYS->->getPreviousDateRange());
-
-$formatted_results = $formatter->format(TimeSeriesModes::DAY, $stat, $compare);
-
-// returns the following
-/*
-[
- [
- 'date_range' => '2024-06-28 00:00 - 2024-07-04 23:59',
- 'day' => '2024-06-28',
- 'logins' => 0,
- ],
- [
- 'date_range' => '2024-06-21 00:00 - 2024-06-27 23:59',
- 'day' => '2024-06-21',
- 'logins' => 0,
- ],
-
- [
- 'date_range' => '2024-06-28 00:00 - 2024-07-04 23:59',
- 'day' => '2024-06-29',
- 'logins' => 0,
- ],
- [
- 'date_range' => '2024-06-21 00:00 - 2024-06-27 23:59',
- 'day' => '2024-06-22',
- 'logins' => 0,
- ],
-
- [
- 'date_range' => '2024-06-28 00:00 - 2024-07-04 23:59',
- 'day' => '2024-06-30',
- 'logins' => 0,
- ],
- [
- 'date_range' => '2024-06-21 00:00 - 2024-06-27 23:59',
- 'day' => '2024-06-23',
- 'logins' => 0,
- ],
-
- [
- 'date_range' => '2024-06-28 00:00 - 2024-07-04 23:59',
- 'day' => '2024-07-01',
- 'logins' => 0,
- ],
- [
- 'date_range' => '2024-06-21 00:00 - 2024-06-27 23:59',
- 'day' => '2024-06-24',
- 'logins' => 0,
- ],
-
- [
- 'date_range' => '2024-06-28 00:00 - 2024-07-04 23:59',
- 'day' => '2024-07-02',
- 'logins' => 0,
- ],
- [
- 'date_range' => '2024-06-21 00:00 - 2024-06-27 23:59',
- 'day' => '2024-06-25',
- 'logins' => 0,
- ],
-
- [
- 'date_range' => '2024-06-28 00:00 - 2024-07-04 23:59',
- 'day' => '2024-07-03',
- 'logins' => 5,
- ],
- [
- 'date_range' => '2024-06-21 00:00 - 2024-06-27 23:59',
- 'day' => '2024-06-26',
- 'logins' => 0,
- ],
-
- [
- 'date_range' => '2024-06-28 00:00 - 2024-07-04 23:59',
- 'day' => '2024-07-04',
- 'logins' => 2,
- ],
- [
- 'date_range' => '2024-06-21 00:00 - 2024-06-27 23:59',
- 'day' => '2024-06-27',
- 'logins' => 0,
- ],
-]
- */
-```
-
-# Defining your own formatter
-
-To define your own formatter, you should extend the `AbstractTimeSeriesStatsFormatter` class and implement the `format` method.
-
-```php
-results($mode);
- $compare = $compare?->results($mode);
-
- return compact('stats', 'compare');
- }
-}
-
-```
-
-After creating your formatter class, you should register the formatter by calling the `TimeSeriesStats::registerFormatters()` method in your `AppServiceProvider`'s `boot` method. When registering the formatter, you should provide a unique name for each formatter.
-
-```php
-use \Javaabu\Stats\TimeSeriesStats;
-
-class AppServiceProvider extends ServiceProvider
-{
- /**
- * Bootstrap any application services.
- */
- public function boot(): void
- {
- TimeSeriesStats::registerFormatters([
- 'custom_format' => \App\Stats\Formatters\CustomFormatter::class,
- ]);
- }
-}
-```
diff --git a/docs/time-series/getting-started.md b/docs/time-series/getting-started.md
deleted file mode 100644
index de4cc7a..0000000
--- a/docs/time-series/getting-started.md
+++ /dev/null
@@ -1,99 +0,0 @@
----
-title: Getting started
-sidebar_position: 1
----
-
-# JavaScript Prerequisites
-
-Before getting started, you need to ensure the following JS libraries has been installed and copied to the `public/vendors` directory:
-
-- [`chart.js v4`](https://www.chartjs.org/)
-- [`jquery-sparkline`](https://omnipotent.net/jquery.sparkline)
-- [`flot`](https://www.flotcharts.org/)
-- [`flot.curvedlines`](http://curvedlines.michaelzinsmaier.de/)
-- [`flot-orderbars`](https://www.npmjs.com/package/flot-orderbars)
-
-If you don't have these libraries already installed, you can install the packages by running the following command:
-
-```bash
-npm install chart.js jquery-sparkline flot flot.curvedlines flot-orderbars --save
-```
-
-If using Javaabu's Laravel Skeleton, these packages should already be installed for you. You should check the `material-admin.config.js` file for any missing libraries and add them there. After adding any missing libraries, you can run `npm run material-admin`.
-
-# Setting up permissions
-
-By default, stats can only be viewed by users that have a `view_stats` permission. If you are using [`spatie/laravel-permission`](https://github.com/spatie/laravel-permission), you can seed a permission for `view_stats` and grant the permission to the users you want to be able to view the stats.
-
-It is possible to define specific permissions for each stat you create, which we will cover how to do later.
-
-# Setting up the API Route
-
-For generating the time series stats graph, the data is loaded from an API. For this, you need to register the API routes for the package.
-
-Add the following code to your `api.php` route file. You can place it as a publicly accessible JSON route. The package will automatically add the `stats.view-time-series` middleware which will ensure users will be allowed to view only stats they're authorized to view.
-
-This will add a `GET {api_base_url}/stats/time-series` endpoint.
-
-```php
-// inside api.php route file
-
-/**
- * Public routes
- */
-Route::group([
- 'middleware' => ['oauth.client:read'],
-], function () {
- /**
- * Public JSON routes
- */
- Route::group([
- 'middleware' => ['json'],
- ], function () {
-
- \Javaabu\Stats\TimeSeriesStats::registerApiRoute();
-
- });
-});
-```
-
-# Setting up the Admin Routes
-
-For viewing all the stats in one place, the package comes with a page for displaying the interactive stats graph and exporting the generated stats. To setup this page, you need to register the admin routes for the page.
-
-Add the following code to your `admin.php` (if using Javaabu's Laravel Skeleton) or to your `web.php` route file.
-This will add a `GET {admin_base_url}/stats/time-series` and a `POST {admin_base_url}/stats/time-series` endpoint.
-
-```php
-// inside admin.php route file
-
-/**
- * Protected routes
- */
-Route::group([
- 'middleware' => ['auth:web_admin', 'active:web_admin', 'password-update-not-required:web_admin'],
-], function () {
-
- /**
- * Stats
- */
- \Javaabu\Stats\TimeSeriesStats::registerRoutes();
-});
-
-```
-
-# Setting up sidebar
-
-If you're using Javaabu's Laravel Skeleton, you should also add a link to the stats page to your admin sidebar.
-
-```php
-// inside AdminSidebar.php
-...
-MenuItem::make(__('Stats'))
- ->controller(\Javaabu\Stats\Http\Controllers\TimeSeriesStatsController::class)
- ->can('view_stats')
- ->icon('zmdi-trending-up'),
-...
-```
-
-
diff --git a/docs/time-series/model-specific-stats.md b/docs/time-series/model-specific-stats.md
deleted file mode 100644
index a73893b..0000000
--- a/docs/time-series/model-specific-stats.md
+++ /dev/null
@@ -1,116 +0,0 @@
----
-title: Displaying model specific stats
-sidebar_position: 6
----
-
-Often times you might want to display a model specific stats in a nested route of the model. For example, you might want to have a page where you can display the stats for a specific customer. This package provides helpful traits and view components to achieve that.
-
-# Preparing your Model Controller
-
-You can add the `ExportsTimeSeriesStats` trait to your controller and add a `statsFilters`, `stats` and an `statsExport` methods like so:
-
-```php
- $customer->id,
- ];
- }
-
- /**
- * This method is used to show the stats page.
- */
- public function stats(Customer $customer)
- {
- // good practice to add some authorization
- // you can modify this as needed
- $this->authorize('viewStats', $customer);
-
- $filters = $this->statsFilters($customer);
-
- return view('admin.customers.stats', compact(
- 'customer',
- 'filters',
- ));
- }
-
- /**
- * This method is used to export the stats
- */
- public function statsExport(Customer $customer, TimeSeriesStatsRequest $request)
- {
- // good practice to add some authorization
- // you can modify this as needed
- $this->authorize('viewStats', $customer);
-
- return $this->exportStats(
- $request,
- $this->statsFilters($customer),
- $customer->name.' Customer' // this is a prefix added to the exported file name
- );
- }
-...
-
-```
-
-# Setting up your routes
-
-Next add the routes for the controller methods you just created.
-
-```php
-...
-// inside admin.php route file
-
-/**
-* Customers
-*/
-Route::get('customers/{customer}/stats', [CustomersController::class, 'stats'])->name('customers.stats');
-Route::post('customers/{customer}/stats', [CustomersController::class, 'statsExport'])->name('customers.stats.export');
-...
-```
-
-# Setting up your view
-
-In your view, you can use the `x-stats::time-series` Blade component provided by this package to display the stat graph generator. Below is the code for `admin.customers.stats.blade.php` view file.
-
-```bladehtml
-@extends('admin.customers.customers')
-
-@section('content')
- @parent
-
-
-@endsection
-```
-
-The `stats::time-series` component accepts the following attributes:
-
-- **`url`**: The url of the controller endpoint to submit the stat export form. Defaults to the route defined by `TimeSeriesStats::registerRoutes`.
-- **`filters`**: Associative array of filter values.
-- **`api-url`**: The API url to call for generating the stats graph. Defaults to the route defined by `TimeSeriesStats::registerApiRoute`.
-- **`user`**: The user who you want to display the stats for. Defaults to the current user.
-- **`metrics`**: Key-Value array of metrics to show. Defaults to the metrics visible to the given user for the given filters.
-- **`framework`**: Which CSS framework to use. Defaults to framework defined in the package config.
-
-# Add links
-
-Now you are all set to display model specific stats. You can add links to the model stats page in your model index page and tab links in your model show view.
diff --git a/docs/time-series/predefined-stats.md b/docs/time-series/predefined-stats.md
deleted file mode 100644
index 465f0fb..0000000
--- a/docs/time-series/predefined-stats.md
+++ /dev/null
@@ -1,61 +0,0 @@
----
-title: Predefined stats
-sidebar_position: 7
----
-
-This package ships with several different predefined stats that might be useful. These stats would be included only if you have a `App\Model\User` class.
-
-# Available Predefined Stats
-
-## user_signups
-
-Shows the counts for `App\Model\User` signups.
-
-## user_logins
-
-Shows the login counts for `App\Model\User`. Enabled only if `spatie/laravel-activitylog` package is installed.
-
-# Hiding Predefined Stats
-
-If you want to hide the predefined stats from the stats page, you can call the `TimeSeriesStats::excludeDefaultStats()` method in the `register` method of your `AppServiceProvider`.
-
-```php
-use \Javaabu\Stats\TimeSeriesStats;
-
-class AppServiceProvider extends ServiceProvider
-{
- public function register(): void
- {
- TimeSeriesStats::excludeDefaultStats();
- }
-}
-```
-
-If you want to later include the default stats again, for example to change the order of the stats, you can call the `TimeSeriesStats::registerDefaultStats()` method in the `boot` method of your `AppServiceProvider`.
-
-
-```php
-use \Javaabu\Stats\TimeSeriesStats;
-
-class AppServiceProvider extends ServiceProvider
-{
- public function register(): void
- {
- TimeSeriesStats::excludeDefaultStats();
- }
-
- /**
- * Bootstrap any application services.
- */
- public function boot(): void
- {
- TimeSeriesStats::register([
- 'payments_count' => \App\Stats\TimeSeries\PaymentsCountRepository::class,
- 'payments_amount' => \App\Stats\TimeSeries\PaymentsAmountRepository::class,
- ]);
-
- TimeSeriesStats::registerDefaultStats();
- }
-}
-```
-
diff --git a/docs/upgrading.md b/docs/upgrading.md
index f0af8c2..1dcb727 100644
--- a/docs/upgrading.md
+++ b/docs/upgrading.md
@@ -80,6 +80,7 @@ In v1 a stat's `canView()` checked `$user->can('view_stats')`. In v2 it asks the
- `view_stats` is now registered with javaabu/permissions (group "Stats"). Run your permission sync so the permission and its Dhivehi description exist. Turn this off with `stats.register_permissions`.
- Replace the policy with `config('stats.policy')`, `config('permissions.policies')` or `Gate::policy(TimeSeriesStatsRepository::class, ...)`.
- Stats that override `canView()` keep working as they are.
+- Downloads also check the policy's `export` ability, which by default allows whoever the stat's `canView()` allows.
## Built-in stats
@@ -125,6 +126,10 @@ The requests also accept:
| — | `options()` on each enum: `[['value' => ..., 'label' => ...], ...]` |
| — | `TimeSeriesModes::startOfStep()`, `displayDate()`, `toIsoDate()`, `chartInterval()` |
+## Filter values
+
+v1 skipped a filter whose value was falsy, so `['user' => 0]` returned everything. v2 skips only missing values (`null`, `''`, `[]`); `0`, `'0'` and `false` are applied.
+
## Category search
The categories endpoint and the default search of model category providers now use `whereLike()`, so search is case-insensitive on PostgreSQL too. The endpoint also accepts `search` besides `filter[search]`, and `per_page=-1` for a single page of up to 100.
diff --git a/llms.txt b/llms.txt
new file mode 100644
index 0000000..3e10c64
--- /dev/null
+++ b/llms.txt
@@ -0,0 +1,48 @@
+# javaabu/stats
+
+> Time series and categorical stats for Laravel. A stat is a class (CountStatsRepository, SumStatsRepository, CountCategoricalStatsRepository, SumCategoricalStatsRepository) registered under a metric name with TimeSeriesStats::register() or CategoricalStats::register(). Chart builders (TimeSeriesChart, CategoryChart, PieChart, Sparkline, StatTile) turn metrics into charts, drawn as SVG with Onu's tokens in Blade () and with Onu's chart components in React ( from @javaabu/stats, shipped through javaabu/manifest). Charts can plot several stats or periods and compare with the previous period, and are cached with warming and invalidation. Route::stats() gives stats pages (stencil forms, javaabu/filters filters, CSV export) in Blade or Inertia React; Route::statsApi() gives a JSON API. Dates are grouped on MySQL, MariaDB, PostgreSQL and SQLite. Viewing needs view_stats (javaabu/permissions, StatsPolicy).
+
+Paths are relative to this file. In a project, that's vendor/javaabu/stats/.
+
+## Rules
+
+- Define stats as classes extending the package's repositories; never write your own `GROUP BY DATE(...)`/`YEARWEEK()` queries or fill empty days yourself. Generate them with `php artisan stats:time-series Name Model [--type=sum]` or `php artisan stats:categorical Name Model CategoryModel category_id`.
+- Register every stat under a snake_case metric (`TimeSeriesStats::register(['payments_count' => PaymentsCount::class])` in a service provider, or `config('stats.time_series_stats')`), and refer to it by that metric in charts and requests.
+- Show stats with the chart builders: `TimeSeriesChart::make('payments_count')->range(PresetDateRanges::LAST_30_DAYS)->mode(TimeSeriesModes::DAY)->compare()`. Render them with `` in Blade, or pass them as Inertia props to ``. Don't fetch the JSON API to draw charts in JavaScript, and don't add Chart.js.
+- Several stats on one plot: `->stats([...])`; one stat over several periods: `->periods([...])`; against the previous period: `->compare()`.
+- KPI tiles: `StatTile::make('metric')->compare()->sparkline()` in `` / ``, or ``.
+- Charts are cached (10 minutes for periods that include today, a day for past ones). Register dashboard charts with `Stats::warm(fn () => [...])` and schedule `WarmStatsCache` or `php artisan stats:warm`; invalidate with `Stats::forget('metric')` or the `InvalidatesStats` model trait. Use `->withoutCache()` in tests that change data after building a chart.
+- Filters: `StatsFilter::exact()`, `scope()` and `closure()` read `filters[...]`; `StatsFilter::from(SelectFilter::make('status')->options(...))` (javaabu/filters) adds a field to the stats form and reads its own query parameter. Don't name a filter after a stats parameter (`metric`, `mode`, `date_from`, …).
+- Register routes yourself: `Route::stats(['prefix' => 'admin/stats', 'name' => 'admin.stats', 'guard' => 'web_admin'])` and `Route::statsApi([...])`. Never assume the `web` guard or `App\Models\User`.
+- Authorize through `$stat->canView($user)` / the gate (`view_stats`, `StatsPolicy`); override the policy with `config('stats.policy')` or `Gate::policy()`. Run `php artisan permissions:sync`.
+- For one model's stats, extend `TimeSeriesStatsController` or `CategoricalStatsController` and return fixed filters from `filters(Request $request)`.
+- User-facing strings go through `__()`; stat names come from `getName()`. Use logical CSS (`ms-`, `pe-`) in published views; charts mirror in Dhivehi.
+
+## Docs
+
+- [Introduction](docs/intro.md): what the package does
+- [Requirements](docs/requirements.md): PHP, Laravel, databases, the Javaabu packages and the optional ones
+- [Installation & Setup](docs/installation-and-setup.md): installing, routes, permissions, styles, Blade and Inertia
+- [Upgrading from v1](docs/upgrading.md): what changed and what to do
+- [AI assistants](docs/ai-assistants.md): Laravel Boost and these docs
+- [Time series stats](docs/basic-usage/time-series-stats.md): count, sum and aggregate stats, registering, built-in stats, using a stat in PHP
+- [Categorical stats](docs/basic-usage/categorical-stats.md): stats per category, registering, using them in PHP
+- [Category providers](docs/basic-usage/category-providers.md): models, enums, arrays and custom providers, labels, search and order
+- [Filters](docs/basic-usage/filters.md): exact, scope, closure and javaabu/filters filters, fixed filters and form fields
+- [Charts](docs/basic-usage/charts.md): every chart builder and option, Blade and React, several stats and periods, comparisons, number formats
+- [Dashboards](docs/basic-usage/dashboards.md): stat tiles, sparklines and a full dashboard in Blade and Inertia React
+- [Stats pages](docs/basic-usage/stats-pages.md): Route::stats(), the forms and their parameters, CSV export, pages for one model
+- [Caching and warming](docs/advanced-usage/caching-and-warming.md): keys, TTLs, warming, invalidation
+- [Configuration](docs/advanced-usage/configuration.md): every config option
+- [Permissions and policies](docs/advanced-usage/permissions-and-policies.md): view_stats, StatsPolicy, canView()
+- [Overriding controllers and views](docs/advanced-usage/overriding-controllers-and-views.md): controllers, Blade views, React pages, translations
+- [JSON API](docs/advanced-usage/json-api.md): Route::statsApi(), parameters and responses
+- [Formatters](docs/advanced-usage/formatters.md): default, chartjs, sparkline, flot, combined and your own
+- [Databases](docs/advanced-usage/databases.md): supported databases, week starts, step keys
+- [Artisan commands](docs/advanced-usage/artisan-commands.md): stats:time-series, stats:categorical, stats:warm
+- [Dhivehi and RTL](docs/advanced-usage/dhivehi-and-rtl.md): translations, dates and numbers, right to left, translations to review
+- [API reference](docs/advanced-usage/api-reference.md): public classes and methods
+- [Changelog](docs/changelog.md)
+- [Questions and security](docs/questions-and-security.md)
+- [About us](docs/about-us.md)
+- [Contributing to the docs](docs/contributing-to-docs.md)
From f85b31e996501f6b37ba930ee9bf59720d50c34e Mon Sep 17 00:00:00 2001
From: Naffah Abdulla Rasheed
Date: Fri, 6 Nov 2026 05:21:15 +0500
Subject: [PATCH 08/11] Work with immutable dates
(Date::use(CarbonImmutable::class))
The date range contracts, HasDateRange, ExactDateRange, PresetDateRanges,
TimeSeriesModes, TimeSeriesSteps and the stats requests type-hinted the
mutable Carbon\Carbon but built dates with now(), so every stat threw a
TypeError in apps on Laravel's starter kits. They now type-hint
CarbonInterface (DateTimeInterface for setters) and make dates with the
Date factory, so they are whichever class the app uses. No code relied on
mutating a date in place.
STATS_IMMUTABLE_DATES=1 runs the suite with CarbonImmutable, and a new CI
job does so; ImmutableDatesTest always does. Upgrade guide, docs, Boost
skill, llms.txt and AGENTS.md cover the new hints.
Closes #14
Co-Authored-By: Claude Opus 5.5
---
.github/workflows/tests.yml | 25 +++
AGENTS.md | 2 +
CHANGELOG.md | 1 +
docs/advanced-usage/api-reference.md | 6 +-
docs/advanced-usage/formatters.md | 2 +-
docs/basic-usage/time-series-stats.md | 4 +-
docs/upgrading.md | 15 ++
llms.txt | 1 +
.../references/common-mistakes.md | 2 +
src/Concerns/HasDateRange.php | 28 ++--
src/Contracts/DateRange.php | 6 +-
src/Contracts/InteractsWithDateRange.php | 15 +-
src/Enums/PresetDateRanges.php | 19 +--
src/Enums/TimeSeriesModes.php | 14 +-
src/Http/Requests/Concerns/ResolvesStats.php | 7 +-
src/Support/ExactDateRange.php | 15 +-
src/Support/TimeSeriesSteps.php | 6 +-
tests/Feature/ImmutableDatesTest.php | 147 ++++++++++++++++++
tests/TestCase.php | 18 ++-
19 files changed, 276 insertions(+), 57 deletions(-)
create mode 100644 tests/Feature/ImmutableDatesTest.php
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 5dd3506..67365fb 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -203,6 +203,31 @@ jobs:
- name: Execute tests
run: vendor/bin/phpunit
+ immutable-dates:
+ runs-on: ubuntu-latest
+
+ name: Tests with CarbonImmutable
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v5
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.4
+ extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv
+ coverage: none
+
+ - name: Install dependencies
+ run: composer update --prefer-dist --no-interaction --no-progress
+
+ # Laravel's starter kits call Date::use(CarbonImmutable::class) (#14)
+ - name: Execute tests with Date::use(CarbonImmutable::class)
+ run: vendor/bin/phpunit
+ env:
+ STATS_IMMUTABLE_DATES: 1
+
tests:
runs-on: ubuntu-latest
diff --git a/AGENTS.md b/AGENTS.md
index ace0423..d5e496c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,6 +27,7 @@
- Date SQL must work on MySQL, MariaDB, PostgreSQL and SQLite. Add a driver in `TimeSeriesModes::getSql()` and run the suite on each database.
- Keep the chart array shape and `resources/js/types.ts` in step, and keep the Blade SVG and the React charts showing the same thing. When the page props change, regenerate the fixtures: `UPDATE_FIXTURES=1 vendor/bin/phpunit --filter InertiaPagesTest`.
- Anything a chart depends on goes into its `cacheKey()`.
+- Dates must work as `Carbon` and as `CarbonImmutable` (`Date::use(CarbonImmutable::class)`): type-hint `CarbonInterface` (or `DateTimeInterface` for input), make dates with `now()`, `today()` or `Date::parse()` rather than `Carbon::`, and always use the return value of `addDay()`, `startOfDay()` and the like. `STATS_IMMUTABLE_DATES=1 vendor/bin/phpunit` runs the suite with immutable dates (CI does).
- Nothing may assume the `web` guard or `App\Models\User`. Stats authorize through the gate (`canView()` → `StatsPolicy`).
- Inertia and spatie/laravel-activitylog are optional: feature-detect them, keep them in `suggest`, and mark tests that need them with `#[RequiresMethod]`.
- Every user-facing string goes through `__()` with an English key, and gets a line in `lang/en.json` and `lang/dv.json` (`TranslationsTest` checks). Use Thaana in tests of user-facing behaviour. List uncertain translations in `docs/advanced-usage/dhivehi-and-rtl.md`. Views, CSS and React use logical properties only (`RtlTest` checks).
@@ -41,6 +42,7 @@ composer install
composer lint # vendor/bin/pint --test
composer format # vendor/bin/pint
composer test # vendor/bin/phpunit (SQLite)
+STATS_IMMUTABLE_DATES=1 vendor/bin/phpunit # with Date::use(CarbonImmutable::class)
DB_CONNECTION=pgsql DB_DATABASE=stats_test DB_USERNAME=postgres vendor/bin/phpunit
pnpm install && pnpm check # lint, format, typecheck and Vitest (needs vendor/ for stencil)
pnpm build # dist/ for apps without Vite
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 27365c3..4e9d1b4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,7 @@ See [Upgrading from v1](docs/upgrading.md) for the breaking changes.
- A plain `date_to` covers the whole day.
- Built-in stats use `config('stats.user_model')` or the default guard's user model.
- Category search is case-insensitive on every database.
+- Dates are `CarbonInterface` and made with Laravel's `Date` factory, so stats work in apps that call `Date::use(CarbonImmutable::class)`; CI runs the suite with immutable dates.
### Removed
diff --git a/docs/advanced-usage/api-reference.md b/docs/advanced-usage/api-reference.md
index f7f3174..bd31417 100644
--- a/docs/advanced-usage/api-reference.md
+++ b/docs/advanced-usage/api-reference.md
@@ -108,6 +108,8 @@ See [Charts](../basic-usage/charts.md) for what each does.
| `interval(TimeSeriesModes)` | `applyFilters(Builder)`, `filteredQuery()` |
| `applyDateFilters(Builder)`, `filteredQueryWithoutDateFilters()` | |
+Dates are `CarbonInterface`: `getDateFrom()`, `getDateTo()`, `getMinDate()` and `getMaxDate()` return the app's date class (`Carbon`, or `CarbonImmutable` after `Date::use(CarbonImmutable::class)`), and `setDateFrom()` and `setDateTo()` take a string or any `DateTimeInterface`.
+
`TimeSeriesStatsRepository` adds `query()`, `hour()`, `day()`, `week()`, `month()`, `year()`, `results(TimeSeriesModes)`, `total()`, `format(string $format, TimeSeriesModes $mode = TimeSeriesModes::DAY)`, `getCurrentMode()`, `getAggregateFieldName()`, `getAggregateFieldLabel()`, `getName()`, `canView(?Authorizable)` and `metric()`.
`CategoricalStatsRepository` adds `query()`, `categoryProvider()`, `getCategoryProvider()`, `getCategoryField()`, `getCategoryFieldAlias()`, `getCategoryNameFieldAlias()`, `getCategoryTitle()`, `getCategoryIdTitle()`, `getAggregateFieldName()`, `getAggregateFieldLabel()`, `getName()`, `canView()`, `metric()`, `results()`, `resultsToArray(Collection)`, `resolveCategoryNames(CategoricalModes $mode, array $categorical_values = [])` and `format(string $format, ?CategoricalStatsRepository $compare = null, CategoricalModes $mode = CategoricalModes::NON_EMPTY, array $categorical_values = [])`.
@@ -157,7 +159,7 @@ The traits `Concerns\HasDateRange` and `Concerns\HasFilters` implement the two c
| Enum | Cases | Methods |
| --- | --- | --- |
-| `Enums\TimeSeriesModes` | `HOUR`, `DAY`, `WEEK`, `MONTH`, `YEAR` | `getLabel()`, `getSql(string $date_field, string $driver = 'mysql')`, `interval(Carbon, Carbon)`, `startOfStep(Carbon)`, `increment(Carbon)`, `formatDate(Carbon, bool $for_display = true)`, `displayDate(Carbon, ?string $locale = null)`, `toIsoDate(Carbon)`, `getDateFormat()`, `getInternalDateFormat()`, `chartInterval()` |
+| `Enums\TimeSeriesModes` | `HOUR`, `DAY`, `WEEK`, `MONTH`, `YEAR` | `getLabel()`, `getSql(string $date_field, string $driver = 'mysql')`, `interval(CarbonInterface, CarbonInterface)`, `startOfStep(CarbonInterface)`, `increment(CarbonInterface)`, `formatDate(CarbonInterface, bool $for_display = true)`, `displayDate(CarbonInterface, ?string $locale = null)`, `toIsoDate(CarbonInterface)`, `getDateFormat()`, `getInternalDateFormat()`, `chartInterval()` |
| `Enums\CategoricalModes` | `NON_EMPTY`, `ALL`, `SPECIFIC` | `getLabel()`, `getDescription()`, `resolveCategoryNames(...)` |
| `Enums\PresetDateRanges` | `TODAY`, `YESTERDAY`, `THIS_WEEK`, `LAST_WEEK`, `THIS_MONTH`, `LAST_MONTH`, `THIS_YEAR`, `LAST_YEAR`, `LAST_7_DAYS`, `LAST_14_DAYS`, `LAST_30_DAYS`, `LAST_5_YEARS`, `LAST_10_YEARS`, `LIFETIME` | `getDateFrom()`, `getDateTo()`, `getName()`, `getLabel()`, `getPreviousDateRange()` |
| `Enums\StatListReturnType` | `METRIC`, `METRIC_AND_NAME`, `METRIC_AND_CLASS` | |
@@ -170,7 +172,7 @@ The traits `Concerns\HasDateRange` and `Concerns\HasFilters` implement the two c
| --- | --- |
| `Contracts\DateRange` | `getDateFrom()`, `getDateTo()`, `getName()`, `getPreviousDateRange()` |
| `Support\ExactDateRange` | `new ExactDateRange($date_from, $date_to)`. Its name is `custom` |
-| `Support\TimeSeriesSteps` | `for(TimeSeriesStatsRepository $stats, TimeSeriesModes $mode)`: every step with `date`, `key`, `value`. `rangeLabel(DateRange $range, ?Carbon $from = null, ?Carbon $to = null)` |
+| `Support\TimeSeriesSteps` | `for(TimeSeriesStatsRepository $stats, TimeSeriesModes $mode)`: every step with `date`, `key`, `value`. `rangeLabel(DateRange $range, ?CarbonInterface $from = null, ?CarbonInterface $to = null)` |
| `Support\Numbers` | `format(int\|float\|null $value, string $format = 'number', ?string $locale = null)`, `tick()`, `compact()`, `currency()`, `currencySymbol()` |
| `Support\ExportFileName` | `make(string $title, $stats, $compare = null, string $prefix = '')` |
diff --git a/docs/advanced-usage/formatters.md b/docs/advanced-usage/formatters.md
index 65a8d2a..7416521 100644
--- a/docs/advanced-usage/formatters.md
+++ b/docs/advanced-usage/formatters.md
@@ -172,7 +172,7 @@ class ApexChartsFormatter extends AbstractTimeSeriesStatsFormatter
}
```
-`TimeSeriesSteps::for($stat, $mode)` walks the range one step at a time and gives each step's `date` (Carbon), `key` (as in results) and `value` (`0` without data).
+`TimeSeriesSteps::for($stat, $mode)` walks the range one step at a time and gives each step's `date` (a `CarbonInterface`), `key` (as in results) and `value` (`0` without data).
Register it in a service provider's `boot()`:
diff --git a/docs/basic-usage/time-series-stats.md b/docs/basic-usage/time-series-stats.md
index ba1e51f..4f41cfb 100644
--- a/docs/basic-usage/time-series-stats.md
+++ b/docs/basic-usage/time-series-stats.md
@@ -322,7 +322,7 @@ A date range is a `PresetDateRanges` case or an `ExactDateRange`. Both implement
```php
$range = PresetDateRanges::from('last_30_days');
-$range->getDateFrom(); // Carbon
+$range->getDateFrom(); // CarbonInterface
$range->getDateTo();
$range->getLabel(); // "Last 30 days", translated
$range->getPreviousDateRange(); // the 30 days before, as an ExactDateRange
@@ -334,6 +334,8 @@ $range->getPreviousDateRange(); // the same length, ending the second before
An `ExactDateRange` takes anything Carbon parses. A date without a time starts at midnight, so give the end date a time (or `Carbon::parse(...)->endOfDay()`) to include the whole day. Its end can't be before its start.
+The dates are made with Laravel's `Date` factory, so they are `CarbonImmutable` in an app that calls `Date::use(CarbonImmutable::class)` (as Laravel's starter kits do) and `Carbon` otherwise. Type-hint `CarbonInterface`, and use the return value of methods like `startOfDay()` or `addDay()` (`$date = $date->addDay()`) instead of relying on them changing the date in place, so your code works with both.
+
## The stat's dates
| Method | What |
diff --git a/docs/upgrading.md b/docs/upgrading.md
index 1dcb727..284dbce 100644
--- a/docs/upgrading.md
+++ b/docs/upgrading.md
@@ -115,6 +115,20 @@ The requests also accept:
- `date_range=custom` with `date_from` and `date_to`;
- `compare_to=previous` or `compare_to=custom` with `compare_date_from` and `compare_date_to`. `compare=1` and `compare_date_range` still work.
+## Immutable dates
+
+v1 type-hinted the mutable `Carbon\Carbon`, so an app that called `Date::use(CarbonImmutable::class)` (the default in Laravel's starter kits) got a `TypeError` from every stat. v2 type-hints `Carbon\CarbonInterface` and makes its dates with Laravel's `Date` factory, so they are whichever class the app uses.
+
+| v1 | v2 |
+| --- | --- |
+| `DateRange::getDateFrom(): Carbon`, `getDateTo(): Carbon` | `: CarbonInterface` |
+| `InteractsWithDateRange::getDateFrom()`, `getDateTo()` | `: CarbonInterface` |
+| `setDateFrom(string\|Carbon)`, `setDateTo(string\|Carbon)` | `setDateFrom(string\|DateTimeInterface)`, `setDateTo(string\|DateTimeInterface)` |
+| `getMaxDate(?Carbon $fallback): ?Carbon`, `getMinDate(...)` | `getMaxDate(?CarbonInterface $fallback): ?CarbonInterface`, `getMinDate(...)` |
+| `TimeSeriesModes::interval()`, `startOfStep()`, `increment()`, `formatDate()` took `Carbon` | `CarbonInterface` |
+
+Classes that implement `DateRange` or override these methods can keep returning `Carbon` (a narrower return type is allowed), but overrides of `setDateFrom()`, `setDateTo()`, `getMaxDate()` and `getMinDate()` must widen their parameters to match. Code that reads a stat's dates should type-hint `CarbonInterface` and use the result of `addDay()`, `startOfDay()` and the like rather than rely on them changing the date in place.
+
## Enums
| v1 | v2 |
@@ -156,3 +170,4 @@ Export file names keep their shape: `{App Name} {Stat name} {20240701-20240707}
5. Sync permissions.
6. Check code that reads `week` or `month` keys from `results()` or the `default` format.
7. Replace `Excel::fake()` in tests of exports.
+8. Change `Carbon` type hints on your own `DateRange` classes and overridden date methods to `CarbonInterface` where PHP requires it.
diff --git a/llms.txt b/llms.txt
index 3e10c64..2ec5b2d 100644
--- a/llms.txt
+++ b/llms.txt
@@ -14,6 +14,7 @@ Paths are relative to this file. In a project, that's vendor/javaabu/stats/.
- Charts are cached (10 minutes for periods that include today, a day for past ones). Register dashboard charts with `Stats::warm(fn () => [...])` and schedule `WarmStatsCache` or `php artisan stats:warm`; invalidate with `Stats::forget('metric')` or the `InvalidatesStats` model trait. Use `->withoutCache()` in tests that change data after building a chart.
- Filters: `StatsFilter::exact()`, `scope()` and `closure()` read `filters[...]`; `StatsFilter::from(SelectFilter::make('status')->options(...))` (javaabu/filters) adds a field to the stats form and reads its own query parameter. Don't name a filter after a stats parameter (`metric`, `mode`, `date_from`, …).
- Register routes yourself: `Route::stats(['prefix' => 'admin/stats', 'name' => 'admin.stats', 'guard' => 'web_admin'])` and `Route::statsApi([...])`. Never assume the `web` guard or `App\Models\User`.
+- Dates are `CarbonInterface` (`CarbonImmutable` in apps that call `Date::use(CarbonImmutable::class)`): type-hint `CarbonInterface` in your own date ranges and stat overrides, and use the return value of `addDay()`, `startOfDay()` and the like.
- Authorize through `$stat->canView($user)` / the gate (`view_stats`, `StatsPolicy`); override the policy with `config('stats.policy')` or `Gate::policy()`. Run `php artisan permissions:sync`.
- For one model's stats, extend `TimeSeriesStatsController` or `CategoricalStatsController` and return fixed filters from `filters(Request $request)`.
- User-facing strings go through `__()`; stat names come from `getName()`. Use logical CSS (`ms-`, `pe-`) in published views; charts mirror in Dhivehi.
diff --git a/resources/boost/skills/javaabu-stats/references/common-mistakes.md b/resources/boost/skills/javaabu-stats/references/common-mistakes.md
index 7cf964e..448bffd 100644
--- a/resources/boost/skills/javaabu-stats/references/common-mistakes.md
+++ b/resources/boost/skills/javaabu-stats/references/common-mistakes.md
@@ -10,6 +10,7 @@
- **Naming a form filter after a stats parameter** (`mode`, `metric`, `date_from`, …): it throws. Pick another name and set `->column()` on the javaabu/filters filter.
- **Assuming the `web` guard.** Pass `guard` to `Route::stats()` for admin portals; type users as `Authorizable`/`Model`, not `App\Models\User`.
- **Checking `view_stats` yourself.** Use `$stat->canView($user)` or the gate (`can('view', $stat)`), so an app's policy override applies.
+- **Type-hinting `Carbon`.** A stat's dates are `CarbonImmutable` in apps that call `Date::use(CarbonImmutable::class)` (Laravel's starter kits do). Type-hint `CarbonInterface` in your own `DateRange` classes and stat overrides, and write `$date = $date->addDay()`, never a bare `$date->addDay();`.
- **Left/right CSS in published views.** Use logical utilities (`ms-`, `pe-`, `start-`); the charts mirror in Dhivehi.
## Upgrading from v1
@@ -19,4 +20,5 @@
- Week keys are now the week's start date (`2024-07-14`), month keys `2024-07`; code reading the raw `default` format must change.
- Exports are CSV without maatwebsite/excel: `(new TimeSeriesStatsExport($mode, $stats))->download('file.csv')`; tests assert the download instead of `Excel::fake()`.
- A plain `date_to` covers the whole day.
+- Date methods type-hint `CarbonInterface` instead of `Carbon`; overrides of `setDateFrom()`, `setDateTo()`, `getMaxDate()` and `getMinDate()` must widen their parameters.
- See `docs/upgrading.md` for the full list.
diff --git a/src/Concerns/HasDateRange.php b/src/Concerns/HasDateRange.php
index fe081b3..5c7f705 100644
--- a/src/Concerns/HasDateRange.php
+++ b/src/Concerns/HasDateRange.php
@@ -2,8 +2,10 @@
namespace Javaabu\Stats\Concerns;
-use Carbon\Carbon;
+use Carbon\CarbonInterface;
+use DateTimeInterface;
use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Facades\Date;
use Javaabu\Stats\Contracts\DateRange;
use Javaabu\Stats\Contracts\InteractsWithDateRange;
use Javaabu\Stats\Enums\PresetDateRanges;
@@ -13,18 +15,18 @@
/** @var $this InteractsWithDateRange */
trait HasDateRange
{
- protected Carbon $date_from;
+ protected CarbonInterface $date_from;
- protected Carbon $date_to;
+ protected CarbonInterface $date_to;
protected DateRange $date_range;
- public function getDateFrom(): Carbon
+ public function getDateFrom(): CarbonInterface
{
return $this->date_from;
}
- public function getDateTo(): Carbon
+ public function getDateTo(): CarbonInterface
{
return $this->date_to;
}
@@ -39,16 +41,16 @@ public function formattedDateRange(string $format = 'YYYY-MM-DD HH:mm', string $
return $this->getDateFrom()->isoFormat($format) . $separator . $this->getDateTo()->isoFormat($format);
}
- public function setDateFrom(Carbon|string $date_from)
+ public function setDateFrom(DateTimeInterface|string $date_from)
{
- $date_from = Carbon::parse($date_from);
+ $date_from = Date::parse($date_from);
$this->setDateRange(new ExactDateRange($date_from, $this->getDateTo()));
}
- public function setDateTo(Carbon|string $date_to)
+ public function setDateTo(DateTimeInterface|string $date_to)
{
- $date_to = Carbon::parse($date_to);
+ $date_to = Date::parse($date_to);
$this->setDateRange(new ExactDateRange($this->getDateFrom(), $date_to));
}
@@ -58,18 +60,18 @@ public function getDateField(): string
return 'created_at';
}
- public function getMaxDate(?Carbon $fallback = null): ?Carbon
+ public function getMaxDate(?CarbonInterface $fallback = null): ?CarbonInterface
{
$max = $this->query()->max($this->getDateField());
- return $max ? Carbon::parse($max) : $fallback;
+ return $max ? Date::parse($max) : $fallback;
}
- public function getMinDate(?Carbon $fallback = null): ?Carbon
+ public function getMinDate(?CarbonInterface $fallback = null): ?CarbonInterface
{
$min = $this->query()->min($this->getDateField());
- return $min ? Carbon::parse($min) : $fallback;
+ return $min ? Date::parse($min) : $fallback;
}
public function setDateRange(DateRange $date_range)
diff --git a/src/Contracts/DateRange.php b/src/Contracts/DateRange.php
index b42a93f..3046ebf 100644
--- a/src/Contracts/DateRange.php
+++ b/src/Contracts/DateRange.php
@@ -2,19 +2,19 @@
namespace Javaabu\Stats\Contracts;
-use Carbon\Carbon;
+use Carbon\CarbonInterface;
interface DateRange
{
/**
* Get the date from
*/
- public function getDateFrom(): Carbon;
+ public function getDateFrom(): CarbonInterface;
/**
* Get the date to
*/
- public function getDateTo(): Carbon;
+ public function getDateTo(): CarbonInterface;
/**
* Get the name for the type of date range
diff --git a/src/Contracts/InteractsWithDateRange.php b/src/Contracts/InteractsWithDateRange.php
index 8442967..e223b74 100644
--- a/src/Contracts/InteractsWithDateRange.php
+++ b/src/Contracts/InteractsWithDateRange.php
@@ -2,7 +2,8 @@
namespace Javaabu\Stats\Contracts;
-use Carbon\Carbon;
+use Carbon\CarbonInterface;
+use DateTimeInterface;
use Illuminate\Database\Eloquent\Builder;
use Javaabu\Stats\Enums\TimeSeriesModes;
@@ -11,12 +12,12 @@ interface InteractsWithDateRange
/**
* Get the date from
*/
- public function getDateFrom(): Carbon;
+ public function getDateFrom(): CarbonInterface;
/**
* Get the date to
*/
- public function getDateTo(): Carbon;
+ public function getDateTo(): CarbonInterface;
/**
* Get the date range
@@ -31,12 +32,12 @@ public function formattedDateRange(string $format = 'Y-m-d H:i', string $separat
/**
* Set the date from
*/
- public function setDateFrom(string|Carbon $date_from);
+ public function setDateFrom(string|DateTimeInterface $date_from);
/**
* Set the date to
*/
- public function setDateTo(string|Carbon $date_to);
+ public function setDateTo(string|DateTimeInterface $date_to);
/**
* Get the date field for the repository
@@ -46,12 +47,12 @@ public function getDateField(): string;
/**
* Get the max date for the query
*/
- public function getMaxDate(?Carbon $fallback = null): ?Carbon;
+ public function getMaxDate(?CarbonInterface $fallback = null): ?CarbonInterface;
/**
* Get the min date for the query
*/
- public function getMinDate(?Carbon $fallback = null): ?Carbon;
+ public function getMinDate(?CarbonInterface $fallback = null): ?CarbonInterface;
/**
* Set the date range
diff --git a/src/Enums/PresetDateRanges.php b/src/Enums/PresetDateRanges.php
index 910dc42..79bac62 100644
--- a/src/Enums/PresetDateRanges.php
+++ b/src/Enums/PresetDateRanges.php
@@ -2,7 +2,8 @@
namespace Javaabu\Stats\Enums;
-use Carbon\Carbon;
+use Carbon\CarbonInterface;
+use Illuminate\Support\Facades\Date;
use Javaabu\Stats\Contracts\DateRange;
use Javaabu\Stats\Enums\Concerns\HasLabels;
use Javaabu\Stats\Support\ExactDateRange;
@@ -30,11 +31,11 @@ enum PresetDateRanges: string implements DateRange
case LAST_10_YEARS = 'last_10_years';
case LIFETIME = 'lifetime';
- public function getDateFrom(): Carbon
+ public function getDateFrom(): CarbonInterface
{
return match ($this) {
- self::TODAY => Carbon::today(),
- self::YESTERDAY => Carbon::yesterday(),
+ self::TODAY => Date::today(),
+ self::YESTERDAY => Date::yesterday(),
self::THIS_WEEK => now()->locale(TimeSeriesStats::dateLocale())->startOfWeek(),
self::LAST_WEEK => now()->locale(TimeSeriesStats::dateLocale())->subWeek()->startOfWeek(),
self::THIS_MONTH => now()->startOfMonth(),
@@ -50,11 +51,11 @@ public function getDateFrom(): Carbon
};
}
- public function getDateTo(): Carbon
+ public function getDateTo(): CarbonInterface
{
return match ($this) {
- self::TODAY => Carbon::today()->endOfDay(),
- self::YESTERDAY => Carbon::yesterday()->endOfDay(),
+ self::TODAY => Date::today()->endOfDay(),
+ self::YESTERDAY => Date::yesterday()->endOfDay(),
self::THIS_WEEK => now()->locale(TimeSeriesStats::dateLocale())->endOfWeek(),
self::LAST_WEEK => now()->locale(TimeSeriesStats::dateLocale())->subWeek()->endOfWeek(),
self::THIS_MONTH => now()->endOfMonth(),
@@ -93,7 +94,7 @@ public function getLabel(): string
};
}
- protected function generatePreviousFromDate(): Carbon
+ protected function generatePreviousFromDate(): CarbonInterface
{
$from_date = $this->getDateFrom();
@@ -115,7 +116,7 @@ protected function generatePreviousFromDate(): Carbon
};
}
- protected function generatePreviousToDate(Carbon $from_date): Carbon
+ protected function generatePreviousToDate(CarbonInterface $from_date): CarbonInterface
{
$from_date = $from_date->copy();
diff --git a/src/Enums/TimeSeriesModes.php b/src/Enums/TimeSeriesModes.php
index d335e7b..369418d 100644
--- a/src/Enums/TimeSeriesModes.php
+++ b/src/Enums/TimeSeriesModes.php
@@ -2,7 +2,7 @@
namespace Javaabu\Stats\Enums;
-use Carbon\Carbon;
+use Carbon\CarbonInterface;
use Illuminate\Support\Str;
use Javaabu\Stats\Enums\Concerns\HasLabels;
use Javaabu\Stats\Exceptions\StatsException;
@@ -50,7 +50,7 @@ public function queryMethodName(): string
/**
* The number of steps between the two dates, not counting the first
*/
- public function interval(Carbon $date_from, Carbon $date_to): int
+ public function interval(CarbonInterface $date_from, CarbonInterface $date_to): int
{
$diff_method = $this->diffMethodName();
@@ -136,7 +136,7 @@ protected function sqliteSql(string $date_field): string
/**
* The start of the step the date is in
*/
- public function startOfStep(Carbon $date): Carbon
+ public function startOfStep(CarbonInterface $date): CarbonInterface
{
$date = $date->copy();
@@ -149,7 +149,7 @@ public function startOfStep(Carbon $date): Carbon
};
}
- public function increment(Carbon $date): Carbon
+ public function increment(CarbonInterface $date): CarbonInterface
{
$date = $date->copy()->locale(TimeSeriesStats::dateLocale());
@@ -161,7 +161,7 @@ public function increment(Carbon $date): Carbon
/**
* Format a date for display, or as the key the database groups by
*/
- public function formatDate(Carbon $date, bool $for_display = true): string
+ public function formatDate(CarbonInterface $date, bool $for_display = true): string
{
$date = $date->copy()->locale(TimeSeriesStats::dateLocale());
@@ -180,7 +180,7 @@ public function formatDate(Carbon $date, bool $for_display = true): string
* A step's label in the app's locale (Dhivehi month names in `dv`), for
* charts. Weeks read "Week 29, 2024", numbered by `stats.date_locale`.
*/
- public function displayDate(Carbon $date, ?string $locale = null): string
+ public function displayDate(CarbonInterface $date, ?string $locale = null): string
{
$locale ??= app()->getLocale();
@@ -196,7 +196,7 @@ public function displayDate(Carbon $date, ?string $locale = null): string
/**
* The step as an ISO 8601 date, for charts with a time axis
*/
- public function toIsoDate(Carbon $date): string
+ public function toIsoDate(CarbonInterface $date): string
{
$date = $this->startOfStep($date);
diff --git a/src/Http/Requests/Concerns/ResolvesStats.php b/src/Http/Requests/Concerns/ResolvesStats.php
index e185ae4..fbaee3e 100644
--- a/src/Http/Requests/Concerns/ResolvesStats.php
+++ b/src/Http/Requests/Concerns/ResolvesStats.php
@@ -2,8 +2,9 @@
namespace Javaabu\Stats\Http\Requests\Concerns;
-use Carbon\Carbon;
+use Carbon\CarbonInterface;
use Illuminate\Contracts\Auth\Access\Authorizable;
+use Illuminate\Support\Facades\Date;
use Javaabu\Stats\Contracts\DateRange;
use Javaabu\Stats\Contracts\HasFields;
use Javaabu\Stats\Contracts\InteractsWithFilters;
@@ -106,10 +107,10 @@ public function dateRange(?DateRange $default = null): DateRange
/**
* A date from the input. A date without a time covers the whole day.
*/
- protected function dateInput(string $key, bool $end_of_day = false): Carbon
+ protected function dateInput(string $key, bool $end_of_day = false): CarbonInterface
{
$value = (string) $this->input($key);
- $date = Carbon::parse($value);
+ $date = Date::parse($value);
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', trim($value))) {
return $date;
diff --git a/src/Support/ExactDateRange.php b/src/Support/ExactDateRange.php
index ce8d9f6..eaee427 100644
--- a/src/Support/ExactDateRange.php
+++ b/src/Support/ExactDateRange.php
@@ -2,25 +2,26 @@
namespace Javaabu\Stats\Support;
-use Carbon\Carbon;
+use Carbon\CarbonInterface;
use Carbon\Month;
use Carbon\WeekDay;
use DateTimeInterface;
+use Illuminate\Support\Facades\Date;
use InvalidArgumentException;
use Javaabu\Stats\Contracts\DateRange;
class ExactDateRange implements DateRange
{
- protected Carbon $date_from;
+ protected CarbonInterface $date_from;
- protected Carbon $date_to;
+ protected CarbonInterface $date_to;
public function __construct(
DateTimeInterface|WeekDay|Month|string|int|float $date_from,
DateTimeInterface|WeekDay|Month|string|int|float $date_to
) {
- $date_from = Carbon::parse($date_from);
- $date_to = Carbon::parse($date_to);
+ $date_from = Date::parse($date_from);
+ $date_to = Date::parse($date_to);
if ($date_to < $date_from) {
throw new InvalidArgumentException('date_to cannot be less than date_from');
@@ -30,12 +31,12 @@ public function __construct(
$this->date_to = $date_to;
}
- public function getDateFrom(): Carbon
+ public function getDateFrom(): CarbonInterface
{
return $this->date_from;
}
- public function getDateTo(): Carbon
+ public function getDateTo(): CarbonInterface
{
return $this->date_to;
}
diff --git a/src/Support/TimeSeriesSteps.php b/src/Support/TimeSeriesSteps.php
index 5c4ae54..924a323 100644
--- a/src/Support/TimeSeriesSteps.php
+++ b/src/Support/TimeSeriesSteps.php
@@ -2,7 +2,7 @@
namespace Javaabu\Stats\Support;
-use Carbon\Carbon;
+use Carbon\CarbonInterface;
use Javaabu\Stats\Contracts\DateRange;
use Javaabu\Stats\Contracts\TimeSeriesStatsRepository;
use Javaabu\Stats\Enums\PresetDateRanges;
@@ -15,7 +15,7 @@
class TimeSeriesSteps
{
/**
- * @return list
+ * @return list
*/
public static function for(TimeSeriesStatsRepository $stats, TimeSeriesModes $mode): array
{
@@ -43,7 +43,7 @@ public static function for(TimeSeriesStatsRepository $stats, TimeSeriesModes $mo
/**
* A short name for a date range: the preset's label, or its dates
*/
- public static function rangeLabel(DateRange $range, ?Carbon $from = null, ?Carbon $to = null): string
+ public static function rangeLabel(DateRange $range, ?CarbonInterface $from = null, ?CarbonInterface $to = null): string
{
if ($range instanceof PresetDateRanges && $range !== PresetDateRanges::LIFETIME) {
return $range->getLabel();
diff --git a/tests/Feature/ImmutableDatesTest.php b/tests/Feature/ImmutableDatesTest.php
new file mode 100644
index 0000000..0294f3b
--- /dev/null
+++ b/tests/Feature/ImmutableDatesTest.php
@@ -0,0 +1,147 @@
+group(fn () => Route::stats());
+
+ $this->registerTestStats();
+ $this->allowViewingStats();
+ }
+
+ #[Test]
+ public function it_gives_preset_and_exact_ranges_in_the_apps_date_class(): void
+ {
+ $this->travelTo('2024-07-04 12:00:00');
+
+ $this->assertInstanceOf(CarbonImmutable::class, now());
+
+ foreach (PresetDateRanges::cases() as $range) {
+ $this->assertInstanceOf(CarbonImmutable::class, $range->getDateFrom(), $range->value);
+ $this->assertInstanceOf(CarbonImmutable::class, $range->getDateTo(), $range->value);
+ $this->assertInstanceOf(CarbonImmutable::class, $range->getPreviousDateRange()->getDateFrom(), $range->value);
+ }
+
+ $this->assertSame('2024-06-28 00:00:00', PresetDateRanges::LAST_7_DAYS->getDateFrom()->toDateTimeString());
+ $this->assertSame('2024-06-21 00:00:00', PresetDateRanges::LAST_7_DAYS->getPreviousDateRange()->getDateFrom()->toDateTimeString());
+ $this->assertSame('2024-06-27 23:59:59', PresetDateRanges::LAST_7_DAYS->getPreviousDateRange()->getDateTo()->toDateTimeString());
+
+ // A mutable date passed in is read, not kept
+ $from = Carbon::parse('2024-07-01');
+ $range = new ExactDateRange($from, '2024-07-04 23:59:59');
+ $from->addYear();
+
+ $this->assertInstanceOf(CarbonImmutable::class, $range->getDateFrom());
+ $this->assertSame('2024-07-01 00:00:00', $range->getDateFrom()->toDateTimeString());
+ $this->assertSame('2024-06-27 00:00:00', $range->getPreviousDateRange()->getDateFrom()->toDateTimeString());
+ }
+
+ #[Test]
+ public function it_walks_the_steps_without_changing_the_stats_dates(): void
+ {
+ $this->seedPayments();
+
+ $stat = new PaymentsCount(new ExactDateRange('2024-06-29', '2024-07-04 23:59:59'));
+
+ $this->assertSame(5, $stat->interval(TimeSeriesModes::DAY));
+
+ $steps = TimeSeriesSteps::for($stat, TimeSeriesModes::DAY);
+
+ $this->assertSame(
+ ['2024-06-29' => 1, '2024-06-30' => 0, '2024-07-01' => 0, '2024-07-02' => 0, '2024-07-03' => 3, '2024-07-04' => 2],
+ collect($steps)->pluck('value', 'key')->all()
+ );
+ $this->assertInstanceOf(CarbonImmutable::class, $steps[0]['date']);
+
+ $weeks = TimeSeriesSteps::for($stat, TimeSeriesModes::WEEK);
+
+ $this->assertSame(['2024-06-23' => 1, '2024-06-30' => 5], collect($weeks)->pluck('value', 'key')->all());
+
+ $this->assertSame('2024-06-29 00:00:00', $stat->getDateFrom()->toDateTimeString());
+ $this->assertSame('2024-06-29 00:00:00 - 2024-07-04 23:59:59', $stat->formattedDateRange('YYYY-MM-DD HH:mm:ss'));
+ $this->assertInstanceOf(CarbonImmutable::class, $stat->getMinDate());
+ }
+
+ #[Test]
+ public function it_shows_the_stats_pages_charts_and_tiles(): void
+ {
+ [$ahmed] = $this->seedPayments();
+
+ $this->actingAs($ahmed)
+ ->get('/stats/time-series')
+ ->assertOk()
+ ->assertSee('